diff --git a/Cargo.lock b/Cargo.lock index 69587e0..91043a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -345,7 +345,7 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "bzod" -version = "0.5.1" +version = "0.5.2" dependencies = [ "argon2", "askama", diff --git a/src/cli/doctor.rs b/src/cli/doctor.rs index ee8841d..9556ed9 100644 --- a/src/cli/doctor.rs +++ b/src/cli/doctor.rs @@ -101,32 +101,69 @@ pub async fn run( Connection::open(&users_db_path), ) { (Ok(sys_conn), Ok(usr_conn)) => { - match crate::db::users::verify_global_slug_registry_integrity( + match crate::services::registry_validator::RegistryValidator::scan( &sys_conn, &usr_conn, &config.data_dir, + None, ) { - Ok((errors, warnings)) => { - if errors.is_empty() && warnings.is_empty() { + Ok(issues) => { + if issues.is_empty() { println!(" Status: HEALTHY (no issues found)"); } else { - if !errors.is_empty() { - println!(" Errors (Action Required):"); - for err in &errors { - println!(" - {}", err); - } - all_healthy = false; - } - if !warnings.is_empty() { - println!(" Warnings (Attention Needed):"); - for warn in &warnings { - println!(" - {}", warn); + println!(" Status: ISSUES DETECTED"); + all_healthy = false; + + for issue in &issues { + println!(); + println!("ERROR"); + println!(); + println!("Slug:"); + println!(" {}", issue.slug); + println!(); + println!("Type:"); + println!( + " {}", + if issue.target_type == "url" { + "URL" + } else if issue.target_type == "page" { + "Landing Page" + } else { + &issue.target_type + } + ); + println!(); + println!("Owner:"); + println!(" User ID {}", issue.owner_user_id); + println!(); + println!("Database:"); + println!(" {}", issue.database_path.display()); + println!(); + println!("Target UUID:"); + println!(" {}", issue.target_id); + println!(); + println!("Issue:"); + println!(" {:?}", issue.issue_type); + println!(); + println!("Description:"); + println!(" {}", issue.description); + println!(); + println!("Suggested Repair:"); + println!(); + if issue.slug != "*" { + println!( + " bzod repair registry --slug {} --dry-run", + issue.slug + ); + } else { + println!(" bzod repair registry --dry-run"); } + println!("--------------------"); } } } Err(e) => { - println!(" Status: ERROR running integrity check: {}", e); + println!(" Status: ERROR running registry scan: {}", e); all_healthy = false; } } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index ad2d422..c251f18 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,26 +1,26 @@ use clap::{Parser, Subcommand}; +pub mod admin_migrate; pub mod backup; +pub mod backup_user; pub mod create_admin; +pub mod create_user; +pub mod delete_user; +pub mod disable_user; pub mod doctor; +pub mod enable_user; pub mod expand; +pub mod list_users; pub mod migrate; +pub mod repair; +pub mod reset_password; pub mod restore; +pub mod restore_user; pub mod serve; pub mod shorten; pub mod stats; pub mod validate; -pub mod admin_migrate; -pub mod backup_user; -pub mod create_user; -pub mod delete_user; -pub mod disable_user; -pub mod enable_user; -pub mod list_users; -pub mod reset_password; -pub mod restore_user; - #[derive(Parser)] #[command(name = "bzod")] #[command(about = "BZOD - Personal Redirector & Landing Page Platform")] @@ -179,4 +179,24 @@ pub enum Commands { #[arg(long)] force: bool, }, + /// Repair registry and database inconsistencies + Repair { + #[command(subcommand)] + command: RepairCommands, + }, +} + +#[derive(clap::Subcommand)] +pub enum RepairCommands { + /// Repair Global Slug Registry inconsistencies + Registry { + #[arg(long)] + dry_run: bool, + #[arg(long)] + force: bool, + #[arg(long)] + slug: Option, + #[arg(long)] + data_dir: Option, + }, } diff --git a/src/cli/repair.rs b/src/cli/repair.rs new file mode 100644 index 0000000..7c8721f --- /dev/null +++ b/src/cli/repair.rs @@ -0,0 +1,178 @@ +use crate::cli::RepairCommands; +use crate::config::Config; +use crate::services::registry_validator::{RegistryIssueType, RegistryValidator}; +use rusqlite::Connection; +use std::path::PathBuf; +use tracing::info; + +pub async fn run( + command: RepairCommands, + mut config: Config, +) -> Result<(), Box> { + match command { + RepairCommands::Registry { + dry_run, + force, + slug, + data_dir, + } => { + if let Some(d) = data_dir { + config.data_dir = PathBuf::from(d); + } + + if !dry_run && !force { + println!("Error: You must specify either --dry-run or --force"); + return Ok(()); + } + if dry_run && force { + println!("Error: Cannot specify both --dry-run and --force"); + return Ok(()); + } + + let start_time = std::time::Instant::now(); + let admin_dir = config.data_dir.join("admin"); + let system_db_path = admin_dir.join("system.db"); + let users_db_path = admin_dir.join("users.db"); + + if !system_db_path.exists() || !users_db_path.exists() { + println!("Error: system.db or users.db not found."); + return Ok(()); + } + + let mut sys_conn = Connection::open(&system_db_path)?; + let usr_conn = Connection::open(&users_db_path)?; + + let slug_filter = slug.as_deref(); + + if dry_run { + println!("BZOD Registry Repair\n"); + println!("Scanning Global Slug Registry..."); + + let issues = + RegistryValidator::scan(&sys_conn, &usr_conn, &config.data_dir, slug_filter)?; + let orphaned = issues + .into_iter() + .filter(|i| { + matches!( + i.issue_type, + RegistryIssueType::MissingTarget + | RegistryIssueType::MissingDatabase + | RegistryIssueType::MissingOwner + ) + }) + .collect::>(); + + let orphaned_pages = orphaned.iter().filter(|i| i.target_type == "page").count(); + let orphaned_urls = orphaned.iter().filter(|i| i.target_type == "url").count(); + + println!("\nDetected:"); + println!("\nPages:\n {} orphaned", orphaned_pages); + println!("\nURLs:\n {} orphaned", orphaned_urls); + + if !orphaned.is_empty() { + println!("\nThe following entries would be removed:"); + for issue in &orphaned { + println!("\n{}\n {}", issue.target_type.to_uppercase(), issue.slug); + } + } + + println!("\nNo changes have been made."); + println!( + "\nRun again with:\n\n bzod repair registry --force{}", + if let Some(s) = slug_filter { + format!(" --slug {}", s) + } else { + "".to_string() + } + ); + + info!( + "Registry Repair Started. Scanned. Orphaned Pages: {}, Orphaned URLs: {}. Duration: {:?}", + orphaned_pages, orphaned_urls, start_time.elapsed() + ); + } else if force { + let tx = sys_conn.transaction()?; + + let issues = + RegistryValidator::scan(&tx, &usr_conn, &config.data_dir, slug_filter)?; + let orphaned = issues + .into_iter() + .filter(|i| { + matches!( + i.issue_type, + RegistryIssueType::MissingTarget + | RegistryIssueType::MissingDatabase + | RegistryIssueType::MissingOwner + ) + }) + .collect::>(); + + let orphaned_pages = orphaned.iter().filter(|i| i.target_type == "page").count(); + let orphaned_urls = orphaned.iter().filter(|i| i.target_type == "url").count(); + + if orphaned.is_empty() { + println!("No repairs required."); + return Ok(()); + } + + if let Some(s) = slug_filter { + println!("Checking slug:\n\n{}\n", s); + if let Some(issue) = orphaned.first() { + println!("Owner:\n\n{}\n", issue.owner_user_id); + println!("Status:\n\nOrphaned\n"); + } + } + + let mut removed_count = 0; + for issue in &orphaned { + let rows = tx.execute( + "DELETE FROM global_slugs WHERE slug = ?1", + rusqlite::params![issue.slug], + )?; + removed_count += rows; + } + + tx.commit()?; + + if slug_filter.is_some() { + println!("Removed:\n\nSUCCESS"); + } else { + println!("Repair Complete\n"); + println!("Removed:\n"); + println!("Pages:\n {}\n", orphaned_pages); + println!("URLs:\n {}\n", orphaned_urls); + + let remaining: i64 = + sys_conn + .query_row("SELECT COUNT(*) FROM global_slugs;", [], |r| r.get(0))?; + println!("Remaining Registry Entries:\n {}\n", remaining); + + let post_issues = + RegistryValidator::scan(&sys_conn, &usr_conn, &config.data_dir, None)?; + let post_orphaned = post_issues + .iter() + .filter(|i| { + matches!( + i.issue_type, + RegistryIssueType::MissingTarget + | RegistryIssueType::MissingDatabase + | RegistryIssueType::MissingOwner + ) + }) + .count(); + + println!( + "Integrity:\n {}", + if post_orphaned == 0 { "PASS" } else { "FAIL" } + ); + } + + info!( + "Registry Repair Started. Scanned. Orphaned Pages: {}, Orphaned URLs: {}. Removed: {}. Duration: {:?}", + orphaned_pages, orphaned_urls, removed_count, start_time.elapsed() + ); + } + } + } + Ok(()) +} diff --git a/src/cli/restore.rs b/src/cli/restore.rs index fccce5d..ecacc9d 100644 --- a/src/cli/restore.rs +++ b/src/cli/restore.rs @@ -60,15 +60,20 @@ pub fn perform_restore( if system_db_path.exists() && users_db_path.exists() { let system_conn = rusqlite::Connection::open(&system_db_path)?; let users_conn = rusqlite::Connection::open(&users_db_path)?; - match crate::db::users::verify_global_slug_registry_integrity( + match crate::services::registry_validator::RegistryValidator::scan( &system_conn, &users_conn, &temp_dir, + None, ) { - Ok((errors, _warnings)) => { - if !errors.is_empty() { + Ok(issues) => { + if !issues.is_empty() { let _ = std::fs::remove_dir_all(&temp_dir); - return Err(format!("Registry integrity errors in backup: {:?}", errors).into()); + return Err(format!( + "Registry integrity errors in backup: {} issues detected", + issues.len() + ) + .into()); } } Err(e) => { diff --git a/src/db/mod.rs b/src/db/mod.rs index e07449c..6b4f3d6 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -356,17 +356,19 @@ impl Db { { let system_conn = db.system.lock().unwrap(); let users_conn = db.users.lock().unwrap(); - match crate::db::users::verify_global_slug_registry_integrity( + match crate::services::registry_validator::RegistryValidator::scan( &system_conn, &users_conn, &config.data_dir, + None, ) { - Ok((errors, warnings)) => { - for err in errors { - tracing::error!("Global registry integrity error: {}", err); - } - for warn in warnings { - tracing::warn!("Global registry integrity warning: {}", warn); + Ok(issues) => { + for issue in issues { + tracing::error!( + "Global registry integrity issue: {:?} for slug {}", + issue.issue_type, + issue.slug + ); } } Err(e) => { diff --git a/src/db/users.rs b/src/db/users.rs index cec9991..bfb3c2e 100644 --- a/src/db/users.rs +++ b/src/db/users.rs @@ -722,216 +722,6 @@ pub fn cleanup_stale_reservations( Ok(cleaned_count) } -pub fn verify_global_slug_registry_integrity( - system_conn: &Connection, - users_conn: &Connection, - data_dir: &std::path::Path, -) -> Result<(Vec, Vec), Box> { - use chrono::{DateTime, Utc}; - let mut errors = Vec::new(); - let mut warnings = Vec::new(); - - // 1. Check duplicate slugs - let total_count: i64 = - system_conn.query_row("SELECT COUNT(*) FROM global_slugs;", [], |r| r.get(0))?; - let distinct_count: i64 = - system_conn.query_row("SELECT COUNT(DISTINCT slug) FROM global_slugs;", [], |r| { - r.get(0) - })?; - if total_count != distinct_count { - errors.push(format!( - "Duplicate slugs found in global_slugs table (total rows: {}, distinct slugs: {})", - total_count, distinct_count - )); - } - - // 2. Scan all global slugs - let mut stmt = system_conn.prepare( - "SELECT slug, owner_user_id, target_type, target_id, created_at, status FROM global_slugs;", - )?; - let mut rows = stmt.query([])?; - - while let Some(row) = rows.next()? { - let slug: String = row.get(0)?; - let owner_user_id: i64 = row.get(1)?; - let target_type: String = row.get(2)?; - let target_id: String = row.get(3)?; - let created_at_str: String = row.get(4)?; - let status: String = row.get(5)?; - - // Target type check - if target_type != "url" && target_type != "page" { - errors.push(format!( - "Slug '{}' has invalid target_type '{}'", - slug, target_type - )); - } - - // Status check - if status != "active" && status != "disabled" && status != "reserving" { - errors.push(format!("Slug '{}' has invalid status '{}'", slug, status)); - } - - // Check owner - let owner_exists: bool = users_conn - .query_row( - "SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);", - [owner_user_id], - |r| r.get(0), - ) - .unwrap_or(false); - - if !owner_exists { - errors.push(format!( - "Slug '{}' references missing owner user ID {}", - slug, owner_user_id - )); - continue; - } - - // Stale warning check - if status == "reserving" { - if let Ok(created_at) = DateTime::parse_from_rfc3339(&created_at_str) { - let age = Utc::now().signed_duration_since(created_at.with_timezone(&Utc)); - if age > chrono::Duration::minutes(15) { - warnings.push(format!( - "Reserving slug '{}' has been stale for over 15 minutes", - slug - )); - } - } - } - - // Check target record exists for active / disabled (and reserving with target_id) - if status == "active" - || status == "disabled" - || (status == "reserving" && !target_id.is_empty()) - { - let content_db_path = if owner_user_id == 1 { - data_dir.join("users").join("1").join("content.db") - } else { - data_dir - .join("users") - .join(owner_user_id.to_string()) - .join("content.db") - }; - - if !content_db_path.exists() { - errors.push(format!( - "Slug '{}' owner content database does not exist at {:?}", - slug, content_db_path - )); - } else { - match Connection::open(&content_db_path) { - Ok(conn) => { - let exists = if target_type == "url" { - conn.query_row( - "SELECT EXISTS(SELECT 1 FROM urls WHERE id = ?1);", - [&target_id], - |r| r.get(0), - ) - .unwrap_or(false) - } else if target_type == "page" { - conn.query_row( - "SELECT EXISTS(SELECT 1 FROM landing_pages WHERE id = ?1);", - [&target_id], - |r| r.get(0), - ) - .unwrap_or(false) - } else { - false - }; - - if !exists { - errors.push(format!("Slug '{}' (type: '{}', id: '{}') references missing target record in owner's content database", slug, target_type, target_id)); - } - } - Err(e) => { - errors.push(format!( - "Slug '{}' owner content database could not be opened: {}", - slug, e - )); - } - } - } - } - } - - // 3. Admin Content Reverse Consistency Check (Legacy DB) - let admin_content_db_path = data_dir.join("users").join("1").join("content.db"); - - if admin_content_db_path.exists() { - if let Ok(admin_content_conn) = Connection::open(&admin_content_db_path) { - // Check URLs - if let Ok(mut stmt) = admin_content_conn.prepare("SELECT code, id FROM urls;") { - if let Ok(mut rows) = stmt.query([]) { - while let Ok(Some(row)) = rows.next() { - let code: String = row.get(0).unwrap_or_default(); - let id: String = row.get(1).unwrap_or_default(); - let exists: bool = system_conn.query_row( - "SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = ?1 AND owner_user_id = 1 AND target_id = ?2);", - rusqlite::params![code, id], - |r| r.get(0) - ).unwrap_or(false); - if !exists { - warnings.push(format!("Orphaned admin URL detected in legacy content DB: code='{}', id='{}' is missing from global_slugs", code, id)); - } - } - } - } - // Check Landing Pages - if let Ok(mut stmt) = admin_content_conn.prepare("SELECT code, id FROM landing_pages;") - { - if let Ok(mut rows) = stmt.query([]) { - while let Ok(Some(row)) = rows.next() { - let code: String = row.get(0).unwrap_or_default(); - let id: String = row.get(1).unwrap_or_default(); - let exists: bool = system_conn.query_row( - "SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = ?1 AND owner_user_id = 1 AND target_id = ?2);", - rusqlite::params![code, id], - |r| r.get(0) - ).unwrap_or(false); - if !exists { - warnings.push(format!("Orphaned admin Landing Page detected in legacy content DB: code='{}', id='{}' is missing from global_slugs", code, id)); - } - } - } - } - } - } - - // 4. Check for admin content in non-legacy tenant DBs - if let Ok(mut stmt) = users_conn - .prepare("SELECT id, username FROM users WHERE account_type = 'admin' AND id != 1;") - { - if let Ok(mut rows) = stmt.query([]) { - while let Ok(Some(row)) = rows.next() { - let id: i64 = row.get(0).unwrap_or(0); - let username: String = row.get(1).unwrap_or_default(); - let tenant_db_path = data_dir - .join("users") - .join(id.to_string()) - .join("content.db"); - if tenant_db_path.exists() { - if let Ok(conn) = Connection::open(&tenant_db_path) { - let url_count: i64 = conn - .query_row("SELECT COUNT(*) FROM urls;", [], |r| r.get(0)) - .unwrap_or(0); - let page_count: i64 = conn - .query_row("SELECT COUNT(*) FROM landing_pages;", [], |r| r.get(0)) - .unwrap_or(0); - if url_count > 0 || page_count > 0 { - warnings.push(format!("Admin user '{}' (ID {}) has content in isolated tenant DB ({} URLs, {} pages). Admin content should be in legacy DB 1.", username, id, url_count, page_count)); - } - } - } - } - } - } - - Ok((errors, warnings)) -} - pub fn register_restored_user_slugs( system_conn: &Connection, target_user_id: i64, diff --git a/src/main.rs b/src/main.rs index 3877117..e77542a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -103,6 +103,9 @@ async fn main() -> Result<(), Box> { bzod::cli::admin_migrate::run(target_admin_id, data_dir, dry_run, force, config) .await?; } + Commands::Repair { command } => { + bzod::cli::repair::run(command, config).await?; + } } Ok(()) diff --git a/src/services/mod.rs b/src/services/mod.rs index e4c3ffa..393ce95 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -3,4 +3,5 @@ pub mod audit; pub mod bulk; pub mod landing_pages; pub mod qr; +pub mod registry_validator; pub mod shortener; diff --git a/src/services/registry_validator.rs b/src/services/registry_validator.rs new file mode 100644 index 0000000..35c0d74 --- /dev/null +++ b/src/services/registry_validator.rs @@ -0,0 +1,285 @@ +use rusqlite::Connection; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, PartialEq)] +pub enum RegistryIssueType { + DuplicateSlug, + InvalidTargetType, + InvalidStatus, + MissingOwner, + MissingDatabase, + MissingTarget, + StaleReservation, + TenantAdminHasIsolatedContent, +} + +#[derive(Debug, Clone)] +pub struct RegistryIssue { + pub slug: String, + pub target_type: String, + pub owner_user_id: i64, + pub database_path: PathBuf, + pub target_id: String, + pub issue_type: RegistryIssueType, + pub description: String, +} + +pub struct RegistryValidator; + +impl RegistryValidator { + /// Scans the global_slugs registry and returns a list of detected issues. + pub fn scan( + system_conn: &Connection, + users_conn: &Connection, + data_dir: &Path, + slug_filter: Option<&str>, + ) -> Result, Box> { + use chrono::{DateTime, Utc}; + let mut issues = Vec::new(); + + // 1. Check duplicate slugs (only if not filtering by single slug) + if slug_filter.is_none() { + let total_count: i64 = + system_conn.query_row("SELECT COUNT(*) FROM global_slugs;", [], |r| r.get(0))?; + let distinct_count: i64 = system_conn.query_row( + "SELECT COUNT(DISTINCT slug) FROM global_slugs;", + [], + |r| r.get(0), + )?; + if total_count != distinct_count { + issues.push(RegistryIssue { + slug: "*".to_string(), + target_type: "system".to_string(), + owner_user_id: 0, + database_path: data_dir.join("admin/system.db"), + target_id: "".to_string(), + issue_type: RegistryIssueType::DuplicateSlug, + description: format!( + "Duplicate slugs found in global_slugs table (total rows: {}, distinct slugs: {})", + total_count, distinct_count + ), + }); + } + } + + // 2. Scan global slugs + let (query, params_string) = if let Some(slug) = slug_filter { + ( + "SELECT slug, owner_user_id, target_type, target_id, created_at, status FROM global_slugs WHERE slug = ?1;", + vec![slug.to_string()], + ) + } else { + ( + "SELECT slug, owner_user_id, target_type, target_id, created_at, status FROM global_slugs;", + vec![], + ) + }; + + let mut stmt = system_conn.prepare(query)?; + let mut rows = stmt.query(rusqlite::params_from_iter(params_string))?; + + while let Some(row) = rows.next()? { + let slug: String = row.get(0)?; + let owner_user_id: i64 = row.get(1)?; + let target_type: String = row.get(2)?; + let target_id: String = row.get(3)?; + let created_at_str: String = row.get(4)?; + let status: String = row.get(5)?; + + let content_db_path = if owner_user_id == 1 { + data_dir.join("users").join("1").join("content.db") + } else { + data_dir + .join("users") + .join(owner_user_id.to_string()) + .join("content.db") + }; + + // Target type check + if target_type != "url" && target_type != "page" { + issues.push(RegistryIssue { + slug: slug.clone(), + target_type: target_type.clone(), + owner_user_id, + database_path: content_db_path.clone(), + target_id: target_id.clone(), + issue_type: RegistryIssueType::InvalidTargetType, + description: format!( + "Slug '{}' has invalid target_type '{}'", + slug, target_type + ), + }); + } + + // Status check + if status != "active" && status != "disabled" && status != "reserving" { + issues.push(RegistryIssue { + slug: slug.clone(), + target_type: target_type.clone(), + owner_user_id, + database_path: content_db_path.clone(), + target_id: target_id.clone(), + issue_type: RegistryIssueType::InvalidStatus, + description: format!("Slug '{}' has invalid status '{}'", slug, status), + }); + } + + // Check owner + let owner_exists: bool = users_conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);", + [owner_user_id], + |r| r.get(0), + ) + .unwrap_or(false); + + if !owner_exists { + issues.push(RegistryIssue { + slug: slug.clone(), + target_type: target_type.clone(), + owner_user_id, + database_path: content_db_path.clone(), + target_id: target_id.clone(), + issue_type: RegistryIssueType::MissingOwner, + description: format!( + "Slug '{}' references missing owner user ID {}", + slug, owner_user_id + ), + }); + continue; + } + + // Stale warning check + if status == "reserving" { + if let Ok(created_at) = DateTime::parse_from_rfc3339(&created_at_str) { + let age = Utc::now().signed_duration_since(created_at.with_timezone(&Utc)); + if age > chrono::Duration::try_minutes(15).unwrap_or_default() { + issues.push(RegistryIssue { + slug: slug.clone(), + target_type: target_type.clone(), + owner_user_id, + database_path: content_db_path.clone(), + target_id: target_id.clone(), + issue_type: RegistryIssueType::StaleReservation, + description: format!( + "Reserving slug '{}' has been stale for over 15 minutes", + slug + ), + }); + } + } + } + + // Check target record exists for active / disabled (and reserving with target_id) + if status == "active" + || status == "disabled" + || (status == "reserving" && !target_id.is_empty()) + { + if !content_db_path.exists() { + issues.push(RegistryIssue { + slug: slug.clone(), + target_type: target_type.clone(), + owner_user_id, + database_path: content_db_path.clone(), + target_id: target_id.clone(), + issue_type: RegistryIssueType::MissingDatabase, + description: format!( + "Slug '{}' owner content database does not exist at {:?}", + slug, content_db_path + ), + }); + } else { + match Connection::open(&content_db_path) { + Ok(conn) => { + let exists = if target_type == "url" { + conn.query_row( + "SELECT EXISTS(SELECT 1 FROM urls WHERE id = ?1);", + [&target_id], + |r| r.get(0), + ) + .unwrap_or(false) + } else if target_type == "page" { + conn.query_row( + "SELECT EXISTS(SELECT 1 FROM landing_pages WHERE id = ?1);", + [&target_id], + |r| r.get(0), + ) + .unwrap_or(false) + } else { + false + }; + + if !exists { + issues.push(RegistryIssue { + slug: slug.clone(), + target_type: target_type.clone(), + owner_user_id, + database_path: content_db_path.clone(), + target_id: target_id.clone(), + issue_type: RegistryIssueType::MissingTarget, + description: format!("Slug '{}' (type: '{}', id: '{}') references missing target record in owner's content database", slug, target_type, target_id), + }); + } + } + Err(e) => { + issues.push(RegistryIssue { + slug: slug.clone(), + target_type: target_type.clone(), + owner_user_id, + database_path: content_db_path.clone(), + target_id: target_id.clone(), + issue_type: RegistryIssueType::MissingDatabase, + description: format!( + "Slug '{}' owner content database could not be opened: {}", + slug, e + ), + }); + } + } + } + } + } + + // 3. Admin Content Reverse Consistency Check (Legacy DB) + // Check if tenant databases contain content for admin users incorrectly (isolated admin content) + if slug_filter.is_none() { + let mut stmt = users_conn.prepare( + "SELECT id, username FROM users WHERE account_type = 'admin' AND id != 1;", + )?; + let mut admin_rows = stmt.query([])?; + while let Some(row) = admin_rows.next()? { + let id: i64 = row.get(0)?; + let username: String = row.get(1)?; + let tenant_db_path = data_dir + .join("users") + .join(id.to_string()) + .join("content.db"); + + if tenant_db_path.exists() { + if let Ok(tenant_conn) = Connection::open(&tenant_db_path) { + let url_count: i64 = tenant_conn + .query_row("SELECT COUNT(*) FROM urls;", [], |r| r.get(0)) + .unwrap_or(0); + let page_count: i64 = tenant_conn + .query_row("SELECT COUNT(*) FROM landing_pages;", [], |r| r.get(0)) + .unwrap_or(0); + + if url_count > 0 || page_count > 0 { + issues.push(RegistryIssue { + slug: "*".to_string(), + target_type: "system".to_string(), + owner_user_id: id, + database_path: tenant_db_path.clone(), + target_id: "".to_string(), + issue_type: RegistryIssueType::TenantAdminHasIsolatedContent, + description: format!("Admin user '{}' (ID {}) has content in isolated tenant DB ({} URLs, {} pages). Admin content should be in legacy DB 1.", username, id, url_count, page_count), + }); + } + } + } + } + } + + Ok(issues) + } +} diff --git a/src/web/admin.rs b/src/web/admin.rs index effd330..ee453af 100644 --- a/src/web/admin.rs +++ b/src/web/admin.rs @@ -5658,17 +5658,37 @@ pub async fn health_get( let (registry_errors, registry_warnings) = { let system_conn = state.system_db.lock().unwrap(); let users_conn = state.users_db.lock().unwrap(); - crate::db::users::verify_global_slug_registry_integrity( + match crate::services::registry_validator::RegistryValidator::scan( &system_conn, &users_conn, &state.config.data_dir, - ) - .unwrap_or_else(|e| { - ( - vec![format!("Failed to run integrity check: {}", e)], - vec![], - ) - }) + None, + ) { + Ok(issues) => { + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + for issue in issues { + use crate::services::registry_validator::RegistryIssueType; + match issue.issue_type { + RegistryIssueType::StaleReservation + | RegistryIssueType::TenantAdminHasIsolatedContent => { + warnings.push(format!( + "Warning for slug {}: {}", + issue.slug, issue.description + )); + } + _ => { + errors.push(format!( + "Error for slug {}: {}", + issue.slug, issue.description + )); + } + } + } + (errors, warnings) + } + Err(e) => (vec![format!("Failed to run registry scan: {}", e)], vec![]), + } }; let template = crate::templates::HealthTemplate { diff --git a/tests/registry_repair_tests.rs b/tests/registry_repair_tests.rs new file mode 100644 index 0000000..7729697 --- /dev/null +++ b/tests/registry_repair_tests.rs @@ -0,0 +1,200 @@ +use bzod::cli::RepairCommands; +use bzod::config::Config; +use bzod::db::Db; +use std::fs; +use std::path::PathBuf; + +fn create_temp_config(temp_dir: PathBuf) -> Config { + let mut config = Config::load(); + config.data_dir = temp_dir.clone(); + config.backup_dir = temp_dir.clone(); + config.base_url = Some("http://bzo.in".to_string()); + config +} + +#[tokio::test] +async fn test_registry_repair_dry_run() { + let temp_dir = + std::env::temp_dir().join(format!("bzod_test_repair_dry_{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&temp_dir).unwrap(); + let config = create_temp_config(temp_dir.clone()); + let db = Db::init(&config).expect("Failed to init Db"); + + { + let system_conn = db.system.lock().unwrap(); + // Insert orphaned slug (owner exists, but no target db/record) + bzod::db::users::register_global_slug( + &system_conn, + "orphan1", + 1, + "url", + "target1", + "active", + ) + .unwrap(); + } + + let command = RepairCommands::Registry { + dry_run: true, + force: false, + slug: None, + data_dir: Some(temp_dir.to_string_lossy().to_string()), + }; + + bzod::cli::repair::run(command, config.clone()) + .await + .unwrap(); + + // Verify it was NOT deleted + { + let system_conn = db.system.lock().unwrap(); + let exists: bool = system_conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = 'orphan1')", + [], + |r| r.get(0), + ) + .unwrap(); + assert!(exists, "Slug should not be deleted in dry run"); + } + + let _ = fs::remove_dir_all(&temp_dir); +} + +#[tokio::test] +async fn test_registry_repair_force() { + let temp_dir = + std::env::temp_dir().join(format!("bzod_test_repair_force_{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&temp_dir).unwrap(); + let config = create_temp_config(temp_dir.clone()); + let db = Db::init(&config).expect("Failed to init Db"); + + { + let system_conn = db.system.lock().unwrap(); + // Insert orphaned slug + bzod::db::users::register_global_slug( + &system_conn, + "orphan2", + 1, + "url", + "target2", + "active", + ) + .unwrap(); + } + + let command = RepairCommands::Registry { + dry_run: false, + force: true, + slug: None, + data_dir: Some(temp_dir.to_string_lossy().to_string()), + }; + + bzod::cli::repair::run(command, config.clone()) + .await + .unwrap(); + + // Verify it WAS deleted + { + let system_conn = db.system.lock().unwrap(); + let exists: bool = system_conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = 'orphan2')", + [], + |r| r.get(0), + ) + .unwrap(); + assert!(!exists, "Slug should be deleted in force mode"); + } + + let _ = fs::remove_dir_all(&temp_dir); +} + +#[tokio::test] +async fn test_registry_repair_single_slug() { + let temp_dir = + std::env::temp_dir().join(format!("bzod_test_repair_single_{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&temp_dir).unwrap(); + let config = create_temp_config(temp_dir.clone()); + let db = Db::init(&config).expect("Failed to init Db"); + + { + let system_conn = db.system.lock().unwrap(); + // Insert two orphaned slugs + bzod::db::users::register_global_slug( + &system_conn, + "orphan3", + 1, + "url", + "target3", + "active", + ) + .unwrap(); + bzod::db::users::register_global_slug( + &system_conn, + "orphan4", + 1, + "url", + "target4", + "active", + ) + .unwrap(); + } + + let command = RepairCommands::Registry { + dry_run: false, + force: true, + slug: Some("orphan3".to_string()), + data_dir: Some(temp_dir.to_string_lossy().to_string()), + }; + + bzod::cli::repair::run(command, config.clone()) + .await + .unwrap(); + + // Verify targeted slug was deleted + { + let system_conn = db.system.lock().unwrap(); + let exists3: bool = system_conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = 'orphan3')", + [], + |r| r.get(0), + ) + .unwrap(); + assert!(!exists3, "Targeted slug should be deleted"); + + let exists4: bool = system_conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = 'orphan4')", + [], + |r| r.get(0), + ) + .unwrap(); + assert!(exists4, "Non-targeted slug should NOT be deleted"); + } + + let _ = fs::remove_dir_all(&temp_dir); +} + +#[tokio::test] +async fn test_registry_repair_transaction_safety() { + let temp_dir = + std::env::temp_dir().join(format!("bzod_test_repair_tx_{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&temp_dir).unwrap(); + let config = create_temp_config(temp_dir.clone()); + let _db = Db::init(&config).expect("Failed to init Db"); + + // Simulate transaction rollback safety indirectly by validating dual flags return immediately + let command = RepairCommands::Registry { + dry_run: true, + force: true, + slug: None, + data_dir: Some(temp_dir.to_string_lossy().to_string()), + }; + + let result = bzod::cli::repair::run(command, config.clone()).await; + assert!(result.is_ok()); + + let _ = fs::remove_dir_all(&temp_dir); +}