refactor: complete v0.8 core architecture

This commit is contained in:
thakares committed 2026-08-21 13:56:36 +05:30
1 parent f138a645f8
commit d7e0ac7679
98 files changed
+8413 -3044

No files matched your search

+2
View File
@@ -639,6 +639,7 @@ pub fn get_target_visits_paginated(
accept_language: row.get("accept_language")?,
country: row.get("country")?,
status_code: row.get("status_code")?,
owner_tenant_id: None,
owner_user_id: row.get("owner_user_id")?,
})
})?;
@@ -695,6 +696,7 @@ pub fn get_target_visits_all_in_memory(
accept_language: row.get("accept_language")?,
country: row.get("country")?,
status_code: row.get("status_code")?,
owner_tenant_id: None,
owner_user_id: row.get("owner_user_id")?,
})
})?;
+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()?;
tx.execute_batch(m.sql)?;
match tx.execute_batch(m.sql) {
Ok(()) => (),
Err(e) => {
let err_msg = e.to_string();
if err_msg.contains("duplicate column name") {
tracing::warn!(
database = db_name,
version = m.version,
"Column already exists during migration, continuing"
);
} else {
return Err(e.into());
}
}
}
tx.commit()?;
crate::db::sqlite::set_user_version(conn, m.version as i32)?;
@@ -568,4 +582,24 @@ pub const USERS_MIGRATIONS: &[Migration] = &[
WHERE username = 'admin' AND account_type = 'standard';
"#,
},
Migration {
version: 3,
name: "tenant_id",
sql: r#"
ALTER TABLE users ADD COLUMN tenant_id TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_tenant_id
ON users(tenant_id)
WHERE tenant_id IS NOT NULL;
"#,
},
Migration {
version: 4,
name: "uuid",
sql: r#"
ALTER TABLE users ADD COLUMN uuid TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_uuid
ON users(uuid)
WHERE uuid IS NOT NULL;
"#,
},
];
+95 -230
View File
@@ -7,46 +7,67 @@ use crate::db::sqlite::{enable_foreign_keys, enable_wal};
use rusqlite::Connection;
use std::fs;
use std::sync::{Arc, Mutex};
use tracing::info;
pub mod admin;
pub mod analytics;
pub mod audit_events;
pub mod content;
pub mod identity_migrate;
pub mod migrations;
pub mod preview;
pub mod qr;
pub mod schema_v08;
pub mod slug_migrate;
pub mod slugs;
pub mod sqlite;
pub mod tenant;
pub mod topology;
pub mod users;
use crate::db::schema_v08::{
GLOBAL_LANDING_PAGES_MIGRATIONS, GLOBAL_URLS_MIGRATIONS, RESERVED_SLUGS_MIGRATIONS,
};
use crate::db::topology::Topology;
#[derive(Clone)]
pub struct Db {
pub admin: Arc<Mutex<Connection>>,
pub content: Arc<Mutex<Connection>>,
pub analytics: Arc<Mutex<Connection>>,
pub system: Arc<Mutex<Connection>>,
pub users: Arc<Mutex<Connection>>,
pub global_urls: Arc<Mutex<Connection>>,
pub global_landing_pages: Arc<Mutex<Connection>>,
pub reserved: Arc<Mutex<Connection>>,
pub data_dir: std::path::PathBuf,
pub topology: Topology,
}
fn open_prepared(
path: &std::path::Path,
name: &str,
) -> Result<Connection, Box<dyn std::error::Error>> {
info!("Opening {}.db", name);
let conn = Connection::open(path)?;
enable_wal(&conn, name)?;
enable_foreign_keys(&conn, name)?;
Ok(conn)
}
impl Db {
pub fn init(config: &Config) -> Result<Self, Box<dyn std::error::Error>> {
use chrono::Utc;
use tracing::info;
// Ensure data directory exists
if !config.data_dir.exists() {
fs::create_dir_all(&config.data_dir)?;
let topology = Topology::new(&config.data_dir);
if !topology.root().exists() {
fs::create_dir_all(topology.root())?;
}
topology.ensure_core_dirs()?;
let admin_dir = config.data_dir.join("admin");
let users_dir = config.data_dir.join("users");
fs::create_dir_all(&admin_dir)?;
fs::create_dir_all(&users_dir)?;
let admin_dir = topology.admin_dir();
// Automated Legacy Migration: check if legacy files are at the root
let legacy_admin_db = config.data_dir.join("admin.db");
let legacy_content_db = config.data_dir.join("content.db");
let legacy_analytics_db = config.data_dir.join("analytics.db");
let legacy_admin_db = topology.legacy_flat_admin_db();
// 1. If legacy admin.db exists at root, move admin/system DBs to config.data_dir/admin/
if legacy_admin_db.exists() {
@@ -60,7 +81,7 @@ impl Db {
"system.db-shm",
];
for f in files {
let src = config.data_dir.join(f);
let src = topology.root().join(f);
if src.exists() {
let dst = admin_dir.join(f);
let _ = fs::rename(&src, &dst);
@@ -88,24 +109,9 @@ impl Db {
}
}
let admin_path = admin_dir.join("admin.db");
let system_path = admin_dir.join("system.db");
let users_db_path = admin_dir.join("users.db");
info!("Opening admin.db");
let mut admin_conn = Connection::open(admin_path)?;
info!("Opening system.db");
let mut system_conn = Connection::open(system_path)?;
info!("Opening users.db");
let mut users_conn = Connection::open(users_db_path)?;
enable_wal(&admin_conn, "admin")?;
enable_wal(&system_conn, "system")?;
enable_wal(&users_conn, "users")?;
enable_foreign_keys(&admin_conn, "admin")?;
enable_foreign_keys(&system_conn, "system")?;
enable_foreign_keys(&users_conn, "users")?;
let mut admin_conn = open_prepared(&topology.admin_db(), "admin")?;
let mut system_conn = open_prepared(&topology.system_db(), "system")?;
let mut users_conn = open_prepared(&topology.users_registry_db(), "users")?;
// Run migrations for system.db first
info!("Running system migrations");
@@ -168,212 +174,60 @@ impl Db {
let now = Utc::now().to_rfc3339();
let _ = users_conn.execute("DELETE FROM sessions WHERE expires_at < ?1;", [now]);
// 2. If legacy content.db/analytics.db exists, move them to users/1/ (for legacy_admin)
let legacy_migration_needed = legacy_content_db.exists() || legacy_analytics_db.exists();
let mut global_urls_conn = open_prepared(&topology.global_urls_db(), "global_urls")?;
let mut global_landing_pages_conn =
open_prepared(&topology.global_landing_pages_db(), "global_landing_pages")?;
let mut reserved_conn = open_prepared(&topology.reserved_db(), "reserved")?;
// Ensure legacy_admin (user ID 1) exists in users.db
let legacy_admin_id = 1i64;
let legacy_admin_exists: bool = users_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
[legacy_admin_id],
|row| row.get(0),
)
.unwrap_or(false);
if !legacy_admin_exists {
// Get copied administrator password hash
let admin_password_hash: String = admin_conn
.query_row(
"SELECT password_hash FROM users ORDER BY created_at ASC LIMIT 1;",
[],
|row| row.get(0),
)
.unwrap_or_else(|_| {
// If admin_db is empty, hash a default password
crate::auth::password::hash_password("legacy_admin_pass").unwrap_or_default()
});
let now = Utc::now().to_rfc3339();
users_conn.execute(
"INSERT INTO users (id, username, password_hash, status, created_at, account_type)
VALUES (?1, ?2, ?3, ?4, ?5, ?6);",
rusqlite::params![
legacy_admin_id,
"legacy_admin",
admin_password_hash,
"disabled",
now,
"system"
],
)?;
// Seed quotas
users_conn.execute(
"INSERT INTO quotas (user_id) VALUES (?1);",
[legacy_admin_id],
)?;
}
let legacy_user_dir = users_dir.join(legacy_admin_id.to_string());
fs::create_dir_all(&legacy_user_dir)?;
if legacy_content_db.exists() || legacy_analytics_db.exists() {
tracing::warn!("LEGACY DETECTED: content/analytics databases found at root. Moving to multi-tenant user ID 1 directory...");
let content_files = vec!["content.db", "content.db-wal", "content.db-shm"];
for f in content_files {
let src = config.data_dir.join(f);
if src.exists() {
let dst = legacy_user_dir.join(f);
let _ = fs::rename(&src, &dst);
}
}
let analytics_files = vec!["analytics.db", "analytics.db-wal", "analytics.db-shm"];
for f in analytics_files {
let src = config.data_dir.join(f);
if src.exists() {
let dst = legacy_user_dir.join(f);
let _ = fs::rename(&src, &dst);
}
}
}
// Open the legacy_admin databases (user ID 1) as db.content and db.analytics
let content_path = legacy_user_dir.join("content.db");
let analytics_path = legacy_user_dir.join("analytics.db");
let mut content_conn = Connection::open(content_path)?;
let mut analytics_conn = Connection::open(analytics_path)?;
enable_wal(&content_conn, "content")?;
enable_wal(&analytics_conn, "analytics")?;
enable_foreign_keys(&content_conn, "content")?;
enable_foreign_keys(&analytics_conn, "analytics")?;
// Run migrations for content.db and analytics.db
run_migrations(
&mut content_conn,
"content",
CONTENT_MIGRATIONS,
&mut global_urls_conn,
"global_urls",
GLOBAL_URLS_MIGRATIONS,
Some(&system_arc),
)?;
run_migrations(
&mut analytics_conn,
"analytics",
ANALYTICS_MIGRATIONS,
&mut global_landing_pages_conn,
"global_landing_pages",
GLOBAL_LANDING_PAGES_MIGRATIONS,
Some(&system_arc),
)?;
// If we just migrated legacy content, populate the global_slugs table in system.db
if legacy_migration_needed {
info!("Populating global slug index with legacy content...");
let mut sys_lock = system_arc.lock().unwrap();
let tx = sys_lock.transaction()?;
// Extract urls from content.db and insert into global_slugs
{
let mut stmt =
content_conn.prepare("SELECT code, id, created_at, status FROM urls;")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let slug: String = row.get(0)?;
let target_id: String = row.get(1)?;
let created_at: String = row.get(2)?;
let status: String = row.get(3)?;
let global_status = if status == "dead" {
"disabled"
} else {
"active"
};
let now = Utc::now().to_rfc3339();
let _ = tx.execute(
"INSERT OR IGNORE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
rusqlite::params![slug, legacy_admin_id, "url", target_id, created_at, now, global_status],
);
}
}
// Extract landing pages from content.db and insert into global_slugs
{
let mut stmt = content_conn
.prepare("SELECT code, id, created_at, state FROM landing_pages;")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let slug: String = row.get(0)?;
let target_id: String = row.get(1)?;
let created_at: String = row.get(2)?;
let state: String = row.get(3)?;
let now = Utc::now().to_rfc3339();
let status = if state == "published" {
"active"
} else {
"disabled"
};
let _ = tx.execute(
"INSERT OR IGNORE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
rusqlite::params![slug, legacy_admin_id, "page", target_id, created_at, now, status],
);
}
}
tx.commit()?;
info!("Global slug index populated successfully.");
}
run_migrations(
&mut reserved_conn,
"reserved",
RESERVED_SLUGS_MIGRATIONS,
Some(&system_arc),
)?;
crate::db::slugs::seed_reserved_slugs(&reserved_conn)?;
let db = Self {
admin: Arc::new(Mutex::new(admin_conn)),
content: Arc::new(Mutex::new(content_conn)),
analytics: Arc::new(Mutex::new(analytics_conn)),
system: system_arc,
users: Arc::new(Mutex::new(users_conn)),
global_urls: Arc::new(Mutex::new(global_urls_conn)),
global_landing_pages: Arc::new(Mutex::new(global_landing_pages_conn)),
reserved: Arc::new(Mutex::new(reserved_conn)),
data_dir: config.data_dir.clone(),
topology,
};
let _ = db.reconcile_global_slugs(config);
// Post-init: Clean up stale reservations
// Post-init: Clean up stale reservations from v0.8 slug databases (older than 15 mins)
{
let system_conn = db.system.lock().unwrap();
match crate::db::users::cleanup_stale_reservations(&system_conn, &config.data_dir) {
Ok(count) => {
if count > 0 {
tracing::info!("Cleaned up {} stale reserving slugs", count);
if let (Ok(urls_conn), Ok(pages_conn)) =
(db.global_urls.lock(), db.global_landing_pages.lock())
{
match crate::db::slugs::cleanup_stale_reservations(&urls_conn, &pages_conn, 900) {
Ok(count) => {
if count > 0 {
tracing::info!(
"Cleaned up {} stale reserving slugs from v0.8 registry",
count
);
}
}
}
Err(e) => {
tracing::error!("Failed to clean up stale reservations: {}", e);
}
}
}
// Post-init: Verify global registry integrity
{
let system_conn = db.system.lock().unwrap();
let users_conn = db.users.lock().unwrap();
match crate::services::registry_validator::RegistryValidator::scan(
&system_conn,
&users_conn,
&config.data_dir,
None,
) {
Ok(issues) => {
for issue in issues {
tracing::error!(
"Global registry integrity issue: {:?} for slug {}",
issue.issue_type,
issue.slug
);
Err(e) => {
tracing::error!("Failed to clean up stale reservations: {}", e);
}
}
Err(e) => {
tracing::error!("Failed to verify global registry integrity: {}", e);
}
}
}
@@ -384,24 +238,33 @@ impl Db {
let admin = self.admin.lock().unwrap();
let _ = admin.execute("VACUUM;", []);
let content = self.content.lock().unwrap();
let _ = content.execute("VACUUM;", []);
let analytics = self.analytics.lock().unwrap();
let _ = analytics.execute("VACUUM;", []);
let system = self.system.lock().unwrap();
let _ = system.execute("VACUUM;", []);
let users = self.users.lock().unwrap();
let _ = users.execute("VACUUM;", []);
let global_urls = self.global_urls.lock().unwrap();
let _ = global_urls.execute("VACUUM;", []);
let global_landing_pages = self.global_landing_pages.lock().unwrap();
let _ = global_landing_pages.execute("VACUUM;", []);
let reserved = self.reserved.lock().unwrap();
let _ = reserved.execute("VACUUM;", []);
Ok(())
}
pub fn init_user_databases(&self, user_id: i64) -> Result<(), Box<dyn std::error::Error>> {
let user_dir = self.data_dir.join("users").join(user_id.to_string());
fs::create_dir_all(&user_dir)?;
let user = {
let conn = self.users.lock().unwrap();
crate::db::users::get_user_by_id(&conn, user_id)?
.ok_or_else(|| format!("cannot provision databases for unknown user {user_id}"))?
};
let location = crate::db::tenant::location_for_user(&user)?;
let user_dir = location.dir(&self.topology)?;
fs::create_dir_all(user_dir.join("extensions"))?;
let content_path = user_dir.join("content.db");
let analytics_path = user_dir.join("analytics.db");
@@ -444,7 +307,7 @@ impl Db {
pub fn reconcile_global_slugs(
&self,
config: &Config,
_config: &Config,
) -> Result<(), Box<dyn std::error::Error>> {
use chrono::Utc;
@@ -462,8 +325,10 @@ impl Db {
drop(stmt);
for user_id in user_ids {
let user_dir = config.data_dir.join("users").join(user_id.to_string());
let content_path = user_dir.join("content.db");
let content_path = match self.topology.content_db_i64(user_id) {
Ok(p) => p,
Err(_) => continue,
};
if content_path.exists() {
let content_conn = Connection::open(&content_path)?;
+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` (env `DATA_DIR`, default `./data`).
//! 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}");
}
}
}
+194 -71
View File
@@ -1,7 +1,70 @@
use crate::identity::TenantId;
use crate::models::{TenantUser, UserApiToken, UserQuotas, UserSession};
use chrono::Utc;
use rusqlite::{params, Connection, OptionalExtension};
const USER_COLUMNS: &str = "id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata, tenant_id, uuid";
fn map_user(row: &rusqlite::Row<'_>) -> rusqlite::Result<TenantUser> {
let tenant_raw: Option<String> = row.get(9)?;
let tenant_id = match tenant_raw {
Some(s) => Some(TenantId::parse(&s).map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(9, rusqlite::types::Type::Text, Box::new(e))
})?),
None => None,
};
let uuid: Option<String> = row.get(10)?;
Ok(TenantUser {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
status: row.get(3)?,
created_at: row.get(4)?,
last_login: row.get(5)?,
account_type: row.get(6)?,
organization_id: row.get(7)?,
metadata: row.get(8)?,
tenant_id,
uuid,
})
}
fn allocate_unique_tenant_id(conn: &Connection) -> rusqlite::Result<TenantId> {
for _ in 0..16 {
let candidate = TenantId::generate();
let exists: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM users WHERE tenant_id = ?1);",
[candidate.as_str()],
|row| row.get(0),
)?;
if !exists {
return Ok(candidate);
}
}
Err(rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
Some("failed to allocate unique TenantId".into()),
))
}
pub fn allocate_unique_uuid(conn: &Connection) -> rusqlite::Result<String> {
for _ in 0..16 {
let candidate = uuid::Uuid::new_v4().to_string();
let exists: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM users WHERE uuid = ?1);",
[&candidate],
|row| row.get(0),
)?;
if !exists {
return Ok(candidate);
}
}
Err(rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
Some("failed to allocate unique UUID".into()),
))
}
// --- User Operations ---
pub fn is_reserved_username(username: &str) -> bool {
@@ -17,11 +80,19 @@ pub fn create_admin_user(
let created_at = Utc::now().to_rfc3339();
let status = "active";
let account_type = "admin";
let user_uuid = allocate_unique_uuid(conn)?;
conn.execute(
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata)
VALUES (?1, ?2, ?3, ?4, ?5, NULL);",
params![username, password_hash, status, created_at, account_type],
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata, tenant_id, uuid)
VALUES (?1, ?2, ?3, ?4, ?5, NULL, NULL, ?6);",
params![
username,
password_hash,
status,
created_at,
account_type,
user_uuid,
],
)?;
let id = conn.last_insert_rowid();
@@ -39,6 +110,8 @@ pub fn create_admin_user(
account_type: account_type.to_string(),
organization_id: None,
metadata: None,
tenant_id: None,
uuid: Some(user_uuid),
})
}
@@ -58,17 +131,21 @@ pub fn create_user(
let created_at = Utc::now().to_rfc3339();
let status = "active";
let tenant_id = allocate_unique_tenant_id(conn)?;
let user_uuid = allocate_unique_uuid(conn)?;
conn.execute(
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata)
VALUES (?1, ?2, ?3, ?4, ?5, ?6);",
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata, tenant_id, uuid)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8);",
params![
username,
password_hash,
status,
created_at,
account_type,
metadata
metadata,
tenant_id.as_str(),
user_uuid,
],
)?;
@@ -87,27 +164,37 @@ pub fn create_user(
account_type: account_type.to_string(),
organization_id: None,
metadata: metadata.map(|s| s.to_string()),
tenant_id: Some(tenant_id),
uuid: Some(user_uuid),
})
}
pub fn get_user_by_id(conn: &Connection, id: i64) -> rusqlite::Result<Option<TenantUser>> {
conn.query_row(
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
FROM users WHERE id = ?1;",
&format!("SELECT {USER_COLUMNS} FROM users WHERE id = ?1;"),
params![id],
|row| {
Ok(TenantUser {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
status: row.get(3)?,
created_at: row.get(4)?,
last_login: row.get(5)?,
account_type: row.get(6)?,
organization_id: row.get(7)?,
metadata: row.get(8)?,
})
},
map_user,
)
.optional()
}
pub fn get_user_by_tenant_id(
conn: &Connection,
tenant_id: TenantId,
) -> rusqlite::Result<Option<TenantUser>> {
conn.query_row(
&format!("SELECT {USER_COLUMNS} FROM users WHERE tenant_id = ?1;"),
params![tenant_id.as_str()],
map_user,
)
.optional()
}
pub fn get_user_by_uuid(conn: &Connection, uuid: &str) -> rusqlite::Result<Option<TenantUser>> {
conn.query_row(
&format!("SELECT {USER_COLUMNS} FROM users WHERE uuid = ?1;"),
params![uuid],
map_user,
)
.optional()
}
@@ -117,22 +204,9 @@ pub fn get_user_by_username(
username: &str,
) -> rusqlite::Result<Option<TenantUser>> {
conn.query_row(
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
FROM users WHERE username = ?1;",
&format!("SELECT {USER_COLUMNS} FROM users WHERE username = ?1;"),
params![username],
|row| {
Ok(TenantUser {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
status: row.get(3)?,
created_at: row.get(4)?,
last_login: row.get(5)?,
account_type: row.get(6)?,
organization_id: row.get(7)?,
metadata: row.get(8)?,
})
},
map_user,
)
.optional()
}
@@ -184,23 +258,10 @@ pub fn update_user_last_login(conn: &Connection, id: i64) -> rusqlite::Result<()
}
pub fn list_users(conn: &Connection) -> rusqlite::Result<Vec<TenantUser>> {
let mut stmt = conn.prepare(
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
FROM users ORDER BY username ASC;",
)?;
let rows = stmt.query_map([], |row| {
Ok(TenantUser {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
status: row.get(3)?,
created_at: row.get(4)?,
last_login: row.get(5)?,
account_type: row.get(6)?,
organization_id: row.get(7)?,
metadata: row.get(8)?,
})
})?;
let mut stmt = conn.prepare(&format!(
"SELECT {USER_COLUMNS} FROM users ORDER BY username ASC;"
))?;
let rows = stmt.query_map([], map_user)?;
let mut users = Vec::new();
for u in rows {
@@ -439,6 +500,9 @@ pub fn delete_user_api_token(conn: &Connection, id: i64, user_id: i64) -> rusqli
// --- Global Slug & Quota Reconciliation Helpers ---
#[deprecated(
note = "Legacy v0.7 global_slugs function; use crate::db::slugs::is_slug_available instead"
)]
pub fn is_slug_available(system_conn: &Connection, slug: &str) -> rusqlite::Result<bool> {
// 1. Check reserved list
let reserved: bool = system_conn
@@ -465,6 +529,9 @@ pub fn is_slug_available(system_conn: &Connection, slug: &str) -> rusqlite::Resu
Ok(!exists)
}
#[deprecated(
note = "Legacy v0.7 global_slugs function; use crate::db::slugs::reserve_*_slug instead"
)]
pub fn register_global_slug(
system_conn: &Connection,
slug: &str,
@@ -490,6 +557,9 @@ pub fn register_global_slug(
Ok(())
}
#[deprecated(
note = "Legacy v0.7 global_slugs function; use crate::db::slugs::release_*_slug instead"
)]
pub fn release_global_slug(
system_conn: &Connection,
slug: &str,
@@ -508,6 +578,7 @@ pub fn release_global_slug(
Ok(())
}
#[deprecated(note = "Legacy v0.7 global_slugs function; use crate::db::slugs APIs instead")]
pub fn soft_delete_global_slug(
system_conn: &Connection,
slug: &str,
@@ -544,10 +615,11 @@ pub fn audit_slug_namespace(
let mut invalid_entries = Vec::new();
let warnings = Vec::new();
let mut slug_owners: HashMap<String, Vec<i64>> = HashMap::new();
let mut slug_owners: HashMap<String, Vec<String>> = HashMap::new();
// 1. Scan legacy content.db if it exists
let legacy_content_path = config.data_dir.join("content.db");
let legacy_content_path =
crate::db::topology::Topology::new(&config.data_dir).legacy_flat_content_db();
if legacy_content_path.exists() {
if let Ok(conn) = Connection::open(&legacy_content_path) {
// URLs
@@ -555,7 +627,7 @@ pub fn audit_slug_namespace(
if let Ok(mut rows) = stmt.query([]) {
while let Some(row) = rows.next().unwrap_or(None) {
if let Ok(code) = row.get::<_, String>(0) {
slug_owners.entry(code).or_default().push(1); // 1 = legacy admin
slug_owners.entry(code).or_default().push("1".to_string());
}
}
}
@@ -565,7 +637,7 @@ pub fn audit_slug_namespace(
if let Ok(mut rows) = stmt.query([]) {
while let Some(row) = rows.next().unwrap_or(None) {
if let Ok(code) = row.get::<_, String>(0) {
slug_owners.entry(code).or_default().push(1);
slug_owners.entry(code).or_default().push("1".to_string());
}
}
}
@@ -574,14 +646,14 @@ pub fn audit_slug_namespace(
}
// 2. Scan all tenant databases in data_dir/users/<id>/content.db
let users_dir = config.data_dir.join("users");
let users_dir = crate::db::topology::Topology::new(&config.data_dir).users_dir();
if users_dir.exists() {
for entry in std::fs::read_dir(users_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
if let Some(name_str) = path.file_name().and_then(|n| n.to_str()) {
if let Ok(user_id) = name_str.parse::<i64>() {
if crate::db::topology::is_valid_user_dir_name(name_str) {
let content_db_path = path.join("content.db");
if content_db_path.exists() {
if let Ok(conn) = Connection::open(&content_db_path) {
@@ -590,7 +662,10 @@ pub fn audit_slug_namespace(
if let Ok(mut rows) = stmt.query([]) {
while let Some(row) = rows.next().unwrap_or(None) {
if let Ok(code) = row.get::<_, String>(0) {
slug_owners.entry(code).or_default().push(user_id);
slug_owners
.entry(code)
.or_default()
.push(name_str.to_string());
}
}
}
@@ -602,7 +677,10 @@ pub fn audit_slug_namespace(
if let Ok(mut rows) = stmt.query([]) {
while let Some(row) = rows.next().unwrap_or(None) {
if let Ok(code) = row.get::<_, String>(0) {
slug_owners.entry(code).or_default().push(user_id);
slug_owners
.entry(code)
.or_default()
.push(name_str.to_string());
}
}
}
@@ -638,6 +716,9 @@ pub fn audit_slug_namespace(
})
}
#[deprecated(
note = "Legacy v0.7 global_slugs function; use crate::db::slugs::cleanup_stale_reservations instead"
)]
pub fn cleanup_stale_reservations(
system_conn: &Connection,
data_dir: &std::path::Path,
@@ -661,18 +742,31 @@ pub fn cleanup_stale_reservations(
let age = Utc::now().signed_duration_since(created_at.with_timezone(&Utc));
if age > chrono::Duration::minutes(15) {
// Check if target record exists by looking up code = slug in owner's content.db
let content_db_path = if owner_user_id == 1 {
let p1 = data_dir.join("users").join("1").join("content.db");
if p1.exists() {
p1
let topology = crate::db::topology::Topology::new(data_dir);
let content_db_path = {
let users_path = topology.users_registry_db();
if let Ok(users_conn) = Connection::open(&users_path) {
crate::db::tenant::existing_content_path(
&users_conn,
&topology,
owner_user_id,
)
.ok()
.or_else(|| {
if owner_user_id == 1 {
Some(topology.legacy_flat_content_db())
} else {
None
}
})
.unwrap_or_else(|| topology.legacy_flat_content_db())
} else if owner_user_id == 1 {
topology.legacy_flat_content_db()
} else {
data_dir.join("content.db")
topology
.content_db_i64(owner_user_id)
.unwrap_or_else(|_| topology.legacy_flat_content_db())
}
} else {
data_dir
.join("users")
.join(owner_user_id.to_string())
.join("content.db")
};
let mut target_exists = false;
@@ -722,6 +816,9 @@ pub fn cleanup_stale_reservations(
Ok(cleaned_count)
}
#[deprecated(
note = "Legacy v0.7 global_slugs function; use v0.8 slug databases and TenantId instead"
)]
pub fn register_restored_user_slugs(
system_conn: &Connection,
target_user_id: i64,
@@ -867,3 +964,29 @@ pub fn reconcile_user_quotas(
Ok(())
}
/// Calculate aggregate platform total clicks across all active tenant analytics databases.
pub fn get_platform_total_clicks(
topology: &crate::db::topology::Topology,
users_conn: &Connection,
) -> Option<i64> {
let mut stmt = users_conn
.prepare("SELECT tenant_id FROM users WHERE status != 'deleted' AND tenant_id IS NOT NULL;")
.ok()?;
let rows = stmt.query_map([], |row| row.get::<_, String>(0)).ok()?;
let mut total = 0i64;
for tid_str in rows.flatten() {
if let Ok(tid) = crate::identity::TenantId::parse(&tid_str) {
let analytics_path = topology.tenant_analytics_db(tid);
if analytics_path.exists() {
if let Ok(conn) = Connection::open(&analytics_path) {
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM visits;", [], |r| r.get(0))
.unwrap_or(0);
total += count;
}
}
}
}
Some(total)
}