8 Commits
Author SHA1 Message Date
thakares fb5d827322 chore: ignore local backups
Rust CI / Test & Quality Checks (push) Canceled after 0s
Rust CI / Build Docker Image (push) Canceled after 0s
2026-08-27 20:54:05 +05:30
thakares feed352c2e fix: only initialize admin explicitly 2026-08-27 19:27:24 +05:30
thakares e42d1a5d80 fix: standardize deployment data directory and CI linting
Rust CI / Test & Quality Checks (push) Canceled after 0s
Rust CI / Build Docker Image (push) Canceled after 0s
2026-08-27 15:41:24 +05:30
thakares c2e138f823 refactor(config): require explicit BZOD data directory
Rust CI / Test & Quality Checks (push) Canceled after 0s
Rust CI / Build Docker Image (push) Canceled after 0s
2026-08-27 15:06:05 +05:30
thakares d398341f01 release: finalize BZOD v0.8.0
Rust CI / Test & Quality Checks (push) Canceled after 0s
Rust CI / Build Docker Image (push) Canceled after 0s
2026-08-21 16:23:32 +05:30
thakares d7e0ac7679 refactor: complete v0.8 core architecture 2026-08-21 13:56:36 +05:30
thakares f138a645f8 fix: configure production base URL
Rust CI / Test & Quality Checks (push) Canceled after 0s
Rust CI / Build Docker Image (push) Canceled after 0s
2026-08-13 12:23:39 +05:30
thakares ac66945c93 docs: refresh v0.7.1 UI screenshots
Rust CI / Test & Quality Checks (push) Canceled after 0s
Rust CI / Build Docker Image (push) Canceled after 0s
2026-08-12 20:02:40 +05:30
145 changed files with 10908 additions and 3276 deletions

No files matched your search

+3 -1
View File
@@ -1,7 +1,9 @@
# BZOD Platform Configuration # BZOD Platform Configuration
HOST=0.0.0.0 HOST=0.0.0.0
PORT=8080 PORT=8080
DATA_DIR=./data # Physical BZOD data root.
# REQUIRED: Set this explicitly; there is no implicit ./data fallback.
NX9_BZOD_DATA_DIR=/var/lib/bzod/data
# Security Settings # Security Settings
COOKIE_SECURE=false COOKIE_SECURE=false
+3
View File
@@ -7,3 +7,6 @@ data/
.idea/ .idea/
bzod.env bzod.env
# Local database backups
backups/
Generated
+1 -1
View File
@@ -345,7 +345,7 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]] [[package]]
name = "bzod" name = "bzod"
version = "0.7.0" version = "0.8.0"
dependencies = [ dependencies = [
"argon2", "argon2",
"askama", "askama",
+1 -1
View File
@@ -1,7 +1,7 @@
[package] [package]
name = "bzod" name = "bzod"
description = "Self-hosted multi-user URL management, landing page and QR analytics platform" description = "Self-hosted multi-user URL management, landing page and QR analytics platform"
version = "0.7.0" version = "0.8.0"
edition = "2021" edition = "2021"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
repository = "https://github.com/thakares/nx9-url-shortener" repository = "https://github.com/thakares/nx9-url-shortener"
+1 -1
View File
@@ -73,7 +73,7 @@ RUN mkdir -p \
/usr/local/bin/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
# Runtime configuration # Runtime configuration
ENV DATA_DIR=/app/data \ ENV NX9_BZOD_DATA_DIR=/app/data \
CONFIG_DIR=/app/config \ CONFIG_DIR=/app/config \
IMAGES_DIR=/app/images \ IMAGES_DIR=/app/images \
PORT=8654 \ PORT=8654 \
+32 -8
View File
@@ -6,7 +6,7 @@
![SQLite](https://img.shields.io/badge/SQLite-Embedded-blue) ![SQLite](https://img.shields.io/badge/SQLite-Embedded-blue)
![Platform](https://img.shields.io/badge/Platform-Linux-lightgrey) ![Platform](https://img.shields.io/badge/Platform-Linux-lightgrey)
![License](https://img.shields.io/badge/License-MIT%20OR%20Apache--2.0-green) ![License](https://img.shields.io/badge/License-MIT%20OR%20Apache--2.0-green)
![Version](https://img.shields.io/badge/Version-v0.7.0-purple) ![Version](https://img.shields.io/badge/Version-v0.8.0-purple)
[![GitHub](https://img.shields.io/badge/GitHub-thakares%2Fbzod-181717?logo=github)](https://github.com/thakares/bzod) [![GitHub](https://img.shields.io/badge/GitHub-thakares%2Fbzod-181717?logo=github)](https://github.com/thakares/bzod)
[![Codeberg](https://img.shields.io/badge/Codeberg-thakares%2Fbzod-2185D0?logo=codeberg)](https://codeberg.org/thakares/bzod) [![Codeberg](https://img.shields.io/badge/Codeberg-thakares%2Fbzod-2185D0?logo=codeberg)](https://codeberg.org/thakares/bzod)
@@ -93,7 +93,7 @@ No recurring subscription fees.
--- ---
## Runtime Efficiency (v0.7.0) ## Runtime Efficiency (v0.8.0)
| Metric | Value | | Metric | Value |
|---------|------:| |---------|------:|
@@ -168,9 +168,10 @@ services:
- "8654:8654" - "8654:8654"
volumes: volumes:
- ./data:/app/data - /var/lib/bzod/data:/app/data
environment: environment:
- NX9_BZOD_DATA_DIR=/app/data
- RUST_LOG=info - RUST_LOG=info
``` ```
@@ -182,6 +183,28 @@ docker compose up -d
--- ---
# Data Directory & Configuration Precedence
BZOD requires an explicit physical data root. There is **no implicit `./data` fallback**.
Precedence:
1. **CLI `--data-dir`** (Highest)
2. **`NX9_BZOD_DATA_DIR`** environment variable
3. **`config.toml` / `bzod.toml` `data_dir`** setting
4. **Configuration Error** if no data directory is configured.
Examples:
```bash
# Via environment variable
NX9_BZOD_DATA_DIR=/var/lib/bzod/data bzod serve
# Via CLI argument
bzod migrate --data-dir=/var/lib/bzod/data --dry-run
```
---
# Native Installation # Native Installation
Download the latest release. Download the latest release.
@@ -202,16 +225,16 @@ bzod --help
# Initialize # Initialize
Create the administrator. Create the administrator (specifying `--data-dir` or `NX9_BZOD_DATA_DIR`):
```bash ```bash
bzod create-admin NX9_BZOD_DATA_DIR=/var/lib/bzod/data bzod create-admin
``` ```
Start the server. Start the server:
```bash ```bash
bzod serve NX9_BZOD_DATA_DIR=/var/lib/bzod/data bzod serve
``` ```
Default server: Default server:
@@ -234,6 +257,7 @@ After=network.target
[Service] [Service]
Environment=NX9_BZOD_DATA_DIR=/var/lib/bzod/data
ExecStart=/usr/local/bin/bzod serve ExecStart=/usr/local/bin/bzod serve
Restart=always Restart=always
@@ -1337,7 +1361,7 @@ Community feedback helps guide future development.
**Current Version** **Current Version**
**v0.7.0** **v0.8.0**
Production Ready Production Ready
+22 -10
View File
@@ -11,7 +11,7 @@
# sudo bash deploy.sh # sudo bash deploy.sh
# #
# Environment overrides: # Environment overrides:
# BZOD_VERSION=0.7.0 # BZOD_VERSION=0.8.0
# BZOD_IMAGE=nx9-url-shortener # BZOD_IMAGE=nx9-url-shortener
# BZOD_ROOT=/DATA/AppData/nx9-url-shortener # BZOD_ROOT=/DATA/AppData/nx9-url-shortener
# BZOD_PORT=8654 # BZOD_PORT=8654
@@ -23,14 +23,15 @@ set -euo pipefail
# Configuration # Configuration
# ============================================================ # ============================================================
BZOD_VERSION="${BZOD_VERSION:-0.7.0}" BZOD_VERSION="${BZOD_VERSION:-0.8.0}"
BZOD_IMAGE="${BZOD_IMAGE:-nx9-url-shortener}" BZOD_IMAGE="${BZOD_IMAGE:-nx9-url-shortener}"
BZOD_ROOT="${BZOD_ROOT:-/DATA/AppData/nx9-url-shortener}" BZOD_ROOT="${BZOD_ROOT:-/DATA/AppData/nx9-url-shortener}"
BZOD_PORT="${BZOD_PORT:-8654}" BZOD_PORT="${BZOD_PORT:-8654}"
BASE_URL="${BASE_URL:-https://bzo.in}"
CONTAINER_NAME="${CONTAINER_NAME:-bzod}" CONTAINER_NAME="${CONTAINER_NAME:-bzod}"
DATA_DIR="${BZOD_ROOT}/data" NX9_BZOD_DATA_DIR="${BZOD_ROOT}/data"
CONFIG_DIR="${BZOD_ROOT}/config" CONFIG_DIR="${BZOD_ROOT}/config"
IMAGES_DIR="${BZOD_ROOT}/images" IMAGES_DIR="${BZOD_ROOT}/images"
COMPOSE_DIR="${BZOD_ROOT}/compose" COMPOSE_DIR="${BZOD_ROOT}/compose"
@@ -88,7 +89,7 @@ echo
echo "Version: ${BZOD_VERSION}" echo "Version: ${BZOD_VERSION}"
echo "Image: ${IMAGE}" echo "Image: ${IMAGE}"
echo "Application: ${BZOD_ROOT}" echo "Application: ${BZOD_ROOT}"
echo "Data: ${DATA_DIR}" echo "Data: ${NX9_BZOD_DATA_DIR}"
echo "Config: ${CONFIG_DIR}" echo "Config: ${CONFIG_DIR}"
echo "Images: ${IMAGES_DIR}" echo "Images: ${IMAGES_DIR}"
echo "Port: ${BZOD_PORT}" echo "Port: ${BZOD_PORT}"
@@ -153,7 +154,7 @@ success "✓ Docker and Docker Compose available"
info "[2/8] Creating persistent application directories..." info "[2/8] Creating persistent application directories..."
mkdir -p \ mkdir -p \
"${DATA_DIR}" \ "${NX9_BZOD_DATA_DIR}" \
"${CONFIG_DIR}" \ "${CONFIG_DIR}" \
"${IMAGES_DIR}" \ "${IMAGES_DIR}" \
"${COMPOSE_DIR}" \ "${COMPOSE_DIR}" \
@@ -179,12 +180,13 @@ BZOD_IMAGE=${BZOD_IMAGE}
HOST=0.0.0.0 HOST=0.0.0.0
PORT=8654 PORT=8654
DATA_DIR=/app/data NX9_BZOD_DATA_DIR=/app/data
CONFIG_DIR=/app/config CONFIG_DIR=/app/config
IMAGES_DIR=/app/images IMAGES_DIR=/app/images
COOKIE_SECURE=true COOKIE_SECURE=true
RUST_LOG=info RUST_LOG=info
BASE_URL=${BASE_URL}
EOF EOF
chmod 600 "${ENV_FILE}" chmod 600 "${ENV_FILE}"
@@ -203,6 +205,15 @@ else
sed -i \ sed -i \
-E "s#^BZOD_IMAGE=.*#BZOD_IMAGE=${BZOD_IMAGE}#" \ -E "s#^BZOD_IMAGE=.*#BZOD_IMAGE=${BZOD_IMAGE}#" \
"${ENV_FILE}" || true "${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 fi
# ============================================================ # ============================================================
@@ -227,12 +238,13 @@ services:
HOST: "${HOST:-0.0.0.0}" HOST: "${HOST:-0.0.0.0}"
PORT: "${PORT:-8654}" PORT: "${PORT:-8654}"
DATA_DIR: "/app/data" NX9_BZOD_DATA_DIR: "/app/data"
CONFIG_DIR: "/app/config" CONFIG_DIR: "/app/config"
IMAGES_DIR: "/app/images" IMAGES_DIR: "/app/images"
COOKIE_SECURE: "${COOKIE_SECURE:-true}" COOKIE_SECURE: "${COOKIE_SECURE:-true}"
RUST_LOG: "${RUST_LOG:-info}" RUST_LOG: "${RUST_LOG:-info}"
BASE_URL: "${BASE_URL:-https://bzo.in}"
volumes: volumes:
@@ -288,8 +300,8 @@ BACKUP_DIR="${BACKUP_ROOT}/pre-upgrade-${TIMESTAMP}-v${BZOD_VERSION}"
mkdir -p "${BACKUP_DIR}" mkdir -p "${BACKUP_DIR}"
if [[ -d "${DATA_DIR}" ]]; then if [[ -d "${NX9_BZOD_DATA_DIR}" ]]; then
cp -a "${DATA_DIR}" "${BACKUP_DIR}/data" cp -a "${NX9_BZOD_DATA_DIR}" "${BACKUP_DIR}/data"
fi fi
if [[ -d "${CONFIG_DIR}" ]]; then if [[ -d "${CONFIG_DIR}" ]]; then
@@ -439,7 +451,7 @@ echo " http://<server-ip>:${BZOD_PORT}"
echo echo
echo "Persistent data:" echo "Persistent data:"
echo " ${DATA_DIR}" echo " ${NX9_BZOD_DATA_DIR}"
echo echo
echo "Persistent images:" echo "Persistent images:"
+2 -2
View File
@@ -18,7 +18,7 @@ services:
- ADMIN_USERNAME=${ADMIN_USERNAME} - ADMIN_USERNAME=${ADMIN_USERNAME}
- CONFIG_DIR=/app/config - CONFIG_DIR=/app/config
- COOKIE_SECURE=false - COOKIE_SECURE=false
- DATA_DIR=/app/data - NX9_BZOD_DATA_DIR=/app/data
- HOST=0.0.0.0 - HOST=0.0.0.0
- IMAGES_DIR=/app/images - IMAGES_DIR=/app/images
- PORT=8654 - PORT=8654
@@ -27,7 +27,7 @@ services:
hostname: bzod hostname: bzod
image: nx9-url-shortener:v0.7.0 image: nx9-url-shortener:v0.8.0
ports: ports:
- mode: ingress - mode: ingress
-5
View File
@@ -1,9 +1,4 @@
#!/bin/sh #!/bin/sh
set -e set -e
if [ "$1" = 'serve' ]; then
/usr/local/bin/bzod init-admin
exec /usr/local/bin/bzod serve
fi
exec /usr/local/bin/bzod "$@" exec /usr/local/bin/bzod "$@"
+1 -1
View File
@@ -1,6 +1,6 @@
# BZOD Administrator Guide # BZOD Administrator Guide
Version: v0.7.0 Version: v0.8.0
--- ---
+1 -1
View File
@@ -1,6 +1,6 @@
# BZOD Architecture Guide # BZOD Architecture Guide
Version: v0.7.0 Version: v0.8.0
--- ---
+1 -1
View File
@@ -1,6 +1,6 @@
# Backup & Restore Guide # Backup & Restore Guide
Version: v0.7.0 Version: v0.8.0
Applies To: BZOD Multi-User Platform Applies To: BZOD Multi-User Platform
--- ---
+25
View File
@@ -4,6 +4,31 @@ All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog and this project follows Semantic Versioning. 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 # v0.7.0 — Responsive UI, Theme Support & Build Metadata
+25 -1
View File
@@ -2,7 +2,7 @@
BZOD includes a comprehensive command-line interface for server administration, backups, migrations, diagnostics, validation, and multi-user management. BZOD includes a comprehensive command-line interface for server administration, backups, migrations, diagnostics, validation, and multi-user management.
The current command list for BZOD v0.7.0 is: The current command list for BZOD v0.8.0 is:
```text ```text
$ bzod --help $ bzod --help
@@ -284,3 +284,27 @@ bzod doctor
* SECURITY.md * SECURITY.md
* API.md * API.md
* ARCHITECTURE.md * ARCHITECTURE.md
---
# Data Directory Configuration
BZOD requires an explicit physical data directory root. There is **no implicit `./data` fallback**.
### Configuration Precedence
1. **CLI `--data-dir`** (Highest priority)
```bash
bzod serve --data-dir /var/lib/bzod/data
bzod migrate --data-dir=/var/lib/bzod/data --dry-run
```
2. **Environment Variable `NX9_BZOD_DATA_DIR`**
```bash
export NX9_BZOD_DATA_DIR=/var/lib/bzod/data
bzod serve
```
3. **Configuration File (`bzod.toml` / `config.toml`)**
```toml
data_dir = "/var/lib/bzod/data"
```
4. **Error**: If no data directory is provided via CLI, `NX9_BZOD_DATA_DIR`, or config file, BZOD terminates with an actionable error.
+1 -1
View File
@@ -1,4 +1,4 @@
# BZOD v0.7.0 vs Self-Hosted URL Management Platforms # BZOD v0.8.0 vs Self-Hosted URL Management Platforms
BZOD is a modern, privacy-focused, self-hosted URL Management Platform written in Rust and developed as part of the NX9 Platform. BZOD is a modern, privacy-focused, self-hosted URL Management Platform written in Rust and developed as part of the NX9 Platform.
+1 -1
View File
@@ -2,7 +2,7 @@
# BZOD Database Architecture # BZOD Database Architecture
BZOD v0.7.0 uses SQLite exclusively. BZOD v0.8.0 uses SQLite exclusively.
Rather than using a single monolithic database, BZOD separates data into administrative and tenant-specific databases. This architecture improves security, isolation, backup flexibility, disaster recovery, and scalability. 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.
+3 -3
View File
@@ -100,7 +100,7 @@ services:
environment: environment:
HOST: 0.0.0.0 HOST: 0.0.0.0
PORT: 8654 PORT: 8654
DATA_DIR: /app/data NX9_BZOD_DATA_DIR: /app/data
COOKIE_SECURE: "false" COOKIE_SECURE: "false"
healthcheck: healthcheck:
@@ -205,10 +205,10 @@ BZOD Docker Container
# Environment Variables # Environment Variables
| Variable | Description | Default | | Variable | Description | Default |
| ------------- | ------------------ | --------- | | ----------------- | ------------------ | ------------- |
| HOST | Bind address | 0.0.0.0 | | HOST | Bind address | 0.0.0.0 |
| PORT | Listen port | 8654 | | PORT | Listen port | 8654 |
| DATA_DIR | Database directory | /app/data | | NX9_BZOD_DATA_DIR | Database directory | (required) |
| COOKIE_SECURE | Secure cookies | false | | COOKIE_SECURE | Secure cookies | false |
| RUST_LOG | Logging level | info | | RUST_LOG | Logging level | info |
+3 -3
View File
@@ -1,6 +1,6 @@
# BZOD Installation Guide # BZOD Installation Guide
Version: v0.7.0 Version: v0.8.0
--- ---
@@ -183,13 +183,13 @@ sudo pacman -S \
Example: Example:
```bash ```bash
wget https://example.com/bzod-v0.7.0-linux-amd64.tar.gz wget https://example.com/bzod-v0.8.0-linux-amd64.tar.gz
``` ```
Extract: Extract:
```bash ```bash
tar -xzf bzod-v0.7.0-linux-amd64.tar.gz tar -xzf bzod-v0.8.0-linux-amd64.tar.gz
``` ```
Install: Install:
+1 -1
View File
@@ -1,6 +1,6 @@
# BZOD Multi-User Architecture Guide # BZOD Multi-User Architecture Guide
Version: v0.7.0 Version: v0.8.0
--- ---
+27
View File
@@ -1,3 +1,30 @@
# BZOD v0.8.0 — Multi-Tenant Core Separation & Authorization Hardening
Release Date: 2026-08-21
## Highlights
- **Core Admin separation**: Admin is a platform operator, not a tenant and not an application resource owner.
- **Global slug registries**: Active URL and landing-page ownership uses `slugs/global_urls.db` and `slugs/global_landing_pages.db`.
- **Strict route boundary**: Core Admin is forbidden from `/user/*`; normal tenant users are forbidden from `/admin/*`.
- **Capability enforcement**: Admin resource creation through UI and REST/bulk endpoints returns `403 Forbidden`.
- **Tenant identity hardening**: Active tenant operations require an immutable `TenantId`; no request-time fallback generation or `users/1` application fallback.
- **Session hygiene**: Admin/user session cookies and server-side sessions are invalidated when switching principals or logging out.
- **Tenant-aware analytics**: Analytics events are grouped and persisted by `TenantId`.
- **Legacy compatibility preserved**: Legacy migration and restore paths remain available without being active production paths.
## Verification
- Phase 5 Core Separation tests: **4/4 passed**
- Admin capability boundary tests: **11/11 passed**
- Admin/user route and session boundary tests: **27/27 passed**
- Phase 6A elimination tests: **6/6 passed**
- Workspace regression suite: **all tests passed**
- `cargo fmt --all -- --check`: **PASS**
- `cargo clippy --workspace --all-targets --all-features -- -D warnings`: **PASS**
---
# BZOD v0.7.0 — Responsive UI, Theme Support & Build Metadata # BZOD v0.7.0 — Responsive UI, Theme Support & Build Metadata
Release Date: 2026-08-11 Release Date: 2026-08-11
+4 -4
View File
@@ -1,6 +1,6 @@
# BZOD Security Guide # BZOD Security Guide
Version: v0.7.0 Version: v0.8.0
--- ---
@@ -16,7 +16,7 @@ BZOD is designed as a self-hosted URL shortener and landing page platform with a
* Disaster recovery * Disaster recovery
* Operational simplicity * Operational simplicity
This document describes the security architecture, threat model, authentication mechanisms, authorization controls, and operational security recommendations for BZOD v0.7.0. This document describes the security architecture, threat model, authentication mechanisms, authorization controls, and operational security recommendations for BZOD v0.8.0.
--- ---
@@ -620,7 +620,7 @@ If compromise is suspected:
# Security Testing # Security Testing
BZOD v0.7.0 includes tests covering: BZOD v0.8.0 includes tests covering:
* Authentication * Authentication
* Authorization * Authorization
@@ -665,7 +665,7 @@ These may be addressed in future releases.
# Summary # Summary
BZOD v0.7.0 provides: BZOD v0.8.0 provides:
* Centralized authentication * Centralized authentication
* Secure session management * Secure session management
+14 -2
View File
@@ -1,11 +1,23 @@
# Upgrade Guide # Upgrade Guide
Version: v0.7.0 Version: v0.8.0
This document describes the upgrade process for existing BZOD deployments upgrading to BZOD v0.7.0. This document describes the upgrade process for existing BZOD deployments upgrading to BZOD v0.8.0.
--- ---
# BZOD v0.8.0 Upgrade Overview
BZOD v0.8.0 completes the TenantId-based multi-tenant topology and separates Core Admin from tenant application resources. The active runtime uses the Core databases under `admin/`, global slug registries under `slugs/`, and tenant databases under `users/<TenantId>/`.
Key upgrade characteristics:
* Core Admin has no tenant directory, `content.db`, or `analytics.db`.
* Active production operations no longer use `system.db.global_slugs`.
* Active tenant ownership is represented by immutable `TenantId`.
* Legacy integer IDs and legacy slug data remain available only to migration/restore compatibility paths.
* Existing legacy deployments should use the repository's migration and restore commands rather than manually copying legacy tenant directories into the v0.8 topology.
# Overview # Overview
BZOD v0.5.1 is a platform hardening release focused on: BZOD v0.5.1 is a platform hardening release focused on:
Binary file not shown.

After

Width:  |  Height:  |  Size: 399 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

+37 -18
View File
@@ -56,26 +56,33 @@ fn flush_batch(db: &Db, batch: &mut Vec<VisitRecord>) {
} }
info!( info!(
"Flushing {} visits to user analytics databases", "Flushing {} visits to tenant analytics databases",
batch.len() batch.len()
); );
// Group visits by owner_user_id // Group visits by owner_tenant_id (or legacy user 1 fallback)
let mut groups: std::collections::HashMap<i64, Vec<VisitRecord>> = let mut groups: std::collections::HashMap<crate::identity::TenantId, Vec<VisitRecord>> =
std::collections::HashMap::new(); std::collections::HashMap::new();
let mut legacy_user1_visits: Vec<VisitRecord> = Vec::new();
for record in batch.drain(..) { for record in batch.drain(..) {
let user_id = record.owner_user_id.unwrap_or(1); // fallback to legacy_admin (user 1) if let Some(tenant_id) = record.owner_tenant_id {
groups.entry(user_id).or_default().push(record); groups.entry(tenant_id).or_default().push(record);
} else if record.owner_user_id == Some(1) {
legacy_user1_visits.push(record);
} else {
error!("Dropping analytics visit with no owner_tenant_id");
}
} }
for (user_id, user_visits) in groups { for (tenant_id, tenant_visits) in groups {
let db_path = db let db_path = db.topology.tenant_analytics_db(tenant_id);
.data_dir if !db_path.exists() {
.join("users") error!(
.join(user_id.to_string()) "Refusing to write analytics for non-existent tenant analytics path: {:?}",
.join("analytics.db"); db_path
if let Some(parent) = db_path.parent() { );
let _ = std::fs::create_dir_all(parent); continue;
} }
match rusqlite::Connection::open(&db_path) { match rusqlite::Connection::open(&db_path) {
@@ -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_wal(&conn, "analytics");
let _ = crate::db::sqlite::enable_foreign_keys(&conn, "analytics"); let _ = crate::db::sqlite::enable_foreign_keys(&conn, "analytics");
if let Err(e) = insert_visits_batch(&mut conn, &user_visits) { if let Err(e) = insert_visits_batch(&mut conn, &tenant_visits) {
error!( error!(
"Failed to write analytics batch to user {} database: {:?}", "Failed to write analytics batch to tenant {} database: {:?}",
user_id, e tenant_id, e
); );
} }
} }
Err(e) => { Err(e) => {
error!( error!(
"Failed to open analytics database for user {}: {:?}", "Failed to open analytics database for tenant {}: {:?}",
user_id, e tenant_id, e
); );
} }
} }
} }
if !legacy_user1_visits.is_empty() {
if let Ok(legacy_db_path) = db.topology.analytics_db("1") {
if legacy_db_path.exists() {
if let Ok(mut conn) = rusqlite::Connection::open(&legacy_db_path) {
let _ = crate::db::sqlite::enable_wal(&conn, "analytics");
let _ = crate::db::sqlite::enable_foreign_keys(&conn, "analytics");
let _ = insert_visits_batch(&mut conn, &legacy_user1_visits);
}
}
}
}
} }
+5 -39
View File
@@ -198,26 +198,9 @@ pub fn authenticate_user_session(
return Ok(None); return Ok(None);
} }
// Get tenant user (status must be 'active') // Get tenant user (status must be 'active', account_type != 'admin', and tenant_id is Some)
let mut stmt = users_conn.prepare( let user_opt = crate::db::users::get_user_by_id(users_conn, session.user_id)?
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata .filter(|u| u.status == "active" && u.account_type != "admin" && u.tenant_id.is_some());
FROM users WHERE id = ?1 AND status = 'active';"
)?;
let user_opt = stmt
.query_row([session.user_id], |row| {
Ok(TenantUser {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
status: row.get(3)?,
created_at: row.get(4)?,
last_login: row.get(5)?,
account_type: row.get(6)?,
organization_id: row.get(7)?,
metadata: row.get(8)?,
})
})
.optional()?;
if let Some(user) = user_opt { if let Some(user) = user_opt {
Ok(Some((user, session.id))) Ok(Some((user, session.id)))
@@ -251,25 +234,8 @@ pub fn authenticate_api_key(
let user_id_opt: Option<i64> = stmt.query_row([&hashed_key], |row| row.get(0)).optional()?; let user_id_opt: Option<i64> = stmt.query_row([&hashed_key], |row| row.get(0)).optional()?;
if let Some(user_id) = user_id_opt { if let Some(user_id) = user_id_opt {
let mut stmt = users_conn.prepare( let user_opt = crate::db::users::get_user_by_id(users_conn, user_id)?
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata .filter(|u| u.status == "active" && u.account_type != "admin" && u.tenant_id.is_some());
FROM users WHERE id = ?1 AND status = 'active';"
)?;
let user_opt = stmt
.query_row([user_id], |row| {
Ok(TenantUser {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
status: row.get(3)?,
created_at: row.get(4)?,
last_login: row.get(5)?,
account_type: row.get(6)?,
organization_id: row.get(7)?,
metadata: row.get(8)?,
})
})
.optional()?;
if let Some(user) = user_opt { if let Some(user) = user_opt {
return Ok(Some(ApiActor::User(user))); return Ok(Some(ApiActor::User(user)));
+3 -6
View File
@@ -44,7 +44,8 @@ pub async fn run(
} }
// 2. Open databases // 2. Open databases
let legacy_content_path = config.data_dir.join("users").join("1").join("content.db"); let topology = crate::db::topology::Topology::new(&config.data_dir);
let legacy_content_path = topology.content_db(crate::db::topology::LEGACY_ADMIN_USER_KEY)?;
if !legacy_content_path.exists() { if !legacy_content_path.exists() {
info!( info!(
@@ -55,11 +56,7 @@ pub async fn run(
} }
db.init_user_databases(target_admin_id)?; db.init_user_databases(target_admin_id)?;
let target_content_path = config let target_content_path = topology.content_db_i64(target_admin_id)?;
.data_dir
.join("users")
.join(target_admin_id.to_string())
.join("content.db");
let mut legacy_conn = Connection::open(&legacy_content_path)?; let mut legacy_conn = Connection::open(&legacy_content_path)?;
let mut target_conn = Connection::open(&target_content_path)?; let mut target_conn = Connection::open(&target_content_path)?;
+7 -1
View File
@@ -16,6 +16,10 @@ struct UserBackupMetadata {
created_at: String, created_at: String,
account_type: String, account_type: String,
metadata: Option<String>, metadata: Option<String>,
#[serde(default)]
tenant_id: Option<String>,
#[serde(default)]
uuid: Option<String>,
quotas: UserBackupQuotas, quotas: UserBackupQuotas,
} }
@@ -94,7 +98,7 @@ pub async fn run(
); );
// 4. Force checkpoint on user's databases // 4. Force checkpoint on user's databases
let user_dir = config.data_dir.join("users").join(user_id.to_string()); let user_dir = crate::db::tenant::location_for_user(&user)?.dir(&db.topology)?;
if let Ok(c) = rusqlite::Connection::open(user_dir.join("content.db")) { if let Ok(c) = rusqlite::Connection::open(user_dir.join("content.db")) {
let _ = c.execute("PRAGMA wal_checkpoint(TRUNCATE);", []); let _ = c.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
} }
@@ -119,6 +123,8 @@ pub async fn run(
created_at: user.created_at, created_at: user.created_at,
account_type: user.account_type, account_type: user.account_type,
metadata: user.metadata, metadata: user.metadata,
tenant_id: user.tenant_id.map(|t| t.to_string()),
uuid: user.uuid,
quotas, quotas,
}; };
let metadata_bytes = serde_json::to_vec_pretty(&metadata_obj)?; let metadata_bytes = serde_json::to_vec_pretty(&metadata_obj)?;
+7 -4
View File
@@ -7,6 +7,7 @@ use tracing::{error, info};
pub async fn run( pub async fn run(
username: Option<String>, username: Option<String>,
password: Option<String>,
data_dir: Option<String>, data_dir: Option<String>,
mut config: Config, mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
@@ -25,16 +26,18 @@ pub async fn run(
return Ok(()); return Ok(());
} }
let password = read_input("Enter password: "); let final_password = match password {
if password.trim().is_empty() { Some(p) => p,
None => read_input("Enter password: "),
};
if final_password.trim().is_empty() {
error!("Password cannot be empty"); error!("Password cannot be empty");
return Ok(()); return Ok(());
} }
let hash = hash_password(&password).map_err(|e| e.to_string())?; let hash = hash_password(&final_password).map_err(|e| e.to_string())?;
let conn = db.users.lock().unwrap(); let conn = db.users.lock().unwrap();
let u = crate::db::users::create_admin_user(&conn, &final_username, &hash)?; let u = crate::db::users::create_admin_user(&conn, &final_username, &hash)?;
db.init_user_databases(u.id)?;
info!( info!(
"Successfully created admin user: {} (ID: {})", "Successfully created admin user: {} (ID: {})",
u.username, u.id u.username, u.id
+44 -23
View File
@@ -15,11 +15,6 @@ pub async fn run(
} }
let db = Db::init(&config)?; let db = Db::init(&config)?;
if user_id == 1 && !force {
error!("Deleting legacy_admin system account requires --force flag");
return Ok(());
}
// Capture user details // Capture user details
let user_details = { let user_details = {
let conn = db.users.lock().unwrap(); let conn = db.users.lock().unwrap();
@@ -32,37 +27,63 @@ pub async fn run(
} }
}; };
// 1. Transactional clean up on system.db (deleting their global slug mappings) if user_details.account_type == "admin" && !force {
{ error!("Deleting administrator account requires --force flag");
let mut system_conn = db.system.lock().unwrap(); return Ok(());
let tx = system_conn.transaction()?; }
// Get all slugs owned by the user // 1. Retire/cleanup slugs from v0.8 global_urls.db and global_landing_pages.db
let slugs: Vec<String> = {
let mut stmt = tx.prepare("SELECT slug FROM global_slugs WHERE owner_user_id = ?1;")?;
let rows = stmt.query_map([user_id], |row| row.get(0))?;
rows.filter_map(|r| r.ok()).collect()
};
// Delete from global_slugs and write to history
let now = Utc::now().to_rfc3339(); let now = Utc::now().to_rfc3339();
for slug in slugs { if let Some(ref tid) = user_details.tenant_id {
let _ = tx.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&slug]); let tid_str = tid.to_string();
let _ = tx.execute( let urls_conn = db.global_urls.lock().unwrap();
let pages_conn = db.global_landing_pages.lock().unwrap();
let system_conn = db.system.lock().unwrap();
// Get all URL slugs owned by tenant
let mut stmt =
urls_conn.prepare("SELECT slug FROM global_urls WHERE owner_tenant_id = ?1;")?;
let url_slugs: Vec<String> = stmt
.query_map([&tid_str], |row| row.get(0))?
.filter_map(|r| r.ok())
.collect();
// Get all page slugs owned by tenant
let mut stmt2 = pages_conn
.prepare("SELECT slug FROM global_landing_pages WHERE owner_tenant_id = ?1;")?;
let page_slugs: Vec<String> = stmt2
.query_map([&tid_str], |row| row.get(0))?
.filter_map(|r| r.ok())
.collect();
// Record history and retire/delete slugs
for slug in url_slugs {
let _ = urls_conn.execute("DELETE FROM global_urls WHERE slug = ?1;", [&slug]);
let _ = system_conn.execute(
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username) "INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username)
VALUES (?1, ?2, NULL, 'deleted', ?3, ?4);", VALUES (?1, ?2, NULL, 'deleted', ?3, ?4);",
rusqlite::params![slug, user_id, now, "cli"], rusqlite::params![slug, user_id, now, "cli"],
); );
} }
tx.commit()?; for slug in page_slugs {
let _ =
pages_conn.execute("DELETE FROM global_landing_pages WHERE slug = ?1;", [&slug]);
let _ = system_conn.execute(
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username)
VALUES (?1, ?2, NULL, 'deleted', ?3, ?4);",
rusqlite::params![slug, user_id, now, "cli"],
);
}
} }
// 2. Delete user folder and database files from disk // 2. Delete tenant directory from disk
let user_dir = config.data_dir.join("users").join(user_id.to_string()); if let Some(ref tid) = user_details.tenant_id {
let user_dir = db.topology.tenant_dir(*tid);
if user_dir.exists() { if user_dir.exists() {
let _ = std::fs::remove_dir_all(&user_dir); let _ = std::fs::remove_dir_all(&user_dir);
} }
}
// 3. Remove user entry from users.db (cascading deletes quotas/sessions/tokens) // 3. Remove user entry from users.db (cascading deletes quotas/sessions/tokens)
{ {
+22 -11
View File
@@ -24,16 +24,27 @@ pub async fn run(
let mut all_healthy = true; let mut all_healthy = true;
// Define target databases in the new layout let topology = crate::db::topology::Topology::new(&config.data_dir);
let admin_dir = config.data_dir.join("admin");
let legacy_user_dir = config.data_dir.join("users").join("1");
let dbs = vec![ let dbs = vec![
("admin", admin_dir.join("admin.db")), ("admin", topology.admin_db()),
("system", admin_dir.join("system.db")), ("system", topology.system_db()),
("users", admin_dir.join("users.db")), ("users", topology.users_registry_db()),
("legacy content", legacy_user_dir.join("content.db")), ("global_urls", topology.global_urls_db()),
("legacy analytics", legacy_user_dir.join("analytics.db")), ("global_landing_pages", topology.global_landing_pages_db()),
("reserved", topology.reserved_db()),
(
"legacy content",
topology
.content_db(crate::db::topology::LEGACY_ADMIN_USER_KEY)
.expect("legacy admin user key is valid"),
),
(
"legacy analytics",
topology
.analytics_db(crate::db::topology::LEGACY_ADMIN_USER_KEY)
.expect("legacy admin user key is valid"),
),
]; ];
for (db_name, db_path) in dbs { for (db_name, db_path) in dbs {
@@ -92,8 +103,8 @@ pub async fn run(
// Global Slug Registry Integrity Check // Global Slug Registry Integrity Check
println!("Global Slug Registry Integrity Check"); println!("Global Slug Registry Integrity Check");
println!("===================================="); println!("====================================");
let system_db_path = admin_dir.join("system.db"); let system_db_path = topology.system_db();
let users_db_path = admin_dir.join("users.db"); let users_db_path = topology.users_registry_db();
if system_db_path.exists() && users_db_path.exists() { if system_db_path.exists() && users_db_path.exists() {
match ( match (
@@ -134,7 +145,7 @@ pub async fn run(
); );
println!(); println!();
println!("Owner:"); println!("Owner:");
println!(" User ID {}", issue.owner_user_id); println!(" Tenant ID {}", issue.owner_tenant_id);
println!(); println!();
println!("Database:"); println!("Database:");
println!(" {}", issue.database_path.display()); println!(" {}", issue.database_path.display());
+20 -3
View File
@@ -17,11 +17,28 @@ pub async fn run(
return Err("Invalid short code or custom slug format".into()); return Err("Invalid short code or custom slug format".into());
} }
let url_opt = { let owner_info = {
let conn = db.content.lock().unwrap(); let conn = db.global_urls.lock().unwrap();
crate::db::content::get_url_by_code(&conn, &normalized_code)? conn.query_row(
"SELECT owner_tenant_id FROM global_urls WHERE slug = ?1 AND status = 'active';",
rusqlite::params![normalized_code],
|row| row.get::<_, String>(0),
)
.ok()
}; };
let tid_str = match owner_info {
Some(t) => t,
None => return Err(format!("Short code not found: {}", normalized_code).into()),
};
let tid = tid_str
.parse::<crate::identity::TenantId>()
.map_err(|e| format!("Invalid tenant id for slug {}: {:?}", normalized_code, e))?;
let content_path = db.topology.tenant_content_db(tid);
let conn = rusqlite::Connection::open(&content_path)?;
let url_opt = crate::db::content::get_url_by_code(&conn, &normalized_code)?;
match url_opt { match url_opt {
Some(url) => { Some(url) => {
println!("{}", url.destination); println!("{}", url.destination);
+8 -5
View File
@@ -13,13 +13,14 @@ pub async fn run(
config.data_dir = PathBuf::from(d); config.data_dir = PathBuf::from(d);
} }
let db = Db::init(&config)?; let db = Db::init(&config)?;
let admin_count: i64 = {
let conn = db.users.lock().unwrap(); let conn = db.users.lock().unwrap();
conn.query_row(
let admin_count: i64 = conn.query_row(
"SELECT COUNT(*) FROM users WHERE account_type = 'admin';", "SELECT COUNT(*) FROM users WHERE account_type = 'admin';",
[], [],
|row| row.get(0), |row| row.get(0),
)?; )?
};
if admin_count > 0 { if admin_count > 0 {
info!("Administrator already exists; initialization skipped."); info!("Administrator already exists; initialization skipped.");
@@ -45,8 +46,10 @@ pub async fn run(
}; };
let hash = hash_password(&password).map_err(|e| e.to_string())?; let hash = hash_password(&password).map_err(|e| e.to_string())?;
let u = crate::db::users::create_admin_user(&conn, &username, &hash)?; {
db.init_user_databases(u.id)?; let conn = db.users.lock().unwrap();
let _ = crate::db::users::create_admin_user(&conn, &username, &hash)?;
}
info!("Administrator initialized successfully."); info!("Administrator initialized successfully.");
Ok(()) Ok(())
+81 -2
View File
@@ -15,12 +15,91 @@ pub async fn run(
if dry_run { if dry_run {
info!("Dry run enabled: pending database migrations will be reported but not applied."); info!("Dry run enabled: pending database migrations will be reported but not applied.");
info!("Data directory: {:?}", config.data_dir); info!("Data directory: {:?}", config.data_dir);
let id_report =
crate::db::identity_migrate::run_identity_migration(&config, true, false).await?;
println!("\nIdentity Migration Preflight Report (Dry Run):");
println!("----------------------------------------------");
println!("Total users: {}", id_report.total_users);
println!(
"Users needing TenantId: {}",
id_report.users_assigned_tenant_id
);
println!("Users needing UUID: {}", id_report.users_assigned_uuid);
println!("Directories to move: {}", id_report.directories_moved);
println!(
"Directories already migrated: {}",
id_report.directories_already_migrated
);
println!(
"Legacy admin (users/1) preserved: {}",
id_report.legacy_admin_preserved
);
let slug_report =
crate::db::slug_migrate::run_global_slug_migration(&config, true, false).await?;
println!("\nGlobal Slug Migration Preflight Report (Dry Run):");
println!("-------------------------------------------------");
println!("Total legacy slugs: {}", slug_report.total_legacy_slugs);
println!("URL slugs to migrate: {}", slug_report.url_slugs_migrated);
println!("Page slugs to migrate: {}", slug_report.page_slugs_migrated);
println!("Reserved slugs: {}", slug_report.reserved_slugs_migrated);
return Ok(()); return Ok(());
} }
info!("Running database migrations..."); info!("Running database schema migrations...");
let _db = Db::init(&config)?; let _db = Db::init(&config)?;
info!("Database migrations applied successfully."); info!("Database schema migrations applied successfully.");
info!("Running identity & directory migration lifecycle...");
let id_report =
crate::db::identity_migrate::run_identity_migration(&config, false, false).await?;
println!("\nIdentity Migration Report:");
println!("--------------------------");
println!("Total users: {}", id_report.total_users);
println!("TenantIds assigned: {}", id_report.users_assigned_tenant_id);
println!("UUIDs assigned: {}", id_report.users_assigned_uuid);
println!("Directories migrated: {}", id_report.directories_moved);
println!(
"Directories already migrated: {}",
id_report.directories_already_migrated
);
println!(
"Legacy admin preserved: {}",
id_report.legacy_admin_preserved
);
println!("Validation passed: {}", id_report.validation_passed);
if !id_report.warnings.is_empty() {
println!("\nWarnings:");
for w in &id_report.warnings {
println!(" - {}", w);
}
}
info!("Running global slug migration lifecycle...");
let slug_report =
crate::db::slug_migrate::run_global_slug_migration(&config, false, false).await?;
println!("\nGlobal Slug Migration Report:");
println!("-----------------------------");
println!("Total legacy slugs: {}", slug_report.total_legacy_slugs);
println!("URL slugs migrated: {}", slug_report.url_slugs_migrated);
println!("Page slugs migrated: {}", slug_report.page_slugs_migrated);
println!(
"Reserved slugs verified: {}",
slug_report.reserved_slugs_migrated
);
println!(
"Existing target records verified: {}",
slug_report.existing_records_verified
);
println!("Validation passed: {}", slug_report.validation_passed);
if !slug_report.warnings.is_empty() {
println!("\nWarnings:");
for w in &slug_report.warnings {
println!(" - {}", w);
}
}
Ok(()) Ok(())
} }
+31
View File
@@ -213,3 +213,34 @@ pub enum RepairCommands {
data_dir: Option<String>, 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(),
},
}
}
}
+105 -87
View File
@@ -30,55 +30,89 @@ pub async fn run(
} }
let start_time = std::time::Instant::now(); let start_time = std::time::Instant::now();
let admin_dir = config.data_dir.join("admin"); let topology = crate::db::topology::Topology::new(&config.data_dir);
let system_db_path = admin_dir.join("system.db"); let system_db_path = topology.system_db();
let users_db_path = admin_dir.join("users.db"); let users_db_path = topology.users_registry_db();
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() { if !system_db_path.exists()
println!("Error: system.db or users.db not found."); || !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(()); 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 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(); let slug_filter = slug.as_deref();
if dry_run { if dry_run {
println!("BZOD Registry Repair\n"); println!("BZOD Registry Repair (v0.8)\n");
println!("Scanning Global Slug Registry..."); println!("Scanning Authoritative Slug Registries (global_urls.db, global_landing_pages.db)...");
let issues = let issues =
RegistryValidator::scan(&sys_conn, &usr_conn, &config.data_dir, slug_filter)?; RegistryValidator::scan(&sys_conn, &usr_conn, &config.data_dir, slug_filter)?;
let orphaned = issues
.into_iter() let true_orphans = issues
.iter()
.filter(|i| { .filter(|i| {
matches!( matches!(
i.issue_type, i.issue_type,
RegistryIssueType::MissingTarget RegistryIssueType::MissingTarget
| RegistryIssueType::MissingDatabase | RegistryIssueType::TrueOrphan
| RegistryIssueType::MissingOwner | RegistryIssueType::MissingTenant
) )
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let orphaned_pages = orphaned.iter().filter(|i| i.target_type == "page").count(); let corrupt = issues
let orphaned_urls = orphaned.iter().filter(|i| i.target_type == "url").count(); .iter()
.filter(|i| i.issue_type == RegistryIssueType::CorruptDatabase)
.collect::<Vec<_>>();
println!("\nDetected:"); let access_failures = issues
println!("\nPages:\n {} orphaned", orphaned_pages); .iter()
println!("\nURLs:\n {} orphaned", orphaned_urls); .filter(|i| i.issue_type == RegistryIssueType::AccessFailure)
.collect::<Vec<_>>();
if !orphaned.is_empty() { println!("\nDetected Issues (Total: {}):", issues.len());
println!("\nThe following entries would be removed:"); println!(" Orphaned/Missing Targets: {}", true_orphans.len());
for issue in &orphaned { println!(" Corrupt Databases (Protected): {}", corrupt.len());
println!("\n{}\n {}", issue.target_type.to_uppercase(), issue.slug); println!(" Access Failures (Protected): {}", access_failures.len());
}
}
println!("\nNo changes have been made."); if !true_orphans.is_empty() {
println!("\nThe following orphaned entries would be repaired/removed:");
for issue in &true_orphans {
println!( println!(
"\nRun again with:\n\n bzod repair registry --force{}", " {} [{}] — Tenant: {}",
issue.slug,
issue.target_type.to_uppercase(),
issue.owner_tenant_id
);
}
}
if !corrupt.is_empty() {
println!("\nProtected from destructive repair (Corrupt DBs):");
for issue in &corrupt {
println!(
" {} [{}] — Path: {:?}",
issue.slug,
issue.target_type.to_uppercase(),
issue.database_path
);
}
}
println!("\nNo changes have been made (dry-run).");
println!(
"Run again with:\n bzod repair registry --force{}",
if let Some(s) = slug_filter { if let Some(s) = slug_filter {
format!(" --slug {}", s) format!(" --slug {}", s)
} else { } else {
@@ -87,89 +121,73 @@ pub async fn run(
); );
info!( info!(
"Registry Repair Started. Scanned. Orphaned Pages: {}, Orphaned URLs: {}. Duration: {:?}", "Registry Repair Scan completed. Orphaned: {}, Corrupt: {}, Duration: {:?}",
orphaned_pages, orphaned_urls, start_time.elapsed() true_orphans.len(),
corrupt.len(),
start_time.elapsed()
); );
} else if force { } else if force {
let tx = sys_conn.transaction()?;
let issues = let issues =
RegistryValidator::scan(&tx, &usr_conn, &config.data_dir, slug_filter)?; RegistryValidator::scan(&sys_conn, &usr_conn, &config.data_dir, slug_filter)?;
let orphaned = issues
// Only repair true orphans, missing targets, or missing tenants.
// Never delete records with CorruptDatabase or AccessFailure per safety policies.
let repairable = issues
.into_iter() .into_iter()
.filter(|i| { .filter(|i| {
matches!( matches!(
i.issue_type, i.issue_type,
RegistryIssueType::MissingTarget RegistryIssueType::MissingTarget
| RegistryIssueType::MissingDatabase | RegistryIssueType::TrueOrphan
| RegistryIssueType::MissingOwner | RegistryIssueType::MissingTenant
) )
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let orphaned_pages = orphaned.iter().filter(|i| i.target_type == "page").count(); if repairable.is_empty() {
let orphaned_urls = orphaned.iter().filter(|i| i.target_type == "url").count(); println!("No repairable registry issues found.");
if orphaned.is_empty() {
println!("No repairs required.");
return Ok(()); return Ok(());
} }
if let Some(s) = slug_filter { let tx_urls = urls_conn.transaction()?;
println!("Checking slug:\n\n{}\n", s); let tx_pages = pages_conn.transaction()?;
if let Some(issue) = orphaned.first() {
println!("Owner:\n\n{}\n", issue.owner_user_id);
println!("Status:\n\nOrphaned\n");
}
}
let mut removed_count = 0; let mut removed_count = 0;
for issue in &orphaned { for issue in &repairable {
let rows = tx.execute( if issue.target_type == "url" {
"DELETE FROM global_slugs WHERE slug = ?1", removed_count += tx_urls
rusqlite::params![issue.slug], .execute("DELETE FROM global_urls WHERE slug = ?1;", [&issue.slug])?;
)?;
removed_count += rows;
}
tx.commit()?;
if slug_filter.is_some() {
println!("Removed:\n\nSUCCESS");
} else { } else {
println!("Repair Complete\n"); removed_count += tx_pages.execute(
println!("Removed:\n"); "DELETE FROM global_landing_pages WHERE slug = ?1;",
println!("Pages:\n {}\n", orphaned_pages); [&issue.slug],
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" }
);
} }
}
tx_urls.commit()?;
tx_pages.commit()?;
// Record audit event in system.db
let _ = crate::db::audit_events::write_audit_event(
&sys_conn,
"cli",
"REGISTRY_REPAIR",
"registry",
slug_filter.unwrap_or("*"),
Some(&format!(
"Repaired/removed {} orphaned slug entries",
removed_count
)),
);
println!("Registry Repair Complete.");
println!("Repaired/Removed: {} entries", removed_count);
info!( info!(
"Registry Repair Started. Scanned. Orphaned Pages: {}, Orphaned URLs: {}. Removed: {}. Duration: {:?}", "Registry Repair Complete. Removed: {}. Duration: {:?}",
orphaned_pages, orphaned_urls, removed_count, start_time.elapsed() removed_count,
start_time.elapsed()
); );
} }
} }
+3 -8
View File
@@ -215,19 +215,15 @@ fn classify_registry_issues(
for issue in issues { for issue in issues {
match issue.issue_type { match issue.issue_type {
RegistryIssueType::DuplicateSlug RegistryIssueType::Conflict
| RegistryIssueType::InvalidTargetType | RegistryIssueType::InvalidTargetType
| RegistryIssueType::InvalidStatus => { | RegistryIssueType::InvalidStatus => {
errors.push(issue); errors.push(issue);
} }
RegistryIssueType::MissingOwner if !is_legacy => { RegistryIssueType::MissingTenant if !is_legacy => {
errors.push(issue); 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); warnings.push(issue);
} }
} }
@@ -281,8 +277,7 @@ pub fn perform_restore(
} }
// 5. Run validation on the normalized temp_dir // 5. Run validation on the normalized temp_dir
let mut temp_config = Config::load(); let temp_config = Config::load_with_cli(Some(&temp_dir))?;
temp_config.data_dir = temp_dir.clone();
// Namespace audit // Namespace audit
match crate::db::users::audit_slug_namespace(&temp_config) { match crate::db::users::audit_slug_namespace(&temp_config) {
+272 -30
View File
@@ -1,9 +1,13 @@
use crate::config::Config; use crate::config::Config;
use crate::db::Db; use crate::db::Db;
use crate::identity::TenantId;
use chrono::Utc;
use rusqlite::OptionalExtension;
use std::fs::File; use std::fs::File;
use std::path::PathBuf; use std::path::PathBuf;
use tar::Archive; use tar::Archive;
use tracing::{error, info}; use tracing::{error, info};
use uuid::Uuid;
use zstd::Decoder; use zstd::Decoder;
#[derive(serde::Serialize, serde::Deserialize)] #[derive(serde::Serialize, serde::Deserialize)]
@@ -15,6 +19,10 @@ struct UserBackupMetadata {
created_at: String, created_at: String,
account_type: String, account_type: String,
metadata: Option<String>, metadata: Option<String>,
#[serde(default)]
tenant_id: Option<String>,
#[serde(default)]
uuid: Option<String>,
quotas: UserBackupQuotas, quotas: UserBackupQuotas,
} }
@@ -70,26 +78,72 @@ pub async fn run(
info!("Restoring user {} from backup...", metadata.username); info!("Restoring user {} from backup...", metadata.username);
// 2. Resolve target user ID and upsert user record in users.db // 2. Resolve identity per Correction #1:
let target_user_id = { // Order:
// 1. Archive contains TenantId + UUID -> preserve exactly.
// 2. Legacy archive matched to existing users.db account -> resolve and preserve that user's existing TenantId + UUID.
// 3. Genuinely new legacy restore with no existing identity match -> explicitly allocate new TenantId + UUID.
let (target_user_id, target_tenant_id) = {
let users_conn = db.users.lock().unwrap(); let users_conn = db.users.lock().unwrap();
let existing_user = let existing_user =
crate::db::users::get_user_by_username(&users_conn, &metadata.username)?; crate::db::users::get_user_by_username(&users_conn, &metadata.username)?;
match existing_user { match existing_user {
Some(u) => { Some(u) => {
let tenant_id = if let Some(tid) = u.tenant_id {
tid
} else if let Some(ref tid_str) = metadata.tenant_id {
TenantId::parse(tid_str).unwrap_or_else(|_| TenantId::generate())
} else {
TenantId::generate()
};
let user_uuid = if let Some(ref uid) = u.uuid {
uid.clone()
} else if let Some(ref uid_str) = metadata.uuid {
uid_str.clone()
} else {
Uuid::new_v4().to_string()
};
users_conn.execute( users_conn.execute(
"UPDATE users SET password_hash = ?1, status = ?2, account_type = ?3, metadata = ?4 WHERE id = ?5;", "UPDATE users SET password_hash = ?1, status = ?2, account_type = ?3, metadata = ?4, tenant_id = ?5, uuid = ?6 WHERE id = ?7;",
rusqlite::params![metadata.password_hash, metadata.status, metadata.account_type, metadata.metadata, u.id], rusqlite::params![
metadata.password_hash,
metadata.status,
metadata.account_type,
metadata.metadata,
tenant_id.as_str(),
user_uuid,
u.id
],
)?; )?;
users_conn.execute( users_conn.execute(
"INSERT OR REPLACE INTO quotas (user_id, max_urls, max_landings, max_api_tokens, max_storage_mb) "INSERT OR REPLACE INTO quotas (user_id, max_urls, max_landings, max_api_tokens, max_storage_mb)
VALUES (?1, ?2, ?3, ?4, ?5);", VALUES (?1, ?2, ?3, ?4, ?5);",
rusqlite::params![u.id, metadata.quotas.max_urls, metadata.quotas.max_landings, metadata.quotas.max_api_tokens, metadata.quotas.max_storage_mb], rusqlite::params![
u.id,
metadata.quotas.max_urls,
metadata.quotas.max_landings,
metadata.quotas.max_api_tokens,
metadata.quotas.max_storage_mb
],
)?; )?;
u.id (u.id, tenant_id)
} }
None => { None => {
let tenant_id = if let Some(ref tid_str) = metadata.tenant_id {
TenantId::parse(tid_str).unwrap_or_else(|_| TenantId::generate())
} else {
TenantId::generate()
};
let user_uuid = if let Some(ref uid_str) = metadata.uuid {
uid_str.clone()
} else {
Uuid::new_v4().to_string()
};
let id_taken: bool = users_conn let id_taken: bool = users_conn
.query_row( .query_row(
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);", "SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
@@ -100,16 +154,35 @@ pub async fn run(
let new_id = if !id_taken { let new_id = if !id_taken {
users_conn.execute( users_conn.execute(
"INSERT INTO users (id, username, password_hash, status, created_at, account_type, metadata) "INSERT INTO users (id, username, password_hash, status, created_at, account_type, metadata, tenant_id, uuid)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9);",
rusqlite::params![metadata.id, metadata.username, metadata.password_hash, metadata.status, metadata.created_at, metadata.account_type, metadata.metadata], rusqlite::params![
metadata.id,
metadata.username,
metadata.password_hash,
metadata.status,
metadata.created_at,
metadata.account_type,
metadata.metadata,
tenant_id.as_str(),
user_uuid
],
)?; )?;
metadata.id metadata.id
} else { } else {
users_conn.execute( users_conn.execute(
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata) "INSERT INTO users (username, password_hash, status, created_at, account_type, metadata, tenant_id, uuid)
VALUES (?1, ?2, ?3, ?4, ?5, ?6);", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8);",
rusqlite::params![metadata.username, metadata.password_hash, metadata.status, metadata.created_at, metadata.account_type, metadata.metadata], rusqlite::params![
metadata.username,
metadata.password_hash,
metadata.status,
metadata.created_at,
metadata.account_type,
metadata.metadata,
tenant_id.as_str(),
user_uuid
],
)?; )?;
users_conn.last_insert_rowid() users_conn.last_insert_rowid()
}; };
@@ -117,19 +190,23 @@ pub async fn run(
users_conn.execute( users_conn.execute(
"INSERT OR REPLACE INTO quotas (user_id, max_urls, max_landings, max_api_tokens, max_storage_mb) "INSERT OR REPLACE INTO quotas (user_id, max_urls, max_landings, max_api_tokens, max_storage_mb)
VALUES (?1, ?2, ?3, ?4, ?5);", VALUES (?1, ?2, ?3, ?4, ?5);",
rusqlite::params![new_id, metadata.quotas.max_urls, metadata.quotas.max_landings, metadata.quotas.max_api_tokens, metadata.quotas.max_storage_mb], rusqlite::params![
new_id,
metadata.quotas.max_urls,
metadata.quotas.max_landings,
metadata.quotas.max_api_tokens,
metadata.quotas.max_storage_mb
],
)?; )?;
new_id (new_id, tenant_id)
} }
} }
}; };
// 3. Extract database files to /data/users/<target_user_id>/ // 3. Extract database files to /data/users/<TenantId>/
let dest_dir = config let dest_dir = db.topology.tenant_dir(target_tenant_id);
.data_dir
.join("users")
.join(target_user_id.to_string());
std::fs::create_dir_all(&dest_dir)?; std::fs::create_dir_all(&dest_dir)?;
std::fs::create_dir_all(dest_dir.join("extensions"))?;
let f2 = File::open(&file_path)?; let f2 = File::open(&file_path)?;
let zst_dec2 = Decoder::new(f2)?; let zst_dec2 = Decoder::new(f2)?;
@@ -156,27 +233,192 @@ pub async fn run(
} }
} }
// 4. Register slugs in global_slugs using the shared helper // 4. Register restored slugs in v0.8 slug databases (global_urls.db, global_landing_pages.db)
{ let content_path = dest_dir.join("content.db");
let system_conn = db.system.lock().unwrap(); if content_path.exists() {
crate::db::users::register_restored_user_slugs( let restored_content_conn = rusqlite::Connection::open(&content_path)?;
&system_conn, let now = Utc::now().to_rfc3339();
target_user_id,
&dest_dir.join("content.db"), let urls_conn = db.global_urls.lock().unwrap();
)?; let pages_conn = db.global_landing_pages.lock().unwrap();
let reserved_conn = db.reserved.lock().unwrap();
// 4a. Validate URL slugs for collisions
let mut url_slugs = Vec::new();
let mut stmt =
restored_content_conn.prepare("SELECT code, id, created_at, status FROM urls;")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let code: String = row.get(0)?;
let target_id: String = row.get(1)?;
let created_at: String = row.get(2)?;
let status: String = row.get(3)?;
let global_status = if status == "dead" {
"disabled"
} else {
"active"
};
// Check collision with global_urls
let existing_url_owner: Option<String> = urls_conn
.query_row(
"SELECT owner_tenant_id FROM global_urls WHERE slug = ?1;",
[&code],
|r| r.get(0),
)
.optional()?;
if let Some(ref owner) = existing_url_owner {
if owner != target_tenant_id.as_str() {
return Err(format!(
"Slug collision: URL slug '{code}' is already registered to another tenant ({owner})"
)
.into());
}
}
// Check collision with global_landing_pages
let existing_page_owner: Option<String> = pages_conn
.query_row(
"SELECT owner_tenant_id FROM global_landing_pages WHERE slug = ?1;",
[&code],
|r| r.get(0),
)
.optional()?;
if let Some(ref owner) = existing_page_owner {
if owner != target_tenant_id.as_str() {
return Err(format!(
"Slug collision: URL slug '{code}' collides with landing page owned by tenant ({owner})"
)
.into());
}
}
// Check reserved
let is_reserved: bool = reserved_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM reserved_slugs WHERE slug = ?1);",
[&code],
|r| r.get(0),
)
.unwrap_or(false);
if is_reserved {
return Err(format!(
"Slug collision: URL slug '{code}' is a reserved system keyword"
)
.into());
}
url_slugs.push((code, target_id, created_at, global_status));
}
// 4b. Validate Landing Page slugs for collisions
let mut page_slugs = Vec::new();
let mut stmt = restored_content_conn
.prepare("SELECT code, id, created_at, state FROM landing_pages;")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let code: String = row.get(0)?;
let target_id: String = row.get(1)?;
let created_at: String = row.get(2)?;
let state: String = row.get(3)?;
let global_status = if state == "published" {
"active"
} else {
"disabled"
};
let existing_url_owner: Option<String> = urls_conn
.query_row(
"SELECT owner_tenant_id FROM global_urls WHERE slug = ?1;",
[&code],
|r| r.get(0),
)
.optional()?;
if let Some(ref owner) = existing_url_owner {
if owner != target_tenant_id.as_str() {
return Err(format!(
"Slug collision: Landing page slug '{code}' collides with URL owned by tenant ({owner})"
)
.into());
}
}
let existing_page_owner: Option<String> = pages_conn
.query_row(
"SELECT owner_tenant_id FROM global_landing_pages WHERE slug = ?1;",
[&code],
|r| r.get(0),
)
.optional()?;
if let Some(ref owner) = existing_page_owner {
if owner != target_tenant_id.as_str() {
return Err(format!(
"Slug collision: Landing page slug '{code}' is already registered to another tenant ({owner})"
)
.into());
}
}
let is_reserved: bool = reserved_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM reserved_slugs WHERE slug = ?1);",
[&code],
|r| r.get(0),
)
.unwrap_or(false);
if is_reserved {
return Err(format!(
"Slug collision: Landing page slug '{code}' is a reserved system keyword"
)
.into());
}
page_slugs.push((code, target_id, created_at, global_status));
}
// 4c. Register validated URL slugs
for (code, target_id, created_at, global_status) in url_slugs {
let _ = urls_conn.execute(
"INSERT OR REPLACE INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL);",
rusqlite::params![
code,
target_tenant_id.as_str(),
target_id,
created_at,
now,
global_status
],
);
}
// 4d. Register validated Landing Page slugs
for (code, target_id, created_at, global_status) in page_slugs {
let _ = pages_conn.execute(
"INSERT OR REPLACE INTO global_landing_pages (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL);",
rusqlite::params![
code,
target_tenant_id.as_str(),
target_id,
created_at,
now,
global_status
],
);
} }
// 5. Reconcile quotas for restored user // 5. Reconcile quotas for restored user
let restored_content_conn = rusqlite::Connection::open(dest_dir.join("content.db"))?;
crate::db::users::reconcile_user_quotas( crate::db::users::reconcile_user_quotas(
&db.users.lock().unwrap(), &db.users.lock().unwrap(),
target_user_id, target_user_id,
&restored_content_conn, &restored_content_conn,
)?; )?;
}
info!( info!(
"User '{}' (ID: {}) successfully restored from backup.", "User '{}' (ID: {}, Tenant: {}) successfully restored from backup.",
metadata.username, target_user_id metadata.username, target_user_id, target_tenant_id
); );
Ok(()) Ok(())
} }
-2
View File
@@ -133,8 +133,6 @@ pub async fn run(
let state = AppState { let state = AppState {
admin_db: db.admin.clone(), admin_db: db.admin.clone(),
content_db: db.content.clone(),
analytics_db: db.analytics.clone(),
system_db: db.system.clone(), system_db: db.system.clone(),
users_db: db.users.clone(), users_db: db.users.clone(),
user_dbs: std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())), user_dbs: std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
+39 -17
View File
@@ -18,6 +18,24 @@ pub async fn run(
} }
let db = Db::init(&config)?; let db = Db::init(&config)?;
// Resolve active standard user tenant
let (user_id, tid) = {
let users_conn = db.users.lock().unwrap();
let users = crate::db::users::list_users(&users_conn)?;
let active_user = users.into_iter().find(|u| {
u.account_type == "standard" && u.status == "active" && u.tenant_id.is_some()
});
match active_user {
Some(u) => (u.id, u.tenant_id.unwrap()),
None => {
return Err(
"Cannot shorten URL: No active standard user tenant found. Create a standard user first with `bzod user create`."
.into(),
)
}
}
};
// 2. Validate/normalize slug/code // 2. Validate/normalize slug/code
let code = match slug { let code = match slug {
Some(s) => { Some(s) => {
@@ -33,17 +51,24 @@ pub async fn run(
None => crate::utils::random::generate_token(3), None => crate::utils::random::generate_token(3),
}; };
// 3. Register slug in system.db with status 'reserving' and check availability // 3. Register slug in global_urls with status 'reserving'
{ {
let system_conn = db.system.lock().unwrap(); let urls_conn = db.global_urls.lock().unwrap();
if !crate::db::users::is_slug_available(&system_conn, &code)? { let pages_conn = db.global_landing_pages.lock().unwrap();
return Err("Short code/slug already exists".into()); let reserved_conn = db.reserved.lock().unwrap();
if let Err(e) =
crate::db::slugs::reserve_url_slug(&reserved_conn, &urls_conn, &pages_conn, &code, &tid)
{
return Err(format!("Slug '{}' already exists or is unavailable: {}", code, e).into());
} }
crate::db::users::register_global_slug(&system_conn, &code, 1, "url", "", "reserving")?;
} }
// 4. Persist URL // 4. Persist URL in tenant content DB
let conn = db.content.lock().unwrap(); let content_path = db.topology.tenant_content_db(tid);
let conn = rusqlite::Connection::open(&content_path)?;
let _ = crate::db::sqlite::enable_wal(&conn, "content");
let _ = crate::db::sqlite::enable_foreign_keys(&conn, "content");
let res = crate::db::content::create_url_extended( let res = crate::db::content::create_url_extended(
&conn, &conn,
&code, &code,
@@ -58,18 +83,15 @@ pub async fn run(
match res { match res {
Ok(url) => { Ok(url) => {
// Activate slug in system.db // Activate slug in global_urls
{ {
let system_conn = db.system.lock().unwrap(); let urls_conn = db.global_urls.lock().unwrap();
system_conn.execute( crate::db::slugs::activate_url_slug(&urls_conn, &code, &url.id)?;
"UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;",
rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), code],
)?;
} }
// Increment quota for user ID 1 // Increment quota
{ {
let users_conn = db.users.lock().unwrap(); let users_conn = db.users.lock().unwrap();
crate::db::users::increment_quota_counter(&users_conn, 1, "urls")?; crate::db::users::increment_quota_counter(&users_conn, user_id, "urls")?;
} }
let proto = if config.cookie_secure { let proto = if config.cookie_secure {
@@ -87,8 +109,8 @@ pub async fn run(
Ok(()) Ok(())
} }
Err(e) => { Err(e) => {
let system_conn = db.system.lock().unwrap(); let urls_conn = db.global_urls.lock().unwrap();
let _ = crate::db::users::release_global_slug(&system_conn, &code, 1); let _ = crate::db::slugs::release_url_slug(&urls_conn, &code, &tid);
Err(e.into()) Err(e.into())
} }
} }
+45 -11
View File
@@ -15,16 +15,24 @@ pub async fn run(
println!("Storage Directory: {:?}", config.data_dir); println!("Storage Directory: {:?}", config.data_dir);
let files = vec![ let files = vec![
("admin.db", config.data_dir.join("admin/admin.db")), ("admin.db", db.topology.admin_db()),
("system.db", config.data_dir.join("admin/system.db")), ("system.db", db.topology.system_db()),
("users.db", config.data_dir.join("admin/users.db")), ("users.db", db.topology.users_registry_db()),
("global_urls.db", db.topology.global_urls_db()),
(
"global_landing_pages.db",
db.topology.global_landing_pages_db(),
),
("reserved.db", db.topology.reserved_db()),
( (
"legacy content.db", "legacy content.db",
config.data_dir.join("users/1/content.db"), db.topology
.content_db(crate::db::topology::LEGACY_ADMIN_USER_KEY)?,
), ),
( (
"legacy analytics.db", "legacy analytics.db",
config.data_dir.join("users/1/analytics.db"), db.topology
.analytics_db(crate::db::topology::LEGACY_ADMIN_USER_KEY)?,
), ),
]; ];
@@ -47,8 +55,29 @@ pub async fn run(
println!("Users Count: {}", users_count); println!("Users Count: {}", users_count);
let (urls_total, urls_active, urls_dead) = { let (urls_total, urls_active, urls_dead) = {
let conn = db.content.lock().unwrap(); let conn = db.global_urls.lock().unwrap();
crate::db::content::get_url_counts(&conn)? let total: i64 = conn
.query_row(
"SELECT COUNT(*) FROM global_urls WHERE status != 'retired';",
[],
|r| r.get(0),
)
.unwrap_or(0);
let active: i64 = conn
.query_row(
"SELECT COUNT(*) FROM global_urls WHERE status = 'active';",
[],
|r| r.get(0),
)
.unwrap_or(0);
let dead: i64 = conn
.query_row(
"SELECT COUNT(*) FROM global_urls WHERE status = 'disabled';",
[],
|r| r.get(0),
)
.unwrap_or(0);
(total, active, dead)
}; };
println!( println!(
"Shortened URLs: {} total ({} active / {} dead)", "Shortened URLs: {} total ({} active / {} dead)",
@@ -56,14 +85,19 @@ pub async fn run(
); );
let pages_count = { let pages_count = {
let conn = db.content.lock().unwrap(); let conn = db.global_landing_pages.lock().unwrap();
crate::db::content::get_landing_page_count(&conn)? conn.query_row(
"SELECT COUNT(*) FROM global_landing_pages WHERE status != 'retired';",
[],
|r| r.get(0),
)
.unwrap_or(0)
}; };
println!("Landing Pages: {}", pages_count); println!("Landing Pages: {}", pages_count);
let total_visits = { let total_visits = {
let conn = db.analytics.lock().unwrap(); let users_conn = db.users.lock().unwrap();
crate::db::analytics::get_total_clicks(&conn)? crate::db::users::get_platform_total_clicks(&db.topology, &users_conn).unwrap_or(0)
}; };
println!("Redirect Clicks: {}", total_visits); println!("Redirect Clicks: {}", total_visits);
+101 -15
View File
@@ -3,6 +3,45 @@ use std::env;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
pub const NX9_BZOD_DATA_DIR_ENV: &str = "NX9_BZOD_DATA_DIR";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfigError {
MissingDataDir,
InvalidConfig(String),
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingDataDir => write!(
f,
"data directory is not configured; set NX9_BZOD_DATA_DIR or use --data-dir=<path>"
),
Self::InvalidConfig(msg) => write!(f, "configuration error: {msg}"),
}
}
}
impl std::error::Error for ConfigError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DataDirSource {
Cli,
Env,
ConfigFile,
}
impl std::fmt::Display for DataDirSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Cli => write!(f, "CLI"),
Self::Env => write!(f, "NX9_BZOD_DATA_DIR"),
Self::ConfigFile => write!(f, "config.toml"),
}
}
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Config { pub struct Config {
pub host: String, pub host: String,
@@ -45,11 +84,23 @@ struct TomlBackupConfig {
} }
impl Config { impl Config {
pub fn load() -> Self { pub fn load() -> Result<Self, ConfigError> {
// 1. Built-in defaults Self::load_with_cli(None::<&std::path::Path>)
}
pub fn load_with_cli<P: AsRef<std::path::Path>>(
cli_data_dir: Option<P>,
) -> Result<Self, ConfigError> {
Self::load_from_sources(cli_data_dir, true)
}
pub fn load_from_sources<P: AsRef<std::path::Path>>(
cli_data_dir: Option<P>,
load_dotenv: bool,
) -> Result<Self, ConfigError> {
// 1. Built-in defaults (no implicit data_dir default)
let mut host = "0.0.0.0".to_string(); let mut host = "0.0.0.0".to_string();
let mut port = 8080u16; let mut port = 8080u16;
let mut data_dir = PathBuf::from("./data");
let mut admin_username = "admin".to_string(); let mut admin_username = "admin".to_string();
let mut bootstrap_password_sha256 = "".to_string(); let mut bootstrap_password_sha256 = "".to_string();
let mut session_secret = let mut session_secret =
@@ -63,12 +114,18 @@ impl Config {
let mut backup_dir = PathBuf::from("./backups"); let mut backup_dir = PathBuf::from("./backups");
let mut base_url = None; let mut base_url = None;
// 2. Load bzod.toml if it exists let mut resolved_data_dir: Option<PathBuf> = None;
let mut toml_path = "bzod.toml".to_string(); let mut data_dir_source: Option<DataDirSource> = None;
if fs::metadata("bzod.toml").is_err() && fs::metadata("config/bzod.toml").is_ok() {
toml_path = "config/bzod.toml".to_string(); // 2. Load bzod.toml / config.toml if it exists
} let possible_tomls = [
if let Ok(toml_content) = fs::read_to_string(&toml_path) { "bzod.toml",
"config/bzod.toml",
"config.toml",
"config/config.toml",
];
for toml_path in possible_tomls {
if let Ok(toml_content) = fs::read_to_string(toml_path) {
if let Ok(toml_config) = toml::from_str::<TomlConfig>(&toml_content) { if let Ok(toml_config) = toml::from_str::<TomlConfig>(&toml_content) {
if let Some(h) = toml_config.host { if let Some(h) = toml_config.host {
host = h; host = h;
@@ -77,7 +134,10 @@ impl Config {
port = p; port = p;
} }
if let Some(d) = toml_config.data_dir { if let Some(d) = toml_config.data_dir {
data_dir = PathBuf::from(d); if !d.trim().is_empty() {
resolved_data_dir = Some(PathBuf::from(d));
data_dir_source = Some(DataDirSource::ConfigFile);
}
} }
if let Some(u) = toml_config.admin_username { if let Some(u) = toml_config.admin_username {
admin_username = u; admin_username = u;
@@ -118,16 +178,20 @@ impl Config {
if let Some(bu) = toml_config.base_url { if let Some(bu) = toml_config.base_url {
base_url = Some(bu); base_url = Some(bu);
} }
break;
}
} }
} }
// 3. Load .env if present // 3. Load .env if present
if load_dotenv {
let _ = dotenvy::dotenv(); let _ = dotenvy::dotenv();
if fs::metadata("config/.env").is_ok() { if fs::metadata("config/.env").is_ok() {
let _ = dotenvy::from_path("config/.env"); let _ = dotenvy::from_path("config/.env");
} }
}
// 4. Load from Environment Variables (taking highest precedence) // 4. Load from Environment Variables (taking precedence over config file)
if let Ok(h) = env::var("HOST") { if let Ok(h) = env::var("HOST") {
host = h; host = h;
} }
@@ -136,8 +200,11 @@ impl Config {
port = p; port = p;
} }
} }
if let Ok(d_str) = env::var("DATA_DIR") { if let Ok(d_str) = env::var(NX9_BZOD_DATA_DIR_ENV) {
data_dir = PathBuf::from(d_str); if !d_str.trim().is_empty() {
resolved_data_dir = Some(PathBuf::from(d_str));
data_dir_source = Some(DataDirSource::Env);
}
} }
if let Ok(u) = env::var("ADMIN_USERNAME") { if let Ok(u) = env::var("ADMIN_USERNAME") {
admin_username = u; admin_username = u;
@@ -187,7 +254,26 @@ impl Config {
base_url = Some(bu); base_url = Some(bu);
} }
Self { // 5. CLI override (highest precedence)
if let Some(cli_dir) = cli_data_dir {
let path_ref = cli_dir.as_ref();
if !path_ref.as_os_str().is_empty() {
resolved_data_dir = Some(path_ref.to_path_buf());
data_dir_source = Some(DataDirSource::Cli);
}
}
let data_dir = match resolved_data_dir {
Some(dir) => dir,
None => return Err(ConfigError::MissingDataDir),
};
if let Some(source) = data_dir_source {
tracing::info!("Data directory: {}", data_dir.display());
tracing::info!("Data directory source: {}", source);
}
Ok(Self {
host, host,
port, port,
data_dir, data_dir,
@@ -202,6 +288,6 @@ impl Config {
backup_interval_mins, backup_interval_mins,
backup_dir, backup_dir,
base_url, base_url,
} })
} }
} }
+2
View File
@@ -639,6 +639,7 @@ pub fn get_target_visits_paginated(
accept_language: row.get("accept_language")?, accept_language: row.get("accept_language")?,
country: row.get("country")?, country: row.get("country")?,
status_code: row.get("status_code")?, status_code: row.get("status_code")?,
owner_tenant_id: None,
owner_user_id: row.get("owner_user_id")?, owner_user_id: row.get("owner_user_id")?,
}) })
})?; })?;
@@ -695,6 +696,7 @@ pub fn get_target_visits_all_in_memory(
accept_language: row.get("accept_language")?, accept_language: row.get("accept_language")?,
country: row.get("country")?, country: row.get("country")?,
status_code: row.get("status_code")?, status_code: row.get("status_code")?,
owner_tenant_id: None,
owner_user_id: row.get("owner_user_id")?, owner_user_id: row.get("owner_user_id")?,
}) })
})?; })?;
+398
View File
@@ -0,0 +1,398 @@
//! Explicit Phase 3 Identity & Directory Migration Engine.
//!
//! Lifecycle:
//! 1. Preflight (inspect DB and filesystem, identify unmigrated users)
//! 2. Backup (create pre-migration tarball)
//! 3. Identity Migration (assign immutable TenantId + UUID in users.db)
//! 4. Filesystem Migration (atomically move users/<id>/ to users/<TenantId>/)
//! 5. Validation (verify all users, directories, and databases)
//! 6. Completion Marker (record audit event and system setting)
//!
//! Legacy Admin (users/1) is preserved as a Core/legacy concern and NOT converted
//! to a normal TenantId directory.
use rusqlite::Connection;
use std::fs;
use std::path::PathBuf;
use tracing::{info, warn};
use crate::config::Config;
use crate::db::topology::{is_v08_user_id, Topology};
use crate::db::Db;
use crate::identity::TenantId;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct IdentityMigrationReport {
pub total_users: usize,
pub users_assigned_tenant_id: usize,
pub users_assigned_uuid: usize,
pub directories_moved: usize,
pub directories_already_migrated: usize,
pub legacy_admin_preserved: bool,
pub validation_passed: bool,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct PreflightPlan {
pub total_users: usize,
pub normal_users: usize,
pub users_needing_tenant_id: Vec<(i64, String)>,
pub users_needing_uuid: Vec<(i64, String)>,
pub directories_to_move: Vec<(i64, PathBuf, TenantId)>,
pub directories_already_migrated: usize,
}
/// Run the full explicit identity migration lifecycle.
pub async fn run_identity_migration(
config: &Config,
dry_run: bool,
skip_backup: bool,
) -> Result<IdentityMigrationReport, Box<dyn std::error::Error>> {
let topology = Topology::new(&config.data_dir);
let mut warnings = Vec::new();
// 1. Initialize Db for Core connections
let db = Db::init(config)?;
// 2. Preflight
let plan = {
let users_conn = db.users.lock().unwrap();
inspect_preflight(&users_conn, &topology)?
};
info!(
"Preflight: total_users={}, normal_users={}, needing_tenant_id={}, needing_uuid={}, dirs_to_move={}",
plan.total_users,
plan.normal_users,
plan.users_needing_tenant_id.len(),
plan.users_needing_uuid.len(),
plan.directories_to_move.len()
);
if dry_run {
info!("Dry run mode enabled. No identity changes or directory moves will be performed.");
return Ok(IdentityMigrationReport {
total_users: plan.total_users,
users_assigned_tenant_id: plan.users_needing_tenant_id.len(),
users_assigned_uuid: plan.users_needing_uuid.len(),
directories_moved: plan.directories_to_move.len(),
directories_already_migrated: plan.directories_already_migrated,
legacy_admin_preserved: true,
validation_passed: true,
warnings,
});
}
// 3. Backup before migration (if there is work to do and backup is not skipped)
let needs_migration = !plan.users_needing_tenant_id.is_empty()
|| !plan.users_needing_uuid.is_empty()
|| !plan.directories_to_move.is_empty();
if needs_migration && !skip_backup {
info!("Creating pre-migration backup...");
match crate::jobs::backup::perform_backup(&db, config).await {
Ok(path) => info!("Pre-migration backup created at {:?}", path),
Err(e) => {
warn!(
"Pre-migration backup failed: {}. Continuing with caution.",
e
);
warnings.push(format!("Pre-migration backup warning: {e}"));
}
}
}
// 4. Identity Migration (users.db backfill)
let (assigned_tids, assigned_uuids) = {
let mut users_conn = db.users.lock().unwrap();
migrate_user_table_identities(&mut users_conn)?
};
// 5. Filesystem Migration (users/<id>/ -> users/<TenantId>/)
let moved_dirs = {
let users_conn = db.users.lock().unwrap();
migrate_tenant_directories(&users_conn, &topology, &mut warnings)?
};
// 6. Validation
let validation_passed = {
let users_conn = db.users.lock().unwrap();
validate_identity_migration(&users_conn, &topology, &mut warnings)?
};
// 7. Completion Marker in system.db
if validation_passed {
let system_conn = db.system.lock().unwrap();
let now = chrono::Utc::now().to_rfc3339();
let details = format!(
"Identity migration completed: {} tenant_ids assigned, {} uuids assigned, {} directories moved",
assigned_tids, assigned_uuids, moved_dirs
);
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
"system",
"IDENTITY_MIGRATION_COMPLETED",
"identity",
"users.db",
Some(&details),
);
let _ = system_conn.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('v08_identity_migration_completed', ?1);",
[&now],
);
}
Ok(IdentityMigrationReport {
total_users: plan.total_users,
users_assigned_tenant_id: assigned_tids,
users_assigned_uuid: assigned_uuids,
directories_moved: moved_dirs,
directories_already_migrated: plan.directories_already_migrated,
legacy_admin_preserved: true,
validation_passed,
warnings,
})
}
/// Inspect existing database and filesystem to build a preflight plan.
pub fn inspect_preflight(
users_conn: &Connection,
topology: &Topology,
) -> Result<PreflightPlan, Box<dyn std::error::Error>> {
let users = crate::db::users::list_users(users_conn)?;
let total_users = users.len();
let mut normal_users = 0;
let mut users_needing_tenant_id = Vec::new();
let mut users_needing_uuid = Vec::new();
let mut directories_to_move = Vec::new();
let mut directories_already_migrated = 0;
for user in &users {
// Skip legacy admin / system accounts
if user.account_type == "admin"
|| user.account_type == "system"
|| user.username == "legacy_admin"
{
continue;
}
normal_users += 1;
if user.tenant_id.is_none() {
users_needing_tenant_id.push((user.id, user.username.clone()));
}
if user.uuid.is_none() {
users_needing_uuid.push((user.id, user.username.clone()));
}
if let Some(tid) = user.tenant_id {
let legacy_dir = topology.user_dir_i64(user.id)?;
let target_dir = topology.tenant_dir(tid);
if legacy_dir.exists() && legacy_dir != target_dir {
directories_to_move.push((user.id, legacy_dir, tid));
} else if target_dir.exists() {
directories_already_migrated += 1;
}
}
}
Ok(PreflightPlan {
total_users,
normal_users,
users_needing_tenant_id,
users_needing_uuid,
directories_to_move,
directories_already_migrated,
})
}
/// Assign immutable TenantId and UUID for all normal users missing them in users.db.
/// Restart-safe: never regenerates or modifies existing identities.
pub fn migrate_user_table_identities(
users_conn: &mut Connection,
) -> Result<(usize, usize), Box<dyn std::error::Error>> {
let users = crate::db::users::list_users(users_conn)?;
let mut assigned_tids = 0;
let mut assigned_uuids = 0;
let tx = users_conn.transaction()?;
for user in users {
// Skip legacy admin / system accounts
if user.account_type == "admin"
|| user.account_type == "system"
|| user.username == "legacy_admin"
{
continue;
}
let mut needs_update = false;
let mut tid_str = user.tenant_id.map(|t| t.as_str().to_string());
let mut uuid_str = user.uuid;
if tid_str.is_none() {
// Allocate unique TenantId
let tid = crate::identity::TenantId::generate();
tid_str = Some(tid.as_str().to_string());
assigned_tids += 1;
needs_update = true;
}
if uuid_str.is_none() {
// Allocate unique UUID
let u = uuid::Uuid::new_v4().to_string();
uuid_str = Some(u);
assigned_uuids += 1;
needs_update = true;
}
if needs_update {
tx.execute(
"UPDATE users SET tenant_id = ?1, uuid = ?2 WHERE id = ?3;",
rusqlite::params![tid_str, uuid_str, user.id],
)?;
}
}
tx.commit()?;
Ok((assigned_tids, assigned_uuids))
}
/// Move tenant directories from users/<id>/ to users/<TenantId>/ for normal users.
/// Legacy users/1 is preserved.
pub fn migrate_tenant_directories(
users_conn: &Connection,
topology: &Topology,
warnings: &mut Vec<String>,
) -> Result<usize, Box<dyn std::error::Error>> {
let users = crate::db::users::list_users(users_conn)?;
let mut moved = 0;
for user in users {
if user.account_type == "admin"
|| user.account_type == "system"
|| user.username == "legacy_admin"
{
// Preserve Core / system accounts intact
continue;
}
let Some(tid) = user.tenant_id else {
warnings.push(format!(
"User '{}' (ID {}) has no TenantId; skipping directory move",
user.username, user.id
));
continue;
};
let legacy_dir = match topology.user_dir_i64(user.id) {
Ok(d) => d,
Err(_) => continue,
};
let target_dir = topology.tenant_dir(tid);
if legacy_dir.exists() && legacy_dir != target_dir {
if !target_dir.exists() {
// Atomic rename on same filesystem
fs::rename(&legacy_dir, &target_dir)?;
let _ = fs::create_dir_all(target_dir.join("extensions"));
info!(
"Migrated tenant directory for user '{}': {:?} -> {:?}",
user.username, legacy_dir, target_dir
);
moved += 1;
} else {
// Target already exists (interrupted / partial run)
warn!(
"Target directory {:?} already exists for user '{}'. Verifying contents.",
target_dir, user.username
);
// Ensure extensions dir exists
let _ = fs::create_dir_all(target_dir.join("extensions"));
// If legacy directory is now empty or duplicate, clean it up safely
if let Ok(entries) = fs::read_dir(&legacy_dir) {
if entries.count() == 0 {
let _ = fs::remove_dir(&legacy_dir);
}
}
}
}
}
Ok(moved)
}
/// Validate that every normal user has a valid TenantId, UUID, and matching directory.
pub fn validate_identity_migration(
users_conn: &Connection,
topology: &Topology,
warnings: &mut Vec<String>,
) -> Result<bool, Box<dyn std::error::Error>> {
let users = crate::db::users::list_users(users_conn)?;
let mut valid = true;
for user in &users {
if user.account_type == "admin"
|| user.account_type == "system"
|| user.username == "legacy_admin"
{
continue;
}
// Validate TenantId
match user.tenant_id {
Some(tid) => {
if !is_v08_user_id(tid.as_str()) {
warnings.push(format!(
"Invalid TenantId format '{}' for user '{}'",
tid.as_str(),
user.username
));
valid = false;
}
let target_dir = topology.tenant_dir(tid);
if !target_dir.exists() {
// Check if content db was initialized or if directory was not yet created
warnings.push(format!(
"Tenant directory {:?} does not exist for user '{}'",
target_dir, user.username
));
}
}
None => {
warnings.push(format!(
"Normal user '{}' (ID {}) is missing TenantId",
user.username, user.id
));
valid = false;
}
}
// Validate UUID
match &user.uuid {
Some(u) => {
if uuid::Uuid::parse_str(u).is_err() {
warnings.push(format!(
"Invalid UUID format '{}' for user '{}'",
u, user.username
));
valid = false;
}
}
None => {
warnings.push(format!(
"Normal user '{}' (ID {}) is missing UUID",
user.username, user.id
));
valid = false;
}
}
}
Ok(valid)
}
+35 -1
View File
@@ -35,7 +35,21 @@ pub fn run_migrations(
); );
let tx = conn.transaction()?; let tx = conn.transaction()?;
tx.execute_batch(m.sql)?; match tx.execute_batch(m.sql) {
Ok(()) => (),
Err(e) => {
let err_msg = e.to_string();
if err_msg.contains("duplicate column name") {
tracing::warn!(
database = db_name,
version = m.version,
"Column already exists during migration, continuing"
);
} else {
return Err(e.into());
}
}
}
tx.commit()?; tx.commit()?;
crate::db::sqlite::set_user_version(conn, m.version as i32)?; crate::db::sqlite::set_user_version(conn, m.version as i32)?;
@@ -568,4 +582,24 @@ pub const USERS_MIGRATIONS: &[Migration] = &[
WHERE username = 'admin' AND account_type = 'standard'; WHERE username = 'admin' AND account_type = 'standard';
"#, "#,
}, },
Migration {
version: 3,
name: "tenant_id",
sql: r#"
ALTER TABLE users ADD COLUMN tenant_id TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_tenant_id
ON users(tenant_id)
WHERE tenant_id IS NOT NULL;
"#,
},
Migration {
version: 4,
name: "uuid",
sql: r#"
ALTER TABLE users ADD COLUMN uuid TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_uuid
ON users(uuid)
WHERE uuid IS NOT NULL;
"#,
},
]; ];
+92 -229
View File
@@ -7,46 +7,67 @@ use crate::db::sqlite::{enable_foreign_keys, enable_wal};
use rusqlite::Connection; use rusqlite::Connection;
use std::fs; use std::fs;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use tracing::info;
pub mod admin; pub mod admin;
pub mod analytics; pub mod analytics;
pub mod audit_events; pub mod audit_events;
pub mod content; pub mod content;
pub mod identity_migrate;
pub mod migrations; pub mod migrations;
pub mod preview; pub mod preview;
pub mod qr; pub mod qr;
pub mod schema_v08;
pub mod slug_migrate;
pub mod slugs;
pub mod sqlite; pub mod sqlite;
pub mod tenant;
pub mod topology;
pub mod users; pub mod users;
use crate::db::schema_v08::{
GLOBAL_LANDING_PAGES_MIGRATIONS, GLOBAL_URLS_MIGRATIONS, RESERVED_SLUGS_MIGRATIONS,
};
use crate::db::topology::Topology;
#[derive(Clone)] #[derive(Clone)]
pub struct Db { pub struct Db {
pub admin: Arc<Mutex<Connection>>, pub admin: Arc<Mutex<Connection>>,
pub content: Arc<Mutex<Connection>>,
pub analytics: Arc<Mutex<Connection>>,
pub system: Arc<Mutex<Connection>>, pub system: Arc<Mutex<Connection>>,
pub users: Arc<Mutex<Connection>>, pub users: Arc<Mutex<Connection>>,
pub global_urls: Arc<Mutex<Connection>>,
pub global_landing_pages: Arc<Mutex<Connection>>,
pub reserved: Arc<Mutex<Connection>>,
pub data_dir: std::path::PathBuf, pub data_dir: std::path::PathBuf,
pub topology: Topology,
}
fn open_prepared(
path: &std::path::Path,
name: &str,
) -> Result<Connection, Box<dyn std::error::Error>> {
info!("Opening {}.db", name);
let conn = Connection::open(path)?;
enable_wal(&conn, name)?;
enable_foreign_keys(&conn, name)?;
Ok(conn)
} }
impl Db { impl Db {
pub fn init(config: &Config) -> Result<Self, Box<dyn std::error::Error>> { pub fn init(config: &Config) -> Result<Self, Box<dyn std::error::Error>> {
use chrono::Utc; use chrono::Utc;
use tracing::info;
// Ensure data directory exists let topology = Topology::new(&config.data_dir);
if !config.data_dir.exists() {
fs::create_dir_all(&config.data_dir)?; if !topology.root().exists() {
fs::create_dir_all(topology.root())?;
} }
topology.ensure_core_dirs()?;
let admin_dir = config.data_dir.join("admin"); let admin_dir = topology.admin_dir();
let users_dir = config.data_dir.join("users");
fs::create_dir_all(&admin_dir)?;
fs::create_dir_all(&users_dir)?;
// Automated Legacy Migration: check if legacy files are at the root // Automated Legacy Migration: check if legacy files are at the root
let legacy_admin_db = config.data_dir.join("admin.db"); let legacy_admin_db = topology.legacy_flat_admin_db();
let legacy_content_db = config.data_dir.join("content.db");
let legacy_analytics_db = config.data_dir.join("analytics.db");
// 1. If legacy admin.db exists at root, move admin/system DBs to config.data_dir/admin/ // 1. If legacy admin.db exists at root, move admin/system DBs to config.data_dir/admin/
if legacy_admin_db.exists() { if legacy_admin_db.exists() {
@@ -60,7 +81,7 @@ impl Db {
"system.db-shm", "system.db-shm",
]; ];
for f in files { for f in files {
let src = config.data_dir.join(f); let src = topology.root().join(f);
if src.exists() { if src.exists() {
let dst = admin_dir.join(f); let dst = admin_dir.join(f);
let _ = fs::rename(&src, &dst); let _ = fs::rename(&src, &dst);
@@ -88,24 +109,9 @@ impl Db {
} }
} }
let admin_path = admin_dir.join("admin.db"); let mut admin_conn = open_prepared(&topology.admin_db(), "admin")?;
let system_path = admin_dir.join("system.db"); let mut system_conn = open_prepared(&topology.system_db(), "system")?;
let users_db_path = admin_dir.join("users.db"); let mut users_conn = open_prepared(&topology.users_registry_db(), "users")?;
info!("Opening admin.db");
let mut admin_conn = Connection::open(admin_path)?;
info!("Opening system.db");
let mut system_conn = Connection::open(system_path)?;
info!("Opening users.db");
let mut users_conn = Connection::open(users_db_path)?;
enable_wal(&admin_conn, "admin")?;
enable_wal(&system_conn, "system")?;
enable_wal(&users_conn, "users")?;
enable_foreign_keys(&admin_conn, "admin")?;
enable_foreign_keys(&system_conn, "system")?;
enable_foreign_keys(&users_conn, "users")?;
// Run migrations for system.db first // Run migrations for system.db first
info!("Running system migrations"); info!("Running system migrations");
@@ -168,182 +174,54 @@ impl Db {
let now = Utc::now().to_rfc3339(); let now = Utc::now().to_rfc3339();
let _ = users_conn.execute("DELETE FROM sessions WHERE expires_at < ?1;", [now]); let _ = users_conn.execute("DELETE FROM sessions WHERE expires_at < ?1;", [now]);
// 2. If legacy content.db/analytics.db exists, move them to users/1/ (for legacy_admin) let mut global_urls_conn = open_prepared(&topology.global_urls_db(), "global_urls")?;
let legacy_migration_needed = legacy_content_db.exists() || legacy_analytics_db.exists(); let mut global_landing_pages_conn =
open_prepared(&topology.global_landing_pages_db(), "global_landing_pages")?;
let mut reserved_conn = open_prepared(&topology.reserved_db(), "reserved")?;
// Ensure legacy_admin (user ID 1) exists in users.db
let legacy_admin_id = 1i64;
let legacy_admin_exists: bool = users_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
[legacy_admin_id],
|row| row.get(0),
)
.unwrap_or(false);
if !legacy_admin_exists {
// Get copied administrator password hash
let admin_password_hash: String = admin_conn
.query_row(
"SELECT password_hash FROM users ORDER BY created_at ASC LIMIT 1;",
[],
|row| row.get(0),
)
.unwrap_or_else(|_| {
// If admin_db is empty, hash a default password
crate::auth::password::hash_password("legacy_admin_pass").unwrap_or_default()
});
let now = Utc::now().to_rfc3339();
users_conn.execute(
"INSERT INTO users (id, username, password_hash, status, created_at, account_type)
VALUES (?1, ?2, ?3, ?4, ?5, ?6);",
rusqlite::params![
legacy_admin_id,
"legacy_admin",
admin_password_hash,
"disabled",
now,
"system"
],
)?;
// Seed quotas
users_conn.execute(
"INSERT INTO quotas (user_id) VALUES (?1);",
[legacy_admin_id],
)?;
}
let legacy_user_dir = users_dir.join(legacy_admin_id.to_string());
fs::create_dir_all(&legacy_user_dir)?;
if legacy_content_db.exists() || legacy_analytics_db.exists() {
tracing::warn!("LEGACY DETECTED: content/analytics databases found at root. Moving to multi-tenant user ID 1 directory...");
let content_files = vec!["content.db", "content.db-wal", "content.db-shm"];
for f in content_files {
let src = config.data_dir.join(f);
if src.exists() {
let dst = legacy_user_dir.join(f);
let _ = fs::rename(&src, &dst);
}
}
let analytics_files = vec!["analytics.db", "analytics.db-wal", "analytics.db-shm"];
for f in analytics_files {
let src = config.data_dir.join(f);
if src.exists() {
let dst = legacy_user_dir.join(f);
let _ = fs::rename(&src, &dst);
}
}
}
// Open the legacy_admin databases (user ID 1) as db.content and db.analytics
let content_path = legacy_user_dir.join("content.db");
let analytics_path = legacy_user_dir.join("analytics.db");
let mut content_conn = Connection::open(content_path)?;
let mut analytics_conn = Connection::open(analytics_path)?;
enable_wal(&content_conn, "content")?;
enable_wal(&analytics_conn, "analytics")?;
enable_foreign_keys(&content_conn, "content")?;
enable_foreign_keys(&analytics_conn, "analytics")?;
// Run migrations for content.db and analytics.db
run_migrations( run_migrations(
&mut content_conn, &mut global_urls_conn,
"content", "global_urls",
CONTENT_MIGRATIONS, GLOBAL_URLS_MIGRATIONS,
Some(&system_arc), Some(&system_arc),
)?; )?;
run_migrations( run_migrations(
&mut analytics_conn, &mut global_landing_pages_conn,
"analytics", "global_landing_pages",
ANALYTICS_MIGRATIONS, GLOBAL_LANDING_PAGES_MIGRATIONS,
Some(&system_arc), Some(&system_arc),
)?; )?;
run_migrations(
// If we just migrated legacy content, populate the global_slugs table in system.db &mut reserved_conn,
if legacy_migration_needed { "reserved",
info!("Populating global slug index with legacy content..."); RESERVED_SLUGS_MIGRATIONS,
let mut sys_lock = system_arc.lock().unwrap(); Some(&system_arc),
let tx = sys_lock.transaction()?; )?;
crate::db::slugs::seed_reserved_slugs(&reserved_conn)?;
// Extract urls from content.db and insert into global_slugs
{
let mut stmt =
content_conn.prepare("SELECT code, id, created_at, status FROM urls;")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let slug: String = row.get(0)?;
let target_id: String = row.get(1)?;
let created_at: String = row.get(2)?;
let status: String = row.get(3)?;
let global_status = if status == "dead" {
"disabled"
} else {
"active"
};
let now = Utc::now().to_rfc3339();
let _ = tx.execute(
"INSERT OR IGNORE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
rusqlite::params![slug, legacy_admin_id, "url", target_id, created_at, now, global_status],
);
}
}
// Extract landing pages from content.db and insert into global_slugs
{
let mut stmt = content_conn
.prepare("SELECT code, id, created_at, state FROM landing_pages;")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let slug: String = row.get(0)?;
let target_id: String = row.get(1)?;
let created_at: String = row.get(2)?;
let state: String = row.get(3)?;
let now = Utc::now().to_rfc3339();
let status = if state == "published" {
"active"
} else {
"disabled"
};
let _ = tx.execute(
"INSERT OR IGNORE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
rusqlite::params![slug, legacy_admin_id, "page", target_id, created_at, now, status],
);
}
}
tx.commit()?;
info!("Global slug index populated successfully.");
}
let db = Self { let db = Self {
admin: Arc::new(Mutex::new(admin_conn)), admin: Arc::new(Mutex::new(admin_conn)),
content: Arc::new(Mutex::new(content_conn)),
analytics: Arc::new(Mutex::new(analytics_conn)),
system: system_arc, system: system_arc,
users: Arc::new(Mutex::new(users_conn)), users: Arc::new(Mutex::new(users_conn)),
global_urls: Arc::new(Mutex::new(global_urls_conn)),
global_landing_pages: Arc::new(Mutex::new(global_landing_pages_conn)),
reserved: Arc::new(Mutex::new(reserved_conn)),
data_dir: config.data_dir.clone(), data_dir: config.data_dir.clone(),
topology,
}; };
let _ = db.reconcile_global_slugs(config); // Post-init: Clean up stale reservations from v0.8 slug databases (older than 15 mins)
// Post-init: Clean up stale reservations
{ {
let system_conn = db.system.lock().unwrap(); if let (Ok(urls_conn), Ok(pages_conn)) =
match crate::db::users::cleanup_stale_reservations(&system_conn, &config.data_dir) { (db.global_urls.lock(), db.global_landing_pages.lock())
{
match crate::db::slugs::cleanup_stale_reservations(&urls_conn, &pages_conn, 900) {
Ok(count) => { Ok(count) => {
if count > 0 { if count > 0 {
tracing::info!("Cleaned up {} stale reserving slugs", count); tracing::info!(
"Cleaned up {} stale reserving slugs from v0.8 registry",
count
);
} }
} }
Err(e) => { Err(e) => {
@@ -351,30 +229,6 @@ impl Db {
} }
} }
} }
// Post-init: Verify global registry integrity
{
let system_conn = db.system.lock().unwrap();
let users_conn = db.users.lock().unwrap();
match crate::services::registry_validator::RegistryValidator::scan(
&system_conn,
&users_conn,
&config.data_dir,
None,
) {
Ok(issues) => {
for issue in issues {
tracing::error!(
"Global registry integrity issue: {:?} for slug {}",
issue.issue_type,
issue.slug
);
}
}
Err(e) => {
tracing::error!("Failed to verify global registry integrity: {}", e);
}
}
} }
Ok(db) Ok(db)
@@ -384,24 +238,33 @@ impl Db {
let admin = self.admin.lock().unwrap(); let admin = self.admin.lock().unwrap();
let _ = admin.execute("VACUUM;", []); let _ = admin.execute("VACUUM;", []);
let content = self.content.lock().unwrap();
let _ = content.execute("VACUUM;", []);
let analytics = self.analytics.lock().unwrap();
let _ = analytics.execute("VACUUM;", []);
let system = self.system.lock().unwrap(); let system = self.system.lock().unwrap();
let _ = system.execute("VACUUM;", []); let _ = system.execute("VACUUM;", []);
let users = self.users.lock().unwrap(); let users = self.users.lock().unwrap();
let _ = users.execute("VACUUM;", []); let _ = users.execute("VACUUM;", []);
let global_urls = self.global_urls.lock().unwrap();
let _ = global_urls.execute("VACUUM;", []);
let global_landing_pages = self.global_landing_pages.lock().unwrap();
let _ = global_landing_pages.execute("VACUUM;", []);
let reserved = self.reserved.lock().unwrap();
let _ = reserved.execute("VACUUM;", []);
Ok(()) Ok(())
} }
pub fn init_user_databases(&self, user_id: i64) -> Result<(), Box<dyn std::error::Error>> { pub fn init_user_databases(&self, user_id: i64) -> Result<(), Box<dyn std::error::Error>> {
let user_dir = self.data_dir.join("users").join(user_id.to_string()); let user = {
fs::create_dir_all(&user_dir)?; let conn = self.users.lock().unwrap();
crate::db::users::get_user_by_id(&conn, user_id)?
.ok_or_else(|| format!("cannot provision databases for unknown user {user_id}"))?
};
let location = crate::db::tenant::location_for_user(&user)?;
let user_dir = location.dir(&self.topology)?;
fs::create_dir_all(user_dir.join("extensions"))?;
let content_path = user_dir.join("content.db"); let content_path = user_dir.join("content.db");
let analytics_path = user_dir.join("analytics.db"); let analytics_path = user_dir.join("analytics.db");
@@ -444,7 +307,7 @@ impl Db {
pub fn reconcile_global_slugs( pub fn reconcile_global_slugs(
&self, &self,
config: &Config, _config: &Config,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
use chrono::Utc; use chrono::Utc;
@@ -462,8 +325,10 @@ impl Db {
drop(stmt); drop(stmt);
for user_id in user_ids { for user_id in user_ids {
let user_dir = config.data_dir.join("users").join(user_id.to_string()); let content_path = match self.topology.content_db_i64(user_id) {
let content_path = user_dir.join("content.db"); Ok(p) => p,
Err(_) => continue,
};
if content_path.exists() { if content_path.exists() {
let content_conn = Connection::open(&content_path)?; let content_conn = Connection::open(&content_path)?;
@@ -523,16 +388,14 @@ impl Db {
#[cfg(test)] #[cfg(test)]
mod db_init_tests { mod db_init_tests {
use super::*; use super::*;
use std::path::PathBuf;
#[test] #[test]
fn test_db_init() { fn test_db_init() {
let temp_dir = PathBuf::from("./temp_test_db_dir"); let temp_dir = std::env::temp_dir().join(format!("test_db_init_{}", uuid::Uuid::new_v4()));
if temp_dir.exists() { if temp_dir.exists() {
let _ = std::fs::remove_dir_all(&temp_dir); let _ = std::fs::remove_dir_all(&temp_dir);
} }
let mut config = Config::load(); let config = Config::load_with_cli(Some(&temp_dir)).unwrap();
config.data_dir = temp_dir.clone();
let db = Db::init(&config); let db = Db::init(&config);
// Cleanup // Cleanup
+90
View File
@@ -0,0 +1,90 @@
//! Independently versioned v0.8 schemas.
//!
//! Phase 1 creates the frozen slug databases. Live slug allocate/lookup still
//! uses `system.db.global_slugs` until Phase 4 moves ownership.
use super::migrations::Migration;
/// `slugs/global_urls.db` — globally unique URL slugs.
///
/// `owner_user_id` remains INTEGER to match v0.7 `users.id`. Phase 3 will
/// migrate it to the 12-hex user id.
pub const GLOBAL_URLS_MIGRATIONS: &[Migration] = &[
Migration {
version: 1,
name: "initial_schema",
sql: r#"
CREATE TABLE IF NOT EXISTS global_urls (
slug TEXT PRIMARY KEY,
owner_tenant_id TEXT NOT NULL,
target_id TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
status TEXT NOT NULL,
retired_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_global_urls_tenant ON global_urls(owner_tenant_id);
CREATE INDEX IF NOT EXISTS idx_global_urls_status ON global_urls(status);
"#,
},
Migration {
version: 2,
name: "tenant_id_column",
sql: r#"
ALTER TABLE global_urls ADD COLUMN owner_tenant_id TEXT;
CREATE INDEX IF NOT EXISTS idx_global_urls_tenant ON global_urls(owner_tenant_id);
"#,
},
];
/// `slugs/global_landing_pages.db` — globally unique landing-page slugs.
pub const GLOBAL_LANDING_PAGES_MIGRATIONS: &[Migration] = &[
Migration {
version: 1,
name: "initial_schema",
sql: r#"
CREATE TABLE IF NOT EXISTS global_landing_pages (
slug TEXT PRIMARY KEY,
owner_tenant_id TEXT NOT NULL,
target_id TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
status TEXT NOT NULL,
retired_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_global_landing_pages_tenant ON global_landing_pages(owner_tenant_id);
CREATE INDEX IF NOT EXISTS idx_global_landing_pages_status ON global_landing_pages(status);
"#,
},
Migration {
version: 2,
name: "tenant_id_column",
sql: r#"
ALTER TABLE global_landing_pages ADD COLUMN owner_tenant_id TEXT;
CREATE INDEX IF NOT EXISTS idx_global_landing_pages_tenant ON global_landing_pages(owner_tenant_id);
"#,
},
];
/// `slugs/reserved.db` — system route / reserved names that must never allocate.
pub const RESERVED_SLUGS_MIGRATIONS: &[Migration] = &[Migration {
version: 1,
name: "initial_schema",
sql: r#"
CREATE TABLE IF NOT EXISTS reserved_slugs (
slug TEXT PRIMARY KEY,
reason TEXT
);
"#,
}];
/// Allowed statuses for the v0.8 slug databases.
///
/// `reserving` is an allocation lock, not a published state.
/// Frozen published states: `active`, `disabled`, `retired`.
pub const SLUG_STATUS_RESERVING: &str = "reserving";
pub const SLUG_STATUS_ACTIVE: &str = "active";
pub const SLUG_STATUS_DISABLED: &str = "disabled";
pub const SLUG_STATUS_RETIRED: &str = "retired";
+538
View File
@@ -0,0 +1,538 @@
//! Explicit Phase 4 Global Slug Migration Engine.
//!
//! Migrates `system.db.global_slugs` and `system.db.reserved_slugs` to:
//! - `slugs/global_urls.db` (`global_urls` table)
//! - `slugs/global_landing_pages.db` (`global_landing_pages` table)
//! - `slugs/reserved.db` (`reserved_slugs` table)
//!
//! Lifecycle:
//! 1. Preflight (inspect system.db, resolve owners against users.db, check for ambiguities)
//! 2. Backup (create pre-migration tarball)
//! 3. Transactional Migration (insert into target databases with restart safety)
//! 4. Validation (row counts, field parity, global uniqueness across databases)
//! 5. Completion Marker (record audit event and system setting)
use rusqlite::Connection;
use std::collections::HashMap;
use tracing::{info, warn};
use crate::config::Config;
use crate::db::topology::Topology;
use crate::db::Db;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SlugMigrationReport {
pub total_legacy_slugs: usize,
pub url_slugs_migrated: usize,
pub page_slugs_migrated: usize,
pub reserved_slugs_migrated: usize,
pub existing_records_verified: usize,
pub validation_passed: bool,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct SlugPreflightReport {
pub total_slugs: usize,
pub url_slugs: usize,
pub page_slugs: usize,
pub reserved_slugs: usize,
pub unresolvable_owners: Vec<(String, i64)>,
pub unknown_target_types: Vec<(String, String)>,
}
/// Helper struct for legacy slug record
struct LegacySlugRecord {
slug: String,
owner_user_id: i64,
target_type: String,
target_id: String,
created_at: String,
updated_at: String,
status: String,
deleted_at: Option<String>,
}
/// Run the full explicit global slug migration.
pub async fn run_global_slug_migration(
config: &Config,
dry_run: bool,
skip_backup: bool,
) -> Result<SlugMigrationReport, Box<dyn std::error::Error>> {
let _topology = Topology::new(&config.data_dir);
let mut warnings = Vec::new();
// 1. Initialize Db
let db = Db::init(config)?;
// 2. Preflight
let preflight = {
let system_conn = db.system.lock().unwrap();
let users_conn = db.users.lock().unwrap();
inspect_slug_preflight(&system_conn, &users_conn)?
};
info!(
"Slug Migration Preflight: total={}, urls={}, pages={}, reserved={}, unresolvable_owners={}, unknown_types={}",
preflight.total_slugs,
preflight.url_slugs,
preflight.page_slugs,
preflight.reserved_slugs,
preflight.unresolvable_owners.len(),
preflight.unknown_target_types.len()
);
if !preflight.unresolvable_owners.is_empty() {
let msg = format!(
"Fatal: Found {} slugs with unresolvable owner_user_id in users.db: {:?}",
preflight.unresolvable_owners.len(),
preflight.unresolvable_owners
);
return Err(msg.into());
}
if !preflight.unknown_target_types.is_empty() {
let msg = format!(
"Fatal: Found {} slugs with unknown target_type: {:?}",
preflight.unknown_target_types.len(),
preflight.unknown_target_types
);
return Err(msg.into());
}
if dry_run {
info!("Dry run enabled: no slug records will be migrated.");
return Ok(SlugMigrationReport {
total_legacy_slugs: preflight.total_slugs,
url_slugs_migrated: preflight.url_slugs,
page_slugs_migrated: preflight.page_slugs,
reserved_slugs_migrated: preflight.reserved_slugs,
existing_records_verified: 0,
validation_passed: true,
warnings,
});
}
// 3. Backup before migration (if needed and not skipped)
if preflight.total_slugs > 0 && !skip_backup {
info!("Creating pre-migration backup before slug migration...");
match crate::jobs::backup::perform_backup(&db, config).await {
Ok(path) => info!("Pre-migration backup created at {:?}", path),
Err(e) => {
warn!(
"Pre-migration backup failed: {}. Continuing with caution.",
e
);
warnings.push(format!("Pre-migration backup warning: {e}"));
}
}
}
// 4. Transactional Migration
let (migrated_urls, migrated_pages, verified_existing) = {
let system_conn = db.system.lock().unwrap();
let users_conn = db.users.lock().unwrap();
let mut urls_conn = db.global_urls.lock().unwrap();
let mut pages_conn = db.global_landing_pages.lock().unwrap();
let reserved_conn = db.reserved.lock().unwrap();
migrate_slugs_transactional(
&system_conn,
&users_conn,
&mut urls_conn,
&mut pages_conn,
&reserved_conn,
&mut warnings,
)?
};
// 5. Validation
let validation_passed = {
let system_conn = db.system.lock().unwrap();
let urls_conn = db.global_urls.lock().unwrap();
let pages_conn = db.global_landing_pages.lock().unwrap();
let reserved_conn = db.reserved.lock().unwrap();
validate_slug_migration(
&system_conn,
&urls_conn,
&pages_conn,
&reserved_conn,
&mut warnings,
)?
};
// 6. Completion Marker in system.db
if validation_passed {
let system_conn = db.system.lock().unwrap();
let now = chrono::Utc::now().to_rfc3339();
let details = format!(
"Global slug migration completed: {} URLs migrated, {} landing pages migrated, {} verified existing",
migrated_urls, migrated_pages, verified_existing
);
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
"system",
"GLOBAL_SLUG_MIGRATION_COMPLETED",
"slugs",
"slugs/*.db",
Some(&details),
);
let _ = system_conn.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('v08_global_slug_migration_completed', ?1);",
[&now],
);
}
Ok(SlugMigrationReport {
total_legacy_slugs: preflight.total_slugs,
url_slugs_migrated: migrated_urls,
page_slugs_migrated: migrated_pages,
reserved_slugs_migrated: preflight.reserved_slugs,
existing_records_verified: verified_existing,
validation_passed,
warnings,
})
}
/// Inspect system.db.global_slugs and reserved_slugs to build preflight plan.
pub fn inspect_slug_preflight(
system_conn: &Connection,
users_conn: &Connection,
) -> Result<SlugPreflightReport, Box<dyn std::error::Error>> {
let has_global_slugs: bool = system_conn.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='global_slugs');",
[],
|r| r.get(0),
)?;
if !has_global_slugs {
return Ok(SlugPreflightReport {
total_slugs: 0,
url_slugs: 0,
page_slugs: 0,
reserved_slugs: 0,
unresolvable_owners: Vec::new(),
unknown_target_types: Vec::new(),
});
}
let mut stmt = system_conn.prepare(
"SELECT slug, owner_user_id, target_type, target_id, created_at, updated_at, status, deleted_at
FROM global_slugs;",
)?;
let records = stmt.query_map([], |row| {
Ok(LegacySlugRecord {
slug: row.get(0)?,
owner_user_id: row.get(1)?,
target_type: row.get(2)?,
target_id: row.get(3)?,
created_at: row.get(4)?,
updated_at: row.get(5)?,
status: row.get(6)?,
deleted_at: row.get(7)?,
})
})?;
// Build owner map from users.db: user_id -> TenantId/legacy_admin
let mut owner_map = HashMap::new();
let users = crate::db::users::list_users(users_conn)?;
for u in users {
if let Some(tid) = u.tenant_id {
owner_map.insert(u.id, tid.as_str().to_string());
} else if u.account_type == "admin"
|| u.account_type == "system"
|| u.username == "legacy_admin"
{
owner_map.insert(u.id, "legacy_admin".to_string());
}
}
let mut total_slugs = 0;
let mut url_slugs = 0;
let mut page_slugs = 0;
let mut unresolvable_owners = Vec::new();
let mut unknown_target_types = Vec::new();
for r in records {
let rec = r?;
total_slugs += 1;
if !owner_map.contains_key(&rec.owner_user_id) {
unresolvable_owners.push((rec.slug.clone(), rec.owner_user_id));
}
match rec.target_type.as_str() {
"url" => url_slugs += 1,
"page" => page_slugs += 1,
other => unknown_target_types.push((rec.slug.clone(), other.to_string())),
}
}
let reserved_slugs: usize = system_conn
.query_row("SELECT COUNT(*) FROM reserved_slugs;", [], |r| r.get(0))
.unwrap_or(0);
Ok(SlugPreflightReport {
total_slugs,
url_slugs,
page_slugs,
reserved_slugs,
unresolvable_owners,
unknown_target_types,
})
}
/// Transactional migration of slugs from system.db to global_urls.db and global_landing_pages.db.
pub fn migrate_slugs_transactional(
system_conn: &Connection,
users_conn: &Connection,
urls_conn: &mut Connection,
pages_conn: &mut Connection,
reserved_conn: &Connection,
warnings: &mut Vec<String>,
) -> Result<(usize, usize, usize), Box<dyn std::error::Error>> {
// 1. Build owner map: user_id -> TenantId
let mut owner_map = HashMap::new();
let users = crate::db::users::list_users(users_conn)?;
for u in users {
if let Some(tid) = u.tenant_id {
owner_map.insert(u.id, tid.as_str().to_string());
} else if u.account_type == "admin"
|| u.account_type == "system"
|| u.username == "legacy_admin"
{
owner_map.insert(u.id, "legacy_admin".to_string());
}
}
// 2. Fetch all legacy slugs
let mut stmt = system_conn.prepare(
"SELECT slug, owner_user_id, target_type, target_id, created_at, updated_at, status, deleted_at
FROM global_slugs;",
)?;
let records: Vec<LegacySlugRecord> = stmt
.query_map([], |row| {
Ok(LegacySlugRecord {
slug: row.get(0)?,
owner_user_id: row.get(1)?,
target_type: row.get(2)?,
target_id: row.get(3)?,
created_at: row.get(4)?,
updated_at: row.get(5)?,
status: row.get(6)?,
deleted_at: row.get(7)?,
})
})?
.collect::<Result<_, _>>()?;
let mut migrated_urls = 0;
let mut migrated_pages = 0;
let mut verified_existing = 0;
let tx_urls = urls_conn.transaction()?;
let tx_pages = pages_conn.transaction()?;
for rec in records {
let owner_tenant_id = match owner_map.get(&rec.owner_user_id) {
Some(t) => t.clone(),
None => {
warnings.push(format!(
"Unresolvable owner_user_id {} for slug '{}'; skipping",
rec.owner_user_id, rec.slug
));
continue;
}
};
let retired_at = rec.deleted_at;
if rec.target_type == "url" {
// Check if record already exists in global_urls.db
let existing: Option<(String, String)> = tx_urls
.query_row(
"SELECT owner_tenant_id, target_id FROM global_urls WHERE slug = ?1;",
[&rec.slug],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.ok();
if let Some((existing_owner, existing_target)) = existing {
if existing_owner == owner_tenant_id && existing_target == rec.target_id {
verified_existing += 1;
} else {
warnings.push(format!(
"Conflict: Slug '{}' already exists in global_urls with different owner/target",
rec.slug
));
}
} else {
tx_urls.execute(
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
rusqlite::params![
rec.slug,
owner_tenant_id,
rec.target_id,
rec.created_at,
rec.updated_at,
rec.status,
retired_at
],
)?;
migrated_urls += 1;
}
} else if rec.target_type == "page" {
// Check if record already exists in global_landing_pages.db
let existing: Option<(String, String)> = tx_pages
.query_row(
"SELECT owner_tenant_id, target_id FROM global_landing_pages WHERE slug = ?1;",
[&rec.slug],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.ok();
if let Some((existing_owner, existing_target)) = existing {
if existing_owner == owner_tenant_id && existing_target == rec.target_id {
verified_existing += 1;
} else {
warnings.push(format!(
"Conflict: Slug '{}' already exists in global_landing_pages with different owner/target",
rec.slug
));
}
} else {
tx_pages.execute(
"INSERT INTO global_landing_pages (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
rusqlite::params![
rec.slug,
owner_tenant_id,
rec.target_id,
rec.created_at,
rec.updated_at,
rec.status,
retired_at
],
)?;
migrated_pages += 1;
}
}
}
tx_urls.commit()?;
tx_pages.commit()?;
// Seed reserved slugs
let _ = crate::db::slugs::seed_reserved_slugs(reserved_conn);
Ok((migrated_urls, migrated_pages, verified_existing))
}
/// Validate that every legacy slug was migrated correctly and global uniqueness holds.
pub fn validate_slug_migration(
system_conn: &Connection,
urls_conn: &Connection,
pages_conn: &Connection,
reserved_conn: &Connection,
warnings: &mut Vec<String>,
) -> Result<bool, Box<dyn std::error::Error>> {
let mut valid = true;
let has_global_slugs: bool = system_conn.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='global_slugs');",
[],
|r| r.get(0),
)?;
if !has_global_slugs {
return Ok(true);
}
let legacy_url_count: usize = system_conn.query_row(
"SELECT COUNT(*) FROM global_slugs WHERE target_type = 'url';",
[],
|r| r.get(0),
)?;
let legacy_page_count: usize = system_conn.query_row(
"SELECT COUNT(*) FROM global_slugs WHERE target_type = 'page';",
[],
|r| r.get(0),
)?;
let target_url_count: usize =
urls_conn.query_row("SELECT COUNT(*) FROM global_urls;", [], |r| r.get(0))?;
let target_page_count: usize =
pages_conn.query_row("SELECT COUNT(*) FROM global_landing_pages;", [], |r| {
r.get(0)
})?;
if target_url_count < legacy_url_count {
warnings.push(format!(
"URL slug count mismatch: legacy has {}, target has {}",
legacy_url_count, target_url_count
));
valid = false;
}
if target_page_count < legacy_page_count {
warnings.push(format!(
"Page slug count mismatch: legacy has {}, target has {}",
legacy_page_count, target_page_count
));
valid = false;
}
// Global Uniqueness Invariant Check: 0 intersection between reserved, urls, and pages
let collisions_urls_pages: usize = urls_conn.query_row(
"SELECT COUNT(*) FROM global_urls WHERE slug IN (SELECT slug FROM global_landing_pages);",
[],
|r| r.get(0),
).unwrap_or(0);
if collisions_urls_pages > 0 {
warnings.push(format!(
"Invariant Violation: {} slugs appear in both global_urls and global_landing_pages",
collisions_urls_pages
));
valid = false;
}
let collisions_reserved_urls: usize = urls_conn
.query_row(
"SELECT COUNT(*) FROM global_urls WHERE slug IN (SELECT slug FROM reserved_slugs);",
[],
|r| r.get(0),
)
.unwrap_or(0);
if collisions_reserved_urls > 0 {
warnings.push(format!(
"Invariant Violation: {} slugs appear in both global_urls and reserved_slugs",
collisions_reserved_urls
));
valid = false;
}
let collisions_reserved_pages: usize = pages_conn.query_row(
"SELECT COUNT(*) FROM global_landing_pages WHERE slug IN (SELECT slug FROM reserved_slugs);",
[],
|r| r.get(0),
).unwrap_or(0);
if collisions_reserved_pages > 0 {
warnings.push(format!(
"Invariant Violation: {} slugs appear in both global_landing_pages and reserved_slugs",
collisions_reserved_pages
));
valid = false;
}
let _ = reserved_conn;
Ok(valid)
}
+472
View File
@@ -0,0 +1,472 @@
//! Frozen v0.8 Slug Registry Layer.
//!
//! Owns `slugs/global_urls.db`, `slugs/global_landing_pages.db`, and `slugs/reserved.db`.
//!
//! Guarantees:
//! - Exact TenantId ownership (no integer ID primitives in v0.8 API).
//! - Cross-database global uniqueness invariant: a slug exists in AT MOST ONE of
//! `reserved.db`, `global_urls.db`, `global_landing_pages.db`.
//! - Retired slugs remain permanently unavailable for reuse across both URLs and landing pages.
//! - Concurrency-safe global allocations.
use chrono::Utc;
use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
use crate::db::schema_v08::{
SLUG_STATUS_ACTIVE, SLUG_STATUS_DISABLED, SLUG_STATUS_RESERVING, SLUG_STATUS_RETIRED,
};
use crate::identity::TenantId;
/// Core reserved slugs, matching the v0.7 `system.db` seed list.
pub const CORE_RESERVED_SLUGS: &[(&str, &str)] = &[
("admin", "System route"),
("login", "System route"),
("logout", "System route"),
("dashboard", "System route"),
("api", "System route"),
("docs", "System route"),
("assets", "System route"),
("static", "System route"),
("favicon.ico", "System route"),
("robots.txt", "System route"),
("health", "System route"),
("metrics", "System route"),
("install", "System route"),
("setup", "System route"),
("support", "System route"),
("help", "System route"),
("security", "System route"),
("abuse", "System route"),
("billing", "System route"),
("status", "System route"),
("legacy_admin", "System reserved"),
("administrator", "System reserved"),
("system", "System reserved"),
("root", "System reserved"),
("www", "System reserved"),
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SlugTargetType {
Url,
LandingPage,
}
impl SlugTargetType {
pub fn as_str(&self) -> &'static str {
match self {
Self::Url => "url",
Self::LandingPage => "page",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResolvedSlugInfo {
pub slug: String,
pub owner_tenant_id: String,
pub target_type: SlugTargetType,
pub target_id: String,
pub created_at: String,
pub updated_at: String,
pub status: String,
pub retired_at: Option<String>,
}
/// Insert the core reserved set. Idempotent (`INSERT OR IGNORE`).
pub fn seed_reserved_slugs(conn: &Connection) -> rusqlite::Result<usize> {
let mut inserted = 0usize;
for (slug, reason) in CORE_RESERVED_SLUGS {
let n = conn.execute(
"INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES (?1, ?2);",
params![slug, reason],
)?;
inserted += n;
}
Ok(inserted)
}
pub fn reserved_slug_count(conn: &Connection) -> rusqlite::Result<i64> {
conn.query_row("SELECT COUNT(*) FROM reserved_slugs;", [], |row| row.get(0))
}
/// Check whether a slug is reserved in `slugs/reserved.db`.
pub fn is_slug_reserved(reserved_conn: &Connection, slug: &str) -> rusqlite::Result<bool> {
reserved_conn.query_row(
"SELECT EXISTS(SELECT 1 FROM reserved_slugs WHERE slug = ?1);",
[slug],
|row| row.get(0),
)
}
/// Check whether a slug is available for a new registration.
/// Returns false if reserved or if present in `global_urls.db` or `global_landing_pages.db`
/// in any state (including `retired`).
pub fn is_slug_available(
reserved_conn: &Connection,
urls_conn: &Connection,
pages_conn: &Connection,
slug: &str,
) -> rusqlite::Result<bool> {
if is_slug_reserved(reserved_conn, slug)? {
return Ok(false);
}
let in_urls: bool = urls_conn.query_row(
"SELECT EXISTS(SELECT 1 FROM global_urls WHERE slug = ?1);",
[slug],
|r| r.get(0),
)?;
if in_urls {
return Ok(false);
}
let in_pages: bool = pages_conn.query_row(
"SELECT EXISTS(SELECT 1 FROM global_landing_pages WHERE slug = ?1);",
[slug],
|r| r.get(0),
)?;
if in_pages {
return Ok(false);
}
Ok(true)
}
/// Lookup a slug in `slugs/global_urls.db`.
pub fn lookup_url_slug(
urls_conn: &Connection,
slug: &str,
) -> rusqlite::Result<Option<ResolvedSlugInfo>> {
urls_conn
.query_row(
"SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at
FROM global_urls WHERE slug = ?1;",
[slug],
|row| {
Ok(ResolvedSlugInfo {
slug: row.get(0)?,
owner_tenant_id: row.get(1)?,
target_type: SlugTargetType::Url,
target_id: row.get(2)?,
created_at: row.get(3)?,
updated_at: row.get(4)?,
status: row.get(5)?,
retired_at: row.get(6)?,
})
},
)
.optional()
}
/// Lookup a slug in `slugs/global_landing_pages.db`.
pub fn lookup_landing_page_slug(
pages_conn: &Connection,
slug: &str,
) -> rusqlite::Result<Option<ResolvedSlugInfo>> {
pages_conn
.query_row(
"SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at
FROM global_landing_pages WHERE slug = ?1;",
[slug],
|row| {
Ok(ResolvedSlugInfo {
slug: row.get(0)?,
owner_tenant_id: row.get(1)?,
target_type: SlugTargetType::LandingPage,
target_id: row.get(2)?,
created_at: row.get(3)?,
updated_at: row.get(4)?,
status: row.get(5)?,
retired_at: row.get(6)?,
})
},
)
.optional()
}
/// Unified slug lookup: checks `global_urls.db`, then `global_landing_pages.db`.
pub fn lookup_slug(
urls_conn: &Connection,
pages_conn: &Connection,
slug: &str,
) -> rusqlite::Result<Option<ResolvedSlugInfo>> {
if let Some(info) = lookup_url_slug(urls_conn, slug)? {
return Ok(Some(info));
}
lookup_landing_page_slug(pages_conn, slug)
}
/// Register a URL slug in `slugs/global_urls.db`.
pub fn register_url_slug(
urls_conn: &Connection,
slug: &str,
owner_tenant_id: &TenantId,
target_id: &str,
status: &str,
) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
urls_conn.execute(
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL);",
params![slug, owner_tenant_id.as_str(), target_id, now, now, status],
)?;
Ok(())
}
/// Register a landing page slug in `slugs/global_landing_pages.db`.
pub fn register_landing_page_slug(
pages_conn: &Connection,
slug: &str,
owner_tenant_id: &TenantId,
target_id: &str,
status: &str,
) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
pages_conn.execute(
"INSERT INTO global_landing_pages (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL);",
params![slug, owner_tenant_id.as_str(), target_id, now, now, status],
)?;
Ok(())
}
/// Atomic reservation for a URL slug in `slugs/global_urls.db` (status = 'reserving').
pub fn reserve_url_slug(
reserved_conn: &Connection,
urls_conn: &Connection,
pages_conn: &Connection,
slug: &str,
owner_tenant_id: &TenantId,
) -> rusqlite::Result<()> {
if !is_slug_available(reserved_conn, urls_conn, pages_conn, slug)? {
return Err(rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
Some("Slug is unavailable or reserved".into()),
));
}
let now = Utc::now().to_rfc3339();
urls_conn.execute(
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
VALUES (?1, ?2, '', ?3, ?4, ?5, NULL);",
params![slug, owner_tenant_id.as_str(), now, now, SLUG_STATUS_RESERVING],
)?;
Ok(())
}
/// Atomic reservation for a landing page slug in `slugs/global_landing_pages.db` (status = 'reserving').
pub fn reserve_landing_page_slug(
reserved_conn: &Connection,
urls_conn: &Connection,
pages_conn: &Connection,
slug: &str,
owner_tenant_id: &TenantId,
) -> rusqlite::Result<()> {
if !is_slug_available(reserved_conn, urls_conn, pages_conn, slug)? {
return Err(rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
Some("Slug is unavailable or reserved".into()),
));
}
let now = Utc::now().to_rfc3339();
pages_conn.execute(
"INSERT INTO global_landing_pages (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
VALUES (?1, ?2, '', ?3, ?4, ?5, NULL);",
params![slug, owner_tenant_id.as_str(), now, now, SLUG_STATUS_RESERVING],
)?;
Ok(())
}
/// Release a reserving URL slug (e.g. if content creation fails).
pub fn release_url_slug(
urls_conn: &Connection,
slug: &str,
owner_tenant_id: &TenantId,
) -> rusqlite::Result<()> {
urls_conn.execute(
"DELETE FROM global_urls WHERE slug = ?1 AND owner_tenant_id = ?2 AND status = 'reserving';",
params![slug, owner_tenant_id.as_str()],
)?;
Ok(())
}
/// Release a reserving Landing Page slug (e.g. if content creation fails).
pub fn release_landing_page_slug(
pages_conn: &Connection,
slug: &str,
owner_tenant_id: &TenantId,
) -> rusqlite::Result<()> {
pages_conn.execute(
"DELETE FROM global_landing_pages WHERE slug = ?1 AND owner_tenant_id = ?2 AND status = 'reserving';",
params![slug, owner_tenant_id.as_str()],
)?;
Ok(())
}
/// Update URL slug target and activate after reservation.
pub fn activate_url_slug(
urls_conn: &Connection,
slug: &str,
target_id: &str,
) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
urls_conn.execute(
"UPDATE global_urls SET target_id = ?1, status = ?2, updated_at = ?3 WHERE slug = ?4;",
params![target_id, SLUG_STATUS_ACTIVE, now, slug],
)?;
Ok(())
}
/// Update Landing Page slug target and activate after reservation.
pub fn activate_landing_page_slug(
pages_conn: &Connection,
slug: &str,
target_id: &str,
) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
pages_conn.execute(
"UPDATE global_landing_pages SET target_id = ?1, status = ?2, updated_at = ?3 WHERE slug = ?4;",
params![target_id, SLUG_STATUS_ACTIVE, now, slug],
)?;
Ok(())
}
/// Retire a slug permanently so it cannot be reused.
pub fn retire_slug(
urls_conn: &Connection,
pages_conn: &Connection,
slug: &str,
) -> rusqlite::Result<bool> {
let now = Utc::now().to_rfc3339();
let n_urls = urls_conn.execute(
"UPDATE global_urls SET status = ?1, retired_at = ?2, updated_at = ?3 WHERE slug = ?4;",
params![SLUG_STATUS_RETIRED, now, now, slug],
)?;
if n_urls > 0 {
return Ok(true);
}
let n_pages = pages_conn.execute(
"UPDATE global_landing_pages SET status = ?1, retired_at = ?2, updated_at = ?3 WHERE slug = ?4;",
params![SLUG_STATUS_RETIRED, now, now, slug],
)?;
Ok(n_pages > 0)
}
/// Disable a slug (returns 410 Gone on redirect, but still owned by tenant).
pub fn disable_slug(
urls_conn: &Connection,
pages_conn: &Connection,
slug: &str,
) -> rusqlite::Result<bool> {
let now = Utc::now().to_rfc3339();
let n_urls = urls_conn.execute(
"UPDATE global_urls SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
params![SLUG_STATUS_DISABLED, now, slug],
)?;
if n_urls > 0 {
return Ok(true);
}
let n_pages = pages_conn.execute(
"UPDATE global_landing_pages SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
params![SLUG_STATUS_DISABLED, now, slug],
)?;
Ok(n_pages > 0)
}
/// Transfer slug ownership to a new tenant.
pub fn transfer_slug_owner(
urls_conn: &Connection,
pages_conn: &Connection,
slug: &str,
new_owner_tenant_id: &TenantId,
new_target_id: &str,
) -> rusqlite::Result<bool> {
let now = Utc::now().to_rfc3339();
let n_urls = urls_conn.execute(
"UPDATE global_urls SET owner_tenant_id = ?1, target_id = ?2, updated_at = ?3 WHERE slug = ?4;",
params![new_owner_tenant_id.as_str(), new_target_id, now, slug],
)?;
if n_urls > 0 {
return Ok(true);
}
let n_pages = pages_conn.execute(
"UPDATE global_landing_pages SET owner_tenant_id = ?1, target_id = ?2, updated_at = ?3 WHERE slug = ?4;",
params![new_owner_tenant_id.as_str(), new_target_id, now, slug],
)?;
Ok(n_pages > 0)
}
/// List all slugs owned by a specific tenant.
pub fn list_slugs_by_tenant(
urls_conn: &Connection,
pages_conn: &Connection,
owner_tenant_id: &TenantId,
) -> rusqlite::Result<Vec<ResolvedSlugInfo>> {
let mut results = Vec::new();
let mut stmt = urls_conn.prepare(
"SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at
FROM global_urls WHERE owner_tenant_id = ?1 ORDER BY created_at ASC;",
)?;
let rows = stmt.query_map([owner_tenant_id.as_str()], |row| {
Ok(ResolvedSlugInfo {
slug: row.get(0)?,
owner_tenant_id: row.get(1)?,
target_type: SlugTargetType::Url,
target_id: row.get(2)?,
created_at: row.get(3)?,
updated_at: row.get(4)?,
status: row.get(5)?,
retired_at: row.get(6)?,
})
})?;
for r in rows {
results.push(r?);
}
let mut stmt = pages_conn.prepare(
"SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at
FROM global_landing_pages WHERE owner_tenant_id = ?1 ORDER BY created_at ASC;",
)?;
let rows = stmt.query_map([owner_tenant_id.as_str()], |row| {
Ok(ResolvedSlugInfo {
slug: row.get(0)?,
owner_tenant_id: row.get(1)?,
target_type: SlugTargetType::LandingPage,
target_id: row.get(2)?,
created_at: row.get(3)?,
updated_at: row.get(4)?,
status: row.get(5)?,
retired_at: row.get(6)?,
})
})?;
for r in rows {
results.push(r?);
}
Ok(results)
}
/// Clean up stale reservations older than threshold seconds.
pub fn cleanup_stale_reservations(
urls_conn: &Connection,
pages_conn: &Connection,
older_than_seconds: i64,
) -> rusqlite::Result<usize> {
let threshold = (Utc::now() - chrono::Duration::seconds(older_than_seconds)).to_rfc3339();
let n1 = urls_conn.execute(
"DELETE FROM global_urls WHERE status = 'reserving' AND created_at < ?1;",
[&threshold],
)?;
let n2 = pages_conn.execute(
"DELETE FROM global_landing_pages WHERE status = 'reserving' AND created_at < ?1;",
[&threshold],
)?;
Ok(n1 + n2)
}
+266
View File
@@ -0,0 +1,266 @@
//! Tenant database access boundary.
//!
//! New tenant opens go through [`TenantId`]. Legacy integer row ids are used
//! only to look up a registered Core user, then resolved to a [`TenantLocation`].
//! Unknown ids must not create filesystem databases.
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use rusqlite::Connection;
use crate::db::topology::Topology;
use crate::error::AppError;
use crate::identity::TenantId;
use crate::models::TenantUser;
/// Open tenant SQLite connections (content, analytics, profile).
#[derive(Clone)]
pub struct UserDbs {
pub content: Arc<Mutex<Connection>>,
pub analytics: Arc<Mutex<Connection>>,
pub profile: Arc<Mutex<Connection>>,
}
/// How a tenant database may be opened.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TenantOpenMode {
/// Authenticated tenant user / API actor. Status must be `active`.
Ordinary,
/// Public slug/QR/gate resolution. User must exist and not be deleted.
PublicContent,
/// Core jobs / admin inspection. User must exist and not be deleted.
/// Existing files only — will not create a database.
CoreJob,
/// Explicit provisioning after a Core user row was inserted.
Provision,
}
/// Resolved tenant filesystem location.
///
/// `Id` is the frozen v0.8 path. `Legacy` is unmigrated v0.7 `users/<integer>/`
/// and exists only until Phase 3 directory migration.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TenantLocation {
Id(TenantId),
Legacy(i64),
}
impl TenantLocation {
pub fn cache_key(&self) -> String {
match self {
Self::Id(id) => id.as_str().to_string(),
Self::Legacy(row_id) => format!("legacy:{row_id}"),
}
}
pub fn dir(&self, topology: &Topology) -> Result<PathBuf, crate::db::topology::TopologyError> {
match self {
Self::Id(id) => Ok(topology.tenant_dir(*id)),
Self::Legacy(row_id) => topology.user_dir_i64(*row_id),
}
}
}
pub fn location_for_user(user: &TenantUser) -> Result<TenantLocation, AppError> {
if let Some(id) = user.tenant_id {
return Ok(TenantLocation::Id(id));
}
if user.id <= 0 {
return Err(AppError::NotFound("invalid user id".into()));
}
Ok(TenantLocation::Legacy(user.id))
}
pub fn status_allows_open(status: &str, mode: TenantOpenMode) -> bool {
match mode {
TenantOpenMode::Ordinary => status == "active",
TenantOpenMode::PublicContent | TenantOpenMode::CoreJob | TenantOpenMode::Provision => {
status != "deleted"
}
}
}
pub fn assert_may_open(user: &TenantUser, mode: TenantOpenMode) -> Result<(), AppError> {
if !status_allows_open(&user.status, mode) {
return Err(AppError::Unauthorized(format!(
"tenant access denied for status '{}'",
user.status
)));
}
Ok(())
}
/// Open tenant DBs for a registered Core user (legacy row id compatibility).
pub fn open_for_row_id(
users_conn: &Connection,
topology: &Topology,
system_db: &Arc<Mutex<Connection>>,
pool: &mut std::collections::HashMap<String, UserDbs>,
user_id: i64,
mode: TenantOpenMode,
) -> Result<UserDbs, AppError> {
let user = crate::db::users::get_user_by_id(users_conn, user_id)?
.ok_or_else(|| AppError::NotFound(format!("user {user_id} not found")))?;
assert_may_open(&user, mode)?;
let location = location_for_user(&user)?;
open_location(topology, system_db, pool, &location, mode)
}
/// Open tenant DBs by frozen TenantId. Unknown ids never create files.
pub fn open_for_tenant_id(
users_conn: &Connection,
topology: &Topology,
system_db: &Arc<Mutex<Connection>>,
pool: &mut std::collections::HashMap<String, UserDbs>,
tenant_id: TenantId,
mode: TenantOpenMode,
) -> Result<UserDbs, AppError> {
let user = crate::db::users::get_user_by_tenant_id(users_conn, tenant_id)?
.ok_or_else(|| AppError::NotFound(format!("tenant {tenant_id} not found")))?;
assert_may_open(&user, mode)?;
let location = location_for_user(&user)?;
open_location(topology, system_db, pool, &location, mode)
}
pub fn open_location(
topology: &Topology,
system_db: &Arc<Mutex<Connection>>,
pool: &mut std::collections::HashMap<String, UserDbs>,
location: &TenantLocation,
mode: TenantOpenMode,
) -> Result<UserDbs, AppError> {
let key = location.cache_key();
if let Some(dbs) = pool.get(&key) {
return Ok(dbs.clone());
}
let user_dir = location
.dir(topology)
.map_err(|e| AppError::BadRequest(e.to_string()))?;
let content_path = user_dir.join("content.db");
let analytics_path = user_dir.join("analytics.db");
let profile_path = user_dir.join("profile.db");
let create = matches!(
mode,
TenantOpenMode::Provision | TenantOpenMode::Ordinary | TenantOpenMode::PublicContent
);
if !create && !content_path.exists() {
return Err(AppError::NotFound(format!(
"tenant database missing at {}",
content_path.display()
)));
}
if create {
std::fs::create_dir_all(user_dir.join("extensions"))?;
}
let dbs = open_files(
&content_path,
&analytics_path,
&profile_path,
system_db,
create,
)?;
pool.insert(key, dbs.clone());
Ok(dbs)
}
fn open_files(
content_path: &Path,
analytics_path: &Path,
profile_path: &Path,
system_db: &Arc<Mutex<Connection>>,
create: bool,
) -> Result<UserDbs, AppError> {
if !create && !content_path.exists() {
return Err(AppError::NotFound("content.db missing".into()));
}
let mut content_conn = Connection::open(content_path)?;
let mut analytics_conn = Connection::open(analytics_path)?;
let profile_conn = Connection::open(profile_path)?;
crate::db::sqlite::enable_wal(&content_conn, "content")?;
crate::db::sqlite::enable_wal(&analytics_conn, "analytics")?;
crate::db::sqlite::enable_wal(&profile_conn, "profile")?;
crate::db::sqlite::enable_foreign_keys(&content_conn, "content")?;
crate::db::sqlite::enable_foreign_keys(&analytics_conn, "analytics")?;
crate::db::sqlite::enable_foreign_keys(&profile_conn, "profile")?;
crate::db::migrations::run_migrations(
&mut content_conn,
"content",
crate::db::migrations::CONTENT_MIGRATIONS,
Some(system_db),
)
.map_err(|e| AppError::Internal(e.to_string()))?;
crate::db::migrations::run_migrations(
&mut analytics_conn,
"analytics",
crate::db::migrations::ANALYTICS_MIGRATIONS,
Some(system_db),
)
.map_err(|e| AppError::Internal(e.to_string()))?;
profile_conn.execute_batch(
"CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);",
)?;
Ok(UserDbs {
content: Arc::new(Mutex::new(content_conn)),
analytics: Arc::new(Mutex::new(analytics_conn)),
profile: Arc::new(Mutex::new(profile_conn)),
})
}
/// Job/helper path: registered user, existing content.db only, never create.
pub fn existing_content_path(
users_conn: &Connection,
topology: &Topology,
user_id: i64,
) -> Result<PathBuf, rusqlite::Error> {
let user = crate::db::users::get_user_by_id(users_conn, user_id)?.ok_or_else(|| {
rusqlite::Error::InvalidPath(PathBuf::from(format!("unknown-user-{user_id}")))
})?;
if user.status == "deleted" {
return Err(rusqlite::Error::InvalidPath(PathBuf::from("deleted-user")));
}
let loc = location_for_user(&user)
.map_err(|e| rusqlite::Error::InvalidPath(PathBuf::from(e.to_string())))?;
let dir = loc
.dir(topology)
.map_err(|e| rusqlite::Error::InvalidPath(PathBuf::from(e.to_string())))?;
let path = dir.join("content.db");
if !path.exists() {
return Err(rusqlite::Error::InvalidPath(path));
}
Ok(path)
}
pub fn existing_analytics_path(
users_conn: &Connection,
topology: &Topology,
user_id: i64,
) -> Result<PathBuf, rusqlite::Error> {
let user = crate::db::users::get_user_by_id(users_conn, user_id)?.ok_or_else(|| {
rusqlite::Error::InvalidPath(PathBuf::from(format!("unknown-user-{user_id}")))
})?;
if user.status == "deleted" {
return Err(rusqlite::Error::InvalidPath(PathBuf::from("deleted-user")));
}
let loc = location_for_user(&user)
.map_err(|e| rusqlite::Error::InvalidPath(PathBuf::from(e.to_string())))?;
let dir = loc
.dir(topology)
.map_err(|e| rusqlite::Error::InvalidPath(PathBuf::from(e.to_string())))?;
let path = dir.join("analytics.db");
if !path.exists() {
return Err(rusqlite::Error::InvalidPath(path));
}
Ok(path)
}
+345
View File
@@ -0,0 +1,345 @@
//! Frozen v0.8.0 database topology under a configurable physical root.
//!
//! The logical layout is:
//!
//! ```text
//! <data_dir>/
//! ├── admin/{admin,system,users}.db
//! ├── slugs/{global_urls,global_landing_pages,reserved}.db
//! └── users/<user-key>/{profile,content,analytics}.db
//! └── extensions/<extension>/<extension>.db
//! ```
//!
//! `<data_dir>` is `Config.data_dir` (CLI `--data-dir`, env `NX9_BZOD_DATA_DIR`, or config file).
//! It is not renamed to `database/`. Production Docker, CasaOS, and
//! `deploy.sh` bind this physical root.
use std::path::{Path, PathBuf};
use crate::identity::TenantId;
/// Directory name of the migration-era `legacy_admin` tenant (`users.db` id = 1).
pub const LEGACY_ADMIN_USER_KEY: &str = "1";
/// Frozen first-party extension names. There is no runtime plugin loader.
pub const FIRST_PARTY_EXTENSIONS: &[&str] = &["cv", "certificates", "documents", "portfolio"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TopologyError {
InvalidUserDir { name: String },
InvalidExtension { name: String },
}
impl std::fmt::Display for TopologyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidUserDir { name } => {
write!(f, "invalid tenant directory name: {name:?}")
}
Self::InvalidExtension { name } => {
write!(f, "invalid extension name: {name:?}")
}
}
}
}
impl std::error::Error for TopologyError {}
/// Authoritative resolver for every BZOD database path.
#[derive(Clone, Debug)]
pub struct Topology {
root: PathBuf,
}
impl Topology {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn admin_dir(&self) -> PathBuf {
self.root.join("admin")
}
pub fn slugs_dir(&self) -> PathBuf {
self.root.join("slugs")
}
pub fn users_dir(&self) -> PathBuf {
self.root.join("users")
}
pub fn admin_db(&self) -> PathBuf {
self.admin_dir().join("admin.db")
}
pub fn system_db(&self) -> PathBuf {
self.admin_dir().join("system.db")
}
pub fn users_registry_db(&self) -> PathBuf {
self.admin_dir().join("users.db")
}
pub fn global_urls_db(&self) -> PathBuf {
self.slugs_dir().join("global_urls.db")
}
pub fn global_landing_pages_db(&self) -> PathBuf {
self.slugs_dir().join("global_landing_pages.db")
}
pub fn reserved_db(&self) -> PathBuf {
self.slugs_dir().join("reserved.db")
}
/// Pre-multi-tenant files that may still exist at the physical root.
pub fn legacy_flat_admin_db(&self) -> PathBuf {
self.root.join("admin.db")
}
pub fn legacy_flat_system_db(&self) -> PathBuf {
self.root.join("system.db")
}
pub fn legacy_flat_users_db(&self) -> PathBuf {
self.root.join("users.db")
}
pub fn legacy_flat_content_db(&self) -> PathBuf {
self.root.join("content.db")
}
pub fn legacy_flat_analytics_db(&self) -> PathBuf {
self.root.join("analytics.db")
}
pub fn legacy_admin_dir(&self) -> PathBuf {
self.users_dir().join(LEGACY_ADMIN_USER_KEY)
}
/// Frozen tenant directory: `users/<12-hex-TenantId>/`.
pub fn tenant_dir(&self, tenant_id: TenantId) -> PathBuf {
self.users_dir().join(tenant_id.as_str())
}
pub fn tenant_content_db(&self, tenant_id: TenantId) -> PathBuf {
self.tenant_dir(tenant_id).join("content.db")
}
pub fn tenant_analytics_db(&self, tenant_id: TenantId) -> PathBuf {
self.tenant_dir(tenant_id).join("analytics.db")
}
pub fn tenant_profile_db(&self, tenant_id: TenantId) -> PathBuf {
self.tenant_dir(tenant_id).join("profile.db")
}
pub fn user_dir(&self, user_key: &str) -> Result<PathBuf, TopologyError> {
if !is_valid_user_dir_name(user_key) {
return Err(TopologyError::InvalidUserDir {
name: user_key.to_string(),
});
}
Ok(self.users_dir().join(user_key))
}
pub fn user_dir_i64(&self, user_id: i64) -> Result<PathBuf, TopologyError> {
self.user_dir(&user_id.to_string())
}
pub fn content_db(&self, user_key: &str) -> Result<PathBuf, TopologyError> {
Ok(self.user_dir(user_key)?.join("content.db"))
}
pub fn analytics_db(&self, user_key: &str) -> Result<PathBuf, TopologyError> {
Ok(self.user_dir(user_key)?.join("analytics.db"))
}
pub fn profile_db(&self, user_key: &str) -> Result<PathBuf, TopologyError> {
Ok(self.user_dir(user_key)?.join("profile.db"))
}
pub fn content_db_i64(&self, user_id: i64) -> Result<PathBuf, TopologyError> {
self.content_db(&user_id.to_string())
}
pub fn analytics_db_i64(&self, user_id: i64) -> Result<PathBuf, TopologyError> {
self.analytics_db(&user_id.to_string())
}
pub fn profile_db_i64(&self, user_id: i64) -> Result<PathBuf, TopologyError> {
self.profile_db(&user_id.to_string())
}
pub fn extensions_dir(&self, user_key: &str) -> Result<PathBuf, TopologyError> {
Ok(self.user_dir(user_key)?.join("extensions"))
}
pub fn extensions_dir_i64(&self, user_id: i64) -> Result<PathBuf, TopologyError> {
self.extensions_dir(&user_id.to_string())
}
pub fn extension_db(&self, user_key: &str, extension: &str) -> Result<PathBuf, TopologyError> {
if !is_valid_extension_name(extension) {
return Err(TopologyError::InvalidExtension {
name: extension.to_string(),
});
}
Ok(self
.extensions_dir(user_key)?
.join(extension)
.join(format!("{extension}.db")))
}
/// Create `admin/`, `slugs/`, and `users/` under the physical root.
pub fn ensure_core_dirs(&self) -> std::io::Result<()> {
std::fs::create_dir_all(self.admin_dir())?;
std::fs::create_dir_all(self.slugs_dir())?;
std::fs::create_dir_all(self.users_dir())?;
Ok(())
}
/// Create a tenant directory and its `extensions/` folder.
pub fn ensure_user_dirs(&self, user_key: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
let dir = self.user_dir(user_key)?;
std::fs::create_dir_all(&dir)?;
std::fs::create_dir_all(dir.join("extensions"))?;
Ok(dir)
}
pub fn ensure_user_dirs_i64(
&self,
user_id: i64,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
self.ensure_user_dirs(&user_id.to_string())
}
}
/// Tenant directory names: 12 lowercase hex (v0.8) or a positive decimal id (v0.7).
pub fn is_valid_user_dir_name(name: &str) -> bool {
if name.is_empty() || name == "." || name == ".." {
return false;
}
if name
.as_bytes()
.iter()
.any(|b| *b == b'/' || *b == b'\\' || *b == 0 || *b == b'.')
{
return false;
}
if is_v08_user_id(name) {
return true;
}
is_legacy_integer_user_id(name)
}
pub fn is_v08_user_id(name: &str) -> bool {
name.len() == 12 && name.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
}
pub fn is_legacy_integer_user_id(name: &str) -> bool {
if name.is_empty() || name.as_bytes()[0] == b'0' {
return false;
}
name.bytes().all(|b| b.is_ascii_digit()) && name.parse::<i64>().map(|n| n > 0).unwrap_or(false)
}
/// First-party extension directory names: `^[a-z][a-z0-9_]{0,31}$`.
pub fn is_valid_extension_name(name: &str) -> bool {
let mut chars = name.chars();
match chars.next() {
Some(c) if c.is_ascii_lowercase() => {}
_ => return false,
}
name.len() <= 32
&& name
.bytes()
.all(|b| matches!(b, b'a'..=b'z' | b'0'..=b'9' | b'_'))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn physical_root_is_not_renamed_to_database() {
let t = Topology::new("/var/lib/bzod/data");
assert_eq!(t.root(), Path::new("/var/lib/bzod/data"));
assert!(t.admin_dir().ends_with("data/admin"));
assert!(t.slugs_dir().ends_with("data/slugs"));
assert!(t.users_dir().ends_with("data/users"));
assert!(!t.root().ends_with("database"));
}
#[test]
fn frozen_core_paths() {
let t = Topology::new("/app/data");
assert_eq!(t.admin_db(), PathBuf::from("/app/data/admin/admin.db"));
assert_eq!(t.system_db(), PathBuf::from("/app/data/admin/system.db"));
assert_eq!(
t.users_registry_db(),
PathBuf::from("/app/data/admin/users.db")
);
assert_eq!(
t.global_urls_db(),
PathBuf::from("/app/data/slugs/global_urls.db")
);
assert_eq!(
t.global_landing_pages_db(),
PathBuf::from("/app/data/slugs/global_landing_pages.db")
);
assert_eq!(
t.reserved_db(),
PathBuf::from("/app/data/slugs/reserved.db")
);
}
#[test]
fn tenant_paths_legacy_integer_and_v08_hex() {
let t = Topology::new("/app/data");
assert_eq!(
t.content_db_i64(2).unwrap(),
PathBuf::from("/app/data/users/2/content.db")
);
let tid = crate::identity::TenantId::parse("a1b2c3d4e5f6").unwrap();
assert_eq!(
t.tenant_content_db(tid),
PathBuf::from("/app/data/users/a1b2c3d4e5f6/content.db")
);
assert_eq!(
t.extension_db("a1b2c3d4e5f6", "cv").unwrap(),
PathBuf::from("/app/data/users/a1b2c3d4e5f6/extensions/cv/cv.db")
);
}
#[test]
fn rejects_path_traversal_and_forged_names() {
let t = Topology::new("/app/data");
assert!(t.user_dir("..").is_err());
assert!(t.user_dir("../2").is_err());
assert!(t.user_dir("2/../3").is_err());
assert!(t.user_dir("2/foo").is_err());
assert!(t.user_dir("-1").is_err());
assert!(t.user_dir("0").is_err());
assert!(t.user_dir("01").is_err());
assert!(t.user_dir("").is_err());
assert!(t.user_dir("ABCDEFABCDEF").is_err());
assert!(t.content_db_i64(-5).is_err());
assert!(t.content_db_i64(0).is_err());
assert!(t.extension_db("2", "../cv").is_err());
assert!(t.extension_db("2", "cv/db").is_err());
assert!(t.extension_db("2", "").is_err());
assert!(t.extension_db("2", "CV").is_err());
}
#[test]
fn first_party_extension_names_are_valid() {
for name in FIRST_PARTY_EXTENSIONS {
assert!(is_valid_extension_name(name), "{name}");
}
}
}
+193 -70
View File
@@ -1,7 +1,70 @@
use crate::identity::TenantId;
use crate::models::{TenantUser, UserApiToken, UserQuotas, UserSession}; use crate::models::{TenantUser, UserApiToken, UserQuotas, UserSession};
use chrono::Utc; use chrono::Utc;
use rusqlite::{params, Connection, OptionalExtension}; use rusqlite::{params, Connection, OptionalExtension};
const USER_COLUMNS: &str = "id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata, tenant_id, uuid";
fn map_user(row: &rusqlite::Row<'_>) -> rusqlite::Result<TenantUser> {
let tenant_raw: Option<String> = row.get(9)?;
let tenant_id = match tenant_raw {
Some(s) => Some(TenantId::parse(&s).map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(9, rusqlite::types::Type::Text, Box::new(e))
})?),
None => None,
};
let uuid: Option<String> = row.get(10)?;
Ok(TenantUser {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
status: row.get(3)?,
created_at: row.get(4)?,
last_login: row.get(5)?,
account_type: row.get(6)?,
organization_id: row.get(7)?,
metadata: row.get(8)?,
tenant_id,
uuid,
})
}
fn allocate_unique_tenant_id(conn: &Connection) -> rusqlite::Result<TenantId> {
for _ in 0..16 {
let candidate = TenantId::generate();
let exists: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM users WHERE tenant_id = ?1);",
[candidate.as_str()],
|row| row.get(0),
)?;
if !exists {
return Ok(candidate);
}
}
Err(rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
Some("failed to allocate unique TenantId".into()),
))
}
pub fn allocate_unique_uuid(conn: &Connection) -> rusqlite::Result<String> {
for _ in 0..16 {
let candidate = uuid::Uuid::new_v4().to_string();
let exists: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM users WHERE uuid = ?1);",
[&candidate],
|row| row.get(0),
)?;
if !exists {
return Ok(candidate);
}
}
Err(rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
Some("failed to allocate unique UUID".into()),
))
}
// --- User Operations --- // --- User Operations ---
pub fn is_reserved_username(username: &str) -> bool { pub fn is_reserved_username(username: &str) -> bool {
@@ -17,11 +80,19 @@ pub fn create_admin_user(
let created_at = Utc::now().to_rfc3339(); let created_at = Utc::now().to_rfc3339();
let status = "active"; let status = "active";
let account_type = "admin"; let account_type = "admin";
let user_uuid = allocate_unique_uuid(conn)?;
conn.execute( conn.execute(
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata) "INSERT INTO users (username, password_hash, status, created_at, account_type, metadata, tenant_id, uuid)
VALUES (?1, ?2, ?3, ?4, ?5, NULL);", VALUES (?1, ?2, ?3, ?4, ?5, NULL, NULL, ?6);",
params![username, password_hash, status, created_at, account_type], params![
username,
password_hash,
status,
created_at,
account_type,
user_uuid,
],
)?; )?;
let id = conn.last_insert_rowid(); let id = conn.last_insert_rowid();
@@ -39,6 +110,8 @@ pub fn create_admin_user(
account_type: account_type.to_string(), account_type: account_type.to_string(),
organization_id: None, organization_id: None,
metadata: None, metadata: None,
tenant_id: None,
uuid: Some(user_uuid),
}) })
} }
@@ -58,17 +131,21 @@ pub fn create_user(
let created_at = Utc::now().to_rfc3339(); let created_at = Utc::now().to_rfc3339();
let status = "active"; let status = "active";
let tenant_id = allocate_unique_tenant_id(conn)?;
let user_uuid = allocate_unique_uuid(conn)?;
conn.execute( conn.execute(
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata) "INSERT INTO users (username, password_hash, status, created_at, account_type, metadata, tenant_id, uuid)
VALUES (?1, ?2, ?3, ?4, ?5, ?6);", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8);",
params![ params![
username, username,
password_hash, password_hash,
status, status,
created_at, created_at,
account_type, account_type,
metadata metadata,
tenant_id.as_str(),
user_uuid,
], ],
)?; )?;
@@ -87,27 +164,37 @@ pub fn create_user(
account_type: account_type.to_string(), account_type: account_type.to_string(),
organization_id: None, organization_id: None,
metadata: metadata.map(|s| s.to_string()), metadata: metadata.map(|s| s.to_string()),
tenant_id: Some(tenant_id),
uuid: Some(user_uuid),
}) })
} }
pub fn get_user_by_id(conn: &Connection, id: i64) -> rusqlite::Result<Option<TenantUser>> { pub fn get_user_by_id(conn: &Connection, id: i64) -> rusqlite::Result<Option<TenantUser>> {
conn.query_row( conn.query_row(
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata &format!("SELECT {USER_COLUMNS} FROM users WHERE id = ?1;"),
FROM users WHERE id = ?1;",
params![id], params![id],
|row| { map_user,
Ok(TenantUser { )
id: row.get(0)?, .optional()
username: row.get(1)?, }
password_hash: row.get(2)?,
status: row.get(3)?, pub fn get_user_by_tenant_id(
created_at: row.get(4)?, conn: &Connection,
last_login: row.get(5)?, tenant_id: TenantId,
account_type: row.get(6)?, ) -> rusqlite::Result<Option<TenantUser>> {
organization_id: row.get(7)?, conn.query_row(
metadata: row.get(8)?, &format!("SELECT {USER_COLUMNS} FROM users WHERE tenant_id = ?1;"),
}) params![tenant_id.as_str()],
}, map_user,
)
.optional()
}
pub fn get_user_by_uuid(conn: &Connection, uuid: &str) -> rusqlite::Result<Option<TenantUser>> {
conn.query_row(
&format!("SELECT {USER_COLUMNS} FROM users WHERE uuid = ?1;"),
params![uuid],
map_user,
) )
.optional() .optional()
} }
@@ -117,22 +204,9 @@ pub fn get_user_by_username(
username: &str, username: &str,
) -> rusqlite::Result<Option<TenantUser>> { ) -> rusqlite::Result<Option<TenantUser>> {
conn.query_row( conn.query_row(
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata &format!("SELECT {USER_COLUMNS} FROM users WHERE username = ?1;"),
FROM users WHERE username = ?1;",
params![username], params![username],
|row| { map_user,
Ok(TenantUser {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
status: row.get(3)?,
created_at: row.get(4)?,
last_login: row.get(5)?,
account_type: row.get(6)?,
organization_id: row.get(7)?,
metadata: row.get(8)?,
})
},
) )
.optional() .optional()
} }
@@ -184,23 +258,10 @@ pub fn update_user_last_login(conn: &Connection, id: i64) -> rusqlite::Result<()
} }
pub fn list_users(conn: &Connection) -> rusqlite::Result<Vec<TenantUser>> { pub fn list_users(conn: &Connection) -> rusqlite::Result<Vec<TenantUser>> {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(&format!(
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata "SELECT {USER_COLUMNS} FROM users ORDER BY username ASC;"
FROM users ORDER BY username ASC;", ))?;
)?; let rows = stmt.query_map([], map_user)?;
let rows = stmt.query_map([], |row| {
Ok(TenantUser {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
status: row.get(3)?,
created_at: row.get(4)?,
last_login: row.get(5)?,
account_type: row.get(6)?,
organization_id: row.get(7)?,
metadata: row.get(8)?,
})
})?;
let mut users = Vec::new(); let mut users = Vec::new();
for u in rows { for u in rows {
@@ -439,6 +500,9 @@ pub fn delete_user_api_token(conn: &Connection, id: i64, user_id: i64) -> rusqli
// --- Global Slug & Quota Reconciliation Helpers --- // --- Global Slug & Quota Reconciliation Helpers ---
#[deprecated(
note = "Legacy v0.7 global_slugs function; use crate::db::slugs::is_slug_available instead"
)]
pub fn is_slug_available(system_conn: &Connection, slug: &str) -> rusqlite::Result<bool> { pub fn is_slug_available(system_conn: &Connection, slug: &str) -> rusqlite::Result<bool> {
// 1. Check reserved list // 1. Check reserved list
let reserved: bool = system_conn let reserved: bool = system_conn
@@ -465,6 +529,9 @@ pub fn is_slug_available(system_conn: &Connection, slug: &str) -> rusqlite::Resu
Ok(!exists) Ok(!exists)
} }
#[deprecated(
note = "Legacy v0.7 global_slugs function; use crate::db::slugs::reserve_*_slug instead"
)]
pub fn register_global_slug( pub fn register_global_slug(
system_conn: &Connection, system_conn: &Connection,
slug: &str, slug: &str,
@@ -490,6 +557,9 @@ pub fn register_global_slug(
Ok(()) Ok(())
} }
#[deprecated(
note = "Legacy v0.7 global_slugs function; use crate::db::slugs::release_*_slug instead"
)]
pub fn release_global_slug( pub fn release_global_slug(
system_conn: &Connection, system_conn: &Connection,
slug: &str, slug: &str,
@@ -508,6 +578,7 @@ pub fn release_global_slug(
Ok(()) Ok(())
} }
#[deprecated(note = "Legacy v0.7 global_slugs function; use crate::db::slugs APIs instead")]
pub fn soft_delete_global_slug( pub fn soft_delete_global_slug(
system_conn: &Connection, system_conn: &Connection,
slug: &str, slug: &str,
@@ -544,10 +615,11 @@ pub fn audit_slug_namespace(
let mut invalid_entries = Vec::new(); let mut invalid_entries = Vec::new();
let warnings = Vec::new(); let warnings = Vec::new();
let mut slug_owners: HashMap<String, Vec<i64>> = HashMap::new(); let mut slug_owners: HashMap<String, Vec<String>> = HashMap::new();
// 1. Scan legacy content.db if it exists // 1. Scan legacy content.db if it exists
let legacy_content_path = config.data_dir.join("content.db"); let legacy_content_path =
crate::db::topology::Topology::new(&config.data_dir).legacy_flat_content_db();
if legacy_content_path.exists() { if legacy_content_path.exists() {
if let Ok(conn) = Connection::open(&legacy_content_path) { if let Ok(conn) = Connection::open(&legacy_content_path) {
// URLs // URLs
@@ -555,7 +627,7 @@ pub fn audit_slug_namespace(
if let Ok(mut rows) = stmt.query([]) { if let Ok(mut rows) = stmt.query([]) {
while let Some(row) = rows.next().unwrap_or(None) { while let Some(row) = rows.next().unwrap_or(None) {
if let Ok(code) = row.get::<_, String>(0) { if let Ok(code) = row.get::<_, String>(0) {
slug_owners.entry(code).or_default().push(1); // 1 = legacy admin slug_owners.entry(code).or_default().push("1".to_string());
} }
} }
} }
@@ -565,7 +637,7 @@ pub fn audit_slug_namespace(
if let Ok(mut rows) = stmt.query([]) { if let Ok(mut rows) = stmt.query([]) {
while let Some(row) = rows.next().unwrap_or(None) { while let Some(row) = rows.next().unwrap_or(None) {
if let Ok(code) = row.get::<_, String>(0) { if let Ok(code) = row.get::<_, String>(0) {
slug_owners.entry(code).or_default().push(1); slug_owners.entry(code).or_default().push("1".to_string());
} }
} }
} }
@@ -574,14 +646,14 @@ pub fn audit_slug_namespace(
} }
// 2. Scan all tenant databases in data_dir/users/<id>/content.db // 2. Scan all tenant databases in data_dir/users/<id>/content.db
let users_dir = config.data_dir.join("users"); let users_dir = crate::db::topology::Topology::new(&config.data_dir).users_dir();
if users_dir.exists() { if users_dir.exists() {
for entry in std::fs::read_dir(users_dir)? { for entry in std::fs::read_dir(users_dir)? {
let entry = entry?; let entry = entry?;
let path = entry.path(); let path = entry.path();
if path.is_dir() { if path.is_dir() {
if let Some(name_str) = path.file_name().and_then(|n| n.to_str()) { if let Some(name_str) = path.file_name().and_then(|n| n.to_str()) {
if let Ok(user_id) = name_str.parse::<i64>() { if crate::db::topology::is_valid_user_dir_name(name_str) {
let content_db_path = path.join("content.db"); let content_db_path = path.join("content.db");
if content_db_path.exists() { if content_db_path.exists() {
if let Ok(conn) = Connection::open(&content_db_path) { if let Ok(conn) = Connection::open(&content_db_path) {
@@ -590,7 +662,10 @@ pub fn audit_slug_namespace(
if let Ok(mut rows) = stmt.query([]) { if let Ok(mut rows) = stmt.query([]) {
while let Some(row) = rows.next().unwrap_or(None) { while let Some(row) = rows.next().unwrap_or(None) {
if let Ok(code) = row.get::<_, String>(0) { if let Ok(code) = row.get::<_, String>(0) {
slug_owners.entry(code).or_default().push(user_id); slug_owners
.entry(code)
.or_default()
.push(name_str.to_string());
} }
} }
} }
@@ -602,7 +677,10 @@ pub fn audit_slug_namespace(
if let Ok(mut rows) = stmt.query([]) { if let Ok(mut rows) = stmt.query([]) {
while let Some(row) = rows.next().unwrap_or(None) { while let Some(row) = rows.next().unwrap_or(None) {
if let Ok(code) = row.get::<_, String>(0) { if let Ok(code) = row.get::<_, String>(0) {
slug_owners.entry(code).or_default().push(user_id); slug_owners
.entry(code)
.or_default()
.push(name_str.to_string());
} }
} }
} }
@@ -638,6 +716,9 @@ pub fn audit_slug_namespace(
}) })
} }
#[deprecated(
note = "Legacy v0.7 global_slugs function; use crate::db::slugs::cleanup_stale_reservations instead"
)]
pub fn cleanup_stale_reservations( pub fn cleanup_stale_reservations(
system_conn: &Connection, system_conn: &Connection,
data_dir: &std::path::Path, data_dir: &std::path::Path,
@@ -661,18 +742,31 @@ pub fn cleanup_stale_reservations(
let age = Utc::now().signed_duration_since(created_at.with_timezone(&Utc)); let age = Utc::now().signed_duration_since(created_at.with_timezone(&Utc));
if age > chrono::Duration::minutes(15) { if age > chrono::Duration::minutes(15) {
// Check if target record exists by looking up code = slug in owner's content.db // Check if target record exists by looking up code = slug in owner's content.db
let content_db_path = if owner_user_id == 1 { let topology = crate::db::topology::Topology::new(data_dir);
let p1 = data_dir.join("users").join("1").join("content.db"); let content_db_path = {
if p1.exists() { let users_path = topology.users_registry_db();
p1 if let Ok(users_conn) = Connection::open(&users_path) {
crate::db::tenant::existing_content_path(
&users_conn,
&topology,
owner_user_id,
)
.ok()
.or_else(|| {
if owner_user_id == 1 {
Some(topology.legacy_flat_content_db())
} else { } else {
data_dir.join("content.db") None
} }
})
.unwrap_or_else(|| topology.legacy_flat_content_db())
} else if owner_user_id == 1 {
topology.legacy_flat_content_db()
} else { } else {
data_dir topology
.join("users") .content_db_i64(owner_user_id)
.join(owner_user_id.to_string()) .unwrap_or_else(|_| topology.legacy_flat_content_db())
.join("content.db") }
}; };
let mut target_exists = false; let mut target_exists = false;
@@ -722,6 +816,9 @@ pub fn cleanup_stale_reservations(
Ok(cleaned_count) 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( pub fn register_restored_user_slugs(
system_conn: &Connection, system_conn: &Connection,
target_user_id: i64, target_user_id: i64,
@@ -867,3 +964,29 @@ pub fn reconcile_user_quotas(
Ok(()) Ok(())
} }
/// Calculate aggregate platform total clicks across all active tenant analytics databases.
pub fn get_platform_total_clicks(
topology: &crate::db::topology::Topology,
users_conn: &Connection,
) -> Option<i64> {
let mut stmt = users_conn
.prepare("SELECT tenant_id FROM users WHERE status != 'deleted' AND tenant_id IS NOT NULL;")
.ok()?;
let rows = stmt.query_map([], |row| row.get::<_, String>(0)).ok()?;
let mut total = 0i64;
for tid_str in rows.flatten() {
if let Ok(tid) = crate::identity::TenantId::parse(&tid_str) {
let analytics_path = topology.tenant_analytics_db(tid);
if analytics_path.exists() {
if let Ok(conn) = Connection::open(&analytics_path) {
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM visits;", [], |r| r.get(0))
.unwrap_or(0);
total += count;
}
}
}
}
Some(total)
}
+139
View File
@@ -0,0 +1,139 @@
//! Frozen v0.8 tenant identity.
//!
//! `TenantId` is the filesystem-safe opaque tenant identifier: exactly 12
//! lowercase hexadecimal characters, CSPRNG-generated, independent of username
//! and of SQLite row ids.
use rand::{thread_rng, RngCore};
use std::fmt;
use std::str::FromStr;
/// Opaque tenant identifier: 12 lowercase hex characters (e.g. `fafafa12c3e4`).
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub struct TenantId {
hex: [u8; Self::LEN],
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TenantIdError {
InvalidLength { got: usize },
InvalidCharacter { found: char },
}
impl fmt::Display for TenantIdError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidLength { got } => {
write!(
f,
"TenantId must be {} lowercase hex characters, got {got}",
TenantId::LEN
)
}
Self::InvalidCharacter { found } => {
write!(f, "TenantId must be lowercase hexadecimal, found {found:?}")
}
}
}
}
impl std::error::Error for TenantIdError {}
impl TenantId {
pub const LEN: usize = 12;
/// Cryptographically secure random TenantId. Never derived from user data.
pub fn generate() -> Self {
let mut bytes = [0u8; 6];
thread_rng().fill_bytes(&mut bytes);
let encoded = hex::encode(bytes);
Self::parse(&encoded).expect("CSPRNG hex encoding is 12 lowercase chars")
}
pub fn parse(s: &str) -> Result<Self, TenantIdError> {
if s.len() != Self::LEN {
return Err(TenantIdError::InvalidLength { got: s.len() });
}
if let Some(found) = s.chars().find(|c| !matches!(c, '0'..='9' | 'a'..='f')) {
return Err(TenantIdError::InvalidCharacter { found });
}
let mut hex = [0u8; Self::LEN];
hex.copy_from_slice(s.as_bytes());
Ok(Self { hex })
}
pub fn as_str(&self) -> &str {
std::str::from_utf8(&self.hex).expect("TenantId is validated ASCII hex")
}
}
impl fmt::Display for TenantId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl fmt::Debug for TenantId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_tuple("TenantId").field(&self.as_str()).finish()
}
}
impl FromStr for TenantId {
type Err = TenantIdError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
}
}
impl serde::Serialize for TenantId {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_str())
}
}
impl<'de> serde::Deserialize<'de> for TenantId {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Self::parse(&s).map_err(serde::de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_accepts_lowercase_12_hex() {
let id = TenantId::parse("fafafa12c3e4").unwrap();
assert_eq!(id.as_str(), "fafafa12c3e4");
assert_eq!(id, TenantId::parse("fafafa12c3e4").unwrap());
}
#[test]
fn parse_rejects_uppercase_and_wrong_length() {
assert!(matches!(
TenantId::parse("FAFAFA12C3E4"),
Err(TenantIdError::InvalidCharacter { found: 'F' })
));
assert!(matches!(
TenantId::parse("abc"),
Err(TenantIdError::InvalidLength { got: 3 })
));
assert!(TenantId::parse("a1b2c3d4e5f67").is_err());
assert!(TenantId::parse("../etc/passwd").is_err());
assert!(TenantId::parse("gggggggggggg").is_err());
}
#[test]
fn generate_is_opaque_lowercase_hex() {
let a = TenantId::generate();
let b = TenantId::generate();
assert_ne!(a, b);
assert_eq!(a.as_str().len(), 12);
assert!(a
.as_str()
.chars()
.all(|c| matches!(c, '0'..='9' | 'a'..='f')));
}
}
+33 -10
View File
@@ -65,20 +65,32 @@ pub async fn perform_backup(
if let Ok(conn) = db.admin.lock() { if let Ok(conn) = db.admin.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []); let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
} }
if let Ok(conn) = db.content.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
if let Ok(conn) = db.analytics.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
if let Ok(conn) = db.system.lock() { if let Ok(conn) = db.system.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []); let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
} }
if let Ok(conn) = db.users.lock() { if let Ok(conn) = db.global_urls.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
if let Ok(conn) = db.global_landing_pages.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
if let Ok(conn) = db.reserved.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
let user_ids: Vec<i64> = if let Ok(conn) = db.users.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []); let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
if let Ok(mut stmt) = conn.prepare("SELECT id FROM users;") { if let Ok(mut stmt) = conn.prepare("SELECT id FROM users;") {
if let Ok(rows) = stmt.query_map([], |row| row.get::<_, i64>(0)) { if let Ok(rows) = stmt.query_map([], |row| row.get::<_, i64>(0)) {
let user_ids: Vec<i64> = rows.filter_map(|r| r.ok()).collect(); rows.filter_map(|r| r.ok()).collect()
} else {
Vec::new()
}
} else {
Vec::new()
}
} else {
Vec::new()
};
for user_id in user_ids { for user_id in user_ids {
if let Ok(u_conn) = crate::jobs::open_user_content_conn(db, user_id) { if let Ok(u_conn) = crate::jobs::open_user_content_conn(db, user_id) {
let _ = u_conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []); let _ = u_conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
@@ -87,8 +99,14 @@ pub async fn perform_backup(
let _ = u_conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []); let _ = u_conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
} }
} }
if let Ok(conn) = db.global_urls.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
} }
if let Ok(conn) = db.global_landing_pages.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
} }
if let Ok(conn) = db.reserved.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
} }
let date_str = Utc::now().format("%Y-%m-%d-%H%M%S").to_string(); let date_str = Utc::now().format("%Y-%m-%d-%H%M%S").to_string();
@@ -99,12 +117,17 @@ pub async fn perform_backup(
let enc = GzEncoder::new(file, Compression::default()); let enc = GzEncoder::new(file, Compression::default());
let mut tar = Builder::new(enc); let mut tar = Builder::new(enc);
let admin_dir = config.data_dir.join("admin"); let admin_dir = db.topology.admin_dir();
if admin_dir.exists() { if admin_dir.exists() {
tar.append_dir_all("admin", &admin_dir)?; tar.append_dir_all("admin", &admin_dir)?;
} }
let users_dir = config.data_dir.join("users"); let slugs_dir = db.topology.slugs_dir();
if slugs_dir.exists() {
tar.append_dir_all("slugs", &slugs_dir)?;
}
let users_dir = db.topology.users_dir();
if users_dir.exists() { if users_dir.exists() {
tar.append_dir_all("users", &users_dir)?; tar.append_dir_all("users", &users_dir)?;
} }
+12 -10
View File
@@ -46,11 +46,12 @@ pub fn open_user_content_conn(
db: &Db, db: &Db,
user_id: i64, user_id: i64,
) -> Result<rusqlite::Connection, rusqlite::Error> { ) -> Result<rusqlite::Connection, rusqlite::Error> {
let db_path = db let db_path = {
.data_dir let users = db.users.lock().map_err(|_| {
.join("users") rusqlite::Error::InvalidPath(std::path::PathBuf::from("users-db-poisoned"))
.join(user_id.to_string()) })?;
.join("content.db"); crate::db::tenant::existing_content_path(&users, &db.topology, user_id)?
};
let conn = rusqlite::Connection::open(db_path)?; let conn = rusqlite::Connection::open(db_path)?;
crate::db::sqlite::enable_wal(&conn, "content")?; crate::db::sqlite::enable_wal(&conn, "content")?;
crate::db::sqlite::enable_foreign_keys(&conn, "content")?; crate::db::sqlite::enable_foreign_keys(&conn, "content")?;
@@ -61,11 +62,12 @@ pub fn open_user_analytics_conn(
db: &Db, db: &Db,
user_id: i64, user_id: i64,
) -> Result<rusqlite::Connection, rusqlite::Error> { ) -> Result<rusqlite::Connection, rusqlite::Error> {
let db_path = db let db_path = {
.data_dir let users = db.users.lock().map_err(|_| {
.join("users") rusqlite::Error::InvalidPath(std::path::PathBuf::from("users-db-poisoned"))
.join(user_id.to_string()) })?;
.join("analytics.db"); crate::db::tenant::existing_analytics_path(&users, &db.topology, user_id)?
};
let conn = rusqlite::Connection::open(db_path)?; let conn = rusqlite::Connection::open(db_path)?;
crate::db::sqlite::enable_wal(&conn, "analytics")?; crate::db::sqlite::enable_wal(&conn, "analytics")?;
crate::db::sqlite::enable_foreign_keys(&conn, "analytics")?; crate::db::sqlite::enable_foreign_keys(&conn, "analytics")?;
+1 -1
View File
@@ -37,9 +37,9 @@ pub async fn run_quota_reconciliation(
rows.filter_map(|r| r.ok()).collect() rows.filter_map(|r| r.ok()).collect()
}; };
let users_conn = db.users.lock().unwrap();
for user_id in user_ids { for user_id in user_ids {
if let Ok(content_conn) = super::open_user_content_conn(&db, user_id) { if let Ok(content_conn) = super::open_user_content_conn(&db, user_id) {
let users_conn = db.users.lock().unwrap();
if let Err(e) = if let Err(e) =
crate::db::users::reconcile_user_quotas(&users_conn, user_id, &content_conn) crate::db::users::reconcile_user_quotas(&users_conn, user_id, &content_conn)
{ {
+1
View File
@@ -6,6 +6,7 @@ pub mod cli;
pub mod config; pub mod config;
pub mod db; pub mod db;
pub mod error; pub mod error;
pub mod identity;
pub mod jobs; pub mod jobs;
pub mod models; pub mod models;
pub mod services; pub mod services;
+8 -2
View File
@@ -13,7 +13,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.init(); .init();
let cli = Cli::parse(); let cli = Cli::parse();
let config = Config::load(); let config = match Config::load_with_cli(cli.command.data_dir()) {
Ok(config) => config,
Err(err) => {
eprintln!("Error: {err}");
std::process::exit(1);
}
};
match cli.command { match cli.command {
Commands::Serve { Commands::Serve {
@@ -42,7 +48,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
bzod::cli::audit_destinations::run(data_dir, config).await?; bzod::cli::audit_destinations::run(data_dir, config).await?;
} }
Commands::CreateAdmin { username, data_dir } => { Commands::CreateAdmin { username, data_dir } => {
bzod::cli::create_admin::run(username, data_dir, config).await?; bzod::cli::create_admin::run(username, None, data_dir, config).await?;
} }
Commands::InitAdmin { data_dir } => { Commands::InitAdmin { data_dir } => {
bzod::cli::init_admin::run(data_dir, config).await?; bzod::cli::init_admin::run(data_dir, config).await?;
+18
View File
@@ -27,6 +27,24 @@ pub struct TenantUser {
pub account_type: String, // 'system', 'admin', 'standard', 'organization', 'service' pub account_type: String, // 'system', 'admin', 'standard', 'organization', 'service'
pub organization_id: Option<i64>, pub organization_id: Option<i64>,
pub metadata: Option<String>, pub metadata: Option<String>,
/// Frozen v0.8 tenant id. None only for unmigrated v0.7 rows (Phase 3).
pub tenant_id: Option<crate::identity::TenantId>,
/// Frozen v0.8 immutable UUID.
pub uuid: Option<String>,
}
impl TenantUser {
pub fn require_tenant_id(&self) -> Result<crate::identity::TenantId, crate::error::AppError> {
self.tenant_id.ok_or_else(|| {
crate::error::AppError::Internal(format!("user {} has unmigrated tenant_id", self.id))
})
}
pub fn require_uuid(&self) -> Result<&str, crate::error::AppError> {
self.uuid.as_deref().ok_or_else(|| {
crate::error::AppError::Internal(format!("user {} has unmigrated uuid", self.id))
})
}
} }
#[derive(Serialize, Deserialize, Clone, Debug)] #[derive(Serialize, Deserialize, Clone, Debug)]
+4
View File
@@ -1,3 +1,4 @@
use crate::identity::TenantId;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug)] #[derive(Serialize, Deserialize, Clone, Debug)]
@@ -12,6 +13,9 @@ pub struct VisitRecord {
pub accept_language: String, pub accept_language: String,
pub country: String, pub country: String,
pub status_code: u16, pub status_code: u16,
#[serde(default)]
pub owner_tenant_id: Option<TenantId>,
#[serde(default)]
pub owner_user_id: Option<i64>, pub owner_user_id: Option<i64>,
} }
+18 -7
View File
@@ -5,16 +5,21 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use tracing::warn; use tracing::warn;
use crate::db::topology::{Topology, LEGACY_ADMIN_USER_KEY};
/// Move flat legacy DB files into multi-tenant paths after tarball extract. /// Move flat legacy DB files into multi-tenant paths after tarball extract.
/// ///
/// Layout: /// Layout:
/// - `admin.db` / `system.db` / `users.db` (+ wal/shm) → `{data_dir}/admin/` /// - `admin.db` / `system.db` / `users.db` (+ wal/shm) → `{data_dir}/admin/`
/// - `content.db` / `analytics.db` (+ wal/shm) → `{data_dir}/users/1/` /// - `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<()> { pub fn normalize_restored_layout(data_dir: &Path) -> std::io::Result<()> {
let admin_dir = data_dir.join("admin"); let topology = Topology::new(data_dir);
let users_1_dir = data_dir.join("users").join("1"); 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(&admin_dir)?;
std::fs::create_dir_all(&users_1_dir)?; std::fs::create_dir_all(&users_1_dir)?;
std::fs::create_dir_all(topology.slugs_dir())?;
let admin_files = [ let admin_files = [
"admin.db", "admin.db",
@@ -80,12 +85,17 @@ pub struct RestoredDbPaths {
impl RestoredDbPaths { impl RestoredDbPaths {
pub fn from_data_dir(data_dir: &Path) -> Self { pub fn from_data_dir(data_dir: &Path) -> Self {
let topology = Topology::new(data_dir);
Self { Self {
admin: data_dir.join("admin/admin.db"), admin: topology.admin_db(),
system: data_dir.join("admin/system.db"), system: topology.system_db(),
users: data_dir.join("admin/users.db"), users: topology.users_registry_db(),
content: data_dir.join("users/1/content.db"), content: topology
analytics: data_dir.join("users/1/analytics.db"), .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("admin/users.db").exists());
assert!(dir.join("users/1/content.db").exists()); assert!(dir.join("users/1/content.db").exists());
assert!(dir.join("users/1/analytics.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("admin.db").exists());
assert!(!dir.join("content.db").exists()); assert!(!dir.join("content.db").exists());
+46 -26
View File
@@ -4,6 +4,7 @@
use crate::auth::generate_token; use crate::auth::generate_token;
use crate::auth::password::hash_password; use crate::auth::password::hash_password;
use crate::identity::TenantId;
use crate::models::Url; use crate::models::Url;
use crate::utils::validation::validate_redirect_destination; use crate::utils::validation::validate_redirect_destination;
use rusqlite::{Connection, Transaction}; 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 { 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. /// Create many URLs inside a single content transaction with global slug reservation.
#[allow(clippy::too_many_arguments)]
pub fn create_urls_bulk( pub fn create_urls_bulk(
content_db: &Mutex<Connection>, 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>, users_db: &Mutex<Connection>,
owner_user_id: i64, owner_user_id: i64,
owner_tenant_id: TenantId,
items: Vec<BulkUrlCreateItem>, items: Vec<BulkUrlCreateItem>,
) -> Result<Vec<Url>, BulkUrlError> { ) -> Result<Vec<Url>, BulkUrlError> {
let mut conn = crate::utils::lock_db(content_db, "content_db") 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(); let mut reserved_slugs: Vec<String> = Vec::new();
for item in items { 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), Ok(url) => created_urls.push(url),
Err(e) => { Err(e) => {
let _ = tx.rollback(); let _ = tx.rollback();
if let Ok(system_conn) = crate::utils::lock_db(system_db, "system_db") { if let Ok(urls_conn) = crate::utils::lock_db(global_urls_db, "global_urls_db") {
release_reserved(&system_conn, &reserved_slugs, owner_user_id); release_reserved(&urls_conn, &reserved_slugs, &owner_tenant_id);
} }
return Err(e); return Err(e);
} }
@@ -96,23 +109,20 @@ pub fn create_urls_bulk(
} }
if let Err(e) = tx.commit() { if let Err(e) = tx.commit() {
if let Ok(system_conn) = crate::utils::lock_db(system_db, "system_db") { if let Ok(urls_conn) = crate::utils::lock_db(global_urls_db, "global_urls_db") {
release_reserved(&system_conn, &reserved_slugs, owner_user_id); release_reserved(&urls_conn, &reserved_slugs, &owner_tenant_id);
} }
return Err(BulkUrlError::Internal(format!( return Err(BulkUrlError::Internal(format!(
"Failed to commit transaction: {e}" "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()))?; .map_err(|e| BulkUrlError::Internal(e.to_string()))?;
for url in &created_urls { for url in &created_urls {
let _ = system_conn.execute( let _ = crate::db::slugs::activate_url_slug(&urls_conn, &url.code, &url.id);
"UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;",
rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), url.code],
);
} }
} }
@@ -130,37 +140,47 @@ pub fn create_urls_bulk(
fn create_one_in_tx( fn create_one_in_tx(
tx: &Transaction<'_>, tx: &Transaction<'_>,
system_db: &Mutex<Connection>, reserved_db: &Mutex<Connection>,
owner_user_id: i64, global_urls_db: &Mutex<Connection>,
global_landing_pages_db: &Mutex<Connection>,
owner_tenant_id: &TenantId,
item: BulkUrlCreateItem, item: BulkUrlCreateItem,
reserved_slugs: &mut Vec<String>, reserved_slugs: &mut Vec<String>,
) -> Result<Url, BulkUrlError> { ) -> Result<Url, BulkUrlError> {
let mut code = item.code.unwrap_or_default().trim().to_lowercase(); let mut code = item.code.unwrap_or_default().trim().to_lowercase();
if code.is_empty() { if code.is_empty() {
code = generate_token(3); 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!( 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()))?; .map_err(|e| BulkUrlError::Internal(e.to_string()))?;
let available = crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) let urls_conn = crate::utils::lock_db(global_urls_db, "global_urls_db")
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
let pages_conn = crate::utils::lock_db(global_landing_pages_db, "global_landing_pages_db")
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
let available =
crate::db::slugs::is_slug_available(&reserved_conn, &urls_conn, &pages_conn, &code)
.unwrap_or(false)
&& !reserved_slugs.contains(&code); && !reserved_slugs.contains(&code);
if !available { if !available {
return Err(BulkUrlError::Conflict(format!( return Err(BulkUrlError::Conflict(format!(
"Short code '{code}' already exists" "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, &code,
owner_user_id, owner_tenant_id,
"url",
"",
"reserving",
) { ) {
return Err(BulkUrlError::Internal(format!( return Err(BulkUrlError::Internal(format!(
"Failed to reserve slug '{code}': {e}" "Failed to reserve slug '{code}': {e}"
+7 -10
View File
@@ -6,7 +6,6 @@
use crate::db::Db; use crate::db::Db;
use crate::utils::validation::{classify_redirect_destination, DestinationClass}; use crate::utils::validation::{classify_redirect_destination, DestinationClass};
use rusqlite::Connection; use rusqlite::Connection;
use std::path::Path;
use tracing::{error, info, warn}; use tracing::{error, info, warn};
/// Summary counters for a destination audit run. /// Summary counters for a destination audit run.
@@ -124,14 +123,12 @@ pub fn audit_content_conn(
Ok(()) Ok(())
} }
fn open_user_content(data_dir: &Path, user_id: i64) -> Result<Connection, rusqlite::Error> { fn open_user_content(db: &Db, user_id: i64) -> Result<Connection, rusqlite::Error> {
let path = data_dir let users = db
.join("users") .users
.join(user_id.to_string()) .lock()
.join("content.db"); .map_err(|_| rusqlite::Error::InvalidPath(std::path::PathBuf::from("users-db-poisoned")))?;
if !path.exists() { let path = crate::db::tenant::existing_content_path(&users, &db.topology, user_id)?;
return Err(rusqlite::Error::InvalidPath(path));
}
let conn = Connection::open(path)?; let conn = Connection::open(path)?;
crate::db::sqlite::enable_wal(&conn, "content")?; crate::db::sqlite::enable_wal(&conn, "content")?;
Ok(conn) Ok(conn)
@@ -158,7 +155,7 @@ pub fn audit_all_destinations(db: &Db) -> Result<DestinationAuditReport, String>
}; };
for user_id in user_ids { for user_id in user_ids {
match open_user_content(&db.data_dir, user_id) { match open_user_content(db, user_id) {
Ok(conn) => { Ok(conn) => {
report.scanned_users += 1; report.scanned_users += 1;
if let Err(e) = audit_content_conn(&conn, user_id, &mut report) { if let Err(e) = audit_content_conn(&conn, user_id, &mut report) {
+7 -7
View File
@@ -1,23 +1,23 @@
use crate::db::Db;
use crate::error::AppError; use crate::error::AppError;
use crate::models::LandingPage; use crate::models::LandingPage;
pub fn create_landing_page( pub fn create_landing_page(
db: &Db, conn: &rusqlite::Connection,
code: &str, code: &str,
slug: &str, slug: &str,
title: &str, title: &str,
html_content: &str, html_content: &str,
state: &str, state: &str,
) -> Result<LandingPage, AppError> { ) -> Result<LandingPage, AppError> {
let conn = db.content.lock().unwrap();
let page = let page =
crate::db::content::create_landing_page(&conn, code, slug, title, html_content, state)?; crate::db::content::create_landing_page(conn, code, slug, title, html_content, state)?;
Ok(page) Ok(page)
} }
pub fn get_landing_page_by_code(db: &Db, code: &str) -> Result<Option<LandingPage>, AppError> { pub fn get_landing_page_by_code(
let conn = db.content.lock().unwrap(); conn: &rusqlite::Connection,
let page = crate::db::content::get_landing_page_by_code(&conn, code)?; code: &str,
) -> Result<Option<LandingPage>, AppError> {
let page = crate::db::content::get_landing_page_by_code(conn, code)?;
Ok(page) Ok(page)
} }
+218 -138
View File
@@ -1,23 +1,27 @@
use crate::db::topology::Topology;
use crate::identity::TenantId;
use chrono::{DateTime, Utc};
use rusqlite::Connection; use rusqlite::Connection;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum RegistryIssueType { pub enum RegistryIssueType {
DuplicateSlug, MissingTenant,
InvalidTargetType,
InvalidStatus,
MissingOwner,
MissingDatabase,
MissingTarget, MissingTarget,
CorruptDatabase,
AccessFailure,
TrueOrphan,
Conflict,
StaleReservation, StaleReservation,
TenantAdminHasIsolatedContent, InvalidStatus,
InvalidTargetType,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct RegistryIssue { pub struct RegistryIssue {
pub slug: String, pub slug: String,
pub target_type: String, pub target_type: String,
pub owner_user_id: i64, pub owner_tenant_id: String,
pub database_path: PathBuf, pub database_path: PathBuf,
pub target_id: String, pub target_id: String,
pub issue_type: RegistryIssueType, pub issue_type: RegistryIssueType,
@@ -27,137 +31,237 @@ pub struct RegistryIssue {
pub struct RegistryValidator; pub struct RegistryValidator;
impl 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( pub fn scan(
system_conn: &Connection, _system_conn: &Connection,
users_conn: &Connection, users_conn: &Connection,
data_dir: &Path, data_dir: &Path,
slug_filter: Option<&str>, slug_filter: Option<&str>,
) -> Result<Vec<RegistryIssue>, Box<dyn std::error::Error>> { ) -> Result<Vec<RegistryIssue>, Box<dyn std::error::Error>> {
use chrono::{DateTime, Utc}; let topology = Topology::new(data_dir);
let mut issues = Vec::new(); let mut issues = Vec::new();
// 1. Check duplicate slugs (only if not filtering by single slug) let urls_path = topology.global_urls_db();
if slug_filter.is_none() { let pages_path = topology.global_landing_pages_db();
let total_count: i64 = let reserved_path = topology.reserved_db();
system_conn.query_row("SELECT COUNT(*) FROM global_slugs;", [], |r| r.get(0))?;
let distinct_count: i64 = system_conn.query_row( if !urls_path.exists() || !pages_path.exists() || !reserved_path.exists() {
"SELECT COUNT(DISTINCT slug) FROM global_slugs;",
[],
|r| r.get(0),
)?;
if total_count != distinct_count {
issues.push(RegistryIssue { issues.push(RegistryIssue {
slug: "*".to_string(), slug: "*".to_string(),
target_type: "system".to_string(), target_type: "system".to_string(),
owner_user_id: 0, owner_tenant_id: "".to_string(),
database_path: data_dir.join("admin/system.db"), database_path: urls_path,
target_id: "".to_string(), target_id: "".to_string(),
issue_type: RegistryIssueType::DuplicateSlug, issue_type: RegistryIssueType::AccessFailure,
description: format!( description: "One or more v0.8 slug databases are missing from disk".to_string(),
"Duplicate slugs found in global_slugs table (total rows: {}, distinct slugs: {})",
total_count, distinct_count
),
}); });
} return Ok(issues);
} }
// 2. Scan global slugs let urls_conn = Connection::open(&urls_path)?;
let (query, params_string) = if let Some(slug) = slug_filter { 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()], vec![slug.to_string()],
) )
} else { } 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![], vec![],
) )
}; };
let mut stmt = system_conn.prepare(query)?; let mut stmt = conn.prepare(&query)?;
let mut rows = stmt.query(rusqlite::params_from_iter(params_string))?; let mut rows = stmt.query(rusqlite::params_from_iter(params_vec))?;
while let Some(row) = rows.next()? { while let Some(row) = rows.next()? {
let slug: String = row.get(0)?; let slug: String = row.get(0)?;
let owner_user_id: i64 = row.get(1)?; let owner_tenant_id_str: String = row.get(1)?;
let target_type: String = row.get(2)?; let target_id: String = row.get(2)?;
let target_id: String = row.get(3)?; let created_at_str: String = row.get(3)?;
let created_at_str: String = row.get(4)?; let status: String = row.get(4)?;
let status: String = row.get(5)?;
let content_db_path = if owner_user_id == 1 { let tenant_id_res = TenantId::parse(&owner_tenant_id_str);
data_dir.join("users").join("1").join("content.db") let content_db_path = match tenant_id_res {
} else { Ok(tid) => topology.tenant_dir(tid).join("content.db"),
data_dir Err(_) => topology.users_dir().join("_invalid").join("content.db"),
.join("users")
.join(owner_user_id.to_string())
.join("content.db")
}; };
// Target type check // 1. Conflict with reserved.db
if target_type != "url" && target_type != "page" { 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 { issues.push(RegistryIssue {
slug: slug.clone(), slug: slug.clone(),
target_type: target_type.clone(), target_type: target_type.to_string(),
owner_user_id, owner_tenant_id: owner_tenant_id_str.clone(),
database_path: content_db_path.clone(), database_path: topology.reserved_db(),
target_id: target_id.clone(), 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!( description: format!(
"Slug '{}' has invalid target_type '{}'", "Slug '{}' exists in both global_urls.db and global_landing_pages.db",
slug, target_type slug
), ),
}); });
} }
// Status check // 3. Status check
if status != "active" && status != "disabled" && status != "reserving" { if status != "active"
&& status != "disabled"
&& status != "reserving"
&& status != "retired"
{
issues.push(RegistryIssue { issues.push(RegistryIssue {
slug: slug.clone(), slug: slug.clone(),
target_type: target_type.clone(), target_type: target_type.to_string(),
owner_user_id, owner_tenant_id: owner_tenant_id_str.clone(),
database_path: content_db_path.clone(), database_path: conn.path().map(PathBuf::from).unwrap_or_default(),
target_id: target_id.clone(), target_id: target_id.clone(),
issue_type: RegistryIssueType::InvalidStatus, issue_type: RegistryIssueType::InvalidStatus,
description: format!("Slug '{}' has invalid status '{}'", slug, status), description: format!("Slug '{}' has invalid status '{}'", slug, status),
}); });
} }
// Check owner // If retired, no active target check is needed
let owner_exists: bool = users_conn if status == "retired" {
.query_row( continue;
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);", }
[owner_user_id],
|r| r.get(0), // 4. Owner tenant check in users.db
) let tid = match tenant_id_res {
.unwrap_or(false); 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 { if !owner_exists {
issues.push(RegistryIssue { issues.push(RegistryIssue {
slug: slug.clone(), slug: slug.clone(),
target_type: target_type.clone(), target_type: target_type.to_string(),
owner_user_id, owner_tenant_id: owner_tenant_id_str.clone(),
database_path: content_db_path.clone(), database_path: content_db_path.clone(),
target_id: target_id.clone(), target_id: target_id.clone(),
issue_type: RegistryIssueType::MissingOwner, issue_type: RegistryIssueType::MissingTenant,
description: format!( description: format!(
"Slug '{}' references missing owner user ID {}", "Slug '{}' references missing or deleted owner tenant '{}'",
slug, owner_user_id slug, owner_tenant_id_str
), ),
}); });
continue; continue;
} }
// Stale warning check // 5. Stale reservation check
if status == "reserving" { if status == "reserving" {
if let Ok(created_at) = DateTime::parse_from_rfc3339(&created_at_str) { if let Ok(created_at) = DateTime::parse_from_rfc3339(&created_at_str) {
let age = Utc::now().signed_duration_since(created_at.with_timezone(&Utc)); let age = Utc::now().signed_duration_since(created_at.with_timezone(&Utc));
if age > chrono::Duration::try_minutes(15).unwrap_or_default() { if age > chrono::Duration::try_minutes(15).unwrap_or_default() {
issues.push(RegistryIssue { issues.push(RegistryIssue {
slug: slug.clone(), slug: slug.clone(),
target_type: target_type.clone(), target_type: target_type.to_string(),
owner_user_id, owner_tenant_id: owner_tenant_id_str.clone(),
database_path: content_db_path.clone(), database_path: content_db_path.clone(),
target_id: target_id.clone(), target_id: target_id.clone(),
issue_type: RegistryIssueType::StaleReservation, issue_type: RegistryIssueType::StaleReservation,
@@ -170,19 +274,16 @@ impl RegistryValidator {
} }
} }
// Check target record exists for active / disabled (and reserving with target_id) // 6. Target record check in tenant content DB
if status == "active" if status == "active" || status == "disabled" {
|| status == "disabled"
|| (status == "reserving" && !target_id.is_empty())
{
if !content_db_path.exists() { if !content_db_path.exists() {
issues.push(RegistryIssue { issues.push(RegistryIssue {
slug: slug.clone(), slug: slug.clone(),
target_type: target_type.clone(), target_type: target_type.to_string(),
owner_user_id, owner_tenant_id: owner_tenant_id_str.clone(),
database_path: content_db_path.clone(), database_path: content_db_path.clone(),
target_id: target_id.clone(), target_id: target_id.clone(),
issue_type: RegistryIssueType::MissingDatabase, issue_type: RegistryIssueType::TrueOrphan,
description: format!( description: format!(
"Slug '{}' owner content database does not exist at {:?}", "Slug '{}' owner content database does not exist at {:?}",
slug, content_db_path slug, content_db_path
@@ -190,47 +291,66 @@ impl RegistryValidator {
}); });
} else { } else {
match Connection::open(&content_db_path) { match Connection::open(&content_db_path) {
Ok(conn) => { Ok(tenant_conn) => {
let exists = if target_type == "url" { let exists = if target_type == "url" {
conn.query_row( tenant_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM urls WHERE id = ?1);", "SELECT EXISTS(SELECT 1 FROM urls WHERE id = ?1);",
[&target_id], [&target_id],
|r| r.get(0), |r| r.get(0),
) )
.unwrap_or(false) .unwrap_or(false)
} else if target_type == "page" { } else {
conn.query_row( tenant_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM landing_pages WHERE id = ?1);", "SELECT EXISTS(SELECT 1 FROM landing_pages WHERE id = ?1);",
[&target_id], [&target_id],
|r| r.get(0), |r| r.get(0),
) )
.unwrap_or(false) .unwrap_or(false)
} else {
false
}; };
if !exists { if !exists {
issues.push(RegistryIssue { issues.push(RegistryIssue {
slug: slug.clone(), slug: slug.clone(),
target_type: target_type.clone(), target_type: target_type.to_string(),
owner_user_id, owner_tenant_id: owner_tenant_id_str.clone(),
database_path: content_db_path.clone(), database_path: content_db_path.clone(),
target_id: target_id.clone(), target_id: target_id.clone(),
issue_type: RegistryIssueType::MissingTarget, 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) => { Err(e) => {
issues.push(RegistryIssue { issues.push(RegistryIssue {
slug: slug.clone(), slug: slug.clone(),
target_type: target_type.clone(), target_type: target_type.to_string(),
owner_user_id, owner_tenant_id: owner_tenant_id_str.clone(),
database_path: content_db_path.clone(), database_path: content_db_path.clone(),
target_id: target_id.clone(), target_id: target_id.clone(),
issue_type: RegistryIssueType::MissingDatabase, issue_type: RegistryIssueType::AccessFailure,
description: format!( description: format!(
"Slug '{}' owner content database could not be opened: {}", "Slug '{}' owner content database could not be accessed: {}",
slug, e slug, e
), ),
}); });
@@ -240,46 +360,6 @@ impl RegistryValidator {
} }
} }
// 3. Admin Content Reverse Consistency Check (Legacy DB) Ok(())
// 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)
} }
} }
+4 -13
View File
@@ -1,9 +1,8 @@
use crate::db::Db;
use crate::error::AppError; use crate::error::AppError;
use crate::models::Url; use crate::models::Url;
pub fn create_url( pub fn create_url(
db: &Db, conn: &rusqlite::Connection,
code: &str, code: &str,
destination: &str, destination: &str,
title: Option<&str>, title: Option<&str>,
@@ -15,19 +14,11 @@ pub fn create_url(
"Destination must be a valid http(s) URL without control characters".into(), "Destination must be a valid http(s) URL without control characters".into(),
)); ));
} }
let conn = db let url = crate::db::content::create_url(conn, code, destination, title, description, tags)?;
.content
.lock()
.map_err(|e| AppError::Internal(format!("content_db mutex poisoned: {}", e)))?;
let url = crate::db::content::create_url(&conn, code, destination, title, description, tags)?;
Ok(url) Ok(url)
} }
pub fn get_url_by_code(db: &Db, code: &str) -> Result<Option<Url>, AppError> { pub fn get_url_by_code(conn: &rusqlite::Connection, code: &str) -> Result<Option<Url>, AppError> {
let conn = db let url = crate::db::content::get_url_by_code(conn, code)?;
.content
.lock()
.map_err(|e| AppError::Internal(format!("content_db mutex poisoned: {}", e)))?;
let url = crate::db::content::get_url_by_code(&conn, code)?;
Ok(url) Ok(url)
} }
+85 -36
View File
@@ -1,12 +1,13 @@
//! Cross-tenant slug transfer business logic. //! Cross-tenant slug transfer business logic.
//! //!
//! Copies URL/page content between tenant content DBs, then updates global_slugs //! Copies URL/page content between tenant content DBs, then updates v0.8 slug
//! ownership. Handlers own admin auth and HTTP mapping. //! 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::state::{AppState, UserDbs};
use crate::utils::lock_db; use crate::utils::lock_db;
use chrono::Utc; use chrono::Utc;
use rusqlite::OptionalExtension;
#[derive(Debug)] #[derive(Debug)]
pub enum TransferError { pub enum TransferError {
@@ -34,31 +35,21 @@ pub struct SlugTransferRequest {
pub struct SlugTransferResult { pub struct SlugTransferResult {
pub old_owner_user_id: i64, pub old_owner_user_id: i64,
pub new_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 target_type: String,
pub new_target_id: String, pub new_target_id: String,
} }
/// Look up slug ownership in `global_slugs`. /// 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<(i64, String, String), TransferError> { pub fn lookup_slug(
let system_conn = lock_db(&state.system_db, "system_db") state: &AppState,
.map_err(|e| TransferError::Internal(e.to_string()))?; slug: &str,
let mut stmt = system_conn ) -> Result<crate::db::slugs::ResolvedSlugInfo, TransferError> {
.prepare("SELECT owner_user_id, target_type, target_id FROM global_slugs WHERE slug = ?1;") match state.lookup_slug(slug) {
.map_err(|e| TransferError::Internal(e.to_string()))?; Ok(Some(info)) => Ok(info),
let row_opt = stmt Ok(None) => Err(TransferError::NotFound("Slug not found")),
.query_row([slug], |row| { Err(e) => Err(TransferError::Internal(e.to_string())),
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")),
} }
} }
@@ -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( pub fn transfer_slug(
state: &AppState, state: &AppState,
req: &SlugTransferRequest, req: &SlugTransferRequest,
admin_username: &str, admin_username: &str,
) -> Result<SlugTransferResult, TransferError> { ) -> 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( return Err(TransferError::BadRequest(
"New owner must be different from the current owner".into(), "New owner must be different from the current owner".into(),
)); ));
} }
let old_dbs = state 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()))?; .map_err(|_| TransferError::Internal("Failed to load current owner's database".into()))?;
let new_dbs = state 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()))?; .map_err(|_| TransferError::Internal("Failed to load new owner's database".into()))?;
let new_target_id = copy_content( let new_target_id = copy_content(
@@ -188,16 +222,29 @@ pub fn transfer_slug(
req.new_owner_user_id, 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") let system_conn = lock_db(&state.system_db, "system_db")
.map_err(|e| TransferError::Internal(e.to_string()))?; .map_err(|e| TransferError::Internal(e.to_string()))?;
let now = Utc::now().to_rfc3339(); 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( let _ = system_conn.execute(
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username) "INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username)
VALUES (?1, ?2, ?3, 'transferred', ?4, ?5);", VALUES (?1, ?2, ?3, 'transferred', ?4, ?5);",
@@ -228,8 +275,8 @@ pub fn transfer_slug(
"slug", "slug",
&req.slug, &req.slug,
Some(&format!( Some(&format!(
"From owner {} to owner {}", "From tenant {} (user {}) to tenant {} (user {})",
old_owner_user_id, req.new_owner_user_id 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 { Ok(SlugTransferResult {
old_owner_user_id, old_owner_user_id,
new_owner_user_id: req.new_owner_user_id, new_owner_user_id: req.new_owner_user_id,
old_owner_tenant_id,
new_owner_tenant_id,
target_type, target_type,
new_target_id, new_target_id,
}) })
+109 -60
View File
@@ -1,26 +1,20 @@
use crate::analytics::queue::AnalyticsQueue; use crate::analytics::queue::AnalyticsQueue;
use crate::config::Config; use crate::config::Config;
use crate::db::Db; use crate::db::Db;
use rusqlite::Connection; use crate::identity::TenantId;
use rusqlite::{Connection, OptionalExtension};
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Instant; use std::time::Instant;
#[derive(Clone)] pub use crate::db::tenant::{TenantOpenMode, UserDbs};
pub struct UserDbs {
pub content: Arc<Mutex<Connection>>,
pub analytics: Arc<Mutex<Connection>>,
pub profile: Arc<Mutex<Connection>>,
}
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
pub admin_db: Arc<Mutex<Connection>>, pub admin_db: Arc<Mutex<Connection>>,
pub content_db: Arc<Mutex<Connection>>,
pub analytics_db: Arc<Mutex<Connection>>,
pub system_db: Arc<Mutex<Connection>>, pub system_db: Arc<Mutex<Connection>>,
pub users_db: Arc<Mutex<Connection>>, pub users_db: Arc<Mutex<Connection>>,
pub user_dbs: Arc<Mutex<HashMap<i64, UserDbs>>>, pub user_dbs: Arc<Mutex<HashMap<String, UserDbs>>>,
pub db: Db, pub db: Db,
pub config: Config, pub config: Config,
pub analytics_queue: AnalyticsQueue, pub analytics_queue: AnalyticsQueue,
@@ -28,71 +22,126 @@ pub struct AppState {
} }
impl AppState { impl AppState {
/// Compatibility opener: Core `users.id` must refer to a registered,
/// non-deleted user. Unknown ids never create a database.
pub fn get_user_dbs(&self, user_id: i64) -> Result<UserDbs, crate::error::AppError> { pub fn get_user_dbs(&self, user_id: i64) -> Result<UserDbs, crate::error::AppError> {
let mut pool = crate::utils::lock_db(&self.user_dbs, "user_dbs")?; self.get_user_dbs_with_mode(user_id, TenantOpenMode::PublicContent)
if let Some(dbs) = pool.get(&user_id) {
return Ok(dbs.clone());
} }
// Open connection and run migrations pub fn get_user_dbs_with_mode(
let user_dir = self.config.data_dir.join("users").join(user_id.to_string()); &self,
std::fs::create_dir_all(&user_dir)?; user_id: i64,
mode: TenantOpenMode,
let content_path = user_dir.join("content.db"); ) -> Result<UserDbs, crate::error::AppError> {
let analytics_path = user_dir.join("analytics.db"); let users = crate::utils::lock_db(&self.users_db, "users_db")?;
let profile_path = user_dir.join("profile.db"); let mut pool = crate::utils::lock_db(&self.user_dbs, "user_dbs")?;
crate::db::tenant::open_for_row_id(
let mut content_conn = Connection::open(content_path)?; &users,
let mut analytics_conn = Connection::open(analytics_path)?; &self.db.topology,
let profile_conn = Connection::open(profile_path)?; &self.system_db,
&mut pool,
crate::db::sqlite::enable_wal(&content_conn, "content")?; user_id,
crate::db::sqlite::enable_wal(&analytics_conn, "analytics")?; mode,
crate::db::sqlite::enable_wal(&profile_conn, "profile")?;
crate::db::sqlite::enable_foreign_keys(&content_conn, "content")?;
crate::db::sqlite::enable_foreign_keys(&analytics_conn, "analytics")?;
crate::db::sqlite::enable_foreign_keys(&profile_conn, "profile")?;
// Run migrations
crate::db::migrations::run_migrations(
&mut content_conn,
"content",
crate::db::migrations::CONTENT_MIGRATIONS,
Some(&self.system_db),
) )
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?; }
crate::db::migrations::run_migrations(
&mut analytics_conn, /// Frozen tenant opener. Unknown TenantId never creates a database.
"analytics", pub fn open_tenant(
crate::db::migrations::ANALYTICS_MIGRATIONS, &self,
Some(&self.system_db), tenant_id: TenantId,
mode: TenantOpenMode,
) -> Result<UserDbs, crate::error::AppError> {
let users = crate::utils::lock_db(&self.users_db, "users_db")?;
let mut pool = crate::utils::lock_db(&self.user_dbs, "user_dbs")?;
crate::db::tenant::open_for_tenant_id(
&users,
&self.db.topology,
&self.system_db,
&mut pool,
tenant_id,
mode,
) )
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?; }
profile_conn.execute_batch( pub fn lookup_slug(
"CREATE TABLE IF NOT EXISTS settings ( &self,
key TEXT PRIMARY KEY, slug: &str,
value TEXT NOT NULL ) -> Result<Option<crate::db::slugs::ResolvedSlugInfo>, crate::error::AppError> {
);", let urls_conn = crate::utils::lock_db(&self.db.global_urls, "global_urls")?;
)?; let pages_conn =
crate::utils::lock_db(&self.db.global_landing_pages, "global_landing_pages")?;
if let Some(info) = crate::db::slugs::lookup_slug(&urls_conn, &pages_conn, slug)? {
return Ok(Some(info));
}
let dbs = UserDbs { // Fallback to system.db.global_slugs if table exists (backward compatibility during transition)
content: Arc::new(Mutex::new(content_conn)), let system_conn = crate::utils::lock_db(&self.system_db, "system_db")?;
analytics: Arc::new(Mutex::new(analytics_conn)), let has_table: bool = system_conn
profile: Arc::new(Mutex::new(profile_conn)), .query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='global_slugs');",
[],
|r| r.get(0),
)
.unwrap_or(false);
if has_table {
let row_opt: Option<(i64, String, String, String)> = system_conn
.query_row(
"SELECT owner_user_id, target_type, target_id, status FROM global_slugs WHERE slug = ?1;",
[slug],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
)
.optional()?;
if let Some((owner_id, target_type, target_id, status)) = row_opt {
let tt = match target_type.as_str() {
"page" => crate::db::slugs::SlugTargetType::LandingPage,
_ => crate::db::slugs::SlugTargetType::Url,
}; };
return Ok(Some(crate::db::slugs::ResolvedSlugInfo {
slug: slug.to_string(),
owner_tenant_id: owner_id.to_string(),
target_type: tt,
target_id,
created_at: String::new(),
updated_at: String::new(),
status,
retired_at: None,
}));
}
}
pool.insert(user_id, dbs.clone()); Ok(None)
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> { 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.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.system_db, "system_db")?.execute("VACUUM;", [])?;
crate::utils::lock_db(&self.users_db, "users_db")?.execute("VACUUM;", [])?; crate::utils::lock_db(&self.users_db, "users_db")?.execute("VACUUM;", [])?;
crate::utils::lock_db(&self.db.global_urls, "global_urls")?.execute("VACUUM;", [])?;
crate::utils::lock_db(&self.db.global_landing_pages, "global_landing_pages")?
.execute("VACUUM;", [])?;
crate::utils::lock_db(&self.db.reserved, "reserved")?.execute("VACUUM;", [])?;
Ok(()) Ok(())
} }
} }
+7 -1
View File
@@ -5,11 +5,17 @@ use axum::{
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
}; };
pub struct AdminPageRow {
pub page: LandingPage,
pub owner_tenant_id: String,
pub owner_username: Option<String>,
}
#[derive(Template)] #[derive(Template)]
#[template(path = "pages.html")] #[template(path = "pages.html")]
pub struct PagesTemplate { pub struct PagesTemplate {
pub admin_username: String, pub admin_username: String,
pub pages: Vec<LandingPage>, pub pages: Vec<AdminPageRow>,
pub csrf_token: String, pub csrf_token: String,
pub error: Option<String>, pub error: Option<String>,
pub current_page: usize, pub current_page: usize,
+7 -1
View File
@@ -5,11 +5,17 @@ use axum::{
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
}; };
pub struct AdminUrlRow {
pub url: Url,
pub owner_tenant_id: String,
pub owner_username: Option<String>,
}
#[derive(Template)] #[derive(Template)]
#[template(path = "urls.html")] #[template(path = "urls.html")]
pub struct UrlsTemplate { pub struct UrlsTemplate {
pub admin_username: String, pub admin_username: String,
pub urls: Vec<Url>, pub urls: Vec<AdminUrlRow>,
pub csrf_token: String, pub csrf_token: String,
pub error: Option<String>, pub error: Option<String>,
pub tag_filter: Option<String>, pub tag_filter: Option<String>,
+3
View File
@@ -22,6 +22,9 @@ pub fn get_db_file_info(data_dir: &Path) -> String {
("admin/admin.db", "Admin DB"), ("admin/admin.db", "Admin DB"),
("admin/system.db", "System DB"), ("admin/system.db", "System DB"),
("admin/users.db", "Users DB"), ("admin/users.db", "Users DB"),
("slugs/global_urls.db", "Global URLs DB"),
("slugs/global_landing_pages.db", "Global Landing Pages DB"),
("slugs/reserved.db", "Reserved Slugs DB"),
("users/1/content.db", "Legacy Content DB"), ("users/1/content.db", "Legacy Content DB"),
("users/1/analytics.db", "Legacy Analytics DB"), ("users/1/analytics.db", "Legacy Analytics DB"),
]; ];
+121 -34
View File
@@ -12,8 +12,36 @@ pub async fn url_analytics_get(
Err(redir) => return redir.into_response(), 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 url = {
let conn = state.content_db.lock().unwrap(); let conn = user_dbs.content.lock().unwrap();
match get_url_by_id(&conn, &id) { match get_url_by_id(&conn, &id) {
Ok(Some(u)) => u, Ok(Some(u)) => u,
Ok(None) => return (StatusCode::NOT_FOUND, "URL not found").into_response(), 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 schema_cols = get_visits_schema_columns(&conn).unwrap_or_default();
let has_utm_source = schema_cols.contains("utm_source"); 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(), 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 page = {
let conn = state.content_db.lock().unwrap(); let conn = user_dbs.content.lock().unwrap();
match get_landing_page_by_id(&conn, &id) { match get_landing_page_by_id(&conn, &id) {
Ok(Some(p)) => p, Ok(Some(p)) => p,
Ok(None) => return (StatusCode::NOT_FOUND, "Landing page not found").into_response(), 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 schema_cols = get_visits_schema_columns(&conn).unwrap_or_default();
let has_utm_source = schema_cols.contains("utm_source"); let has_utm_source = schema_cols.contains("utm_source");
@@ -439,6 +495,7 @@ pub async fn user_analytics_get(
accept_language: row.get(7)?, accept_language: row.get(7)?,
country: row.get(8)?, country: row.get(8)?,
status_code: row.get(9)?, status_code: row.get(9)?,
owner_tenant_id: user.tenant_id,
owner_user_id: Some(user.id), owner_user_id: Some(user.id),
}) })
}) })
@@ -478,12 +535,12 @@ pub async fn user_url_analytics_get(
}; };
// Ownership check // Ownership check
let (owner_user_id, target_type) = { let owner_tid_str = {
let conn = state.system_db.lock().unwrap(); let conn = state.db.global_urls.lock().unwrap();
match conn.query_row( 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], [&id],
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), |row| row.get::<_, String>(0),
) { ) {
Ok(val) => val, Ok(val) => val,
Err(rusqlite::Error::QueryReturnedNoRows) => { 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(); return StatusCode::FORBIDDEN.into_response();
} }
@@ -674,12 +736,12 @@ pub async fn user_page_analytics_get(
}; };
// Ownership check // Ownership check
let (owner_user_id, target_type) = { let owner_tid_str = {
let conn = state.system_db.lock().unwrap(); let conn = state.db.global_landing_pages.lock().unwrap();
match conn.query_row( 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], [&id],
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), |row| row.get::<_, String>(0),
) { ) {
Ok(val) => val, Ok(val) => val,
Err(rusqlite::Error::QueryReturnedNoRows) => { 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(); return StatusCode::FORBIDDEN.into_response();
} }
@@ -862,12 +929,12 @@ pub async fn user_url_analytics_csv_export(
}; };
// Ownership check // Ownership check
let (owner_user_id, target_type) = { let owner_tid_str = {
let conn = state.system_db.lock().unwrap(); let conn = state.db.global_urls.lock().unwrap();
match conn.query_row( 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], [&id],
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), |row| row.get::<_, String>(0),
) { ) {
Ok(val) => val, Ok(val) => val,
Err(rusqlite::Error::QueryReturnedNoRows) => { 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(); return StatusCode::FORBIDDEN.into_response();
} }
@@ -901,12 +973,12 @@ pub async fn user_url_analytics_json_export(
}; };
// Ownership check // Ownership check
let (owner_user_id, target_type) = { let owner_tid_str = {
let conn = state.system_db.lock().unwrap(); let conn = state.db.global_urls.lock().unwrap();
match conn.query_row( 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], [&id],
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), |row| row.get::<_, String>(0),
) { ) {
Ok(val) => val, Ok(val) => val,
Err(rusqlite::Error::QueryReturnedNoRows) => { 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(); return StatusCode::FORBIDDEN.into_response();
} }
@@ -940,12 +1017,12 @@ pub async fn user_page_analytics_csv_export(
}; };
// Ownership check // Ownership check
let (owner_user_id, target_type) = { let owner_tid_str = {
let conn = state.system_db.lock().unwrap(); let conn = state.db.global_landing_pages.lock().unwrap();
match conn.query_row( 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], [&id],
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), |row| row.get::<_, String>(0),
) { ) {
Ok(val) => val, Ok(val) => val,
Err(rusqlite::Error::QueryReturnedNoRows) => { 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(); return StatusCode::FORBIDDEN.into_response();
} }
@@ -979,12 +1061,12 @@ pub async fn user_page_analytics_json_export(
}; };
// Ownership check // Ownership check
let (owner_user_id, target_type) = { let owner_tid_str = {
let conn = state.system_db.lock().unwrap(); let conn = state.db.global_landing_pages.lock().unwrap();
match conn.query_row( 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], [&id],
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), |row| row.get::<_, String>(0),
) { ) {
Ok(val) => val, Ok(val) => val,
Err(rusqlite::Error::QueryReturnedNoRows) => { 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(); return StatusCode::FORBIDDEN.into_response();
} }
+93 -62
View File
@@ -96,25 +96,7 @@ fn load_tenant_user_by_username(
conn: &rusqlite::Connection, conn: &rusqlite::Connection,
username: &str, username: &str,
) -> Result<Option<crate::models::TenantUser>, rusqlite::Error> { ) -> Result<Option<crate::models::TenantUser>, rusqlite::Error> {
conn.query_row( crate::db::users::get_user_by_username(conn, username)
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
FROM users WHERE username = ?1;",
[username],
|row| {
Ok(crate::models::TenantUser {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
status: row.get(3)?,
created_at: row.get(4)?,
last_login: row.get(5)?,
account_type: row.get(6)?,
organization_id: row.get(7)?,
metadata: row.get(8)?,
})
},
)
.optional()
} }
fn tenant_user_to_admin_user(u: crate::models::TenantUser) -> User { 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 // POST /admin/login
pub async fn login_post( pub async fn login_post(
State(state): State<AppState>, State(state): State<AppState>,
@@ -180,21 +187,18 @@ pub async fn login_post(
} }
}; };
let created_user_res = {
let conn = match state.users_db.lock() { let conn = match state.users_db.lock() {
Ok(c) => c, Ok(c) => c,
Err(_) => { Err(_) => {
return Redirect::to("/admin/login?error=Internal error").into_response(); return Redirect::to("/admin/login?error=Internal error").into_response();
} }
}; };
match crate::db::users::create_admin_user(&conn, &form.username, &hash) { 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
);
}
match created_user_res {
Ok(u) => {
if let Ok(system_conn) = state.system_db.lock() { if let Ok(system_conn) = state.system_db.lock() {
let metadata = audit_meta(&ip, &headers); let metadata = audit_meta(&ip, &headers);
let _ = crate::db::audit_events::write_audit_event( let _ = crate::db::audit_events::write_audit_event(
@@ -254,6 +258,14 @@ pub async fn login_post(
let session_token = generate_token(32); let session_token = generate_token(32);
let expires = (Utc::now() + chrono::Duration::days(30)).to_rfc3339(); 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() { let conn = match state.users_db.lock() {
Ok(c) => c, Ok(c) => c,
@@ -300,6 +312,7 @@ pub async fn login_post(
.build(); .build();
let mut response_jar = jar.clone(); let mut response_jar = jar.clone();
response_jar = clear_user_cookie(response_jar);
response_jar = response_jar.add(cookie).add(clear_temp); response_jar = response_jar.add(cookie).add(clear_temp);
(response_jar, Redirect::to("/admin/dashboard")).into_response() (response_jar, Redirect::to("/admin/dashboard")).into_response()
@@ -322,14 +335,17 @@ pub async fn login_post(
} }
// GET /logout // GET /logout
pub async fn public_logout(State(_state): State<AppState>, jar: CookieJar) -> Response { pub async fn public_logout(State(state): State<AppState>, jar: CookieJar) -> Response {
let cookie = Cookie::build("bzod_user_session") if let Some(user_cookie) = jar.get("bzod_user_session") {
.path("/") invalidate_session_in_db(&state, user_cookie.value());
.max_age(time::Duration::ZERO) }
.build(); if let Some(admin_cookie) = jar.get("bzod_session") {
invalidate_session_in_db(&state, admin_cookie.value());
}
let mut response_jar = jar.clone(); 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() (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 user_opt: Option<crate::models::TenantUser> = {
let conn = state.users_db.lock().unwrap(); let conn = state.users_db.lock().unwrap();
let user_res: Result<Option<crate::models::TenantUser>, rusqlite::Error> = conn.query_row( match crate::db::users::get_user_by_username(&conn, &form.username) {
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata \
FROM users WHERE username = ?1;",
[&form.username],
|row| {
Ok(crate::models::TenantUser {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
status: row.get(3)?,
created_at: row.get(4)?,
last_login: row.get(5)?,
account_type: row.get(6)?,
organization_id: row.get(7)?,
metadata: row.get(8)?,
})
}
).optional();
match user_res {
Ok(Some(u)) => { Ok(Some(u)) => {
if u.status != "active" { if u.status != "active" {
None None
@@ -423,6 +420,14 @@ pub async fn public_login_post(
let session_token = generate_token(32); let session_token = generate_token(32);
let expires = (Utc::now() + chrono::Duration::days(30)).to_rfc3339(); 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 conn = state.users_db.lock().unwrap();
let _ = let _ =
@@ -446,6 +451,32 @@ pub async fn public_login_post(
let secure_flag = let secure_flag =
crate::utils::resolve_cookie_secure(state.config.cookie_secure, &headers); crate::utils::resolve_cookie_secure(state.config.cookie_secure, &headers);
if user.account_type == "admin" {
let cookie = Cookie::build(("bzod_session", session_token))
.path("/")
.secure(secure_flag)
.http_only(true)
.same_site(axum_extra::extract::cookie::SameSite::Strict)
.max_age(time::Duration::days(30))
.build();
let clear_temp = Cookie::build("bzod_temp_csrf")
.path("/login")
.max_age(time::Duration::ZERO)
.build();
let mut response_jar = jar.clone();
response_jar = clear_user_cookie(response_jar);
response_jar = response_jar.add(cookie).add(clear_temp);
(response_jar, Redirect::to("/admin/dashboard")).into_response()
} else {
if user.tenant_id.is_none() {
return Redirect::to("/login?error=Invalid tenant configuration")
.into_response();
}
let cookie = Cookie::build(("bzod_user_session", session_token)) let cookie = Cookie::build(("bzod_user_session", session_token))
.path("/") .path("/")
.secure(secure_flag) .secure(secure_flag)
@@ -460,10 +491,12 @@ pub async fn public_login_post(
.build(); .build();
let mut response_jar = jar.clone(); let mut response_jar = jar.clone();
response_jar = clear_admin_cookie(response_jar);
response_jar = response_jar.add(cookie).add(clear_temp); response_jar = response_jar.add(cookie).add(clear_temp);
(response_jar, Redirect::to("/user/dashboard")).into_response() (response_jar, Redirect::to("/user/dashboard")).into_response()
} }
}
None => { None => {
let system_conn = state.system_db.lock().unwrap(); let system_conn = state.system_db.lock().unwrap();
let metadata = format!( let metadata = format!(
@@ -486,18 +519,16 @@ pub async fn public_login_post(
// GET /admin/logout // GET /admin/logout
pub async fn logout(State(state): State<AppState>, jar: CookieJar) -> Response { pub async fn logout(State(state): State<AppState>, jar: CookieJar) -> Response {
if let Ok((_, session_id)) = require_auth(&state, &jar).await { if let Some(admin_cookie) = jar.get("bzod_session") {
let conn = state.users_db.lock().unwrap(); invalidate_session_in_db(&state, admin_cookie.value());
let _ = conn.execute("DELETE FROM sessions WHERE id = ?1;", [&session_id]); }
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(); 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() (response_jar, Redirect::to("/admin/login")).into_response()
} }
+35 -39
View File
@@ -95,25 +95,44 @@ pub async fn dashboard_get(State(state): State<AppState>, jar: CookieJar) -> Res
}; };
let (total_urls, active_links, dead_links) = { let (total_urls, active_links, dead_links) = {
let conn = state.content_db.lock().unwrap(); let conn = state.db.global_urls.lock().unwrap();
get_url_counts(&conn).unwrap_or((0, 0, 0)) 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 total_pages = {
let conn = state.content_db.lock().unwrap(); let conn = state.db.global_landing_pages.lock().unwrap();
get_landing_page_count(&conn).unwrap_or(0) conn.query_row(
"SELECT COUNT(*) FROM global_landing_pages WHERE status != 'retired';",
[],
|r| r.get(0),
)
.unwrap_or(0)
}; };
let total_clicks = { let total_clicks = {
let conn = state.analytics_db.lock().unwrap(); let users_conn = state.users_db.lock().unwrap();
get_total_clicks(&conn).unwrap_or(0) crate::db::users::get_platform_total_clicks(&state.db.topology, &users_conn).unwrap_or(0)
};
let clicks_data = {
let conn = state.analytics_db.lock().unwrap();
get_clicks_trend(&conn, "url", "all", 30)
.or_else(|_| get_clicks_trend_raw(&conn, "url", "all", 30))
.unwrap_or_default()
}; };
let mut trend_map = std::collections::BTreeMap::new(); 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(); .to_string();
trend_map.insert(date_str, 0i64); 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 formatted_trend: Vec<(String, i64)> = trend_map.into_iter().collect();
let traffic_chart = generate_line_chart(&formatted_trend); let traffic_chart = generate_line_chart(&formatted_trend);
let countries_data = { let countries_chart = generate_bar_chart(&[]);
let conn = state.analytics_db.lock().unwrap(); let referrers_chart = generate_bar_chart(&[]);
get_metric_rankings(&conn, "url", "all", "country", 5) let browsers_chart = generate_bar_chart(&[]);
.or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "country", 5))
.unwrap_or_default()
};
let countries_chart = generate_bar_chart(&countries_data);
let referrers_data = {
let conn = state.analytics_db.lock().unwrap();
get_metric_rankings(&conn, "url", "all", "referrer", 5)
.or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "referrer", 5))
.unwrap_or_default()
};
let referrers_chart = generate_bar_chart(&referrers_data);
let browsers_data = {
let conn = state.analytics_db.lock().unwrap();
get_metric_rankings(&conn, "url", "all", "browser", 5)
.or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "browser", 5))
.unwrap_or_default()
};
let browsers_chart = generate_bar_chart(&browsers_data);
let template = crate::templates::DashboardTemplate { let template = crate::templates::DashboardTemplate {
admin_username: user.username, admin_username: user.username,
+23 -6
View File
@@ -49,9 +49,27 @@ pub async fn health_get(
db_reports.push(r); db_reports.push(r);
} }
let system_db_path = state.config.data_dir.join("admin").join("system.db"); if let Ok(r) = crate::db::sqlite::collect_health_report(
let users_db_path = state.config.data_dir.join("admin").join("users.db"); &state.db.global_urls.lock().unwrap(),
let admin_db_path = state.config.data_dir.join("admin").join("admin.db"); "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( let system_db_size = format_size(
std::fs::metadata(&system_db_path) std::fs::metadata(&system_db_path)
@@ -69,7 +87,7 @@ pub async fn health_get(
.unwrap_or(0), .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 tenants_db_size = format_size(get_dir_size(&users_dir).unwrap_or(0));
let total_data_size = format_size(get_dir_size(&state.config.data_dir).unwrap_or(0)); let 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 { for issue in issues {
use crate::services::registry_validator::RegistryIssueType; use crate::services::registry_validator::RegistryIssueType;
match issue.issue_type { match issue.issue_type {
RegistryIssueType::StaleReservation RegistryIssueType::StaleReservation => {
| RegistryIssueType::TenantAdminHasIsolatedContent => {
warnings.push(format!( warnings.push(format!(
"Warning for slug {}: {}", "Warning for slug {}: {}",
issue.slug, issue.description issue.slug, issue.description
+146 -16
View File
@@ -86,32 +86,82 @@ pub(crate) fn write_audit_log(
pub(crate) const PAGE_SIZE: usize = 25; pub(crate) const PAGE_SIZE: usize = 25;
pub(crate) const ANALYTICS_PAGE_SIZE: usize = 50; pub(crate) const ANALYTICS_PAGE_SIZE: usize = 50;
pub(crate) const MAX_JSON_EXPORT_ROWS: usize = 50_000; 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( pub(crate) async fn require_auth(
state: &AppState, state: &AppState,
jar: &CookieJar, jar: &CookieJar,
) -> Result<(User, String), Redirect> { ) -> Result<(User, String), Box<Response>> {
let conn = match state.users_db.lock() { let conn = match state.users_db.lock() {
Ok(c) => c, 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) { match authenticate_admin_session(&conn, jar) {
Ok(Some((user, session_id))) => Ok((user, session_id)), Ok(Some((user, session_id))) => Ok((user, session_id)),
_ => Err(Redirect::to("/admin/login")), Ok(None) => {
// Check if user is logged in as a normal tenant user trying to access admin route
if let Ok(Some((tenant_user, _))) = authenticate_user_session(&conn, jar) {
if tenant_user.account_type != "admin" {
return Err(Box::new(
(
StatusCode::FORBIDDEN,
"Forbidden: Standard users cannot access Admin routes",
)
.into_response(),
));
} }
} }
// Helper: Verify tenant user session and return user or redirect to login Err(Box::new(Redirect::to("/admin/login").into_response()))
}
Err(_) => Err(Box::new(Redirect::to("/admin/login").into_response())),
}
}
// Helper: Verify tenant user session and return tenant user or error response (403 Forbidden / login redirect)
pub(crate) async fn require_user_auth( pub(crate) async fn require_user_auth(
state: &AppState, state: &AppState,
jar: &CookieJar, jar: &CookieJar,
) -> Result<(crate::models::TenantUser, String), Redirect> { ) -> Result<(crate::models::TenantUser, String), Box<Response>> {
let conn = match state.users_db.lock() { let conn = match state.users_db.lock() {
Ok(c) => c, 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) { match authenticate_user_session(&conn, jar) {
Ok(Some((user, session_id))) => Ok((user, session_id)), Ok(Some((user, session_id))) => {
_ => Err(Redirect::to("/login")), 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)] #[derive(Deserialize)]
@@ -186,8 +236,48 @@ pub(crate) async fn perform_csv_export(
Err(status) => return status.into_response(), 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 target_exists = {
let conn = state.content_db.lock().unwrap(); let conn = user_dbs.content.lock().unwrap();
if target_type == "url" { if target_type == "url" {
get_url_by_id(&conn, &id) get_url_by_id(&conn, &id)
.map(|u| u.is_some()) .map(|u| u.is_some())
@@ -203,7 +293,7 @@ pub(crate) async fn perform_csv_export(
} }
let count = { let count = {
let conn = state.analytics_db.lock().unwrap(); let conn = user_dbs.analytics.lock().unwrap();
get_target_visit_total_filtered( get_target_visit_total_filtered(
&conn, &conn,
target_type, target_type,
@@ -215,14 +305,14 @@ pub(crate) async fn perform_csv_export(
}; };
let (has_utm_source, has_utm_campaign) = { 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(); let cols = get_visits_schema_columns(&conn).unwrap_or_default();
(cols.contains("utm_source"), cols.contains("utm_campaign")) (cols.contains("utm_source"), cols.contains("utm_campaign"))
}; };
let (tx, rx) = let (tx, rx) =
tokio::sync::mpsc::channel::<Result<axum::body::Bytes, std::convert::Infallible>>(32); 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(); let target_id = id.clone();
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
@@ -378,8 +468,48 @@ pub(crate) async fn perform_json_export(
Err(status) => return status.into_response(), 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 target_exists = {
let conn = state.content_db.lock().unwrap(); let conn = user_dbs.content.lock().unwrap();
if target_type == "url" { if target_type == "url" {
get_url_by_id(&conn, &id) get_url_by_id(&conn, &id)
.map(|u| u.is_some()) .map(|u| u.is_some())
@@ -395,7 +525,7 @@ pub(crate) async fn perform_json_export(
} }
let count = { let count = {
let conn = state.analytics_db.lock().unwrap(); let conn = user_dbs.analytics.lock().unwrap();
get_target_visit_total_filtered( get_target_visit_total_filtered(
&conn, &conn,
target_type, target_type,
@@ -411,7 +541,7 @@ pub(crate) async fn perform_json_export(
} }
let visits_raw = { let visits_raw = {
let conn = state.analytics_db.lock().unwrap(); let conn = user_dbs.analytics.lock().unwrap();
match get_target_visits_all_in_memory( match get_target_visits_all_in_memory(
&conn, &conn,
target_type, target_type,
+302 -268
View File
@@ -1,4 +1,5 @@
use super::*; use super::*;
use crate::identity::TenantId;
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct GlobalSlugRow { pub struct GlobalSlugRow {
@@ -48,28 +49,83 @@ pub async fn moderation_get(
Err(redir) => return redir.into_response(), Err(redir) => return redir.into_response(),
}; };
let flagged_items = { let mut flagged_items: Vec<GlobalSlugRow> = Vec::new();
let conn = state.system_db.lock().unwrap();
let mut stmt = conn.prepare( // Query global_urls.db
"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;" let urls_conn = state.db.global_urls.lock().unwrap();
).unwrap(); let users_conn = state.users_db.lock().unwrap();
let rows = stmt let query_res = urls_conn.prepare(
.query_map([], |row| { "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 { Ok(GlobalSlugRow {
slug: row.get(0)?, slug: row.get(0)?,
owner_user_id: row.get(1)?, owner_user_id: uid,
target_type: row.get(2)?, target_type: "url".to_string(),
target_id: row.get(3)?, target_id: row.get(2)?,
created_at: row.get(4)?, created_at: row.get(3)?,
updated_at: row.get(5)?, updated_at: row.get(4)?,
status: row.get(6)?, status: row.get(5)?,
deleted_at: row.get(7)?, deleted_at: row.get(6)?,
}) })
}) })?;
.unwrap(); Ok(rows.filter_map(|r| r.ok()).collect::<Vec<_>>())
rows.filter_map(|r| r.ok()).collect() });
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 logs = {
let conn = state.system_db.lock().unwrap(); 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(); return Redirect::to("/admin/moderation?error=Invalid moderation action").into_response();
} }
let (owner_user_id, target_type) = { let slug_info = match state.lookup_slug(&form.slug) {
let system_conn = state.system_db.lock().unwrap(); Ok(Some(info)) => info,
let row_opt: Option<(i64, String)> = system_conn _ => return Redirect::to("/admin/moderation?error=Slug not found").into_response(),
.query_row( };
"SELECT owner_user_id, target_type FROM global_slugs WHERE slug = ?1;",
[&form.slug],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()
.unwrap_or(None);
match row_opt { let owner_tenant_id = match TenantId::parse(&slug_info.owner_tenant_id) {
Some(r) => r, Ok(t) => t,
None => return Redirect::to("/admin/moderation?error=Slug not found").into_response(), 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(); let users_conn = state.users_db.lock().unwrap();
crate::db::users::get_user_by_id(&users_conn, owner_user_id) match crate::db::users::get_user_by_tenant_id(&users_conn, owner_tenant_id) {
.unwrap_or(None) Ok(Some(u)) => (u.id, Some(u.username)),
.map(|u| u.username) _ => (0, None),
}
}; };
{
let system_conn = state.system_db.lock().unwrap();
let now = Utc::now().to_rfc3339(); let now = Utc::now().to_rfc3339();
// 1. Update v0.8 slug database
if action == "deleted" { if action == "deleted" {
let _ = system_conn.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&form.slug]); if slug_info.target_type == crate::db::slugs::SlugTargetType::Url {
let urls_conn = state.db.global_urls.lock().unwrap();
let _ = urls_conn.execute("DELETE FROM global_urls WHERE slug = ?1;", [&form.slug]);
} else { } else {
let _ = system_conn.execute( let pages_conn = state.db.global_landing_pages.lock().unwrap();
"UPDATE global_slugs SET status = ?1, updated_at = ?2 WHERE slug = ?3;", 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], 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 event_id = Uuid::new_v4().to_string();
let _ = system_conn.execute( 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);", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10);",
rusqlite::params![ rusqlite::params![
event_id, event_id,
@@ -186,7 +287,7 @@ pub async fn moderation_post(
user.username, user.username,
owner_user_id, owner_user_id,
owner_username, owner_username,
target_type, slug_info.target_type.as_str(),
form.slug, form.slug,
action, action,
form.severity, form.severity,
@@ -231,20 +332,21 @@ pub async fn slugs_get(
Err(redir) => return redir.into_response(), Err(redir) => return redir.into_response(),
}; };
let system_conn = state.system_db.lock().unwrap(); let mut slugs: Vec<GlobalSlugRow> = Vec::new();
let mut sql = "SELECT slug, owner_user_id, target_type, target_id, created_at, updated_at, status, deleted_at FROM global_slugs WHERE 1=1".to_string(); // Query global_urls.db
{
let urls_conn = state.db.global_urls.lock().unwrap();
let users_conn = state.users_db.lock().unwrap();
let mut sql = "SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at FROM global_urls WHERE 1=1".to_string();
let mut values = vec![]; let mut values = vec![];
if let Some(ref s) = query.search { if let Some(ref s) = query.search {
if !s.trim().is_empty() { if !s.trim().is_empty() {
sql.push_str(" AND slug LIKE ?"); sql.push_str(" AND slug LIKE ?");
values.push(rusqlite::types::Value::Text(format!("%{}%", s.trim()))); 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 let Some(ref st) = query.status {
if !st.trim().is_empty() { if !st.trim().is_empty() {
sql.push_str(" AND status = ?"); sql.push_str(" AND status = ?");
@@ -253,26 +355,107 @@ pub async fn slugs_get(
} }
sql.push_str(" ORDER BY created_at DESC LIMIT 100;"); sql.push_str(" ORDER BY created_at DESC LIMIT 100;");
let slugs = { let items_res = urls_conn.prepare(&sql).and_then(|mut stmt| {
let mut stmt = system_conn.prepare(&sql).unwrap(); let rows = stmt.query_map(rusqlite::params_from_iter(values.iter()), |row| {
let rows = stmt let tid_str: String = row.get(1)?;
.query_map(rusqlite::params_from_iter(values.iter()), |row| { 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 { Ok(GlobalSlugRow {
slug: row.get(0)?, slug: row.get(0)?,
owner_user_id: row.get(1)?, owner_user_id: uid,
target_type: row.get(2)?, target_type: "url".to_string(),
target_id: row.get(3)?, target_id: row.get(2)?,
created_at: row.get(4)?, created_at: row.get(3)?,
updated_at: row.get(5)?, updated_at: row.get(4)?,
status: row.get(6)?, status: row.get(5)?,
deleted_at: row.get(7)?, deleted_at: row.get(6)?,
}) })
}) })?;
.unwrap(); Ok(rows.filter_map(|r| r.ok()).collect::<Vec<_>>())
rows.filter_map(|r| r.ok()).collect() });
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 history = {
let system_conn = state.system_db.lock().unwrap();
let mut stmt = system_conn.prepare( let mut stmt = system_conn.prepare(
"SELECT id, slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username \ "SELECT id, slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username \
FROM slug_history ORDER BY timestamp DESC LIMIT 50;" 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(); return Redirect::to("/admin/slugs?error=Invalid CSRF token").into_response();
} }
let user_exists = { let req = crate::services::slug_transfer::SlugTransferRequest {
let conn = state.users_db.lock().unwrap(); slug: form.slug.clone(),
conn.query_row( new_owner_user_id: form.new_owner_user_id,
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
[form.new_owner_user_id],
|row| row.get::<_, bool>(0),
)
.unwrap_or(false)
}; };
if !user_exists { match crate::services::slug_transfer::transfer_slug(&state, &req, &user.username) {
return Redirect::to("/admin/slugs?error=New owner user ID does not exist").into_response(); Ok(_) => Redirect::to(&format!(
}
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 {}", "/admin/slugs?success=Slug /{} successfully transferred to user {}",
form.slug, form.new_owner_user_id form.slug, form.new_owner_user_id
)) ))
.into_response() .into_response(),
Err(e) => Redirect::to(&format!("/admin/slugs?error={}", e.message())).into_response(),
}
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -553,17 +557,30 @@ pub async fn slugs_status_post(
return Redirect::to("/admin/slugs?error=Invalid status").into_response(); return Redirect::to("/admin/slugs?error=Invalid status").into_response();
} }
{ let slug_info = match state.lookup_slug(&form.slug) {
let conn = state.system_db.lock().unwrap(); Ok(Some(info)) => info,
let now = Utc::now().to_rfc3339(); _ => return Redirect::to("/admin/slugs?error=Slug not found").into_response(),
};
let _ = conn.execute( let now = Utc::now().to_rfc3339();
"UPDATE global_slugs SET status = ?1, updated_at = ?2 WHERE slug = ?3;", 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], 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( let _ = crate::db::audit_events::write_audit_event(
&conn, &system_conn,
&user.username, &user.username,
"SLUG_STATUS_UPDATE", "SLUG_STATUS_UPDATE",
"slug", "slug",
@@ -600,29 +617,46 @@ pub async fn slugs_delete_post(
return Redirect::to("/admin/slugs?error=Invalid CSRF token").into_response(); return Redirect::to("/admin/slugs?error=Invalid CSRF token").into_response();
} }
{ let slug_info = match state.lookup_slug(&form.slug) {
let conn = state.system_db.lock().unwrap(); Ok(Some(info)) => info,
_ => return Redirect::to("/admin/slugs?error=Slug not found").into_response(),
};
let old_owner_user_id = {
let users_conn = state.users_db.lock().unwrap();
if let Ok(tid) = TenantId::parse(&slug_info.owner_tenant_id) {
crate::db::users::get_user_by_tenant_id(&users_conn, tid)
.ok()
.flatten()
.map(|u| u.id)
} else {
None
}
};
let now = Utc::now().to_rfc3339(); let now = Utc::now().to_rfc3339();
let old_owner_user_id: Option<i64> = conn if slug_info.target_type == crate::db::slugs::SlugTargetType::Url {
.query_row( let urls_conn = state.db.global_urls.lock().unwrap();
"SELECT owner_user_id FROM global_slugs WHERE slug = ?1;", 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], [&form.slug],
|row| row.get(0), );
) }
.optional()
.unwrap_or(None);
let _ = conn.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&form.slug]); {
let system_conn = state.system_db.lock().unwrap();
let _ = conn.execute( let _ = system_conn.execute(
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username) \ "INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username) \
VALUES (?1, ?2, NULL, 'deleted', ?3, ?4);", VALUES (?1, ?2, NULL, 'deleted', ?3, ?4);",
rusqlite::params![form.slug, old_owner_user_id, now, user.username], rusqlite::params![form.slug, old_owner_user_id, now, user.username],
); );
let _ = crate::db::audit_events::write_audit_event( let _ = crate::db::audit_events::write_audit_event(
&conn, &system_conn,
&user.username, &user.username,
"SLUG_RELEASE", "SLUG_RELEASE",
"slug", "slug",
+136 -153
View File
@@ -118,21 +118,26 @@ pub async fn user_pages_create(
} }
} }
let owner_tid = match user.tenant_id {
Some(tid) => tid,
None => return (StatusCode::FORBIDDEN, "Missing tenant identity").into_response(),
};
{ {
let system_conn = state.system_db.lock().unwrap(); let reserved_conn = state.db.reserved.lock().unwrap();
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) { let urls_conn = state.db.global_urls.lock().unwrap();
return Redirect::to("/user/pages?error=Short code/slug already exists") let pages_conn = state.db.global_landing_pages.lock().unwrap();
.into_response(); if let Err(e) = crate::db::slugs::reserve_landing_page_slug(
} &reserved_conn,
if let Err(e) = crate::db::users::register_global_slug( &urls_conn,
&system_conn, &pages_conn,
&code, &code,
user.id, &owner_tid,
"page",
"",
"reserving",
) { ) {
return Redirect::to(&format!("/user/pages?error=Failed to reserve slug: {}", e)) return Redirect::to(&format!(
"/user/pages?error=Short code/slug unavailable: {}",
e
))
.into_response(); .into_response();
} }
} }
@@ -152,16 +157,8 @@ pub async fn user_pages_create(
match res { match res {
Ok(page) => { Ok(page) => {
{ {
let system_conn = state.system_db.lock().unwrap(); let pages_conn = state.db.global_landing_pages.lock().unwrap();
let global_status = if form.state == "published" { let _ = crate::db::slugs::activate_landing_page_slug(&pages_conn, &code, &page.id);
"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 users_conn = state.users_db.lock().unwrap();
@@ -181,8 +178,8 @@ pub async fn user_pages_create(
Redirect::to("/user/pages").into_response() Redirect::to("/user/pages").into_response()
} }
Err(e) => { Err(e) => {
let system_conn = state.system_db.lock().unwrap(); let pages_conn = state.db.global_landing_pages.lock().unwrap();
let _ = crate::db::users::release_global_slug(&system_conn, &code, user.id); let _ = crate::db::slugs::release_landing_page_slug(&pages_conn, &code, &owner_tid);
Redirect::to(&format!("/user/pages?error=Database error: {}", e)).into_response() 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) { match delete_landing_page(&conn, &id) {
Ok(_) => { Ok(_) => {
let _ = crate::db::users::release_global_slug( {
&state.system_db.lock().unwrap(), let pages_conn = state.db.global_landing_pages.lock().unwrap();
&page.code, let _ = pages_conn.execute(
user.id, "DELETE FROM global_landing_pages WHERE slug = ?1;",
[&page.code],
); );
}
let ip = get_client_ip(&headers, connect_info); let ip = get_client_ip(&headers, connect_info);
let _ = write_audit_log( let _ = write_audit_log(
&state.admin_db.lock().unwrap(), &state.admin_db.lock().unwrap(),
@@ -265,8 +264,14 @@ pub async fn pages_get(
}; };
let (pages, total_pages, page, visible_pages) = { let (pages, total_pages, page, visible_pages) = {
let conn = state.content_db.lock().unwrap(); let conn = state.db.global_landing_pages.lock().unwrap();
let total_records = get_landing_page_count(&conn).unwrap_or(0); 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 calculated_total_pages = (total_records as usize).div_ceil(PAGE_SIZE);
let total_pages = std::cmp::max(1, calculated_total_pages); let total_pages = std::cmp::max(1, calculated_total_pages);
let requested_page = query.page.unwrap_or(1); let requested_page = query.page.unwrap_or(1);
@@ -278,7 +283,73 @@ pub async fn pages_get(
.clamp(1, total_pages); .clamp(1, total_pages);
let offset = (current_page - 1) * PAGE_SIZE; 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 start_page = current_page.saturating_sub(3).max(1);
let end_page = std::cmp::min(total_pages, current_page + 3); let end_page = std::cmp::min(total_pages, current_page + 3);
@@ -302,7 +373,8 @@ pub async fn pages_get(
template.into_response() template.into_response()
} }
#[derive(Deserialize)] #[derive(Deserialize, Default)]
#[serde(default)]
pub struct CreatePageForm { pub struct CreatePageForm {
pub title: String, pub title: String,
pub slug: String, pub slug: String,
@@ -317,126 +389,20 @@ pub struct CreatePageForm {
pub async fn pages_create( pub async fn pages_create(
State(state): State<AppState>, State(state): State<AppState>,
jar: CookieJar, jar: CookieJar,
headers: HeaderMap, _headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>, _connect_info: Option<ConnectInfo<SocketAddr>>,
Form(form): Form<CreatePageForm>, Form(_form): Form<CreatePageForm>,
) -> Response { ) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await { let (_user, _session_id) = match require_auth(&state, &jar).await {
Ok(u) => u, Ok(u) => u,
Err(redir) => return redir.into_response(), 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(); StatusCode::FORBIDDEN,
} "Admin is a platform operator and cannot create unowned application pages; create landing pages via a tenant user account",
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(); .into_response()
}
}
} else {
if !crate::utils::validation::validate_custom_slug(&code) {
return Redirect::to("/admin/pages?error=Custom slug must start with ! followed by 1-24 characters of a-z, 0-9, -, _")
.into_response();
}
}
let clean_slug = form.slug.trim().to_lowercase();
if clean_slug.is_empty() {
return Redirect::to("/admin/pages?error=Slug is required").into_response();
}
{
let users_conn = state.users_db.lock().unwrap();
if !crate::db::users::check_quota_limit(&users_conn, admin_user_id, "landings")
.unwrap_or(false)
{
return Redirect::to("/admin/pages?error=Quota limit exceeded").into_response();
}
}
{
let system_conn = state.system_db.lock().unwrap();
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
return Redirect::to("/admin/pages?error=Short code already exists").into_response();
}
// Always use owner_user_id = 1 for admin content so it resolves via state.content_db
if let Err(e) =
crate::db::users::register_global_slug(&system_conn, &code, 1, "page", "", "reserving")
{
return Redirect::to(&format!("/admin/pages?error=Failed to reserve slug: {}", e))
.into_response();
}
}
let res = {
let conn = state.content_db.lock().unwrap();
create_landing_page(
&conn,
&code,
&clean_slug,
&form.title,
&form.html_content,
&form.state,
)
};
match res {
Ok(page) => {
{
let system_conn = state.system_db.lock().unwrap();
let global_status = if form.state == "published" {
"active"
} else {
"disabled"
};
let _ = system_conn.execute(
"UPDATE global_slugs SET target_id = ?1, status = ?2, updated_at = ?3 WHERE slug = ?4;",
rusqlite::params![page.id, global_status, chrono::Utc::now().to_rfc3339(), code],
);
}
{
let users_conn = state.users_db.lock().unwrap();
let _ = crate::db::users::increment_quota_counter(
&users_conn,
admin_user_id,
"landings",
);
}
{
let conn_admin = state.admin_db.lock().unwrap();
let _ = write_audit_log(
&conn_admin,
&state,
&user.username,
"PAGE_CREATION",
Some("page"),
Some(&page.id),
Some(&ip),
headers.get("user-agent").and_then(|h| h.to_str().ok()),
);
}
Redirect::to("/admin/pages").into_response()
}
Err(e) => {
let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::users::release_global_slug(&system_conn, &code, 1);
Redirect::to(&format!("/admin/pages?error=Database error: {}", e)).into_response()
}
}
} }
// POST /admin/pages/delete/:id // POST /admin/pages/delete/:id
@@ -460,9 +426,30 @@ pub async fn pages_delete(
let ip = get_client_ip(&headers, connect_info); let ip = get_client_ip(&headers, connect_info);
let conn = state.content_db.lock().unwrap(); // Look up owner tenant in global_landing_pages
match delete_landing_page(&conn, &id) { let owner_info = {
Ok(_) => { let conn = state.db.global_landing_pages.lock().unwrap();
conn.query_row(
"SELECT slug, owner_tenant_id FROM global_landing_pages WHERE target_id = ?1 OR slug = ?1 LIMIT 1;",
rusqlite::params![id],
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
).ok()
};
if let Some((slug, tid_str)) = owner_info {
if let Ok(tid) = tid_str.parse::<crate::identity::TenantId>() {
if let Ok(user_dbs) = state.open_tenant(tid, crate::state::TenantOpenMode::CoreJob) {
let conn = user_dbs.content.lock().unwrap();
let _ = delete_landing_page(&conn, &id);
}
}
let conn = state.db.global_landing_pages.lock().unwrap();
let _ = conn.execute(
"UPDATE global_landing_pages SET status = 'retired', updated_at = ?1 WHERE slug = ?2;",
rusqlite::params![chrono::Utc::now().to_rfc3339(), slug],
);
}
{ {
let conn_admin = state.admin_db.lock().unwrap(); let conn_admin = state.admin_db.lock().unwrap();
let _ = write_audit_log( let _ = write_audit_log(
@@ -478,7 +465,3 @@ pub async fn pages_delete(
} }
Redirect::to("/admin/pages").into_response() Redirect::to("/admin/pages").into_response()
} }
Err(e) => Redirect::to(&format!("/admin/pages?error=Failed to delete page: {}", e))
.into_response(),
}
}
+217 -118
View File
@@ -106,11 +106,13 @@ pub async fn user_download_backup(
}; };
let ip = get_client_ip(&headers, connect_info); let ip = get_client_ip(&headers, connect_info);
let user_dir = state let user_dir = match crate::db::tenant::location_for_user(&user).and_then(|loc| {
.config loc.dir(&state.db.topology)
.data_dir .map_err(|e| crate::error::AppError::BadRequest(e.to_string()))
.join("users") }) {
.join(user.id.to_string()); Ok(p) => p,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
};
let mut buffer = Vec::new(); let mut buffer = Vec::new();
let res = { let res = {
@@ -224,11 +226,15 @@ pub async fn user_restore_backup_post(
.into_response(); .into_response();
} }
let user_dir = state let user_dir = match crate::db::tenant::location_for_user(&user).and_then(|loc| {
.config loc.dir(&state.db.topology)
.data_dir .map_err(|e| crate::error::AppError::BadRequest(e.to_string()))
.join("users") }) {
.join(user.id.to_string()); Ok(p) => p,
Err(_) => {
return Redirect::to("/user/settings?error=Invalid user directory").into_response()
}
};
let temp_unpack_dir = let temp_unpack_dir =
std::env::temp_dir().join(format!("bzod_restore_unpack_{}", uuid::Uuid::new_v4())); 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(); .into_response();
} }
let restore_res = { let restore_res: Result<(), Box<dyn std::error::Error>> = (|| {
let file = match File::open(&temp_file_path) { let file = 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 tar_gz = GzDecoder::new(file); let tar_gz = GzDecoder::new(file);
let mut archive = tar::Archive::new(tar_gz); let mut archive = tar::Archive::new(tar_gz);
if let Err(e) = archive.unpack(&temp_unpack_dir) { archive.unpack(&temp_unpack_dir)?;
Err(Box::new(e) as Box<dyn std::error::Error>)
} else { let target_tenant_id = user.tenant_id.ok_or("User is missing assigned TenantId")?;
// 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"); let temp_content_db = temp_unpack_dir.join("content.db");
if temp_content_db.exists() { if !temp_content_db.exists() {
if let Err(e) = crate::db::users::register_restored_user_slugs( return Err("Backup is missing content.db".into());
&system_conn, }
user.id,
&temp_content_db, let content_conn = rusqlite::Connection::open(&temp_content_db)?;
) {
Err(e) 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 { } else {
Ok(()) "active"
}
} else {
Err("Backup is missing content.db".into())
}
}
}; };
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); 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(); 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( let _ = write_audit_log(
&state.admin_db.lock().unwrap(), &state.admin_db.lock().unwrap(),
&state, &state,
@@ -678,8 +766,43 @@ pub async fn bulk_qr_export_post(
let ip = get_client_ip(&headers, connect_info); let ip = get_client_ip(&headers, connect_info);
let urls = { let urls = {
let conn = state.content_db.lock().unwrap(); let conn = state.db.global_urls.lock().unwrap();
crate::db::content::list_urls(&conn, 500, 0, None).unwrap_or_default() 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() { 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_duration = state.start_time.elapsed();
let uptime = crate::utils::format_duration(uptime_duration); let uptime = crate::utils::format_duration(uptime_duration);
let urls = { let urls = vec![];
let conn = state.content_db.lock().unwrap();
crate::db::content::list_urls(&conn, 50, 0, None).unwrap_or_default()
};
let template = crate::templates::StatusTemplate { let template = crate::templates::StatusTemplate {
admin_username: user.username, admin_username: user.username,
@@ -911,93 +1031,72 @@ pub async fn restore_backup_post(
let restore_res = { let restore_res = {
// Temporarily suspend access to active SQLite connections // Temporarily suspend access to active SQLite connections
let mut admin_conn = state.admin_db.lock().unwrap(); 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 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 // 1. Close current connections by replacing them with dummy in-memory DBs
*admin_conn = match rusqlite::Connection::open_in_memory() { *admin_conn = rusqlite::Connection::open_in_memory()
Ok(c) => c, .unwrap_or_else(|_| rusqlite::Connection::open(":memory:").unwrap());
Err(e) => { *system_conn = rusqlite::Connection::open_in_memory()
return Redirect::to(&format!( .unwrap_or_else(|_| rusqlite::Connection::open(":memory:").unwrap());
"/admin/settings?error=Failed to open temp in-memory DB: {}", *users_conn = rusqlite::Connection::open_in_memory()
e .unwrap_or_else(|_| rusqlite::Connection::open(":memory:").unwrap());
)) *urls_conn = rusqlite::Connection::open_in_memory()
.into_response() .unwrap_or_else(|_| rusqlite::Connection::open(":memory:").unwrap());
} *pages_conn = rusqlite::Connection::open_in_memory()
}; .unwrap_or_else(|_| rusqlite::Connection::open(":memory:").unwrap());
*content_conn = match rusqlite::Connection::open_in_memory() { *reserved_conn = rusqlite::Connection::open_in_memory()
Ok(c) => c, .unwrap_or_else(|_| rusqlite::Connection::open(":memory:").unwrap());
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()
}
};
// 2. Perform restore unpacking/validation // 2. Clear tenant pool cache
// perform_restore() handles legacy layout normalization internally if let Ok(mut pool) = state.user_dbs.lock() {
// before validation, so no post-restore normalization is needed. pool.clear();
}
// 3. Perform restore unpacking/validation
let res = crate::cli::restore::perform_restore(&temp_file_path, &state.config.data_dir); let res = crate::cli::restore::perform_restore(&temp_file_path, &state.config.data_dir);
// 3. Reinitialize database connections using correct multi-tenant paths // 4. Reinitialize Core database connections
let paths = let topology = crate::db::topology::Topology::new(&state.config.data_dir);
crate::services::backup_layout::RestoredDbPaths::from_data_dir(&state.config.data_dir); let new_admin = rusqlite::Connection::open(topology.admin_db());
let new_admin = rusqlite::Connection::open(&paths.admin); let new_system = rusqlite::Connection::open(topology.system_db());
let new_system = rusqlite::Connection::open(&paths.system); let new_users = rusqlite::Connection::open(topology.users_registry_db());
let new_users = rusqlite::Connection::open(&paths.users); let new_urls = rusqlite::Connection::open(topology.global_urls_db());
let new_content = rusqlite::Connection::open(&paths.content); let new_pages = rusqlite::Connection::open(topology.global_landing_pages_db());
let new_analytics = rusqlite::Connection::open(&paths.analytics); let new_reserved = rusqlite::Connection::open(topology.reserved_db());
match (new_admin, new_content, new_analytics, new_system, new_users) { match (
(Ok(adm), Ok(cnt), Ok(any), Ok(sys), Ok(usr)) => { 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(&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(&sys, "system");
let _ = crate::db::sqlite::enable_wal(&usr, "users"); 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(&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(&sys, "system");
let _ = crate::db::sqlite::enable_foreign_keys(&usr, "users"); 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; *admin_conn = adm;
*content_conn = cnt;
*analytics_conn = any;
*system_conn = sys; *system_conn = sys;
match state.db.users.lock() { *users_conn = usr;
Ok(mut u) => *u = usr, *urls_conn = urls;
Err(_) => { *pages_conn = pages;
return Redirect::to( *reserved_conn = resv;
"/admin/settings?error=Failed to reopen restored databases",
)
.into_response();
}
}
} }
_ => { _ => {
return Redirect::to("/admin/settings?error=Failed to reopen restored databases") return Redirect::to("/admin/settings?error=Failed to reopen restored databases")
+151 -200
View File
@@ -153,20 +153,26 @@ pub async fn user_urls_create(
} }
} }
let owner_tid = match user.tenant_id {
Some(tid) => tid,
None => return (StatusCode::FORBIDDEN, "Missing tenant identity").into_response(),
};
{ {
let system_conn = state.system_db.lock().unwrap(); let reserved_conn = state.db.reserved.lock().unwrap();
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) { let urls_conn = state.db.global_urls.lock().unwrap();
return Redirect::to("/user/urls?error=Short code/slug already exists").into_response(); let pages_conn = state.db.global_landing_pages.lock().unwrap();
} if let Err(e) = crate::db::slugs::reserve_url_slug(
if let Err(e) = crate::db::users::register_global_slug( &reserved_conn,
&system_conn, &urls_conn,
&pages_conn,
&code, &code,
user.id, &owner_tid,
"url",
"",
"reserving",
) { ) {
return Redirect::to(&format!("/user/urls?error=Failed to reserve slug: {}", e)) return Redirect::to(&format!(
"/user/urls?error=Short code/slug unavailable: {}",
e
))
.into_response(); .into_response();
} }
} }
@@ -181,6 +187,8 @@ pub async fn user_urls_create(
) { ) {
Ok(d) => d, Ok(d) => d,
Err(msg) => { 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(); return Redirect::to(&format!("/user/urls?error={}", msg)).into_response();
} }
}; };
@@ -192,7 +200,11 @@ pub async fn user_urls_create(
} else { } else {
match hash_password(&form.password) { match hash_password(&form.password) {
Ok(h) => Some(h), 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>() { match form.max_access_count.trim().parse::<i64>() {
Ok(c) => Some(c), Ok(c) => Some(c),
Err(_) => { 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 { match res {
Ok(url) => { Ok(url) => {
{ {
let system_conn = state.system_db.lock().unwrap(); let urls_conn = state.db.global_urls.lock().unwrap();
let _ = system_conn.execute( let _ = crate::db::slugs::activate_url_slug(&urls_conn, &code, &url.id);
"UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;",
rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), code],
);
} }
{ {
let users_conn = state.users_db.lock().unwrap(); let users_conn = state.users_db.lock().unwrap();
@@ -266,8 +277,8 @@ pub async fn user_urls_create(
Redirect::to("/user/urls").into_response() Redirect::to("/user/urls").into_response()
} }
Err(e) => { Err(e) => {
let system_conn = state.system_db.lock().unwrap(); let urls_conn = state.db.global_urls.lock().unwrap();
let _ = crate::db::users::release_global_slug(&system_conn, &code, user.id); let _ = crate::db::slugs::release_url_slug(&urls_conn, &code, &owner_tid);
Redirect::to(&format!("/user/urls?error=Database error: {}", e)).into_response() 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) { match delete_url(&conn, &id) {
Ok(_) => { Ok(_) => {
let _ = crate::db::users::release_global_slug( {
&state.system_db.lock().unwrap(), let urls_conn = state.db.global_urls.lock().unwrap();
&url.code, let _ = urls_conn
user.id, .execute("DELETE FROM global_urls WHERE slug = ?1;", [&url.code]);
); }
let ip = get_client_ip(&headers, connect_info); let ip = get_client_ip(&headers, connect_info);
let _ = write_audit_log( let _ = write_audit_log(
&state.admin_db.lock().unwrap(), &state.admin_db.lock().unwrap(),
@@ -351,12 +362,14 @@ pub async fn urls_get(
}; };
let (urls, total_pages, page, visible_pages) = { let (urls, total_pages, page, visible_pages) = {
let conn = state.content_db.lock().unwrap(); let conn = state.db.global_urls.lock().unwrap();
let total_records = if let Some(tag_str) = query.tag.as_deref() { let total_records: i64 = conn
get_url_count_by_tag(&conn, tag_str).unwrap_or(0) .query_row(
} else { "SELECT COUNT(*) FROM global_urls WHERE status != 'retired';",
get_url_counts(&conn).map(|(t, _, _)| t).unwrap_or(0) [],
}; |r| r.get(0),
)
.unwrap_or(0);
let calculated_total_pages = (total_records as usize).div_ceil(PAGE_SIZE); let calculated_total_pages = (total_records as usize).div_ceil(PAGE_SIZE);
let total_pages = std::cmp::max(1, calculated_total_pages); let total_pages = std::cmp::max(1, calculated_total_pages);
let requested_page = query.page.unwrap_or(1); let requested_page = query.page.unwrap_or(1);
@@ -368,8 +381,79 @@ pub async fn urls_get(
.clamp(1, total_pages); .clamp(1, total_pages);
let offset = (current_page - 1) * PAGE_SIZE; let offset = (current_page - 1) * PAGE_SIZE;
let urls = list_urls(&conn, PAGE_SIZE as i64, offset as i64, query.tag.as_deref()) let mut stmt = conn.prepare(
.unwrap_or_default(); "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 start_page = current_page.saturating_sub(3).max(1);
let end_page = std::cmp::min(total_pages, current_page + 3); let end_page = std::cmp::min(total_pages, current_page + 3);
@@ -406,7 +490,8 @@ pub async fn urls_get(
template.into_response() template.into_response()
} }
#[derive(Deserialize)] #[derive(Deserialize, Default)]
#[serde(default)]
pub struct CreateUrlForm { pub struct CreateUrlForm {
pub destination: String, pub destination: String,
pub code: String, pub code: String,
@@ -427,170 +512,20 @@ pub struct CreateUrlForm {
pub async fn urls_create( pub async fn urls_create(
State(state): State<AppState>, State(state): State<AppState>,
jar: CookieJar, jar: CookieJar,
headers: HeaderMap, _headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>, _connect_info: Option<ConnectInfo<SocketAddr>>,
Form(form): Form<CreateUrlForm>, Form(_form): Form<CreateUrlForm>,
) -> Response { ) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await { let (_user, _session_id) = match require_auth(&state, &jar).await {
Ok(u) => u, Ok(u) => u,
Err(redir) => return redir.into_response(), 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(); StatusCode::FORBIDDEN,
} "Admin is a platform operator and cannot create unowned application URLs; create links via a tenant user account",
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(); .into_response()
}
}
} else {
if !crate::utils::validation::validate_custom_slug(&code) {
return Redirect::to("/admin/urls?error=Custom slug must start with ! followed by 1-24 characters of a-z, 0-9, -, _")
.into_response();
}
}
let dest = match crate::services::urls::prepare_destination(
&form.destination,
crate::services::urls::UtmParams {
source: Some(&form.utm_source),
medium: Some(&form.utm_medium),
campaign: Some(&form.utm_campaign),
},
) {
Ok(d) => d,
Err(msg) => {
return Redirect::to(&format!("/admin/urls?error={}", msg)).into_response();
}
};
let expires_at_opt = crate::services::urls::parse_expires_at_input(&form.expires_at);
let password_hash_opt = if form.password.trim().is_empty() {
None
} else {
match hash_password(&form.password) {
Ok(h) => Some(h),
Err(_) => return Redirect::to("/admin/urls?error=Hashing error").into_response(),
}
};
let max_access_count_opt = if form.max_access_count.trim().is_empty() {
None
} else {
match form.max_access_count.trim().parse::<i64>() {
Ok(c) => Some(c),
Err(_) => {
return Redirect::to("/admin/urls?error=Invalid max access count").into_response()
}
}
};
let tags_list: Vec<String> = form
.tags
.split(',')
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.collect();
let title_opt = if form.title.trim().is_empty() {
None
} else {
Some(form.title.trim())
};
let desc_opt = if form.description.trim().is_empty() {
None
} else {
Some(form.description.trim())
};
{
let users_conn = state.users_db.lock().unwrap();
if !crate::db::users::check_quota_limit(&users_conn, admin_user_id, "urls").unwrap_or(false)
{
return Redirect::to("/admin/urls?error=Quota limit exceeded").into_response();
}
}
{
let system_conn = state.system_db.lock().unwrap();
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
return Redirect::to("/admin/urls?error=Short code/slug already exists")
.into_response();
}
// Always use owner_user_id = 1 for admin content so it resolves via state.content_db
if let Err(e) =
crate::db::users::register_global_slug(&system_conn, &code, 1, "url", "", "reserving")
{
return Redirect::to(&format!("/admin/urls?error=Failed to reserve slug: {}", e))
.into_response();
}
}
let res = {
let conn = state.content_db.lock().unwrap();
crate::db::content::create_url_extended(
&conn,
&code,
&dest,
title_opt,
desc_opt,
&tags_list,
expires_at_opt.as_deref(),
password_hash_opt.as_deref(),
max_access_count_opt,
)
};
match res {
Ok(url) => {
{
let system_conn = state.system_db.lock().unwrap();
let _ = system_conn.execute(
"UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;",
rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), code],
);
}
{
let users_conn = state.users_db.lock().unwrap();
let _ =
crate::db::users::increment_quota_counter(&users_conn, admin_user_id, "urls");
}
{
let conn = state.admin_db.lock().unwrap();
let _ = write_audit_log(
&conn,
&state,
&user.username,
"URL_CREATION",
Some("url"),
Some(&url.id),
Some(&ip),
headers.get("user-agent").and_then(|h| h.to_str().ok()),
);
}
Redirect::to("/admin/urls").into_response()
}
Err(e) => {
let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::users::release_global_slug(&system_conn, &code, 1);
Redirect::to(&format!("/admin/urls?error=Database error: {}", e)).into_response()
}
}
} }
// POST /admin/urls/delete/:id // POST /admin/urls/delete/:id
@@ -614,9 +549,30 @@ pub async fn urls_delete(
let ip = get_client_ip(&headers, connect_info); let ip = get_client_ip(&headers, connect_info);
let conn = state.content_db.lock().unwrap(); // Look up owner tenant in global_urls
match delete_url(&conn, &id) { let owner_info = {
Ok(_) => { let conn = state.db.global_urls.lock().unwrap();
conn.query_row(
"SELECT slug, owner_tenant_id FROM global_urls WHERE target_id = ?1 OR slug = ?1 LIMIT 1;",
rusqlite::params![id],
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
).ok()
};
if let Some((slug, tid_str)) = owner_info {
if let Ok(tid) = tid_str.parse::<crate::identity::TenantId>() {
if let Ok(user_dbs) = state.open_tenant(tid, crate::state::TenantOpenMode::CoreJob) {
let conn = user_dbs.content.lock().unwrap();
let _ = delete_url(&conn, &id);
}
}
let conn = state.db.global_urls.lock().unwrap();
let _ = conn.execute(
"UPDATE global_urls SET status = 'retired', updated_at = ?1 WHERE slug = ?2;",
rusqlite::params![chrono::Utc::now().to_rfc3339(), slug],
);
}
{ {
let conn_admin = state.admin_db.lock().unwrap(); let conn_admin = state.admin_db.lock().unwrap();
let _ = write_audit_log( let _ = write_audit_log(
@@ -632,8 +588,3 @@ pub async fn urls_delete(
} }
Redirect::to("/admin/urls").into_response() Redirect::to("/admin/urls").into_response()
} }
Err(e) => {
Redirect::to(&format!("/admin/urls?error=Failed to delete link: {}", e)).into_response()
}
}
}
+8 -38
View File
@@ -133,6 +133,7 @@ pub async fn users_create_post(
} }
}; };
if new_user.account_type == "standard" {
if let Err(e) = state.db.init_user_databases(new_user.id) { if let Err(e) = state.db.init_user_databases(new_user.id) {
return Redirect::to(&format!( return Redirect::to(&format!(
"/admin/users?error=Failed to initialize user databases: {}", "/admin/users?error=Failed to initialize user databases: {}",
@@ -140,6 +141,7 @@ pub async fn users_create_post(
)) ))
.into_response(); .into_response();
} }
}
{ {
let conn = state.admin_db.lock().unwrap(); let conn = state.admin_db.lock().unwrap();
@@ -481,25 +483,9 @@ pub async fn user_detail_get(
let target_user = { let target_user = {
let conn = state.users_db.lock().unwrap(); let conn = state.users_db.lock().unwrap();
let u_res = conn.query_row( match crate::db::users::get_user_by_id(&conn, id) {
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata \ Ok(Some(u)) => u,
FROM users WHERE id = ?1;", _ => return Redirect::to("/admin/users?error=User not found").into_response(),
[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(),
} }
}; };
@@ -576,25 +562,9 @@ pub async fn user_edit_get(
let target_user = { let target_user = {
let conn = state.users_db.lock().unwrap(); let conn = state.users_db.lock().unwrap();
let u_res = conn.query_row( match crate::db::users::get_user_by_id(&conn, id) {
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata \ Ok(Some(u)) => u,
FROM users WHERE id = ?1;", _ => return Redirect::to("/admin/users?error=User not found").into_response(),
[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(),
} }
}; };
+271 -117
View File
@@ -12,12 +12,10 @@ use crate::auth::ApiUser;
use crate::db::admin::write_audit_log; use crate::db::admin::write_audit_log;
use crate::db::analytics::{ use crate::db::analytics::{
get_clicks_trend, get_clicks_trend_raw, get_metric_rankings, get_metric_rankings_raw, 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::{ use crate::db::content::{
create_landing_page, delete_landing_page, delete_url, get_landing_page_by_id, create_landing_page, delete_landing_page, delete_url, get_landing_page_by_id, get_url_by_id,
get_landing_page_count, get_url_by_id, get_url_counts, list_landing_pages, list_urls, list_landing_pages, list_urls, update_landing_page, update_url,
update_landing_page, update_url,
}; };
use crate::state::AppState; use crate::state::AppState;
use crate::utils::get_client_ip; use crate::utils::get_client_ip;
@@ -73,6 +71,30 @@ pub struct ApiError {
pub error: String, 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 --- // --- URL Endpoints ---
// POST /api/v1/urls // POST /api/v1/urls
@@ -138,9 +160,17 @@ pub async fn api_create_url(
None None
}; };
// Dynamically resolve target user ID and content DB // Dynamically resolve target user ID, tenant ID, and content DB
let (target_user_id, content_db) = match user.0 { let (target_user_id, target_tenant_id, content_db) = match user.0 {
crate::models::ApiActor::Admin(_) => (1, state.content_db.clone()), 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) => { crate::models::ApiActor::User(ref u) => {
let user_dbs = match state.get_user_dbs(u.id) { let user_dbs = match state.get_user_dbs(u.id) {
Ok(dbs) => dbs, Ok(dbs) => dbs,
@@ -154,7 +184,19 @@ pub async fn api_create_url(
.into_response() .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(); let reserved_conn = state.db.reserved.lock().unwrap();
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) { 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 ( return (
StatusCode::CONFLICT, StatusCode::CONFLICT,
Json(ApiError { Json(ApiError {
error: "Short code already exists".to_string(), error: format!("Short code unavailable: {}", e),
}),
)
.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),
}), }),
) )
.into_response(); .into_response();
@@ -222,13 +256,10 @@ pub async fn api_create_url(
match res { match res {
Ok(url) => { Ok(url) => {
// Activate slug // Activate slug in v0.8 global_urls.db
{ {
let system_conn = state.system_db.lock().unwrap(); let urls_conn = state.db.global_urls.lock().unwrap();
let _ = system_conn.execute( let _ = crate::db::slugs::activate_url_slug(&urls_conn, &code, &url.id);
"UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;",
rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), code],
);
} }
// Increment quota // Increment quota
{ {
@@ -264,8 +295,8 @@ pub async fn api_create_url(
(StatusCode::CREATED, Json(url)).into_response() (StatusCode::CREATED, Json(url)).into_response()
} }
Err(e) => { Err(e) => {
let system_conn = state.system_db.lock().unwrap(); let urls_conn = state.db.global_urls.lock().unwrap();
let _ = crate::db::users::release_global_slug(&system_conn, &code, target_user_id); let _ = crate::db::slugs::release_url_slug(&urls_conn, &code, &target_tenant_id);
( (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError { Json(ApiError {
@@ -287,13 +318,17 @@ pub struct ListQuery {
pub async fn api_list_urls( pub async fn api_list_urls(
State(state): State<AppState>, State(state): State<AppState>,
_user: ApiUser, user: ApiUser,
Query(query): Query<ListQuery>, Query(query): Query<ListQuery>,
) -> Response { ) -> Response {
let limit = query.limit.unwrap_or(100); let limit = query.limit.unwrap_or(100);
let offset = query.offset.unwrap_or(0); 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()) { match list_urls(&conn, limit, offset, query.tag.as_deref()) {
Ok(urls) => Json(urls).into_response(), Ok(urls) => Json(urls).into_response(),
Err(e) => ( Err(e) => (
@@ -309,10 +344,14 @@ pub async fn api_list_urls(
// GET /api/v1/urls/:uuid // GET /api/v1/urls/:uuid
pub async fn api_get_url( pub async fn api_get_url(
State(state): State<AppState>, State(state): State<AppState>,
_user: ApiUser, user: ApiUser,
Path(uuid): Path<String>, Path(uuid): Path<String>,
) -> Response { ) -> 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) { match get_url_by_id(&conn, &uuid) {
Ok(Some(url)) => Json(url).into_response(), Ok(Some(url)) => Json(url).into_response(),
Ok(None) => ( Ok(None) => (
@@ -351,7 +390,11 @@ pub async fn api_update_url(
) )
.into_response(); .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( match update_url(
&conn, &conn,
&uuid, &uuid,
@@ -438,9 +481,21 @@ pub async fn api_delete_url(
user: ApiUser, user: ApiUser,
Path(uuid): Path<String>, Path(uuid): Path<String>,
) -> Response { ) -> 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) { match delete_url(&conn, &uuid) {
Ok(true) => { 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 ip = get_client_ip(&headers, connect_info);
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok()); let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
let _ = write_audit_log( let _ = write_audit_log(
@@ -510,9 +565,17 @@ pub async fn api_create_page(
} }
} }
// Dynamically resolve target user ID and content DB // Dynamically resolve target user ID, tenant ID, and content DB
let (target_user_id, content_db) = match user.0 { let (target_user_id, target_tenant_id, content_db) = match user.0 {
crate::models::ApiActor::Admin(_) => (1, state.content_db.clone()), 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) => { crate::models::ApiActor::User(ref u) => {
let user_dbs = match state.get_user_dbs(u.id) { let user_dbs = match state.get_user_dbs(u.id) {
Ok(dbs) => dbs, Ok(dbs) => dbs,
@@ -526,7 +589,19 @@ pub async fn api_create_page(
.into_response() .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(); let reserved_conn = state.db.reserved.lock().unwrap();
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) { 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 ( return (
StatusCode::CONFLICT, StatusCode::CONFLICT,
Json(ApiError { Json(ApiError {
error: "Short code already exists".to_string(), error: format!("Short code unavailable: {}", e),
}),
)
.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),
}), }),
) )
.into_response(); .into_response();
@@ -590,18 +657,10 @@ pub async fn api_create_page(
match res { match res {
Ok(page) => { Ok(page) => {
// Activate slug // Activate slug in v0.8 global_landing_pages.db
{ {
let system_conn = state.system_db.lock().unwrap(); let pages_conn = state.db.global_landing_pages.lock().unwrap();
let global_status = if payload.state == "published" { let _ = crate::db::slugs::activate_landing_page_slug(&pages_conn, &code, &page.id);
"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],
);
} }
// Increment quota // Increment quota
{ {
@@ -627,8 +686,9 @@ pub async fn api_create_page(
(StatusCode::CREATED, Json(page)).into_response() (StatusCode::CREATED, Json(page)).into_response()
} }
Err(e) => { Err(e) => {
let system_conn = state.system_db.lock().unwrap(); let pages_conn = state.db.global_landing_pages.lock().unwrap();
let _ = crate::db::users::release_global_slug(&system_conn, &code, target_user_id); let _ =
crate::db::slugs::release_landing_page_slug(&pages_conn, &code, &target_tenant_id);
( (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError { Json(ApiError {
@@ -643,13 +703,17 @@ pub async fn api_create_page(
// GET /api/v1/pages // GET /api/v1/pages
pub async fn api_list_pages( pub async fn api_list_pages(
State(state): State<AppState>, State(state): State<AppState>,
_user: ApiUser, user: ApiUser,
Query(query): Query<ListQuery>, Query(query): Query<ListQuery>,
) -> Response { ) -> Response {
let limit = query.limit.unwrap_or(100); let limit = query.limit.unwrap_or(100);
let offset = query.offset.unwrap_or(0); 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) { match list_landing_pages(&conn, limit, offset) {
Ok(pages) => Json(pages).into_response(), Ok(pages) => Json(pages).into_response(),
Err(e) => ( Err(e) => (
@@ -665,10 +729,14 @@ pub async fn api_list_pages(
// GET /api/v1/pages/:uuid // GET /api/v1/pages/:uuid
pub async fn api_get_page( pub async fn api_get_page(
State(state): State<AppState>, State(state): State<AppState>,
_user: ApiUser, user: ApiUser,
Path(uuid): Path<String>, Path(uuid): Path<String>,
) -> Response { ) -> 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) { match get_landing_page_by_id(&conn, &uuid) {
Ok(Some(page)) => Json(page).into_response(), Ok(Some(page)) => Json(page).into_response(),
Ok(None) => ( Ok(None) => (
@@ -697,7 +765,11 @@ pub async fn api_update_page(
Path(uuid): Path<String>, Path(uuid): Path<String>,
Json(payload): Json<UpdatePageRequest>, Json(payload): Json<UpdatePageRequest>,
) -> Response { ) -> 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( match update_landing_page(
&conn, &conn,
&uuid, &uuid,
@@ -745,9 +817,22 @@ pub async fn api_delete_page(
user: ApiUser, user: ApiUser,
Path(uuid): Path<String>, Path(uuid): Path<String>,
) -> Response { ) -> 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) { match delete_landing_page(&conn, &uuid) {
Ok(true) => { 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 ip = get_client_ip(&headers, connect_info);
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok()); let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
let _ = write_audit_log( 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 (total_urls, active_links, dead_links) = {
let conn = state.content_db.lock().unwrap(); let conn = state.db.global_urls.lock().unwrap();
get_url_counts(&conn).unwrap_or((0, 0, 0)) 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 total_pages = {
let conn = state.content_db.lock().unwrap(); let conn = state.db.global_landing_pages.lock().unwrap();
get_landing_page_count(&conn).unwrap_or(0) conn.query_row(
}; "SELECT COUNT(*) FROM global_landing_pages WHERE status != 'retired';",
let (total_clicks, total_page_views) = { [],
let conn = state.analytics_db.lock().unwrap(); |r| r.get(0),
(
get_total_clicks(&conn).unwrap_or(0),
get_total_page_views(&conn).unwrap_or(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 { Json(OverallStatsResponse {
total_urls, total_urls,
@@ -835,12 +944,17 @@ pub struct DetailStatsResponse {
// GET /api/v1/stats/url/:uuid // GET /api/v1/stats/url/:uuid
pub async fn api_url_stats( pub async fn api_url_stats(
State(state): State<AppState>, State(state): State<AppState>,
_user: ApiUser, user: ApiUser,
Path(uuid): Path<String>, Path(uuid): Path<String>,
) -> Response { ) -> Response {
let dbs = match get_api_tenant_dbs(&state, &user) {
Ok(d) => d,
Err(r) => return r,
};
// Verify URL exists // 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() { if get_url_by_id(&conn, &uuid).unwrap_or(None).is_none() {
return ( return (
StatusCode::NOT_FOUND, 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) let clicks = get_clicks_trend(&conn, "url", &uuid, 30)
.or_else(|_| get_clicks_trend_raw(&conn, "url", &uuid, 30)) .or_else(|_| get_clicks_trend_raw(&conn, "url", &uuid, 30))
.unwrap_or_default(); .unwrap_or_default();
@@ -879,12 +993,17 @@ pub async fn api_url_stats(
// GET /api/v1/stats/page/:uuid // GET /api/v1/stats/page/:uuid
pub async fn api_page_stats( pub async fn api_page_stats(
State(state): State<AppState>, State(state): State<AppState>,
_user: ApiUser, user: ApiUser,
Path(uuid): Path<String>, Path(uuid): Path<String>,
) -> Response { ) -> Response {
let dbs = match get_api_tenant_dbs(&state, &user) {
Ok(d) => d,
Err(r) => return r,
};
// Verify Page exists // Verify Page exists
{ {
let conn = state.content_db.lock().unwrap(); let conn = dbs.content.lock().unwrap();
if get_landing_page_by_id(&conn, &uuid) if get_landing_page_by_id(&conn, &uuid)
.unwrap_or(None) .unwrap_or(None)
.is_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) let clicks = get_clicks_trend(&conn, "page", &uuid, 30)
.or_else(|_| get_clicks_trend_raw(&conn, "page", &uuid, 30)) .or_else(|_| get_clicks_trend_raw(&conn, "page", &uuid, 30))
.unwrap_or_default(); .unwrap_or_default();
@@ -935,11 +1054,16 @@ pub struct QrStatsResponse {
// GET /api/v1/qr/:code // GET /api/v1/qr/:code
pub async fn api_get_qr_stats( pub async fn api_get_qr_stats(
State(state): State<AppState>, State(state): State<AppState>,
_user: ApiUser, user: ApiUser,
Path(code): Path<String>, Path(code): Path<String>,
) -> Response { ) -> Response {
let dbs = match get_api_tenant_dbs(&state, &user) {
Ok(d) => d,
Err(r) => return r,
};
let url_opt = { 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) 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 (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 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(); let scans_list = crate::db::qr::get_qr_stats_for_url(&conn, &url.id).unwrap_or_default();
(count, scans_list) (count, scans_list)
@@ -1030,9 +1154,14 @@ pub async fn api_set_preview(
Path(uuid): Path<String>, Path(uuid): Path<String>,
Json(payload): Json<SetPreviewRequest>, Json(payload): Json<SetPreviewRequest>,
) -> Response { ) -> Response {
let dbs = match get_api_tenant_dbs(&state, &user) {
Ok(d) => d,
Err(r) => return r,
};
// Verify URL exists // 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() { if get_url_by_id(&conn, &uuid).unwrap_or(None).is_none() {
return ( return (
StatusCode::NOT_FOUND, 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( match crate::db::preview::upsert_preview(
&conn, &conn,
&uuid, &uuid,
@@ -1081,12 +1210,17 @@ pub async fn api_set_preview(
// GET /api/v1/urls/:uuid/preview // GET /api/v1/urls/:uuid/preview
pub async fn api_get_preview( pub async fn api_get_preview(
State(state): State<AppState>, State(state): State<AppState>,
_user: ApiUser, user: ApiUser,
Path(uuid): Path<String>, Path(uuid): Path<String>,
) -> Response { ) -> Response {
let dbs = match get_api_tenant_dbs(&state, &user) {
Ok(d) => d,
Err(r) => return r,
};
// Verify URL exists // 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() { if get_url_by_id(&conn, &uuid).unwrap_or(None).is_none() {
return ( return (
StatusCode::NOT_FOUND, 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) { match crate::db::preview::get_preview(&conn, &uuid) {
Ok(Some(preview)) => Json(preview).into_response(), Ok(Some(preview)) => Json(preview).into_response(),
Ok(None) => ( Ok(None) => (
@@ -1124,9 +1258,14 @@ pub async fn api_delete_preview(
user: ApiUser, user: ApiUser,
Path(uuid): Path<String>, Path(uuid): Path<String>,
) -> Response { ) -> Response {
let dbs = match get_api_tenant_dbs(&state, &user) {
Ok(d) => d,
Err(r) => return r,
};
// Verify URL exists // 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() { if get_url_by_id(&conn, &uuid).unwrap_or(None).is_none() {
return ( return (
StatusCode::NOT_FOUND, 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) { match crate::db::preview::delete_preview(&conn, &uuid) {
Ok(true) => { Ok(true) => {
// Audit Log // Audit Log
@@ -1186,9 +1325,14 @@ pub async fn api_set_password(
Path(uuid): Path<String>, Path(uuid): Path<String>,
Json(payload): Json<SetPasswordRequest>, Json(payload): Json<SetPasswordRequest>,
) -> Response { ) -> Response {
let dbs = match get_api_tenant_dbs(&state, &user) {
Ok(d) => d,
Err(r) => return r,
};
// Verify URL exists // 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() { if get_url_by_id(&conn, &uuid).unwrap_or(None).is_none() {
return ( return (
StatusCode::NOT_FOUND, 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) { match crate::db::content::set_url_password(&conn, &uuid, &hash) {
Ok(true) => { Ok(true) => {
// Audit Log // Audit Log
@@ -1257,9 +1401,14 @@ pub async fn api_remove_password(
user: ApiUser, user: ApiUser,
Path(uuid): Path<String>, Path(uuid): Path<String>,
) -> Response { ) -> Response {
let dbs = match get_api_tenant_dbs(&state, &user) {
Ok(d) => d,
Err(r) => return r,
};
// Verify URL exists // 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() { if get_url_by_id(&conn, &uuid).unwrap_or(None).is_none() {
return ( return (
StatusCode::NOT_FOUND, 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) { match crate::db::content::remove_url_password(&conn, &uuid) {
Ok(true) => { Ok(true) => {
// Audit Log // Audit Log
@@ -1321,9 +1470,14 @@ pub async fn api_create_qr(
user: ApiUser, user: ApiUser,
Json(payload): Json<CreateQrRequest>, Json(payload): Json<CreateQrRequest>,
) -> Response { ) -> Response {
let dbs = match get_api_tenant_dbs(&state, &user) {
Ok(d) => d,
Err(r) => return r,
};
// Verify URL exists in content.db // 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) if get_url_by_id(&conn, &payload.url_id)
.unwrap_or(None) .unwrap_or(None)
.is_none() .is_none()
@@ -1339,7 +1493,7 @@ pub async fn api_create_qr(
} }
let style = payload.style.unwrap_or_else(|| "default".to_string()); 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) { match crate::db::qr::upsert_qr_code(&conn, &payload.url_id, &style) {
Ok(_) => { Ok(_) => {
// Write Audit Event // Write Audit Event
+56 -6
View File
@@ -67,9 +67,36 @@ pub async fn api_bulk_qr(
} }
// Retrieve URLs from database // 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 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, Ok(c) => c,
Err(e) => { Err(e) => {
return ( return (
@@ -186,10 +213,30 @@ pub async fn api_bulk_url(
.into_response(); .into_response();
} }
// Dynamically resolve target user ID and content DB // Dynamically resolve target user ID, TenantId, and content DB
let (target_user_id, content_db) = match user.0 { let (target_user_id, target_tenant_id, content_db) = match user.0 {
crate::models::ApiActor::Admin(_) => (1, state.content_db.clone()), 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) => { 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) { let user_dbs = match state.get_user_dbs(u.id) {
Ok(dbs) => dbs, Ok(dbs) => dbs,
Err(_) => { Err(_) => {
@@ -202,7 +249,7 @@ pub async fn api_bulk_url(
.into_response() .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( let created_urls = match create_urls_bulk(
&content_db, &content_db,
&state.system_db, &state.db.reserved,
&state.db.global_urls,
&state.db.global_landing_pages,
&state.users_db, &state.users_db,
target_user_id, target_user_id,
target_tenant_id,
items, items,
) { ) {
Ok(urls) => urls, Ok(urls) => urls,
+134 -270
View File
@@ -4,11 +4,11 @@ use axum::{
response::{IntoResponse, Json, Response}, response::{IntoResponse, Json, Response},
}; };
use chrono::Utc; use chrono::Utc;
use rusqlite::OptionalExtension;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
use crate::auth::ApiUser; use crate::auth::ApiUser;
use crate::identity::TenantId;
use crate::models::ApiActor; use crate::models::ApiActor;
use crate::state::AppState; 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) // 1. Transactional clean up on v0.8 slug databases
{ let now = Utc::now().to_rfc3339();
let mut system_conn = state.system_db.lock().unwrap(); if let Some(ref tid) = user_details.tenant_id {
let tx = system_conn.transaction().map_err(|e| e.to_string())?; 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> = { // Get all URL slugs owned by tenant
let mut stmt = tx let url_slugs: Vec<String> = if let Ok(mut stmt) =
.prepare("SELECT slug FROM global_slugs WHERE owner_user_id = ?1;") urls_conn.prepare("SELECT slug FROM global_urls WHERE owner_tenant_id = ?1;")
.map_err(|e| e.to_string())?; {
let rows = stmt stmt.query_map([&tid_str], |row| row.get::<_, String>(0))
.query_map([target_id], |row| row.get(0)) .map(|rows| rows.filter_map(|r| r.ok()).collect())
.map_err(|e| e.to_string())?; .unwrap_or_default()
rows.filter_map(|r| r.ok()).collect() } else {
Vec::new()
}; };
let now = Utc::now().to_rfc3339(); for slug in url_slugs {
for slug in slugs { let _ = urls_conn.execute("DELETE FROM global_urls WHERE slug = ?1;", [&slug]);
let _ = tx.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&slug]); let _ = system_conn.execute(
let _ = tx.execute(
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username) "INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username)
VALUES (?1, ?2, NULL, 'deleted', ?3, ?4);", VALUES (?1, ?2, NULL, 'deleted', ?3, ?4);",
rusqlite::params![slug, target_id, now, admin_username], rusqlite::params![slug, target_id, now, admin_username],
); );
} }
tx.commit() // Get all page slugs owned by tenant
.map_err(|e| format!("Failed to release slugs: {}", e))?; 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 let user_dir = {
.config let conn = state.users_db.lock().unwrap();
.data_dir match crate::db::users::get_user_by_id(&conn, target_id) {
.join("users") Ok(Some(u)) => crate::db::tenant::location_for_user(&u)
.join(target_id.to_string()); .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() { if user_dir.exists() {
let _ = std::fs::remove_dir_all(&user_dir); let _ = std::fs::remove_dir_all(&user_dir);
} }
@@ -444,231 +472,23 @@ pub async fn admin_transfer_slug(
Err(err_resp) => return err_resp, Err(err_resp) => return err_resp,
}; };
// 1. Check if the slug exists and get details let req = crate::services::slug_transfer::SlugTransferRequest {
let (old_owner_user_id, target_type, _target_id) = { slug: payload.slug,
let system_conn = state.system_db.lock().unwrap(); new_owner_user_id: payload.new_owner_user_id,
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(),
}
}; };
if old_owner_user_id == payload.new_owner_user_id { match crate::services::slug_transfer::transfer_slug(&state, &req, &admin.username) {
return ( Ok(_) => StatusCode::OK.into_response(),
StatusCode::BAD_REQUEST, Err(crate::services::slug_transfer::TransferError::NotFound(m)) => {
"New owner must be different from the current owner", (StatusCode::NOT_FOUND, m.to_string()).into_response()
)
.into_response();
} }
Err(crate::services::slug_transfer::TransferError::BadRequest(m)) => {
// 2. Fetch destination databases (StatusCode::BAD_REQUEST, m).into_response()
let old_dbs = match state.get_user_dbs(old_owner_user_id) {
Ok(dbs) => dbs,
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to load current owner's database",
)
.into_response()
} }
}; Err(crate::services::slug_transfer::TransferError::Internal(m)) => {
let new_dbs = match state.get_user_dbs(payload.new_owner_user_id) { (StatusCode::INTERNAL_SERVER_ERROR, m).into_response()
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 --- // --- Admin: Content Moderation ---
@@ -689,44 +509,88 @@ pub async fn admin_moderate_slug(
return (StatusCode::BAD_REQUEST, "Invalid moderation action").into_response(); return (StatusCode::BAD_REQUEST, "Invalid moderation action").into_response();
} }
// 1. Verify slug and get owner user ID // 1. Verify slug using v0.8 slug lookup
let (owner_user_id, target_type) = { let slug_info = match state.lookup_slug(&payload.slug) {
let system_conn = state.system_db.lock().unwrap(); Ok(Some(info)) => info,
let row_opt: Option<(i64, String)> = system_conn Ok(None) => return (StatusCode::NOT_FOUND, "Slug not found").into_response(),
.query_row( Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
"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);
match row_opt { let target_type = slug_info.target_type.as_str().to_string();
Some(r) => r, let owner_tenant_id = match TenantId::parse(&slug_info.owner_tenant_id) {
None => return (StatusCode::NOT_FOUND, "Slug not found").into_response(), Ok(tid) => tid,
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
"Invalid owner tenant ID on slug",
)
.into_response();
} }
}; };
// Resolve owner username for log snapshot // Resolve owner details from users.db for moderation event history
let owner_username = { let (owner_user_id, owner_username) = {
let users_conn = state.users_db.lock().unwrap(); let users_conn = state.users_db.lock().unwrap();
crate::db::users::get_user_by_id(&users_conn, owner_user_id) match crate::db::users::get_user_by_tenant_id(&users_conn, owner_tenant_id) {
.unwrap_or(None) Ok(Some(u)) => (u.id, Some(u.username)),
.map(|u| 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 system_conn = state.system_db.lock().unwrap();
let now = Utc::now().to_rfc3339(); 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 event_id = Uuid::new_v4().to_string();
let _ = system_conn.execute( 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);", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10);",
+12 -29
View File
@@ -4,11 +4,11 @@ use axum::{
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
}; };
use chrono::Utc; use chrono::Utc;
use rusqlite::OptionalExtension;
use std::net::SocketAddr; use std::net::SocketAddr;
use uuid::Uuid; use uuid::Uuid;
use crate::analytics::get_client_country; use crate::analytics::get_client_country;
use crate::db::tenant::TenantOpenMode;
use crate::models::VisitRecord; use crate::models::VisitRecord;
use crate::state::AppState; use crate::state::AppState;
use crate::utils::get_client_ip; use crate::utils::get_client_ip;
@@ -25,37 +25,15 @@ pub async fn resolve_page(
return (StatusCode::NOT_FOUND, "Not Found").into_response(); return (StatusCode::NOT_FOUND, "Not Found").into_response();
} }
// 1. Query global slug namespace in system.db // 1. Query global slug namespace across v0.8 slug databases (with fallback)
let slug_info = { let info = match state.lookup_slug(&code) {
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 {
Ok(Some(info)) => info, Ok(Some(info)) => info,
Ok(None) => { Ok(None) => return (StatusCode::NOT_FOUND, "Not Found").into_response(),
// Fallback to legacy_admin's DB (user_id = 1) if not found in global_slugs
(1, "page".to_string(), "".to_string(), "active".to_string())
}
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").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 is disabled, flagged, or soft_deleted, we return 410 Gone
if slug_status != "active" { if info.status != "active" {
return ( return (
StatusCode::GONE, StatusCode::GONE,
"This content has been disabled or moderated", "This content has been disabled or moderated",
@@ -64,7 +42,8 @@ pub async fn resolve_page(
} }
// 2. Get content database connection via tenant DB resolution // 2. Get content database connection via tenant DB resolution
let content_conn = match state.get_user_dbs(owner_user_id) { let content_conn =
match state.open_slug_owner(&info.owner_tenant_id, TenantOpenMode::PublicContent) {
Ok(dbs) => dbs.content, Ok(dbs) => dbs.content,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
}; };
@@ -103,6 +82,9 @@ pub async fn resolve_page(
.unwrap_or("Unknown") .unwrap_or("Unknown")
.to_string(); .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 { let record = VisitRecord {
id: Uuid::new_v4().to_string(), id: Uuid::new_v4().to_string(),
target_type: "page".to_string(), target_type: "page".to_string(),
@@ -114,7 +96,8 @@ pub async fn resolve_page(
accept_language, accept_language,
country, country,
status_code: 200, status_code: 200,
owner_user_id: Some(owner_user_id), owner_tenant_id,
owner_user_id,
}; };
state.analytics_queue.push(record); state.analytics_queue.push(record);
+53 -24
View File
@@ -7,7 +7,7 @@ use axum_extra::extract::{cookie::Cookie, CookieJar};
use serde::Deserialize; use serde::Deserialize;
use crate::auth::password::verify_password; 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::state::AppState;
use crate::templates::GateTemplate; use crate::templates::GateTemplate;
@@ -21,6 +21,43 @@ pub async fn gate_get(Path(code): Path<String>) -> impl IntoResponse {
GateTemplate { code, error: None } 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 // POST /gate/:code
pub async fn gate_post( pub async fn gate_post(
State(state): State<AppState>, State(state): State<AppState>,
@@ -29,32 +66,25 @@ pub async fn gate_post(
headers: axum::http::HeaderMap, headers: axum::http::HeaderMap,
Form(form): Form<PasswordGateForm>, Form(form): Form<PasswordGateForm>,
) -> Response { ) -> Response {
let url_opt = match get_url_by_code(&state.db, &code) { let password_hash = match lookup_gate(&state, &code) {
Ok(url) => url, Ok(GateLookup::Protected(h)) => h,
Err(_) => { Ok(GateLookup::Unprotected) => {
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
return Redirect::temporary(&format!("/{}", code)).into_response(); 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) { if verify_password(&form.password, &password_hash) {
// Correct password - set 15 min temporary cookie
let cookie_name = format!("bzod_gate_{}", code); let cookie_name = format!("bzod_gate_{}", code);
let secure_flag = crate::utils::resolve_cookie_secure(state.config.cookie_secure, &headers); let secure_flag = crate::utils::resolve_cookie_secure(state.config.cookie_secure, &headers);
let cookie = Cookie::build((cookie_name, "authorized")) let cookie = Cookie::build((cookie_name, "authorized"))
@@ -67,7 +97,6 @@ pub async fn gate_post(
let updated_jar = jar.add(cookie); let updated_jar = jar.add(cookie);
(updated_jar, Redirect::temporary(&format!("/{}", code))).into_response() (updated_jar, Redirect::temporary(&format!("/{}", code))).into_response()
} else { } else {
// Invalid password
GateTemplate { GateTemplate {
code, code,
error: Some("Invalid password".to_string()), error: Some("Invalid password".to_string()),
+21 -59
View File
@@ -1,3 +1,4 @@
use crate::db::tenant::TenantOpenMode;
use crate::services::qr::{generate_qr_png, generate_qr_svg}; use crate::services::qr::{generate_qr_png, generate_qr_svg};
use crate::state::AppState; use crate::state::AppState;
use crate::utils::get_client_ip; use crate::utils::get_client_ip;
@@ -37,57 +38,36 @@ pub async fn qr_handler(
return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(); return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
} }
// We need to look up owner_user_id, target_id, and status from global_slugs // Look up slug info from v0.8 slug registry (with fallback)
let (owner_user_id, target_id, slug_status) = { let info = match state.lookup_slug(&file) {
let system_conn = state.system_db.lock().unwrap(); Ok(Some(info)) => info,
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(), Ok(None) => return (StatusCode::NOT_FOUND, "URL not found").into_response(),
Err(_) => { Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
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(); 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(); 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, Ok(dbs) => dbs,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
}; };
let qr_scans = { let qr_scans = {
let conn = user_dbs.analytics.lock().unwrap(); 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 direct_clicks = {
let conn = user_dbs.analytics.lock().unwrap(); let conn = user_dbs.analytics.lock().unwrap();
conn.query_row( conn.query_row(
"SELECT COUNT(*) FROM visits WHERE target_id = ?1;", "SELECT COUNT(*) FROM visits WHERE target_id = ?1;",
rusqlite::params![target_id], rusqlite::params![info.target_id],
|row| row.get(0), |row| row.get(0),
) )
.unwrap_or(0) .unwrap_or(0)
@@ -109,36 +89,16 @@ pub async fn qr_handler(
return (StatusCode::NOT_FOUND, "Not Found").into_response(); 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 // Look up slug info from v0.8 slug registry (with fallback)
let (owner_user_id, target_type, target_id, slug_status) = { let info = match state.lookup_slug(code) {
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(Some(info)) => info,
Ok(None) => return (StatusCode::NOT_FOUND, "Not Found").into_response(), Ok(None) => return (StatusCode::NOT_FOUND, "Not Found").into_response(),
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
}
}; };
if slug_status == "disabled" { if info.status == "disabled" {
return (StatusCode::GONE, "This content has been disabled").into_response(); 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(); return (StatusCode::NOT_FOUND, "Not Found").into_response();
} }
@@ -159,7 +119,7 @@ pub async fn qr_handler(
.clone() .clone()
.unwrap_or_else(|| format!("{}://{}", proto, host_header)); .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) format!("{}/p/{}", base_url.trim_end_matches('/'), code)
} else { } else {
format!("{}/{}", base_url.trim_end_matches('/'), code) format!("{}/{}", base_url.trim_end_matches('/'), code)
@@ -204,11 +164,13 @@ pub async fn qr_handler(
.and_then(|h| h.to_str().ok()) .and_then(|h| h.to_str().ok())
.map(|s| s.to_string()); .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() { if let Ok(analytics_conn) = user_dbs.analytics.lock() {
let _ = crate::db::qr::log_qr_access( let _ = crate::db::qr::log_qr_access(
&analytics_conn, &analytics_conn,
&target_id, &info.target_id,
Some(ip.as_str()), Some(ip.as_str()),
user_agent.as_deref(), user_agent.as_deref(),
); );
+30 -56
View File
@@ -15,18 +15,26 @@ use axum::{
}; };
use axum_extra::extract::CookieJar; use axum_extra::extract::CookieJar;
use chrono::Utc; use chrono::Utc;
use rusqlite::OptionalExtension;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use tracing::{error, warn}; use tracing::{error, warn};
use uuid::Uuid; use uuid::Uuid;
use crate::analytics::get_client_country; use crate::analytics::get_client_country;
use crate::db::tenant::TenantOpenMode;
use crate::identity::TenantId;
use crate::models::{LinkPreview, Url, VisitRecord}; use crate::models::{LinkPreview, Url, VisitRecord};
use crate::state::AppState; use crate::state::AppState;
use crate::templates::PreviewTemplate; use crate::templates::PreviewTemplate;
use crate::utils::get_client_ip; 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). /// Compact outcome from blocking redirect DB work (avoids large enum / Result variants).
enum ResolveOutcome { enum ResolveOutcome {
Ready(Box<ResolvedUrl>), 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. /// Lookup global slug namespace then load the URL from the tenant content DB.
/// Runs entirely on a blocking thread. /// Runs entirely on a blocking thread.
fn resolve_url_blocking( fn resolve_url_blocking(
system_db: Arc<Mutex<rusqlite::Connection>>, _system_db: Arc<Mutex<rusqlite::Connection>>,
state: AppState, state: AppState,
code: &str, code: &str,
) -> ResolveOutcome { ) -> ResolveOutcome {
// 1. Query global slug namespace in system.db // 1. Query global slug namespace across v0.8 slug databases (with fallback)
let slug_info = { let slug_info = match state.lookup_slug(code) {
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, Ok(info) => info,
Err(e) => { Err(e) => {
return ResolveOutcome::DbError { return ResolveOutcome::DbError {
operation: "query_global_slugs", operation: "lookup_slug",
message: e.to_string(), message: e.to_string(),
owner_user_id: None, owner_user_id: None,
resource_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, Some(info) => info,
None => { None => {
// Fallback to legacy_admin's DB (user_id = 1) if not found in global_slugs return ResolveOutcome::Early {
(1, "url".to_string(), "".to_string(), "active".to_string()) 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 is disabled, flagged, or soft_deleted, we return 410 Gone
if slug_status != "active" { if info.status != "active" {
return ResolveOutcome::Early { return ResolveOutcome::Early {
status: StatusCode::GONE, status: StatusCode::GONE,
body: "This content has been disabled or moderated", body: "This content has been disabled or moderated",
@@ -191,16 +160,17 @@ fn resolve_url_blocking(
} }
// If target type is page, redirect permanently to /p/slug // If target type 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)); return ResolveOutcome::PermanentPath(format!("/p/{}", code));
} }
// 2. Get content database connection via tenant DB resolution // 2. Get content database connection via tenant DB resolution
let content_conn = match state.get_user_dbs(owner_user_id) { let content_conn =
match state.open_slug_owner(&info.owner_tenant_id, TenantOpenMode::PublicContent) {
Ok(dbs) => dbs, Ok(dbs) => dbs,
Err(e) => { Err(e) => {
return ResolveOutcome::DbError { return ResolveOutcome::DbError {
operation: "get_user_dbs", operation: "open_slug_owner",
message: e.to_string(), message: e.to_string(),
owner_user_id: Some(owner_user_id), owner_user_id: Some(owner_user_id),
resource_id: None, resource_id: None,
@@ -239,7 +209,10 @@ fn resolve_url_blocking(
} }
}; };
let owner_tenant_id = TenantId::parse(&info.owner_tenant_id).ok();
ResolveOutcome::Ready(Box::new(ResolvedUrl { ResolveOutcome::Ready(Box::new(ResolvedUrl {
owner_tenant_id,
owner_user_id, owner_user_id,
url, url,
content: content_conn.content, content: content_conn.content,
@@ -347,6 +320,7 @@ pub async fn resolve_redirect(
}; };
let ResolvedUrl { let ResolvedUrl {
owner_tenant_id,
owner_user_id, owner_user_id,
url, url,
content, content,
@@ -424,7 +398,6 @@ pub async fn resolve_redirect(
} }
}; };
// Asynchronously record analytics
let ip = get_client_ip(&headers, connect_info); let ip = get_client_ip(&headers, connect_info);
let country = get_client_country(&headers); let country = get_client_country(&headers);
let user_agent = headers let user_agent = headers
@@ -454,6 +427,7 @@ pub async fn resolve_redirect(
accept_language, accept_language,
country, country,
status_code: if preview_opt.is_some() { 200 } else { 302 }, status_code: if preview_opt.is_some() { 200 } else { 302 },
owner_tenant_id,
owner_user_id: Some(owner_user_id), owner_user_id: Some(owner_user_id),
}; };
+34 -10
View File
@@ -97,22 +97,46 @@ pub async fn metrics_endpoint(
// 1. Gather stats from DBs // 1. Gather stats from DBs
let (total_urls, active_links, dead_links) = { let (total_urls, active_links, dead_links) = {
let conn = state.content_db.lock().unwrap(); let conn = state.db.global_urls.lock().unwrap();
crate::db::content::get_url_counts(&conn).unwrap_or((0, 0, 0)) 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 total_pages = {
let conn = state.content_db.lock().unwrap(); let conn = state.db.global_landing_pages.lock().unwrap();
crate::db::content::get_landing_page_count(&conn).unwrap_or(0) conn.query_row(
"SELECT COUNT(*) FROM global_landing_pages WHERE status != 'retired';",
[],
|r| r.get(0),
)
.unwrap_or(0)
}; };
let (total_clicks, total_page_views) = { let total_clicks = {
let conn = state.analytics_db.lock().unwrap(); let users_conn = state.users_db.lock().unwrap();
( crate::db::users::get_platform_total_clicks(&state.db.topology, &users_conn).unwrap_or(0)
crate::db::analytics::get_total_clicks(&conn).unwrap_or(0),
crate::db::analytics::get_total_page_views(&conn).unwrap_or(0),
)
}; };
let total_page_views = 0i64;
// Calculate memory in bytes // Calculate memory in bytes
let mut mem_bytes = 0; let mut mem_bytes = 0;
+2 -2
View File
@@ -1476,13 +1476,13 @@
<li class="{% block active_urls %}{% endblock %}"> <li class="{% block active_urls %}{% endblock %}">
<a href="/admin/urls"> <a href="/admin/urls">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
Short URLs URL Registry
</a> </a>
</li> </li>
<li class="{% block active_pages %}{% endblock %}"> <li class="{% block active_pages %}{% endblock %}">
<a href="/admin/pages"> <a href="/admin/pages">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
Landing Pages Landing Page Registry
</a> </a>
</li> </li>
<li class="{% block active_users %}{% endblock %}"> <li class="{% block active_users %}{% endblock %}">
+43 -78
View File
@@ -1,10 +1,10 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% block title %}Manage Landing Pages - BZOD{% endblock %} {% block title %}Global Landing Page Registry - BZOD Admin{% endblock %}
{% block active_pages %}active{% endblock %} {% block active_pages %}active{% endblock %}
{% block header_title %}Landing Page Registry{% endblock %} {% block header_title %}Global Landing Page Registry{% endblock %}
{% block content %} {% block content %}
{% if let Some(err) = error %} {% if let Some(err) = error %}
@@ -13,79 +13,29 @@
</div> </div>
{% endif %} {% endif %}
<div class="urls-dashboard">
<div class="card">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Create a New Landing Page
</h3>
<form action="/admin/pages/create" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="page-form-grid-2">
<div class="form-group">
<label for="title">Page Title *</label>
<input type="text" id="title" name="title" class="form-input" placeholder="e.g. Summer Campaign" required>
</div>
<div class="form-group">
<label for="slug">SEO Slug *</label>
<input type="text" id="slug" name="slug" class="form-input" placeholder="e.g. summer-promo" required pattern="[a-zA-Z0-9\-_]+" title="Only alphanumeric characters, dashes, and underscores allowed">
</div>
</div>
<div class="page-form-grid-3">
<div class="form-group">
<label for="code">Short Code (4-Hex, optional)</label>
<input type="text" id="code" name="code" class="form-input" placeholder="e.g. a1b2" pattern="[0-9a-fA-F]{4}" title="Must be exactly 4 hex characters">
</div>
<div class="form-group">
<label for="custom_slug">Custom Slug (optional)</label>
<input type="text" id="custom_slug" name="custom_slug" class="form-input" placeholder="e.g. !my-page" pattern="![a-z0-9\-_]{1,24}" title="Must start with ! followed by 1-24 characters (a-z, 0-9, -, _)">
</div>
<div class="form-group">
<label for="state">Publish State</label>
<select id="state" name="state" class="form-input">
<option value="draft">Draft</option>
<option value="published" selected>Published</option>
<option value="archived">Archived</option>
</select>
</div>
</div>
<div class="form-group">
<label for="html_content">Raw HTML Document *</label>
<textarea id="html_content" name="html_content" class="form-input page-html-editor" placeholder="<!DOCTYPE html>&#10;<html>&#10;<head>&#10; <title>Landing Page</title>&#10;</head>&#10;<body>&#10; <h1>Welcome!</h1>&#10;</body>&#10;</html>" required></textarea>
</div>
<button type="submit" class="btn" style="width: 100%; margin-top: 0.5rem;">Save Page</button>
</form>
</div>
<div class="card registered-links-shell" style="padding: 0; overflow: hidden;"> <div class="card registered-links-shell" style="padding: 0; overflow: hidden;">
<div class="registered-links-header"> <div class="registered-links-header">
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem;"> <h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
Registered Pages Global Landing Page Registry
</h3> </h3>
</div> </div>
<div class="table-container"> <div class="table-container">
<table class="registered-pages-table"> <table class="registered-pages-table">
<colgroup> <colgroup>
<col class="col-page-title"> <col class="col-page-title" style="width: 25%;">
<col class="col-page-paths"> <col class="col-page-paths" style="width: 25%;">
<col class="col-page-status"> <col class="col-owner" style="width: 15%;">
<col class="col-page-qr"> <col class="col-page-status" style="width: 10%;">
<col class="col-page-actions"> <col class="col-page-qr" style="width: 8%;">
<col class="col-page-actions" style="width: 17%;">
</colgroup> </colgroup>
<thead> <thead>
<tr> <tr>
<th>Page</th> <th>Page</th>
<th>Paths</th> <th>Paths</th>
<th>Owner / Tenant</th>
<th>Status</th> <th>Status</th>
<th>QR Code</th> <th>QR Code</th>
<th>Actions</th> <th>Actions</th>
@@ -94,51 +44,67 @@
<tbody> <tbody>
{% if pages.is_empty() %} {% if pages.is_empty() %}
<tr> <tr>
<td colspan="5" style="text-align: center; color: var(--text-secondary); padding: 3rem;"> <td colspan="6" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No landing pages registered. Create one to get started! No landing pages registered across tenants.
</td> </td>
</tr> </tr>
{% else %} {% else %}
{% for page in pages %} {% for row in pages %}
<tr> <tr>
<td data-label="Page"> <td data-label="Page">
<div class="page-title-block"> <div class="page-title-block">
<div class="page-title-heading">{{ page.title }}</div> <div class="page-title-heading">{{ row.page.title }}</div>
<div class="page-title-meta">UUID: {{ page.id[0..8] }}...</div> <div class="page-title-meta">UUID: {{ row.page.id[0..8] }}...</div>
<div class="page-title-meta">Created {{ page.created_at[0..10] }}</div> <div class="page-title-meta">Created {{ row.page.created_at[0..10] }}</div>
</div> </div>
</td> </td>
<td data-label="Paths"> <td data-label="Paths">
<div class="page-paths"> <div class="page-paths">
<div class="page-path-group"> <div class="page-path-group">
<span class="page-path-caption">Short Path</span> <span class="page-path-caption">Short Path</span>
<a href="/p/{{ page.code }}" target="_blank" class="page-path-link"> <a href="/p/{{ row.page.code }}" target="_blank" class="page-path-link">
/p/{{ page.code }} /p/{{ row.page.code }}
</a> </a>
</div> </div>
<div class="page-path-group"> <div class="page-path-group">
<span class="page-path-caption">SEO Preview Path</span> <span class="page-path-caption">SEO Preview Path</span>
<a href="/p/{{ page.code }}/{{ page.slug }}" target="_blank" class="page-path-link secondary"> <a href="/p/{{ row.page.code }}/{{ row.page.slug }}" target="_blank" class="page-path-link secondary">
/p/{{ page.code }}/{{ page.slug }} /p/{{ row.page.code }}/{{ row.page.slug }}
</a> </a>
</div> </div>
</div> </div>
</td> </td>
<td data-label="Owner / Tenant">
<div>
{% if let Some(u) = row.owner_username.as_deref() %}
<strong style="color: var(--text-primary);">{{ u }}</strong>
{% else %}
<span class="text-muted">Unassigned</span>
{% endif %}
<div style="font-family: monospace; font-size: 0.72rem; color: var(--text-muted);">{{ row.owner_tenant_id }}</div>
</div>
</td>
<td data-label="Status" class="status-cell"> <td data-label="Status" class="status-cell">
<span class="badge badge-{{ page.state }}"> <span class="badge badge-{{ row.page.state }}">
{{ page.state }} {{ row.page.state }}
</span> </span>
</td> </td>
{% let code = page.code.as_str() %} {% let code = row.page.code.as_str() %}
{% include "components/qr_preview.html" %} {% include "components/qr_preview.html" %}
<td data-label="Actions" class="actions-cell"> <td data-label="Actions" class="actions-cell">
<div class="action-stack"> <div class="action-stack" style="display: flex; gap: 0.35rem; flex-wrap: wrap;">
<a href="/admin/analytics/page/{{ page.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.6rem; font-size: 0.8rem; display: inline-flex; align-items: center; gap: 0.25rem;"> <a href="/admin/moderation" class="btn btn-secondary" style="padding: 0.3rem 0.5rem; font-size: 0.75rem; display: inline-flex; align-items: center; gap: 0.2rem;">
🛡️ Moderate
</a>
<a href="/admin/slugs" class="btn btn-secondary" style="padding: 0.3rem 0.5rem; font-size: 0.75rem; display: inline-flex; align-items: center; gap: 0.2rem;">
🔄 Transfer
</a>
<a href="/admin/analytics/page/{{ row.page.id }}" class="btn btn-secondary" style="padding: 0.3rem 0.5rem; font-size: 0.75rem; display: inline-flex; align-items: center; gap: 0.2rem;">
📊 Analytics 📊 Analytics
</a> </a>
<form action="/admin/pages/delete/{{ page.id }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this page?');"> <form action="/admin/pages/delete/{{ row.page.id }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this page?');" style="display: inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-danger" style="padding: 0.4rem 0.6rem; font-size: 0.8rem;"> <button type="submit" class="btn btn-danger" style="padding: 0.3rem 0.5rem; font-size: 0.75rem;">
Delete Delete
</button> </button>
</form> </form>
@@ -177,5 +143,4 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div>
{% endblock %} {% endblock %}
+55 -144
View File
@@ -1,18 +1,10 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% block title %}Manage Short URLs - BZOD{% endblock %} {% block title %}Global URL Registry - BZOD Admin{% endblock %}
{% block active_urls %}active{% endblock %} {% block active_urls %}active{% endblock %}
{% block extra_css %} {% block header_title %}Global URL Registry{% endblock %}
@media (max-width: 720px) {
#utm_fields > div {
grid-template-columns: 1fr !important;
}
}
{% endblock %}
{% block header_title %}Short URL Registry{% endblock %}
{% block content %} {% block content %}
{% if let Some(err) = error %} {% if let Some(err) = error %}
@@ -21,94 +13,11 @@
</div> </div>
{% endif %} {% endif %}
<div class="urls-dashboard">
<div class="card url-form-card">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Shorten a New URL
</h3>
<form action="/admin/urls/create" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-group">
<label for="destination">Destination URL *</label>
<input type="url" id="destination" name="destination" class="form-input" placeholder="https://example.com/very-long-path" required>
</div>
<div class="form-group">
<label for="code">Short Code (6-Hex, optional)</label>
<input type="text" id="code" name="code" class="form-input" placeholder="e.g. 4f8c1a (auto-generated if empty)" pattern="[0-9a-fA-F]{6}" title="Must be exactly 6 hex characters (0-9, a-f)">
</div>
<div class="form-group">
<label for="custom_slug">Custom Slug (optional)</label>
<input type="text" id="custom_slug" name="custom_slug" class="form-input" placeholder="e.g. !home (! followed by a-z, 0-9, -, _)" pattern="![a-z0-9\-_]{1,24}" title="Must start with ! followed by 1-24 characters (a-z, 0-9, -, _)">
</div>
<div class="form-group">
<label for="title">Title (optional)</label>
<input type="text" id="title" name="title" class="form-input" placeholder="e.g. My Blog Post">
</div>
<div class="form-group">
<label for="description">Description (optional)</label>
<textarea id="description" name="description" class="form-input" placeholder="Notes or summary..." rows="2"></textarea>
</div>
<div class="form-group">
<label for="tags">Tags (comma-separated, optional)</label>
<input type="text" id="tags" name="tags" class="form-input" placeholder="e.g. blog, tech, personal">
</div>
<div class="form-group">
<label for="expires_at">Expiration Date & Time (optional)</label>
<input type="datetime-local" id="expires_at" name="expires_at" class="form-input">
</div>
<div class="form-group">
<label for="password">Password Protection (optional)</label>
<input type="password" id="password" name="password" class="form-input" placeholder="Leave blank for public access">
</div>
<div class="form-group">
<label for="max_access_count">Access Limit / One-Time (optional)</label>
<input type="number" id="max_access_count" name="max_access_count" class="form-input" placeholder="e.g. 10 (auto-expires after N clicks)" min="1">
</div>
<div class="form-group" style="border-top: 1px solid var(--border-color); padding-top: 0.75rem; margin-top: 0.75rem;">
<label style="font-weight: 600; font-size: 0.85rem; display: flex; align-items: center; gap: 0.25rem; cursor: pointer;">
<input type="checkbox" id="enable_utm" onchange="document.getElementById('utm_fields').style.display = this.checked ? 'flex' : 'none';" style="margin-right: 0.25rem;">
Add Campaign Tracking (UTM)
</label>
</div>
<div id="utm_fields" style="display: none; flex-direction: column; gap: 0.5rem; margin-bottom: 0.75rem;">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem;">
<div class="form-group">
<label for="utm_source" style="font-size: 0.75rem;">UTM Source</label>
<input type="text" id="utm_source" name="utm_source" placeholder="e.g. bzod" class="form-input" style="padding: 0.35rem 0.5rem;">
</div>
<div class="form-group">
<label for="utm_medium" style="font-size: 0.75rem;">UTM Medium</label>
<input type="text" id="utm_medium" name="utm_medium" placeholder="e.g. shortlink" class="form-input" style="padding: 0.35rem 0.5rem;">
</div>
</div>
<div class="form-group">
<label for="utm_campaign" style="font-size: 0.75rem;">UTM Campaign</label>
<input type="text" id="utm_campaign" name="utm_campaign" placeholder="e.g. office" class="form-input" style="padding: 0.35rem 0.5rem;">
</div>
</div>
<button type="submit" class="btn" style="width: 100%; margin-top: 0.5rem;">Create Link</button>
</form>
</div>
<div class="card registered-links-shell" style="padding: 0; overflow: hidden;"> <div class="card registered-links-shell" style="padding: 0; overflow: hidden;">
<div class="registered-links-header"> <div class="registered-links-header">
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem;"> <h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
Registered Links Global URL Registry
</h3> </h3>
{% if let Some(tag) = tag_filter %} {% if let Some(tag) = tag_filter %}
<span class="badge badge-healthy" style="text-transform: none;"> <span class="badge badge-healthy" style="text-transform: none;">
@@ -121,20 +30,20 @@
<div class="table-container"> <div class="table-container">
<table class="registered-links-table"> <table class="registered-links-table">
<colgroup> <colgroup>
<col class="col-short"> <col class="col-short" style="width: 22%;">
<col class="col-destination"> <col class="col-destination" style="width: 26%;">
<col class="col-qr"> <col class="col-owner" style="width: 14%;">
<col class="col-health"> <col class="col-health" style="width: 10%;">
<col class="col-tags"> <col class="col-qr" style="width: 8%;">
<col class="col-actions"> <col class="col-actions" style="width: 20%;">
</colgroup> </colgroup>
<thead> <thead>
<tr> <tr>
<th>Short Link</th> <th>Short Link</th>
<th>Destination</th> <th>Destination</th>
<th>Owner / Tenant</th>
<th>Status / Health</th>
<th>QR Code</th> <th>QR Code</th>
<th>Health</th>
<th>Tags</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
@@ -142,101 +51,104 @@
{% if urls.is_empty() %} {% if urls.is_empty() %}
<tr> <tr>
<td colspan="6" style="text-align: center; color: var(--text-secondary); padding: 3rem;"> <td colspan="6" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No shortened URLs registered. Create one to get started! No shortened URLs registered across tenants.
</td> </td>
</tr> </tr>
{% else %} {% else %}
{% for url in urls %} {% for row in urls %}
<tr> <tr>
<td data-label="Short Link"> <td data-label="Short Link">
<div class="short-link-wrap"> <div class="short-link-wrap">
<div class="short-link-primary"> <div class="short-link-primary">
<a href="/{{ url.code }}" target="_blank" class="short-link-anchor"> <a href="/{{ row.url.code }}" target="_blank" class="short-link-anchor">
{{ base_url.replace("https://", "").replace("http://", "") }}/{{ url.code }} {{ base_url.replace("https://", "").replace("http://", "") }}/{{ row.url.code }}
</a> </a>
{% if url.is_password_protected() %} {% if row.url.is_password_protected() %}
<span title="Password Protected" aria-label="Password protected link" style="color: var(--warning-color); display: inline-flex; align-items: center;"> <span title="Password Protected" aria-label="Password protected link" style="color: var(--warning-color); display: inline-flex; align-items: center;">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
</span> </span>
{% endif %} {% endif %}
</div> </div>
{% if let Some(title) = url.title.as_deref() %} {% if let Some(title) = row.url.title.as_deref() %}
{% if !title.is_empty() %} {% if !title.is_empty() %}
<div class="short-link-title">{{ title }}</div> <div class="short-link-title">{{ title }}</div>
{% endif %} {% endif %}
{% endif %} {% endif %}
<div class="short-link-meta">Created {{ url.created_at[0..10] }}</div> <div class="short-link-meta">Created {{ row.url.created_at[0..10] }}</div>
</div> </div>
</td> </td>
<td data-label="Destination"> <td data-label="Destination">
<div class="destination-block"> <div class="destination-block">
<a href="{{ url.destination }}" target="_blank" class="destination-link" title="{{ url.destination }}" aria-label="Destination URL: {{ url.destination }}"> <a href="{{ row.url.destination }}" target="_blank" class="destination-link" title="{{ row.url.destination }}" aria-label="Destination URL: {{ row.url.destination }}">
{{ url.destination }} {{ row.url.destination }}
</a> </a>
{% if let Some(desc) = url.description.as_deref() %} {% if let Some(desc) = row.url.description.as_deref() %}
{% if !desc.is_empty() %} {% if !desc.is_empty() %}
<div class="destination-description" title="{{ desc }}">{{ desc }}</div> <div class="destination-description" title="{{ desc }}">{{ desc }}</div>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if let Some(expires_at) = url.expires_at.as_deref() %} {% if let Some(expires_at) = row.url.expires_at.as_deref() %}
{% if !expires_at.is_empty() %} {% if !expires_at.is_empty() %}
<div class="destination-meta expiry"> <div class="destination-meta expiry">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg> <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
Expires: {{ expires_at[0..10] }} {{ expires_at[11..16] }} Expires: {{ expires_at[0..10] }}
{% if url.expired %} {% if row.url.expired %}
<span class="badge badge-dead" style="font-size: 0.6rem; padding: 0.1rem 0.25rem; margin-left: 0.25rem;">Expired</span> <span class="badge badge-dead" style="font-size: 0.6rem; padding: 0.1rem 0.25rem; margin-left: 0.25rem;">Expired</span>
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if let Some(max) = url.max_access_count %} {% if let Some(max) = row.url.max_access_count %}
<div class="destination-meta access"> <div class="destination-meta access">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg> <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
Clicks: {{ url.access_count }} / {{ max }} Clicks: {{ row.url.access_count }} / {{ max }}
{% if url.is_access_exhausted() %} {% if row.url.is_access_exhausted() %}
<span class="badge badge-dead" style="font-size: 0.6rem; padding: 0.1rem 0.25rem; margin-left: 0.25rem;">Limit Reached</span> <span class="badge badge-dead" style="font-size: 0.6rem; padding: 0.1rem 0.25rem; margin-left: 0.25rem;">Limit Reached</span>
{% endif %} {% endif %}
</div> </div>
{% else %} {% else %}
{% if url.access_count > 0 %} {% if row.url.access_count > 0 %}
<div class="destination-meta neutral"> <div class="destination-meta neutral">
Clicks: {{ url.access_count }} Clicks: {{ row.url.access_count }}
</div> </div>
{% endif %} {% endif %}
{% endif %} {% endif %}
</div> </div>
</td> </td>
{% let code = url.code.as_str() %} <td data-label="Owner / Tenant">
{% include "components/qr_preview.html" %} <div>
<td data-label="Health" class="status-cell"> {% if let Some(u) = row.owner_username.as_deref() %}
<span class="badge badge-{{ url.status }}"> <strong style="color: var(--text-primary);">{{ u }}</strong>
{{ url.status }} {% else %}
<span class="text-muted">Unassigned</span>
{% endif %}
<div style="font-family: monospace; font-size: 0.72rem; color: var(--text-muted);">{{ row.owner_tenant_id }}</div>
</div>
</td>
<td data-label="Status / Health" class="status-cell">
<span class="badge badge-{{ row.url.status }}">
{{ row.url.status }}
</span> </span>
</td> </td>
<td data-label="Tags"> {% let code = row.url.code.as_str() %}
{% if url.tags.is_empty() %} {% include "components/qr_preview.html" %}
<span class="tags-empty">—</span>
{% else %}
<div class="tags-wrap">
{% for t in url.tags %}
<a href="/admin/urls?tag={{ t }}" class="badge tag-badge">
{{ t }}
</a>
{% endfor %}
</div>
{% endif %}
</td>
<td data-label="Actions" class="actions-cell"> <td data-label="Actions" class="actions-cell">
<div class="action-stack"> <div class="action-stack" style="display: flex; gap: 0.35rem; flex-wrap: wrap;">
<a href="/admin/analytics/url/{{ url.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.6rem; font-size: 0.8rem; display: inline-flex; align-items: center; gap: 0.25rem;"> <a href="/admin/moderation" class="btn btn-secondary" style="padding: 0.3rem 0.5rem; font-size: 0.75rem; display: inline-flex; align-items: center; gap: 0.2rem;">
🛡️ Moderate
</a>
<a href="/admin/slugs" class="btn btn-secondary" style="padding: 0.3rem 0.5rem; font-size: 0.75rem; display: inline-flex; align-items: center; gap: 0.2rem;">
🔄 Transfer
</a>
<a href="/admin/analytics/url/{{ row.url.id }}" class="btn btn-secondary" style="padding: 0.3rem 0.5rem; font-size: 0.75rem; display: inline-flex; align-items: center; gap: 0.2rem;">
📊 Analytics 📊 Analytics
</a> </a>
<form action="/admin/urls/delete/{{ url.id }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this link?');"> <form action="/admin/urls/delete/{{ row.url.id }}" method="POST" onsubmit="return confirm('Are you sure you want to retire this link?');" style="display: inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-danger" style="padding: 0.4rem 0.6rem; font-size: 0.8rem;"> <button type="submit" class="btn btn-danger" style="padding: 0.3rem 0.5rem; font-size: 0.75rem;">
Delete Retire
</button> </button>
</form> </form>
</div> </div>
@@ -274,5 +186,4 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div>
{% endblock %} {% endblock %}
Loaded 100 of 145 files, more files were not shown because too many files have changed in this diff. Show more