refactor: complete v0.8 core architecture
This commit is contained in:
1 parent
f138a645f8
commit
d7e0ac7679
98 files changed
+8413
-3044
No files matched your search
@@ -639,6 +639,7 @@ pub fn get_target_visits_paginated(
|
||||
accept_language: row.get("accept_language")?,
|
||||
country: row.get("country")?,
|
||||
status_code: row.get("status_code")?,
|
||||
owner_tenant_id: None,
|
||||
owner_user_id: row.get("owner_user_id")?,
|
||||
})
|
||||
})?;
|
||||
@@ -695,6 +696,7 @@ pub fn get_target_visits_all_in_memory(
|
||||
accept_language: row.get("accept_language")?,
|
||||
country: row.get("country")?,
|
||||
status_code: row.get("status_code")?,
|
||||
owner_tenant_id: None,
|
||||
owner_user_id: row.get("owner_user_id")?,
|
||||
})
|
||||
})?;
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
//! Explicit Phase 3 Identity & Directory Migration Engine.
|
||||
//!
|
||||
//! Lifecycle:
|
||||
//! 1. Preflight (inspect DB and filesystem, identify unmigrated users)
|
||||
//! 2. Backup (create pre-migration tarball)
|
||||
//! 3. Identity Migration (assign immutable TenantId + UUID in users.db)
|
||||
//! 4. Filesystem Migration (atomically move users/<id>/ to users/<TenantId>/)
|
||||
//! 5. Validation (verify all users, directories, and databases)
|
||||
//! 6. Completion Marker (record audit event and system setting)
|
||||
//!
|
||||
//! Legacy Admin (users/1) is preserved as a Core/legacy concern and NOT converted
|
||||
//! to a normal TenantId directory.
|
||||
|
||||
use rusqlite::Connection;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::db::topology::{is_v08_user_id, Topology};
|
||||
use crate::db::Db;
|
||||
use crate::identity::TenantId;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct IdentityMigrationReport {
|
||||
pub total_users: usize,
|
||||
pub users_assigned_tenant_id: usize,
|
||||
pub users_assigned_uuid: usize,
|
||||
pub directories_moved: usize,
|
||||
pub directories_already_migrated: usize,
|
||||
pub legacy_admin_preserved: bool,
|
||||
pub validation_passed: bool,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PreflightPlan {
|
||||
pub total_users: usize,
|
||||
pub normal_users: usize,
|
||||
pub users_needing_tenant_id: Vec<(i64, String)>,
|
||||
pub users_needing_uuid: Vec<(i64, String)>,
|
||||
pub directories_to_move: Vec<(i64, PathBuf, TenantId)>,
|
||||
pub directories_already_migrated: usize,
|
||||
}
|
||||
|
||||
/// Run the full explicit identity migration lifecycle.
|
||||
pub async fn run_identity_migration(
|
||||
config: &Config,
|
||||
dry_run: bool,
|
||||
skip_backup: bool,
|
||||
) -> Result<IdentityMigrationReport, Box<dyn std::error::Error>> {
|
||||
let topology = Topology::new(&config.data_dir);
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
// 1. Initialize Db for Core connections
|
||||
let db = Db::init(config)?;
|
||||
|
||||
// 2. Preflight
|
||||
let plan = {
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
inspect_preflight(&users_conn, &topology)?
|
||||
};
|
||||
|
||||
info!(
|
||||
"Preflight: total_users={}, normal_users={}, needing_tenant_id={}, needing_uuid={}, dirs_to_move={}",
|
||||
plan.total_users,
|
||||
plan.normal_users,
|
||||
plan.users_needing_tenant_id.len(),
|
||||
plan.users_needing_uuid.len(),
|
||||
plan.directories_to_move.len()
|
||||
);
|
||||
|
||||
if dry_run {
|
||||
info!("Dry run mode enabled. No identity changes or directory moves will be performed.");
|
||||
return Ok(IdentityMigrationReport {
|
||||
total_users: plan.total_users,
|
||||
users_assigned_tenant_id: plan.users_needing_tenant_id.len(),
|
||||
users_assigned_uuid: plan.users_needing_uuid.len(),
|
||||
directories_moved: plan.directories_to_move.len(),
|
||||
directories_already_migrated: plan.directories_already_migrated,
|
||||
legacy_admin_preserved: true,
|
||||
validation_passed: true,
|
||||
warnings,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Backup before migration (if there is work to do and backup is not skipped)
|
||||
let needs_migration = !plan.users_needing_tenant_id.is_empty()
|
||||
|| !plan.users_needing_uuid.is_empty()
|
||||
|| !plan.directories_to_move.is_empty();
|
||||
|
||||
if needs_migration && !skip_backup {
|
||||
info!("Creating pre-migration backup...");
|
||||
match crate::jobs::backup::perform_backup(&db, config).await {
|
||||
Ok(path) => info!("Pre-migration backup created at {:?}", path),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Pre-migration backup failed: {}. Continuing with caution.",
|
||||
e
|
||||
);
|
||||
warnings.push(format!("Pre-migration backup warning: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Identity Migration (users.db backfill)
|
||||
let (assigned_tids, assigned_uuids) = {
|
||||
let mut users_conn = db.users.lock().unwrap();
|
||||
migrate_user_table_identities(&mut users_conn)?
|
||||
};
|
||||
|
||||
// 5. Filesystem Migration (users/<id>/ -> users/<TenantId>/)
|
||||
let moved_dirs = {
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
migrate_tenant_directories(&users_conn, &topology, &mut warnings)?
|
||||
};
|
||||
|
||||
// 6. Validation
|
||||
let validation_passed = {
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
validate_identity_migration(&users_conn, &topology, &mut warnings)?
|
||||
};
|
||||
|
||||
// 7. Completion Marker in system.db
|
||||
if validation_passed {
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let details = format!(
|
||||
"Identity migration completed: {} tenant_ids assigned, {} uuids assigned, {} directories moved",
|
||||
assigned_tids, assigned_uuids, moved_dirs
|
||||
);
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
"system",
|
||||
"IDENTITY_MIGRATION_COMPLETED",
|
||||
"identity",
|
||||
"users.db",
|
||||
Some(&details),
|
||||
);
|
||||
let _ = system_conn.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('v08_identity_migration_completed', ?1);",
|
||||
[&now],
|
||||
);
|
||||
}
|
||||
|
||||
Ok(IdentityMigrationReport {
|
||||
total_users: plan.total_users,
|
||||
users_assigned_tenant_id: assigned_tids,
|
||||
users_assigned_uuid: assigned_uuids,
|
||||
directories_moved: moved_dirs,
|
||||
directories_already_migrated: plan.directories_already_migrated,
|
||||
legacy_admin_preserved: true,
|
||||
validation_passed,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
/// Inspect existing database and filesystem to build a preflight plan.
|
||||
pub fn inspect_preflight(
|
||||
users_conn: &Connection,
|
||||
topology: &Topology,
|
||||
) -> Result<PreflightPlan, Box<dyn std::error::Error>> {
|
||||
let users = crate::db::users::list_users(users_conn)?;
|
||||
let total_users = users.len();
|
||||
|
||||
let mut normal_users = 0;
|
||||
let mut users_needing_tenant_id = Vec::new();
|
||||
let mut users_needing_uuid = Vec::new();
|
||||
let mut directories_to_move = Vec::new();
|
||||
let mut directories_already_migrated = 0;
|
||||
|
||||
for user in &users {
|
||||
// Skip legacy admin / system accounts
|
||||
if user.account_type == "admin"
|
||||
|| user.account_type == "system"
|
||||
|| user.username == "legacy_admin"
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
normal_users += 1;
|
||||
|
||||
if user.tenant_id.is_none() {
|
||||
users_needing_tenant_id.push((user.id, user.username.clone()));
|
||||
}
|
||||
if user.uuid.is_none() {
|
||||
users_needing_uuid.push((user.id, user.username.clone()));
|
||||
}
|
||||
|
||||
if let Some(tid) = user.tenant_id {
|
||||
let legacy_dir = topology.user_dir_i64(user.id)?;
|
||||
let target_dir = topology.tenant_dir(tid);
|
||||
|
||||
if legacy_dir.exists() && legacy_dir != target_dir {
|
||||
directories_to_move.push((user.id, legacy_dir, tid));
|
||||
} else if target_dir.exists() {
|
||||
directories_already_migrated += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(PreflightPlan {
|
||||
total_users,
|
||||
normal_users,
|
||||
users_needing_tenant_id,
|
||||
users_needing_uuid,
|
||||
directories_to_move,
|
||||
directories_already_migrated,
|
||||
})
|
||||
}
|
||||
|
||||
/// Assign immutable TenantId and UUID for all normal users missing them in users.db.
|
||||
/// Restart-safe: never regenerates or modifies existing identities.
|
||||
pub fn migrate_user_table_identities(
|
||||
users_conn: &mut Connection,
|
||||
) -> Result<(usize, usize), Box<dyn std::error::Error>> {
|
||||
let users = crate::db::users::list_users(users_conn)?;
|
||||
let mut assigned_tids = 0;
|
||||
let mut assigned_uuids = 0;
|
||||
|
||||
let tx = users_conn.transaction()?;
|
||||
|
||||
for user in users {
|
||||
// Skip legacy admin / system accounts
|
||||
if user.account_type == "admin"
|
||||
|| user.account_type == "system"
|
||||
|| user.username == "legacy_admin"
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut needs_update = false;
|
||||
let mut tid_str = user.tenant_id.map(|t| t.as_str().to_string());
|
||||
let mut uuid_str = user.uuid;
|
||||
|
||||
if tid_str.is_none() {
|
||||
// Allocate unique TenantId
|
||||
let tid = crate::identity::TenantId::generate();
|
||||
tid_str = Some(tid.as_str().to_string());
|
||||
assigned_tids += 1;
|
||||
needs_update = true;
|
||||
}
|
||||
|
||||
if uuid_str.is_none() {
|
||||
// Allocate unique UUID
|
||||
let u = uuid::Uuid::new_v4().to_string();
|
||||
uuid_str = Some(u);
|
||||
assigned_uuids += 1;
|
||||
needs_update = true;
|
||||
}
|
||||
|
||||
if needs_update {
|
||||
tx.execute(
|
||||
"UPDATE users SET tenant_id = ?1, uuid = ?2 WHERE id = ?3;",
|
||||
rusqlite::params![tid_str, uuid_str, user.id],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit()?;
|
||||
Ok((assigned_tids, assigned_uuids))
|
||||
}
|
||||
|
||||
/// Move tenant directories from users/<id>/ to users/<TenantId>/ for normal users.
|
||||
/// Legacy users/1 is preserved.
|
||||
pub fn migrate_tenant_directories(
|
||||
users_conn: &Connection,
|
||||
topology: &Topology,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Result<usize, Box<dyn std::error::Error>> {
|
||||
let users = crate::db::users::list_users(users_conn)?;
|
||||
let mut moved = 0;
|
||||
|
||||
for user in users {
|
||||
if user.account_type == "admin"
|
||||
|| user.account_type == "system"
|
||||
|| user.username == "legacy_admin"
|
||||
{
|
||||
// Preserve Core / system accounts intact
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(tid) = user.tenant_id else {
|
||||
warnings.push(format!(
|
||||
"User '{}' (ID {}) has no TenantId; skipping directory move",
|
||||
user.username, user.id
|
||||
));
|
||||
continue;
|
||||
};
|
||||
|
||||
let legacy_dir = match topology.user_dir_i64(user.id) {
|
||||
Ok(d) => d,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let target_dir = topology.tenant_dir(tid);
|
||||
|
||||
if legacy_dir.exists() && legacy_dir != target_dir {
|
||||
if !target_dir.exists() {
|
||||
// Atomic rename on same filesystem
|
||||
fs::rename(&legacy_dir, &target_dir)?;
|
||||
let _ = fs::create_dir_all(target_dir.join("extensions"));
|
||||
info!(
|
||||
"Migrated tenant directory for user '{}': {:?} -> {:?}",
|
||||
user.username, legacy_dir, target_dir
|
||||
);
|
||||
moved += 1;
|
||||
} else {
|
||||
// Target already exists (interrupted / partial run)
|
||||
warn!(
|
||||
"Target directory {:?} already exists for user '{}'. Verifying contents.",
|
||||
target_dir, user.username
|
||||
);
|
||||
// Ensure extensions dir exists
|
||||
let _ = fs::create_dir_all(target_dir.join("extensions"));
|
||||
|
||||
// If legacy directory is now empty or duplicate, clean it up safely
|
||||
if let Ok(entries) = fs::read_dir(&legacy_dir) {
|
||||
if entries.count() == 0 {
|
||||
let _ = fs::remove_dir(&legacy_dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(moved)
|
||||
}
|
||||
|
||||
/// Validate that every normal user has a valid TenantId, UUID, and matching directory.
|
||||
pub fn validate_identity_migration(
|
||||
users_conn: &Connection,
|
||||
topology: &Topology,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
let users = crate::db::users::list_users(users_conn)?;
|
||||
let mut valid = true;
|
||||
|
||||
for user in &users {
|
||||
if user.account_type == "admin"
|
||||
|| user.account_type == "system"
|
||||
|| user.username == "legacy_admin"
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate TenantId
|
||||
match user.tenant_id {
|
||||
Some(tid) => {
|
||||
if !is_v08_user_id(tid.as_str()) {
|
||||
warnings.push(format!(
|
||||
"Invalid TenantId format '{}' for user '{}'",
|
||||
tid.as_str(),
|
||||
user.username
|
||||
));
|
||||
valid = false;
|
||||
}
|
||||
|
||||
let target_dir = topology.tenant_dir(tid);
|
||||
if !target_dir.exists() {
|
||||
// Check if content db was initialized or if directory was not yet created
|
||||
warnings.push(format!(
|
||||
"Tenant directory {:?} does not exist for user '{}'",
|
||||
target_dir, user.username
|
||||
));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
warnings.push(format!(
|
||||
"Normal user '{}' (ID {}) is missing TenantId",
|
||||
user.username, user.id
|
||||
));
|
||||
valid = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate UUID
|
||||
match &user.uuid {
|
||||
Some(u) => {
|
||||
if uuid::Uuid::parse_str(u).is_err() {
|
||||
warnings.push(format!(
|
||||
"Invalid UUID format '{}' for user '{}'",
|
||||
u, user.username
|
||||
));
|
||||
valid = false;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
warnings.push(format!(
|
||||
"Normal user '{}' (ID {}) is missing UUID",
|
||||
user.username, user.id
|
||||
));
|
||||
valid = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(valid)
|
||||
}
|
||||
+35
-1
@@ -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
@@ -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)?;
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Independently versioned v0.8 schemas.
|
||||
//!
|
||||
//! Phase 1 creates the frozen slug databases. Live slug allocate/lookup still
|
||||
//! uses `system.db.global_slugs` until Phase 4 moves ownership.
|
||||
|
||||
use super::migrations::Migration;
|
||||
|
||||
/// `slugs/global_urls.db` — globally unique URL slugs.
|
||||
///
|
||||
/// `owner_user_id` remains INTEGER to match v0.7 `users.id`. Phase 3 will
|
||||
/// migrate it to the 12-hex user id.
|
||||
pub const GLOBAL_URLS_MIGRATIONS: &[Migration] = &[
|
||||
Migration {
|
||||
version: 1,
|
||||
name: "initial_schema",
|
||||
sql: r#"
|
||||
CREATE TABLE IF NOT EXISTS global_urls (
|
||||
slug TEXT PRIMARY KEY,
|
||||
owner_tenant_id TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
retired_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_global_urls_tenant ON global_urls(owner_tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_global_urls_status ON global_urls(status);
|
||||
"#,
|
||||
},
|
||||
Migration {
|
||||
version: 2,
|
||||
name: "tenant_id_column",
|
||||
sql: r#"
|
||||
ALTER TABLE global_urls ADD COLUMN owner_tenant_id TEXT;
|
||||
CREATE INDEX IF NOT EXISTS idx_global_urls_tenant ON global_urls(owner_tenant_id);
|
||||
"#,
|
||||
},
|
||||
];
|
||||
|
||||
/// `slugs/global_landing_pages.db` — globally unique landing-page slugs.
|
||||
pub const GLOBAL_LANDING_PAGES_MIGRATIONS: &[Migration] = &[
|
||||
Migration {
|
||||
version: 1,
|
||||
name: "initial_schema",
|
||||
sql: r#"
|
||||
CREATE TABLE IF NOT EXISTS global_landing_pages (
|
||||
slug TEXT PRIMARY KEY,
|
||||
owner_tenant_id TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
retired_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_global_landing_pages_tenant ON global_landing_pages(owner_tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_global_landing_pages_status ON global_landing_pages(status);
|
||||
"#,
|
||||
},
|
||||
Migration {
|
||||
version: 2,
|
||||
name: "tenant_id_column",
|
||||
sql: r#"
|
||||
ALTER TABLE global_landing_pages ADD COLUMN owner_tenant_id TEXT;
|
||||
CREATE INDEX IF NOT EXISTS idx_global_landing_pages_tenant ON global_landing_pages(owner_tenant_id);
|
||||
"#,
|
||||
},
|
||||
];
|
||||
|
||||
/// `slugs/reserved.db` — system route / reserved names that must never allocate.
|
||||
pub const RESERVED_SLUGS_MIGRATIONS: &[Migration] = &[Migration {
|
||||
version: 1,
|
||||
name: "initial_schema",
|
||||
sql: r#"
|
||||
CREATE TABLE IF NOT EXISTS reserved_slugs (
|
||||
slug TEXT PRIMARY KEY,
|
||||
reason TEXT
|
||||
);
|
||||
"#,
|
||||
}];
|
||||
|
||||
/// Allowed statuses for the v0.8 slug databases.
|
||||
///
|
||||
/// `reserving` is an allocation lock, not a published state.
|
||||
/// Frozen published states: `active`, `disabled`, `retired`.
|
||||
pub const SLUG_STATUS_RESERVING: &str = "reserving";
|
||||
pub const SLUG_STATUS_ACTIVE: &str = "active";
|
||||
pub const SLUG_STATUS_DISABLED: &str = "disabled";
|
||||
pub const SLUG_STATUS_RETIRED: &str = "retired";
|
||||
@@ -0,0 +1,538 @@
|
||||
//! Explicit Phase 4 Global Slug Migration Engine.
|
||||
//!
|
||||
//! Migrates `system.db.global_slugs` and `system.db.reserved_slugs` to:
|
||||
//! - `slugs/global_urls.db` (`global_urls` table)
|
||||
//! - `slugs/global_landing_pages.db` (`global_landing_pages` table)
|
||||
//! - `slugs/reserved.db` (`reserved_slugs` table)
|
||||
//!
|
||||
//! Lifecycle:
|
||||
//! 1. Preflight (inspect system.db, resolve owners against users.db, check for ambiguities)
|
||||
//! 2. Backup (create pre-migration tarball)
|
||||
//! 3. Transactional Migration (insert into target databases with restart safety)
|
||||
//! 4. Validation (row counts, field parity, global uniqueness across databases)
|
||||
//! 5. Completion Marker (record audit event and system setting)
|
||||
|
||||
use rusqlite::Connection;
|
||||
use std::collections::HashMap;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::db::topology::Topology;
|
||||
use crate::db::Db;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct SlugMigrationReport {
|
||||
pub total_legacy_slugs: usize,
|
||||
pub url_slugs_migrated: usize,
|
||||
pub page_slugs_migrated: usize,
|
||||
pub reserved_slugs_migrated: usize,
|
||||
pub existing_records_verified: usize,
|
||||
pub validation_passed: bool,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SlugPreflightReport {
|
||||
pub total_slugs: usize,
|
||||
pub url_slugs: usize,
|
||||
pub page_slugs: usize,
|
||||
pub reserved_slugs: usize,
|
||||
pub unresolvable_owners: Vec<(String, i64)>,
|
||||
pub unknown_target_types: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// Helper struct for legacy slug record
|
||||
struct LegacySlugRecord {
|
||||
slug: String,
|
||||
owner_user_id: i64,
|
||||
target_type: String,
|
||||
target_id: String,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
status: String,
|
||||
deleted_at: Option<String>,
|
||||
}
|
||||
|
||||
/// Run the full explicit global slug migration.
|
||||
pub async fn run_global_slug_migration(
|
||||
config: &Config,
|
||||
dry_run: bool,
|
||||
skip_backup: bool,
|
||||
) -> Result<SlugMigrationReport, Box<dyn std::error::Error>> {
|
||||
let _topology = Topology::new(&config.data_dir);
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
// 1. Initialize Db
|
||||
let db = Db::init(config)?;
|
||||
|
||||
// 2. Preflight
|
||||
let preflight = {
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
inspect_slug_preflight(&system_conn, &users_conn)?
|
||||
};
|
||||
|
||||
info!(
|
||||
"Slug Migration Preflight: total={}, urls={}, pages={}, reserved={}, unresolvable_owners={}, unknown_types={}",
|
||||
preflight.total_slugs,
|
||||
preflight.url_slugs,
|
||||
preflight.page_slugs,
|
||||
preflight.reserved_slugs,
|
||||
preflight.unresolvable_owners.len(),
|
||||
preflight.unknown_target_types.len()
|
||||
);
|
||||
|
||||
if !preflight.unresolvable_owners.is_empty() {
|
||||
let msg = format!(
|
||||
"Fatal: Found {} slugs with unresolvable owner_user_id in users.db: {:?}",
|
||||
preflight.unresolvable_owners.len(),
|
||||
preflight.unresolvable_owners
|
||||
);
|
||||
return Err(msg.into());
|
||||
}
|
||||
|
||||
if !preflight.unknown_target_types.is_empty() {
|
||||
let msg = format!(
|
||||
"Fatal: Found {} slugs with unknown target_type: {:?}",
|
||||
preflight.unknown_target_types.len(),
|
||||
preflight.unknown_target_types
|
||||
);
|
||||
return Err(msg.into());
|
||||
}
|
||||
|
||||
if dry_run {
|
||||
info!("Dry run enabled: no slug records will be migrated.");
|
||||
return Ok(SlugMigrationReport {
|
||||
total_legacy_slugs: preflight.total_slugs,
|
||||
url_slugs_migrated: preflight.url_slugs,
|
||||
page_slugs_migrated: preflight.page_slugs,
|
||||
reserved_slugs_migrated: preflight.reserved_slugs,
|
||||
existing_records_verified: 0,
|
||||
validation_passed: true,
|
||||
warnings,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Backup before migration (if needed and not skipped)
|
||||
if preflight.total_slugs > 0 && !skip_backup {
|
||||
info!("Creating pre-migration backup before slug migration...");
|
||||
match crate::jobs::backup::perform_backup(&db, config).await {
|
||||
Ok(path) => info!("Pre-migration backup created at {:?}", path),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Pre-migration backup failed: {}. Continuing with caution.",
|
||||
e
|
||||
);
|
||||
warnings.push(format!("Pre-migration backup warning: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Transactional Migration
|
||||
let (migrated_urls, migrated_pages, verified_existing) = {
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
let mut urls_conn = db.global_urls.lock().unwrap();
|
||||
let mut pages_conn = db.global_landing_pages.lock().unwrap();
|
||||
let reserved_conn = db.reserved.lock().unwrap();
|
||||
|
||||
migrate_slugs_transactional(
|
||||
&system_conn,
|
||||
&users_conn,
|
||||
&mut urls_conn,
|
||||
&mut pages_conn,
|
||||
&reserved_conn,
|
||||
&mut warnings,
|
||||
)?
|
||||
};
|
||||
|
||||
// 5. Validation
|
||||
let validation_passed = {
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let pages_conn = db.global_landing_pages.lock().unwrap();
|
||||
let reserved_conn = db.reserved.lock().unwrap();
|
||||
|
||||
validate_slug_migration(
|
||||
&system_conn,
|
||||
&urls_conn,
|
||||
&pages_conn,
|
||||
&reserved_conn,
|
||||
&mut warnings,
|
||||
)?
|
||||
};
|
||||
|
||||
// 6. Completion Marker in system.db
|
||||
if validation_passed {
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let details = format!(
|
||||
"Global slug migration completed: {} URLs migrated, {} landing pages migrated, {} verified existing",
|
||||
migrated_urls, migrated_pages, verified_existing
|
||||
);
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
"system",
|
||||
"GLOBAL_SLUG_MIGRATION_COMPLETED",
|
||||
"slugs",
|
||||
"slugs/*.db",
|
||||
Some(&details),
|
||||
);
|
||||
let _ = system_conn.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('v08_global_slug_migration_completed', ?1);",
|
||||
[&now],
|
||||
);
|
||||
}
|
||||
|
||||
Ok(SlugMigrationReport {
|
||||
total_legacy_slugs: preflight.total_slugs,
|
||||
url_slugs_migrated: migrated_urls,
|
||||
page_slugs_migrated: migrated_pages,
|
||||
reserved_slugs_migrated: preflight.reserved_slugs,
|
||||
existing_records_verified: verified_existing,
|
||||
validation_passed,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
/// Inspect system.db.global_slugs and reserved_slugs to build preflight plan.
|
||||
pub fn inspect_slug_preflight(
|
||||
system_conn: &Connection,
|
||||
users_conn: &Connection,
|
||||
) -> Result<SlugPreflightReport, Box<dyn std::error::Error>> {
|
||||
let has_global_slugs: bool = system_conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='global_slugs');",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
|
||||
if !has_global_slugs {
|
||||
return Ok(SlugPreflightReport {
|
||||
total_slugs: 0,
|
||||
url_slugs: 0,
|
||||
page_slugs: 0,
|
||||
reserved_slugs: 0,
|
||||
unresolvable_owners: Vec::new(),
|
||||
unknown_target_types: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut stmt = system_conn.prepare(
|
||||
"SELECT slug, owner_user_id, target_type, target_id, created_at, updated_at, status, deleted_at
|
||||
FROM global_slugs;",
|
||||
)?;
|
||||
|
||||
let records = stmt.query_map([], |row| {
|
||||
Ok(LegacySlugRecord {
|
||||
slug: row.get(0)?,
|
||||
owner_user_id: row.get(1)?,
|
||||
target_type: row.get(2)?,
|
||||
target_id: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
updated_at: row.get(5)?,
|
||||
status: row.get(6)?,
|
||||
deleted_at: row.get(7)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
// Build owner map from users.db: user_id -> TenantId/legacy_admin
|
||||
let mut owner_map = HashMap::new();
|
||||
let users = crate::db::users::list_users(users_conn)?;
|
||||
for u in users {
|
||||
if let Some(tid) = u.tenant_id {
|
||||
owner_map.insert(u.id, tid.as_str().to_string());
|
||||
} else if u.account_type == "admin"
|
||||
|| u.account_type == "system"
|
||||
|| u.username == "legacy_admin"
|
||||
{
|
||||
owner_map.insert(u.id, "legacy_admin".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let mut total_slugs = 0;
|
||||
let mut url_slugs = 0;
|
||||
let mut page_slugs = 0;
|
||||
let mut unresolvable_owners = Vec::new();
|
||||
let mut unknown_target_types = Vec::new();
|
||||
|
||||
for r in records {
|
||||
let rec = r?;
|
||||
total_slugs += 1;
|
||||
|
||||
if !owner_map.contains_key(&rec.owner_user_id) {
|
||||
unresolvable_owners.push((rec.slug.clone(), rec.owner_user_id));
|
||||
}
|
||||
|
||||
match rec.target_type.as_str() {
|
||||
"url" => url_slugs += 1,
|
||||
"page" => page_slugs += 1,
|
||||
other => unknown_target_types.push((rec.slug.clone(), other.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
let reserved_slugs: usize = system_conn
|
||||
.query_row("SELECT COUNT(*) FROM reserved_slugs;", [], |r| r.get(0))
|
||||
.unwrap_or(0);
|
||||
|
||||
Ok(SlugPreflightReport {
|
||||
total_slugs,
|
||||
url_slugs,
|
||||
page_slugs,
|
||||
reserved_slugs,
|
||||
unresolvable_owners,
|
||||
unknown_target_types,
|
||||
})
|
||||
}
|
||||
|
||||
/// Transactional migration of slugs from system.db to global_urls.db and global_landing_pages.db.
|
||||
pub fn migrate_slugs_transactional(
|
||||
system_conn: &Connection,
|
||||
users_conn: &Connection,
|
||||
urls_conn: &mut Connection,
|
||||
pages_conn: &mut Connection,
|
||||
reserved_conn: &Connection,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Result<(usize, usize, usize), Box<dyn std::error::Error>> {
|
||||
// 1. Build owner map: user_id -> TenantId
|
||||
let mut owner_map = HashMap::new();
|
||||
let users = crate::db::users::list_users(users_conn)?;
|
||||
for u in users {
|
||||
if let Some(tid) = u.tenant_id {
|
||||
owner_map.insert(u.id, tid.as_str().to_string());
|
||||
} else if u.account_type == "admin"
|
||||
|| u.account_type == "system"
|
||||
|| u.username == "legacy_admin"
|
||||
{
|
||||
owner_map.insert(u.id, "legacy_admin".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fetch all legacy slugs
|
||||
let mut stmt = system_conn.prepare(
|
||||
"SELECT slug, owner_user_id, target_type, target_id, created_at, updated_at, status, deleted_at
|
||||
FROM global_slugs;",
|
||||
)?;
|
||||
|
||||
let records: Vec<LegacySlugRecord> = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(LegacySlugRecord {
|
||||
slug: row.get(0)?,
|
||||
owner_user_id: row.get(1)?,
|
||||
target_type: row.get(2)?,
|
||||
target_id: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
updated_at: row.get(5)?,
|
||||
status: row.get(6)?,
|
||||
deleted_at: row.get(7)?,
|
||||
})
|
||||
})?
|
||||
.collect::<Result<_, _>>()?;
|
||||
|
||||
let mut migrated_urls = 0;
|
||||
let mut migrated_pages = 0;
|
||||
let mut verified_existing = 0;
|
||||
|
||||
let tx_urls = urls_conn.transaction()?;
|
||||
let tx_pages = pages_conn.transaction()?;
|
||||
|
||||
for rec in records {
|
||||
let owner_tenant_id = match owner_map.get(&rec.owner_user_id) {
|
||||
Some(t) => t.clone(),
|
||||
None => {
|
||||
warnings.push(format!(
|
||||
"Unresolvable owner_user_id {} for slug '{}'; skipping",
|
||||
rec.owner_user_id, rec.slug
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let retired_at = rec.deleted_at;
|
||||
|
||||
if rec.target_type == "url" {
|
||||
// Check if record already exists in global_urls.db
|
||||
let existing: Option<(String, String)> = tx_urls
|
||||
.query_row(
|
||||
"SELECT owner_tenant_id, target_id FROM global_urls WHERE slug = ?1;",
|
||||
[&rec.slug],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.ok();
|
||||
|
||||
if let Some((existing_owner, existing_target)) = existing {
|
||||
if existing_owner == owner_tenant_id && existing_target == rec.target_id {
|
||||
verified_existing += 1;
|
||||
} else {
|
||||
warnings.push(format!(
|
||||
"Conflict: Slug '{}' already exists in global_urls with different owner/target",
|
||||
rec.slug
|
||||
));
|
||||
}
|
||||
} else {
|
||||
tx_urls.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
|
||||
rusqlite::params![
|
||||
rec.slug,
|
||||
owner_tenant_id,
|
||||
rec.target_id,
|
||||
rec.created_at,
|
||||
rec.updated_at,
|
||||
rec.status,
|
||||
retired_at
|
||||
],
|
||||
)?;
|
||||
migrated_urls += 1;
|
||||
}
|
||||
} else if rec.target_type == "page" {
|
||||
// Check if record already exists in global_landing_pages.db
|
||||
let existing: Option<(String, String)> = tx_pages
|
||||
.query_row(
|
||||
"SELECT owner_tenant_id, target_id FROM global_landing_pages WHERE slug = ?1;",
|
||||
[&rec.slug],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.ok();
|
||||
|
||||
if let Some((existing_owner, existing_target)) = existing {
|
||||
if existing_owner == owner_tenant_id && existing_target == rec.target_id {
|
||||
verified_existing += 1;
|
||||
} else {
|
||||
warnings.push(format!(
|
||||
"Conflict: Slug '{}' already exists in global_landing_pages with different owner/target",
|
||||
rec.slug
|
||||
));
|
||||
}
|
||||
} else {
|
||||
tx_pages.execute(
|
||||
"INSERT INTO global_landing_pages (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
|
||||
rusqlite::params![
|
||||
rec.slug,
|
||||
owner_tenant_id,
|
||||
rec.target_id,
|
||||
rec.created_at,
|
||||
rec.updated_at,
|
||||
rec.status,
|
||||
retired_at
|
||||
],
|
||||
)?;
|
||||
migrated_pages += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tx_urls.commit()?;
|
||||
tx_pages.commit()?;
|
||||
|
||||
// Seed reserved slugs
|
||||
let _ = crate::db::slugs::seed_reserved_slugs(reserved_conn);
|
||||
|
||||
Ok((migrated_urls, migrated_pages, verified_existing))
|
||||
}
|
||||
|
||||
/// Validate that every legacy slug was migrated correctly and global uniqueness holds.
|
||||
pub fn validate_slug_migration(
|
||||
system_conn: &Connection,
|
||||
urls_conn: &Connection,
|
||||
pages_conn: &Connection,
|
||||
reserved_conn: &Connection,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
let mut valid = true;
|
||||
|
||||
let has_global_slugs: bool = system_conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='global_slugs');",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
|
||||
if !has_global_slugs {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let legacy_url_count: usize = system_conn.query_row(
|
||||
"SELECT COUNT(*) FROM global_slugs WHERE target_type = 'url';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
|
||||
let legacy_page_count: usize = system_conn.query_row(
|
||||
"SELECT COUNT(*) FROM global_slugs WHERE target_type = 'page';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
|
||||
let target_url_count: usize =
|
||||
urls_conn.query_row("SELECT COUNT(*) FROM global_urls;", [], |r| r.get(0))?;
|
||||
|
||||
let target_page_count: usize =
|
||||
pages_conn.query_row("SELECT COUNT(*) FROM global_landing_pages;", [], |r| {
|
||||
r.get(0)
|
||||
})?;
|
||||
|
||||
if target_url_count < legacy_url_count {
|
||||
warnings.push(format!(
|
||||
"URL slug count mismatch: legacy has {}, target has {}",
|
||||
legacy_url_count, target_url_count
|
||||
));
|
||||
valid = false;
|
||||
}
|
||||
|
||||
if target_page_count < legacy_page_count {
|
||||
warnings.push(format!(
|
||||
"Page slug count mismatch: legacy has {}, target has {}",
|
||||
legacy_page_count, target_page_count
|
||||
));
|
||||
valid = false;
|
||||
}
|
||||
|
||||
// Global Uniqueness Invariant Check: 0 intersection between reserved, urls, and pages
|
||||
let collisions_urls_pages: usize = urls_conn.query_row(
|
||||
"SELECT COUNT(*) FROM global_urls WHERE slug IN (SELECT slug FROM global_landing_pages);",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
).unwrap_or(0);
|
||||
|
||||
if collisions_urls_pages > 0 {
|
||||
warnings.push(format!(
|
||||
"Invariant Violation: {} slugs appear in both global_urls and global_landing_pages",
|
||||
collisions_urls_pages
|
||||
));
|
||||
valid = false;
|
||||
}
|
||||
|
||||
let collisions_reserved_urls: usize = urls_conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM global_urls WHERE slug IN (SELECT slug FROM reserved_slugs);",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
|
||||
if collisions_reserved_urls > 0 {
|
||||
warnings.push(format!(
|
||||
"Invariant Violation: {} slugs appear in both global_urls and reserved_slugs",
|
||||
collisions_reserved_urls
|
||||
));
|
||||
valid = false;
|
||||
}
|
||||
|
||||
let collisions_reserved_pages: usize = pages_conn.query_row(
|
||||
"SELECT COUNT(*) FROM global_landing_pages WHERE slug IN (SELECT slug FROM reserved_slugs);",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
).unwrap_or(0);
|
||||
|
||||
if collisions_reserved_pages > 0 {
|
||||
warnings.push(format!(
|
||||
"Invariant Violation: {} slugs appear in both global_landing_pages and reserved_slugs",
|
||||
collisions_reserved_pages
|
||||
));
|
||||
valid = false;
|
||||
}
|
||||
|
||||
let _ = reserved_conn;
|
||||
|
||||
Ok(valid)
|
||||
}
|
||||
+472
@@ -0,0 +1,472 @@
|
||||
//! Frozen v0.8 Slug Registry Layer.
|
||||
//!
|
||||
//! Owns `slugs/global_urls.db`, `slugs/global_landing_pages.db`, and `slugs/reserved.db`.
|
||||
//!
|
||||
//! Guarantees:
|
||||
//! - Exact TenantId ownership (no integer ID primitives in v0.8 API).
|
||||
//! - Cross-database global uniqueness invariant: a slug exists in AT MOST ONE of
|
||||
//! `reserved.db`, `global_urls.db`, `global_landing_pages.db`.
|
||||
//! - Retired slugs remain permanently unavailable for reuse across both URLs and landing pages.
|
||||
//! - Concurrency-safe global allocations.
|
||||
|
||||
use chrono::Utc;
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::db::schema_v08::{
|
||||
SLUG_STATUS_ACTIVE, SLUG_STATUS_DISABLED, SLUG_STATUS_RESERVING, SLUG_STATUS_RETIRED,
|
||||
};
|
||||
use crate::identity::TenantId;
|
||||
|
||||
/// Core reserved slugs, matching the v0.7 `system.db` seed list.
|
||||
pub const CORE_RESERVED_SLUGS: &[(&str, &str)] = &[
|
||||
("admin", "System route"),
|
||||
("login", "System route"),
|
||||
("logout", "System route"),
|
||||
("dashboard", "System route"),
|
||||
("api", "System route"),
|
||||
("docs", "System route"),
|
||||
("assets", "System route"),
|
||||
("static", "System route"),
|
||||
("favicon.ico", "System route"),
|
||||
("robots.txt", "System route"),
|
||||
("health", "System route"),
|
||||
("metrics", "System route"),
|
||||
("install", "System route"),
|
||||
("setup", "System route"),
|
||||
("support", "System route"),
|
||||
("help", "System route"),
|
||||
("security", "System route"),
|
||||
("abuse", "System route"),
|
||||
("billing", "System route"),
|
||||
("status", "System route"),
|
||||
("legacy_admin", "System reserved"),
|
||||
("administrator", "System reserved"),
|
||||
("system", "System reserved"),
|
||||
("root", "System reserved"),
|
||||
("www", "System reserved"),
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SlugTargetType {
|
||||
Url,
|
||||
LandingPage,
|
||||
}
|
||||
|
||||
impl SlugTargetType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Url => "url",
|
||||
Self::LandingPage => "page",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ResolvedSlugInfo {
|
||||
pub slug: String,
|
||||
pub owner_tenant_id: String,
|
||||
pub target_type: SlugTargetType,
|
||||
pub target_id: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub status: String,
|
||||
pub retired_at: Option<String>,
|
||||
}
|
||||
|
||||
/// Insert the core reserved set. Idempotent (`INSERT OR IGNORE`).
|
||||
pub fn seed_reserved_slugs(conn: &Connection) -> rusqlite::Result<usize> {
|
||||
let mut inserted = 0usize;
|
||||
for (slug, reason) in CORE_RESERVED_SLUGS {
|
||||
let n = conn.execute(
|
||||
"INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES (?1, ?2);",
|
||||
params![slug, reason],
|
||||
)?;
|
||||
inserted += n;
|
||||
}
|
||||
Ok(inserted)
|
||||
}
|
||||
|
||||
pub fn reserved_slug_count(conn: &Connection) -> rusqlite::Result<i64> {
|
||||
conn.query_row("SELECT COUNT(*) FROM reserved_slugs;", [], |row| row.get(0))
|
||||
}
|
||||
|
||||
/// Check whether a slug is reserved in `slugs/reserved.db`.
|
||||
pub fn is_slug_reserved(reserved_conn: &Connection, slug: &str) -> rusqlite::Result<bool> {
|
||||
reserved_conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM reserved_slugs WHERE slug = ?1);",
|
||||
[slug],
|
||||
|row| row.get(0),
|
||||
)
|
||||
}
|
||||
|
||||
/// Check whether a slug is available for a new registration.
|
||||
/// Returns false if reserved or if present in `global_urls.db` or `global_landing_pages.db`
|
||||
/// in any state (including `retired`).
|
||||
pub fn is_slug_available(
|
||||
reserved_conn: &Connection,
|
||||
urls_conn: &Connection,
|
||||
pages_conn: &Connection,
|
||||
slug: &str,
|
||||
) -> rusqlite::Result<bool> {
|
||||
if is_slug_reserved(reserved_conn, slug)? {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let in_urls: bool = urls_conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM global_urls WHERE slug = ?1);",
|
||||
[slug],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if in_urls {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let in_pages: bool = pages_conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM global_landing_pages WHERE slug = ?1);",
|
||||
[slug],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if in_pages {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Lookup a slug in `slugs/global_urls.db`.
|
||||
pub fn lookup_url_slug(
|
||||
urls_conn: &Connection,
|
||||
slug: &str,
|
||||
) -> rusqlite::Result<Option<ResolvedSlugInfo>> {
|
||||
urls_conn
|
||||
.query_row(
|
||||
"SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at
|
||||
FROM global_urls WHERE slug = ?1;",
|
||||
[slug],
|
||||
|row| {
|
||||
Ok(ResolvedSlugInfo {
|
||||
slug: row.get(0)?,
|
||||
owner_tenant_id: row.get(1)?,
|
||||
target_type: SlugTargetType::Url,
|
||||
target_id: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
updated_at: row.get(4)?,
|
||||
status: row.get(5)?,
|
||||
retired_at: row.get(6)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
}
|
||||
|
||||
/// Lookup a slug in `slugs/global_landing_pages.db`.
|
||||
pub fn lookup_landing_page_slug(
|
||||
pages_conn: &Connection,
|
||||
slug: &str,
|
||||
) -> rusqlite::Result<Option<ResolvedSlugInfo>> {
|
||||
pages_conn
|
||||
.query_row(
|
||||
"SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at
|
||||
FROM global_landing_pages WHERE slug = ?1;",
|
||||
[slug],
|
||||
|row| {
|
||||
Ok(ResolvedSlugInfo {
|
||||
slug: row.get(0)?,
|
||||
owner_tenant_id: row.get(1)?,
|
||||
target_type: SlugTargetType::LandingPage,
|
||||
target_id: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
updated_at: row.get(4)?,
|
||||
status: row.get(5)?,
|
||||
retired_at: row.get(6)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
}
|
||||
|
||||
/// Unified slug lookup: checks `global_urls.db`, then `global_landing_pages.db`.
|
||||
pub fn lookup_slug(
|
||||
urls_conn: &Connection,
|
||||
pages_conn: &Connection,
|
||||
slug: &str,
|
||||
) -> rusqlite::Result<Option<ResolvedSlugInfo>> {
|
||||
if let Some(info) = lookup_url_slug(urls_conn, slug)? {
|
||||
return Ok(Some(info));
|
||||
}
|
||||
lookup_landing_page_slug(pages_conn, slug)
|
||||
}
|
||||
|
||||
/// Register a URL slug in `slugs/global_urls.db`.
|
||||
pub fn register_url_slug(
|
||||
urls_conn: &Connection,
|
||||
slug: &str,
|
||||
owner_tenant_id: &TenantId,
|
||||
target_id: &str,
|
||||
status: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
urls_conn.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL);",
|
||||
params![slug, owner_tenant_id.as_str(), target_id, now, now, status],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Register a landing page slug in `slugs/global_landing_pages.db`.
|
||||
pub fn register_landing_page_slug(
|
||||
pages_conn: &Connection,
|
||||
slug: &str,
|
||||
owner_tenant_id: &TenantId,
|
||||
target_id: &str,
|
||||
status: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
pages_conn.execute(
|
||||
"INSERT INTO global_landing_pages (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL);",
|
||||
params![slug, owner_tenant_id.as_str(), target_id, now, now, status],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Atomic reservation for a URL slug in `slugs/global_urls.db` (status = 'reserving').
|
||||
pub fn reserve_url_slug(
|
||||
reserved_conn: &Connection,
|
||||
urls_conn: &Connection,
|
||||
pages_conn: &Connection,
|
||||
slug: &str,
|
||||
owner_tenant_id: &TenantId,
|
||||
) -> rusqlite::Result<()> {
|
||||
if !is_slug_available(reserved_conn, urls_conn, pages_conn, slug)? {
|
||||
return Err(rusqlite::Error::SqliteFailure(
|
||||
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
|
||||
Some("Slug is unavailable or reserved".into()),
|
||||
));
|
||||
}
|
||||
|
||||
let now = Utc::now().to_rfc3339();
|
||||
urls_conn.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES (?1, ?2, '', ?3, ?4, ?5, NULL);",
|
||||
params![slug, owner_tenant_id.as_str(), now, now, SLUG_STATUS_RESERVING],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Atomic reservation for a landing page slug in `slugs/global_landing_pages.db` (status = 'reserving').
|
||||
pub fn reserve_landing_page_slug(
|
||||
reserved_conn: &Connection,
|
||||
urls_conn: &Connection,
|
||||
pages_conn: &Connection,
|
||||
slug: &str,
|
||||
owner_tenant_id: &TenantId,
|
||||
) -> rusqlite::Result<()> {
|
||||
if !is_slug_available(reserved_conn, urls_conn, pages_conn, slug)? {
|
||||
return Err(rusqlite::Error::SqliteFailure(
|
||||
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
|
||||
Some("Slug is unavailable or reserved".into()),
|
||||
));
|
||||
}
|
||||
|
||||
let now = Utc::now().to_rfc3339();
|
||||
pages_conn.execute(
|
||||
"INSERT INTO global_landing_pages (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES (?1, ?2, '', ?3, ?4, ?5, NULL);",
|
||||
params![slug, owner_tenant_id.as_str(), now, now, SLUG_STATUS_RESERVING],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Release a reserving URL slug (e.g. if content creation fails).
|
||||
pub fn release_url_slug(
|
||||
urls_conn: &Connection,
|
||||
slug: &str,
|
||||
owner_tenant_id: &TenantId,
|
||||
) -> rusqlite::Result<()> {
|
||||
urls_conn.execute(
|
||||
"DELETE FROM global_urls WHERE slug = ?1 AND owner_tenant_id = ?2 AND status = 'reserving';",
|
||||
params![slug, owner_tenant_id.as_str()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Release a reserving Landing Page slug (e.g. if content creation fails).
|
||||
pub fn release_landing_page_slug(
|
||||
pages_conn: &Connection,
|
||||
slug: &str,
|
||||
owner_tenant_id: &TenantId,
|
||||
) -> rusqlite::Result<()> {
|
||||
pages_conn.execute(
|
||||
"DELETE FROM global_landing_pages WHERE slug = ?1 AND owner_tenant_id = ?2 AND status = 'reserving';",
|
||||
params![slug, owner_tenant_id.as_str()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update URL slug target and activate after reservation.
|
||||
pub fn activate_url_slug(
|
||||
urls_conn: &Connection,
|
||||
slug: &str,
|
||||
target_id: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
urls_conn.execute(
|
||||
"UPDATE global_urls SET target_id = ?1, status = ?2, updated_at = ?3 WHERE slug = ?4;",
|
||||
params![target_id, SLUG_STATUS_ACTIVE, now, slug],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update Landing Page slug target and activate after reservation.
|
||||
pub fn activate_landing_page_slug(
|
||||
pages_conn: &Connection,
|
||||
slug: &str,
|
||||
target_id: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
pages_conn.execute(
|
||||
"UPDATE global_landing_pages SET target_id = ?1, status = ?2, updated_at = ?3 WHERE slug = ?4;",
|
||||
params![target_id, SLUG_STATUS_ACTIVE, now, slug],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retire a slug permanently so it cannot be reused.
|
||||
pub fn retire_slug(
|
||||
urls_conn: &Connection,
|
||||
pages_conn: &Connection,
|
||||
slug: &str,
|
||||
) -> rusqlite::Result<bool> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let n_urls = urls_conn.execute(
|
||||
"UPDATE global_urls SET status = ?1, retired_at = ?2, updated_at = ?3 WHERE slug = ?4;",
|
||||
params![SLUG_STATUS_RETIRED, now, now, slug],
|
||||
)?;
|
||||
if n_urls > 0 {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let n_pages = pages_conn.execute(
|
||||
"UPDATE global_landing_pages SET status = ?1, retired_at = ?2, updated_at = ?3 WHERE slug = ?4;",
|
||||
params![SLUG_STATUS_RETIRED, now, now, slug],
|
||||
)?;
|
||||
Ok(n_pages > 0)
|
||||
}
|
||||
|
||||
/// Disable a slug (returns 410 Gone on redirect, but still owned by tenant).
|
||||
pub fn disable_slug(
|
||||
urls_conn: &Connection,
|
||||
pages_conn: &Connection,
|
||||
slug: &str,
|
||||
) -> rusqlite::Result<bool> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let n_urls = urls_conn.execute(
|
||||
"UPDATE global_urls SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
|
||||
params![SLUG_STATUS_DISABLED, now, slug],
|
||||
)?;
|
||||
if n_urls > 0 {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let n_pages = pages_conn.execute(
|
||||
"UPDATE global_landing_pages SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
|
||||
params![SLUG_STATUS_DISABLED, now, slug],
|
||||
)?;
|
||||
Ok(n_pages > 0)
|
||||
}
|
||||
|
||||
/// Transfer slug ownership to a new tenant.
|
||||
pub fn transfer_slug_owner(
|
||||
urls_conn: &Connection,
|
||||
pages_conn: &Connection,
|
||||
slug: &str,
|
||||
new_owner_tenant_id: &TenantId,
|
||||
new_target_id: &str,
|
||||
) -> rusqlite::Result<bool> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let n_urls = urls_conn.execute(
|
||||
"UPDATE global_urls SET owner_tenant_id = ?1, target_id = ?2, updated_at = ?3 WHERE slug = ?4;",
|
||||
params![new_owner_tenant_id.as_str(), new_target_id, now, slug],
|
||||
)?;
|
||||
if n_urls > 0 {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let n_pages = pages_conn.execute(
|
||||
"UPDATE global_landing_pages SET owner_tenant_id = ?1, target_id = ?2, updated_at = ?3 WHERE slug = ?4;",
|
||||
params![new_owner_tenant_id.as_str(), new_target_id, now, slug],
|
||||
)?;
|
||||
Ok(n_pages > 0)
|
||||
}
|
||||
|
||||
/// List all slugs owned by a specific tenant.
|
||||
pub fn list_slugs_by_tenant(
|
||||
urls_conn: &Connection,
|
||||
pages_conn: &Connection,
|
||||
owner_tenant_id: &TenantId,
|
||||
) -> rusqlite::Result<Vec<ResolvedSlugInfo>> {
|
||||
let mut results = Vec::new();
|
||||
|
||||
let mut stmt = urls_conn.prepare(
|
||||
"SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at
|
||||
FROM global_urls WHERE owner_tenant_id = ?1 ORDER BY created_at ASC;",
|
||||
)?;
|
||||
let rows = stmt.query_map([owner_tenant_id.as_str()], |row| {
|
||||
Ok(ResolvedSlugInfo {
|
||||
slug: row.get(0)?,
|
||||
owner_tenant_id: row.get(1)?,
|
||||
target_type: SlugTargetType::Url,
|
||||
target_id: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
updated_at: row.get(4)?,
|
||||
status: row.get(5)?,
|
||||
retired_at: row.get(6)?,
|
||||
})
|
||||
})?;
|
||||
for r in rows {
|
||||
results.push(r?);
|
||||
}
|
||||
|
||||
let mut stmt = pages_conn.prepare(
|
||||
"SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at
|
||||
FROM global_landing_pages WHERE owner_tenant_id = ?1 ORDER BY created_at ASC;",
|
||||
)?;
|
||||
let rows = stmt.query_map([owner_tenant_id.as_str()], |row| {
|
||||
Ok(ResolvedSlugInfo {
|
||||
slug: row.get(0)?,
|
||||
owner_tenant_id: row.get(1)?,
|
||||
target_type: SlugTargetType::LandingPage,
|
||||
target_id: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
updated_at: row.get(4)?,
|
||||
status: row.get(5)?,
|
||||
retired_at: row.get(6)?,
|
||||
})
|
||||
})?;
|
||||
for r in rows {
|
||||
results.push(r?);
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Clean up stale reservations older than threshold seconds.
|
||||
pub fn cleanup_stale_reservations(
|
||||
urls_conn: &Connection,
|
||||
pages_conn: &Connection,
|
||||
older_than_seconds: i64,
|
||||
) -> rusqlite::Result<usize> {
|
||||
let threshold = (Utc::now() - chrono::Duration::seconds(older_than_seconds)).to_rfc3339();
|
||||
let n1 = urls_conn.execute(
|
||||
"DELETE FROM global_urls WHERE status = 'reserving' AND created_at < ?1;",
|
||||
[&threshold],
|
||||
)?;
|
||||
let n2 = pages_conn.execute(
|
||||
"DELETE FROM global_landing_pages WHERE status = 'reserving' AND created_at < ?1;",
|
||||
[&threshold],
|
||||
)?;
|
||||
Ok(n1 + n2)
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
//! Tenant database access boundary.
|
||||
//!
|
||||
//! New tenant opens go through [`TenantId`]. Legacy integer row ids are used
|
||||
//! only to look up a registered Core user, then resolved to a [`TenantLocation`].
|
||||
//! Unknown ids must not create filesystem databases.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::db::topology::Topology;
|
||||
use crate::error::AppError;
|
||||
use crate::identity::TenantId;
|
||||
use crate::models::TenantUser;
|
||||
|
||||
/// Open tenant SQLite connections (content, analytics, profile).
|
||||
#[derive(Clone)]
|
||||
pub struct UserDbs {
|
||||
pub content: Arc<Mutex<Connection>>,
|
||||
pub analytics: Arc<Mutex<Connection>>,
|
||||
pub profile: Arc<Mutex<Connection>>,
|
||||
}
|
||||
|
||||
/// How a tenant database may be opened.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TenantOpenMode {
|
||||
/// Authenticated tenant user / API actor. Status must be `active`.
|
||||
Ordinary,
|
||||
/// Public slug/QR/gate resolution. User must exist and not be deleted.
|
||||
PublicContent,
|
||||
/// Core jobs / admin inspection. User must exist and not be deleted.
|
||||
/// Existing files only — will not create a database.
|
||||
CoreJob,
|
||||
/// Explicit provisioning after a Core user row was inserted.
|
||||
Provision,
|
||||
}
|
||||
|
||||
/// Resolved tenant filesystem location.
|
||||
///
|
||||
/// `Id` is the frozen v0.8 path. `Legacy` is unmigrated v0.7 `users/<integer>/`
|
||||
/// and exists only until Phase 3 directory migration.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum TenantLocation {
|
||||
Id(TenantId),
|
||||
Legacy(i64),
|
||||
}
|
||||
|
||||
impl TenantLocation {
|
||||
pub fn cache_key(&self) -> String {
|
||||
match self {
|
||||
Self::Id(id) => id.as_str().to_string(),
|
||||
Self::Legacy(row_id) => format!("legacy:{row_id}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dir(&self, topology: &Topology) -> Result<PathBuf, crate::db::topology::TopologyError> {
|
||||
match self {
|
||||
Self::Id(id) => Ok(topology.tenant_dir(*id)),
|
||||
Self::Legacy(row_id) => topology.user_dir_i64(*row_id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn location_for_user(user: &TenantUser) -> Result<TenantLocation, AppError> {
|
||||
if let Some(id) = user.tenant_id {
|
||||
return Ok(TenantLocation::Id(id));
|
||||
}
|
||||
if user.id <= 0 {
|
||||
return Err(AppError::NotFound("invalid user id".into()));
|
||||
}
|
||||
Ok(TenantLocation::Legacy(user.id))
|
||||
}
|
||||
|
||||
pub fn status_allows_open(status: &str, mode: TenantOpenMode) -> bool {
|
||||
match mode {
|
||||
TenantOpenMode::Ordinary => status == "active",
|
||||
TenantOpenMode::PublicContent | TenantOpenMode::CoreJob | TenantOpenMode::Provision => {
|
||||
status != "deleted"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn assert_may_open(user: &TenantUser, mode: TenantOpenMode) -> Result<(), AppError> {
|
||||
if !status_allows_open(&user.status, mode) {
|
||||
return Err(AppError::Unauthorized(format!(
|
||||
"tenant access denied for status '{}'",
|
||||
user.status
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open tenant DBs for a registered Core user (legacy row id compatibility).
|
||||
pub fn open_for_row_id(
|
||||
users_conn: &Connection,
|
||||
topology: &Topology,
|
||||
system_db: &Arc<Mutex<Connection>>,
|
||||
pool: &mut std::collections::HashMap<String, UserDbs>,
|
||||
user_id: i64,
|
||||
mode: TenantOpenMode,
|
||||
) -> Result<UserDbs, AppError> {
|
||||
let user = crate::db::users::get_user_by_id(users_conn, user_id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("user {user_id} not found")))?;
|
||||
assert_may_open(&user, mode)?;
|
||||
let location = location_for_user(&user)?;
|
||||
open_location(topology, system_db, pool, &location, mode)
|
||||
}
|
||||
|
||||
/// Open tenant DBs by frozen TenantId. Unknown ids never create files.
|
||||
pub fn open_for_tenant_id(
|
||||
users_conn: &Connection,
|
||||
topology: &Topology,
|
||||
system_db: &Arc<Mutex<Connection>>,
|
||||
pool: &mut std::collections::HashMap<String, UserDbs>,
|
||||
tenant_id: TenantId,
|
||||
mode: TenantOpenMode,
|
||||
) -> Result<UserDbs, AppError> {
|
||||
let user = crate::db::users::get_user_by_tenant_id(users_conn, tenant_id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("tenant {tenant_id} not found")))?;
|
||||
assert_may_open(&user, mode)?;
|
||||
let location = location_for_user(&user)?;
|
||||
open_location(topology, system_db, pool, &location, mode)
|
||||
}
|
||||
|
||||
pub fn open_location(
|
||||
topology: &Topology,
|
||||
system_db: &Arc<Mutex<Connection>>,
|
||||
pool: &mut std::collections::HashMap<String, UserDbs>,
|
||||
location: &TenantLocation,
|
||||
mode: TenantOpenMode,
|
||||
) -> Result<UserDbs, AppError> {
|
||||
let key = location.cache_key();
|
||||
if let Some(dbs) = pool.get(&key) {
|
||||
return Ok(dbs.clone());
|
||||
}
|
||||
|
||||
let user_dir = location
|
||||
.dir(topology)
|
||||
.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
let content_path = user_dir.join("content.db");
|
||||
let analytics_path = user_dir.join("analytics.db");
|
||||
let profile_path = user_dir.join("profile.db");
|
||||
|
||||
let create = matches!(
|
||||
mode,
|
||||
TenantOpenMode::Provision | TenantOpenMode::Ordinary | TenantOpenMode::PublicContent
|
||||
);
|
||||
if !create && !content_path.exists() {
|
||||
return Err(AppError::NotFound(format!(
|
||||
"tenant database missing at {}",
|
||||
content_path.display()
|
||||
)));
|
||||
}
|
||||
if create {
|
||||
std::fs::create_dir_all(user_dir.join("extensions"))?;
|
||||
}
|
||||
|
||||
let dbs = open_files(
|
||||
&content_path,
|
||||
&analytics_path,
|
||||
&profile_path,
|
||||
system_db,
|
||||
create,
|
||||
)?;
|
||||
pool.insert(key, dbs.clone());
|
||||
Ok(dbs)
|
||||
}
|
||||
|
||||
fn open_files(
|
||||
content_path: &Path,
|
||||
analytics_path: &Path,
|
||||
profile_path: &Path,
|
||||
system_db: &Arc<Mutex<Connection>>,
|
||||
create: bool,
|
||||
) -> Result<UserDbs, AppError> {
|
||||
if !create && !content_path.exists() {
|
||||
return Err(AppError::NotFound("content.db missing".into()));
|
||||
}
|
||||
|
||||
let mut content_conn = Connection::open(content_path)?;
|
||||
let mut analytics_conn = Connection::open(analytics_path)?;
|
||||
let profile_conn = Connection::open(profile_path)?;
|
||||
|
||||
crate::db::sqlite::enable_wal(&content_conn, "content")?;
|
||||
crate::db::sqlite::enable_wal(&analytics_conn, "analytics")?;
|
||||
crate::db::sqlite::enable_wal(&profile_conn, "profile")?;
|
||||
crate::db::sqlite::enable_foreign_keys(&content_conn, "content")?;
|
||||
crate::db::sqlite::enable_foreign_keys(&analytics_conn, "analytics")?;
|
||||
crate::db::sqlite::enable_foreign_keys(&profile_conn, "profile")?;
|
||||
|
||||
crate::db::migrations::run_migrations(
|
||||
&mut content_conn,
|
||||
"content",
|
||||
crate::db::migrations::CONTENT_MIGRATIONS,
|
||||
Some(system_db),
|
||||
)
|
||||
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||
crate::db::migrations::run_migrations(
|
||||
&mut analytics_conn,
|
||||
"analytics",
|
||||
crate::db::migrations::ANALYTICS_MIGRATIONS,
|
||||
Some(system_db),
|
||||
)
|
||||
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||
|
||||
profile_conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);",
|
||||
)?;
|
||||
|
||||
Ok(UserDbs {
|
||||
content: Arc::new(Mutex::new(content_conn)),
|
||||
analytics: Arc::new(Mutex::new(analytics_conn)),
|
||||
profile: Arc::new(Mutex::new(profile_conn)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Job/helper path: registered user, existing content.db only, never create.
|
||||
pub fn existing_content_path(
|
||||
users_conn: &Connection,
|
||||
topology: &Topology,
|
||||
user_id: i64,
|
||||
) -> Result<PathBuf, rusqlite::Error> {
|
||||
let user = crate::db::users::get_user_by_id(users_conn, user_id)?.ok_or_else(|| {
|
||||
rusqlite::Error::InvalidPath(PathBuf::from(format!("unknown-user-{user_id}")))
|
||||
})?;
|
||||
if user.status == "deleted" {
|
||||
return Err(rusqlite::Error::InvalidPath(PathBuf::from("deleted-user")));
|
||||
}
|
||||
let loc = location_for_user(&user)
|
||||
.map_err(|e| rusqlite::Error::InvalidPath(PathBuf::from(e.to_string())))?;
|
||||
let dir = loc
|
||||
.dir(topology)
|
||||
.map_err(|e| rusqlite::Error::InvalidPath(PathBuf::from(e.to_string())))?;
|
||||
let path = dir.join("content.db");
|
||||
if !path.exists() {
|
||||
return Err(rusqlite::Error::InvalidPath(path));
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn existing_analytics_path(
|
||||
users_conn: &Connection,
|
||||
topology: &Topology,
|
||||
user_id: i64,
|
||||
) -> Result<PathBuf, rusqlite::Error> {
|
||||
let user = crate::db::users::get_user_by_id(users_conn, user_id)?.ok_or_else(|| {
|
||||
rusqlite::Error::InvalidPath(PathBuf::from(format!("unknown-user-{user_id}")))
|
||||
})?;
|
||||
if user.status == "deleted" {
|
||||
return Err(rusqlite::Error::InvalidPath(PathBuf::from("deleted-user")));
|
||||
}
|
||||
let loc = location_for_user(&user)
|
||||
.map_err(|e| rusqlite::Error::InvalidPath(PathBuf::from(e.to_string())))?;
|
||||
let dir = loc
|
||||
.dir(topology)
|
||||
.map_err(|e| rusqlite::Error::InvalidPath(PathBuf::from(e.to_string())))?;
|
||||
let path = dir.join("analytics.db");
|
||||
if !path.exists() {
|
||||
return Err(rusqlite::Error::InvalidPath(path));
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
//! Frozen v0.8.0 database topology under a configurable physical root.
|
||||
//!
|
||||
//! The logical layout is:
|
||||
//!
|
||||
//! ```text
|
||||
//! <data_dir>/
|
||||
//! ├── admin/{admin,system,users}.db
|
||||
//! ├── slugs/{global_urls,global_landing_pages,reserved}.db
|
||||
//! └── users/<user-key>/{profile,content,analytics}.db
|
||||
//! └── extensions/<extension>/<extension>.db
|
||||
//! ```
|
||||
//!
|
||||
//! `<data_dir>` is `Config.data_dir` (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
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user