diff --git a/src/analytics/worker.rs b/src/analytics/worker.rs index d3cdbc2..b585c05 100644 --- a/src/analytics/worker.rs +++ b/src/analytics/worker.rs @@ -56,26 +56,33 @@ fn flush_batch(db: &Db, batch: &mut Vec) { } info!( - "Flushing {} visits to user analytics databases", + "Flushing {} visits to tenant analytics databases", batch.len() ); - // Group visits by owner_user_id - let mut groups: std::collections::HashMap> = + // Group visits by owner_tenant_id (or legacy user 1 fallback) + let mut groups: std::collections::HashMap> = std::collections::HashMap::new(); + let mut legacy_user1_visits: Vec = Vec::new(); + for record in batch.drain(..) { - let user_id = record.owner_user_id.unwrap_or(1); // fallback to legacy_admin (user 1) - groups.entry(user_id).or_default().push(record); + if let Some(tenant_id) = record.owner_tenant_id { + groups.entry(tenant_id).or_default().push(record); + } else if record.owner_user_id == Some(1) { + legacy_user1_visits.push(record); + } else { + error!("Dropping analytics visit with no owner_tenant_id"); + } } - for (user_id, user_visits) in groups { - let db_path = db - .data_dir - .join("users") - .join(user_id.to_string()) - .join("analytics.db"); - if let Some(parent) = db_path.parent() { - let _ = std::fs::create_dir_all(parent); + for (tenant_id, tenant_visits) in groups { + let db_path = db.topology.tenant_analytics_db(tenant_id); + if !db_path.exists() { + error!( + "Refusing to write analytics for non-existent tenant analytics path: {:?}", + db_path + ); + continue; } match rusqlite::Connection::open(&db_path) { @@ -83,19 +90,31 @@ fn flush_batch(db: &Db, batch: &mut Vec) { let _ = crate::db::sqlite::enable_wal(&conn, "analytics"); let _ = crate::db::sqlite::enable_foreign_keys(&conn, "analytics"); - if let Err(e) = insert_visits_batch(&mut conn, &user_visits) { + if let Err(e) = insert_visits_batch(&mut conn, &tenant_visits) { error!( - "Failed to write analytics batch to user {} database: {:?}", - user_id, e + "Failed to write analytics batch to tenant {} database: {:?}", + tenant_id, e ); } } Err(e) => { error!( - "Failed to open analytics database for user {}: {:?}", - user_id, e + "Failed to open analytics database for tenant {}: {:?}", + tenant_id, e ); } } } + + if !legacy_user1_visits.is_empty() { + if let Ok(legacy_db_path) = db.topology.analytics_db("1") { + if legacy_db_path.exists() { + if let Ok(mut conn) = rusqlite::Connection::open(&legacy_db_path) { + let _ = crate::db::sqlite::enable_wal(&conn, "analytics"); + let _ = crate::db::sqlite::enable_foreign_keys(&conn, "analytics"); + let _ = insert_visits_batch(&mut conn, &legacy_user1_visits); + } + } + } + } } diff --git a/src/auth/session.rs b/src/auth/session.rs index 4339de2..bbc958f 100644 --- a/src/auth/session.rs +++ b/src/auth/session.rs @@ -199,25 +199,8 @@ pub fn authenticate_user_session( } // Get tenant user (status must be 'active') - let mut stmt = users_conn.prepare( - "SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata - FROM users WHERE id = ?1 AND status = 'active';" - )?; - let user_opt = stmt - .query_row([session.user_id], |row| { - Ok(TenantUser { - id: row.get(0)?, - username: row.get(1)?, - password_hash: row.get(2)?, - status: row.get(3)?, - created_at: row.get(4)?, - last_login: row.get(5)?, - account_type: row.get(6)?, - organization_id: row.get(7)?, - metadata: row.get(8)?, - }) - }) - .optional()?; + let user_opt = crate::db::users::get_user_by_id(users_conn, session.user_id)? + .filter(|u| u.status == "active"); if let Some(user) = user_opt { Ok(Some((user, session.id))) @@ -251,25 +234,8 @@ pub fn authenticate_api_key( let user_id_opt: Option = stmt.query_row([&hashed_key], |row| row.get(0)).optional()?; if let Some(user_id) = user_id_opt { - let mut stmt = users_conn.prepare( - "SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata - FROM users WHERE id = ?1 AND status = 'active';" - )?; - let user_opt = stmt - .query_row([user_id], |row| { - Ok(TenantUser { - id: row.get(0)?, - username: row.get(1)?, - password_hash: row.get(2)?, - status: row.get(3)?, - created_at: row.get(4)?, - last_login: row.get(5)?, - account_type: row.get(6)?, - organization_id: row.get(7)?, - metadata: row.get(8)?, - }) - }) - .optional()?; + let user_opt = + crate::db::users::get_user_by_id(users_conn, user_id)?.filter(|u| u.status == "active"); if let Some(user) = user_opt { return Ok(Some(ApiActor::User(user))); diff --git a/src/cli/admin_migrate.rs b/src/cli/admin_migrate.rs index e18c68e..779abd0 100644 --- a/src/cli/admin_migrate.rs +++ b/src/cli/admin_migrate.rs @@ -44,7 +44,8 @@ pub async fn run( } // 2. Open databases - let legacy_content_path = config.data_dir.join("users").join("1").join("content.db"); + let topology = crate::db::topology::Topology::new(&config.data_dir); + let legacy_content_path = topology.content_db(crate::db::topology::LEGACY_ADMIN_USER_KEY)?; if !legacy_content_path.exists() { info!( @@ -55,11 +56,7 @@ pub async fn run( } db.init_user_databases(target_admin_id)?; - let target_content_path = config - .data_dir - .join("users") - .join(target_admin_id.to_string()) - .join("content.db"); + let target_content_path = topology.content_db_i64(target_admin_id)?; let mut legacy_conn = Connection::open(&legacy_content_path)?; let mut target_conn = Connection::open(&target_content_path)?; diff --git a/src/cli/backup_user.rs b/src/cli/backup_user.rs index 304fef0..5ca4766 100644 --- a/src/cli/backup_user.rs +++ b/src/cli/backup_user.rs @@ -16,6 +16,10 @@ struct UserBackupMetadata { created_at: String, account_type: String, metadata: Option, + #[serde(default)] + tenant_id: Option, + #[serde(default)] + uuid: Option, quotas: UserBackupQuotas, } @@ -94,7 +98,7 @@ pub async fn run( ); // 4. Force checkpoint on user's databases - let user_dir = config.data_dir.join("users").join(user_id.to_string()); + let user_dir = crate::db::tenant::location_for_user(&user)?.dir(&db.topology)?; if let Ok(c) = rusqlite::Connection::open(user_dir.join("content.db")) { let _ = c.execute("PRAGMA wal_checkpoint(TRUNCATE);", []); } @@ -119,6 +123,8 @@ pub async fn run( created_at: user.created_at, account_type: user.account_type, metadata: user.metadata, + tenant_id: user.tenant_id.map(|t| t.to_string()), + uuid: user.uuid, quotas, }; let metadata_bytes = serde_json::to_vec_pretty(&metadata_obj)?; diff --git a/src/cli/create_admin.rs b/src/cli/create_admin.rs index c730d01..f53d007 100644 --- a/src/cli/create_admin.rs +++ b/src/cli/create_admin.rs @@ -7,6 +7,7 @@ use tracing::{error, info}; pub async fn run( username: Option, + password: Option, data_dir: Option, mut config: Config, ) -> Result<(), Box> { @@ -25,16 +26,18 @@ pub async fn run( return Ok(()); } - let password = read_input("Enter password: "); - if password.trim().is_empty() { + let final_password = match password { + Some(p) => p, + None => read_input("Enter password: "), + }; + if final_password.trim().is_empty() { error!("Password cannot be empty"); return Ok(()); } - let hash = hash_password(&password).map_err(|e| e.to_string())?; + let hash = hash_password(&final_password).map_err(|e| e.to_string())?; let conn = db.users.lock().unwrap(); let u = crate::db::users::create_admin_user(&conn, &final_username, &hash)?; - db.init_user_databases(u.id)?; info!( "Successfully created admin user: {} (ID: {})", u.username, u.id diff --git a/src/cli/delete_user.rs b/src/cli/delete_user.rs index 1a8eb65..91e7aa6 100644 --- a/src/cli/delete_user.rs +++ b/src/cli/delete_user.rs @@ -15,11 +15,6 @@ pub async fn run( } let db = Db::init(&config)?; - if user_id == 1 && !force { - error!("Deleting legacy_admin system account requires --force flag"); - return Ok(()); - } - // Capture user details let user_details = { let conn = db.users.lock().unwrap(); @@ -32,36 +27,62 @@ pub async fn run( } }; - // 1. Transactional clean up on system.db (deleting their global slug mappings) - { - let mut system_conn = db.system.lock().unwrap(); - let tx = system_conn.transaction()?; + if user_details.account_type == "admin" && !force { + error!("Deleting administrator account requires --force flag"); + return Ok(()); + } - // Get all slugs owned by the user - let slugs: Vec = { - let mut stmt = tx.prepare("SELECT slug FROM global_slugs WHERE owner_user_id = ?1;")?; - let rows = stmt.query_map([user_id], |row| row.get(0))?; - rows.filter_map(|r| r.ok()).collect() - }; + // 1. Retire/cleanup slugs from v0.8 global_urls.db and global_landing_pages.db + let now = Utc::now().to_rfc3339(); + if let Some(ref tid) = user_details.tenant_id { + let tid_str = tid.to_string(); + let urls_conn = db.global_urls.lock().unwrap(); + let pages_conn = db.global_landing_pages.lock().unwrap(); + let system_conn = db.system.lock().unwrap(); - // Delete from global_slugs and write to history - let now = Utc::now().to_rfc3339(); - for slug in slugs { - let _ = tx.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&slug]); - let _ = tx.execute( + // Get all URL slugs owned by tenant + let mut stmt = + urls_conn.prepare("SELECT slug FROM global_urls WHERE owner_tenant_id = ?1;")?; + let url_slugs: Vec = stmt + .query_map([&tid_str], |row| row.get(0))? + .filter_map(|r| r.ok()) + .collect(); + + // Get all page slugs owned by tenant + let mut stmt2 = pages_conn + .prepare("SELECT slug FROM global_landing_pages WHERE owner_tenant_id = ?1;")?; + let page_slugs: Vec = stmt2 + .query_map([&tid_str], |row| row.get(0))? + .filter_map(|r| r.ok()) + .collect(); + + // Record history and retire/delete slugs + for slug in url_slugs { + let _ = urls_conn.execute("DELETE FROM global_urls WHERE slug = ?1;", [&slug]); + let _ = system_conn.execute( "INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username) VALUES (?1, ?2, NULL, 'deleted', ?3, ?4);", rusqlite::params![slug, user_id, now, "cli"], ); } - tx.commit()?; + for slug in page_slugs { + let _ = + pages_conn.execute("DELETE FROM global_landing_pages WHERE slug = ?1;", [&slug]); + let _ = system_conn.execute( + "INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username) + VALUES (?1, ?2, NULL, 'deleted', ?3, ?4);", + rusqlite::params![slug, user_id, now, "cli"], + ); + } } - // 2. Delete user folder and database files from disk - let user_dir = config.data_dir.join("users").join(user_id.to_string()); - if user_dir.exists() { - let _ = std::fs::remove_dir_all(&user_dir); + // 2. Delete tenant directory from disk + if let Some(ref tid) = user_details.tenant_id { + let user_dir = db.topology.tenant_dir(*tid); + if user_dir.exists() { + let _ = std::fs::remove_dir_all(&user_dir); + } } // 3. Remove user entry from users.db (cascading deletes quotas/sessions/tokens) diff --git a/src/cli/doctor.rs b/src/cli/doctor.rs index 9556ed9..e969394 100644 --- a/src/cli/doctor.rs +++ b/src/cli/doctor.rs @@ -24,16 +24,27 @@ pub async fn run( let mut all_healthy = true; - // Define target databases in the new layout - let admin_dir = config.data_dir.join("admin"); - let legacy_user_dir = config.data_dir.join("users").join("1"); + let topology = crate::db::topology::Topology::new(&config.data_dir); let dbs = vec![ - ("admin", admin_dir.join("admin.db")), - ("system", admin_dir.join("system.db")), - ("users", admin_dir.join("users.db")), - ("legacy content", legacy_user_dir.join("content.db")), - ("legacy analytics", legacy_user_dir.join("analytics.db")), + ("admin", topology.admin_db()), + ("system", topology.system_db()), + ("users", topology.users_registry_db()), + ("global_urls", topology.global_urls_db()), + ("global_landing_pages", topology.global_landing_pages_db()), + ("reserved", topology.reserved_db()), + ( + "legacy content", + topology + .content_db(crate::db::topology::LEGACY_ADMIN_USER_KEY) + .expect("legacy admin user key is valid"), + ), + ( + "legacy analytics", + topology + .analytics_db(crate::db::topology::LEGACY_ADMIN_USER_KEY) + .expect("legacy admin user key is valid"), + ), ]; for (db_name, db_path) in dbs { @@ -92,8 +103,8 @@ pub async fn run( // Global Slug Registry Integrity Check println!("Global Slug Registry Integrity Check"); println!("===================================="); - let system_db_path = admin_dir.join("system.db"); - let users_db_path = admin_dir.join("users.db"); + let system_db_path = topology.system_db(); + let users_db_path = topology.users_registry_db(); if system_db_path.exists() && users_db_path.exists() { match ( @@ -134,7 +145,7 @@ pub async fn run( ); println!(); println!("Owner:"); - println!(" User ID {}", issue.owner_user_id); + println!(" Tenant ID {}", issue.owner_tenant_id); println!(); println!("Database:"); println!(" {}", issue.database_path.display()); diff --git a/src/cli/expand.rs b/src/cli/expand.rs index d71bd90..ce3820f 100644 --- a/src/cli/expand.rs +++ b/src/cli/expand.rs @@ -17,11 +17,28 @@ pub async fn run( return Err("Invalid short code or custom slug format".into()); } - let url_opt = { - let conn = db.content.lock().unwrap(); - crate::db::content::get_url_by_code(&conn, &normalized_code)? + let owner_info = { + let conn = db.global_urls.lock().unwrap(); + conn.query_row( + "SELECT owner_tenant_id FROM global_urls WHERE slug = ?1 AND status = 'active';", + rusqlite::params![normalized_code], + |row| row.get::<_, String>(0), + ) + .ok() }; + let tid_str = match owner_info { + Some(t) => t, + None => return Err(format!("Short code not found: {}", normalized_code).into()), + }; + let tid = tid_str + .parse::() + .map_err(|e| format!("Invalid tenant id for slug {}: {:?}", normalized_code, e))?; + + let content_path = db.topology.tenant_content_db(tid); + let conn = rusqlite::Connection::open(&content_path)?; + let url_opt = crate::db::content::get_url_by_code(&conn, &normalized_code)?; + match url_opt { Some(url) => { println!("{}", url.destination); diff --git a/src/cli/init_admin.rs b/src/cli/init_admin.rs index 381d649..b6bb7c6 100644 --- a/src/cli/init_admin.rs +++ b/src/cli/init_admin.rs @@ -13,13 +13,14 @@ pub async fn run( config.data_dir = PathBuf::from(d); } let db = Db::init(&config)?; - let conn = db.users.lock().unwrap(); - - let admin_count: i64 = conn.query_row( - "SELECT COUNT(*) FROM users WHERE account_type = 'admin';", - [], - |row| row.get(0), - )?; + let admin_count: i64 = { + let conn = db.users.lock().unwrap(); + conn.query_row( + "SELECT COUNT(*) FROM users WHERE account_type = 'admin';", + [], + |row| row.get(0), + )? + }; if admin_count > 0 { info!("Administrator already exists; initialization skipped."); @@ -45,8 +46,10 @@ pub async fn run( }; let hash = hash_password(&password).map_err(|e| e.to_string())?; - let u = crate::db::users::create_admin_user(&conn, &username, &hash)?; - db.init_user_databases(u.id)?; + { + let conn = db.users.lock().unwrap(); + let _ = crate::db::users::create_admin_user(&conn, &username, &hash)?; + } info!("Administrator initialized successfully."); Ok(()) diff --git a/src/cli/migrate.rs b/src/cli/migrate.rs index 67dfc1f..7a26435 100644 --- a/src/cli/migrate.rs +++ b/src/cli/migrate.rs @@ -15,12 +15,91 @@ pub async fn run( if dry_run { info!("Dry run enabled: pending database migrations will be reported but not applied."); info!("Data directory: {:?}", config.data_dir); + let id_report = + crate::db::identity_migrate::run_identity_migration(&config, true, false).await?; + println!("\nIdentity Migration Preflight Report (Dry Run):"); + println!("----------------------------------------------"); + println!("Total users: {}", id_report.total_users); + println!( + "Users needing TenantId: {}", + id_report.users_assigned_tenant_id + ); + println!("Users needing UUID: {}", id_report.users_assigned_uuid); + println!("Directories to move: {}", id_report.directories_moved); + println!( + "Directories already migrated: {}", + id_report.directories_already_migrated + ); + println!( + "Legacy admin (users/1) preserved: {}", + id_report.legacy_admin_preserved + ); + + let slug_report = + crate::db::slug_migrate::run_global_slug_migration(&config, true, false).await?; + println!("\nGlobal Slug Migration Preflight Report (Dry Run):"); + println!("-------------------------------------------------"); + println!("Total legacy slugs: {}", slug_report.total_legacy_slugs); + println!("URL slugs to migrate: {}", slug_report.url_slugs_migrated); + println!("Page slugs to migrate: {}", slug_report.page_slugs_migrated); + println!("Reserved slugs: {}", slug_report.reserved_slugs_migrated); return Ok(()); } - info!("Running database migrations..."); + info!("Running database schema migrations..."); let _db = Db::init(&config)?; - info!("Database migrations applied successfully."); + info!("Database schema migrations applied successfully."); + + info!("Running identity & directory migration lifecycle..."); + let id_report = + crate::db::identity_migrate::run_identity_migration(&config, false, false).await?; + println!("\nIdentity Migration Report:"); + println!("--------------------------"); + println!("Total users: {}", id_report.total_users); + println!("TenantIds assigned: {}", id_report.users_assigned_tenant_id); + println!("UUIDs assigned: {}", id_report.users_assigned_uuid); + println!("Directories migrated: {}", id_report.directories_moved); + println!( + "Directories already migrated: {}", + id_report.directories_already_migrated + ); + println!( + "Legacy admin preserved: {}", + id_report.legacy_admin_preserved + ); + println!("Validation passed: {}", id_report.validation_passed); + + if !id_report.warnings.is_empty() { + println!("\nWarnings:"); + for w in &id_report.warnings { + println!(" - {}", w); + } + } + + info!("Running global slug migration lifecycle..."); + let slug_report = + crate::db::slug_migrate::run_global_slug_migration(&config, false, false).await?; + println!("\nGlobal Slug Migration Report:"); + println!("-----------------------------"); + println!("Total legacy slugs: {}", slug_report.total_legacy_slugs); + println!("URL slugs migrated: {}", slug_report.url_slugs_migrated); + println!("Page slugs migrated: {}", slug_report.page_slugs_migrated); + println!( + "Reserved slugs verified: {}", + slug_report.reserved_slugs_migrated + ); + println!( + "Existing target records verified: {}", + slug_report.existing_records_verified + ); + println!("Validation passed: {}", slug_report.validation_passed); + + if !slug_report.warnings.is_empty() { + println!("\nWarnings:"); + for w in &slug_report.warnings { + println!(" - {}", w); + } + } Ok(()) } diff --git a/src/cli/repair.rs b/src/cli/repair.rs index 7c8721f..8154ef1 100644 --- a/src/cli/repair.rs +++ b/src/cli/repair.rs @@ -30,55 +30,89 @@ pub async fn run( } let start_time = std::time::Instant::now(); - let admin_dir = config.data_dir.join("admin"); - let system_db_path = admin_dir.join("system.db"); - let users_db_path = admin_dir.join("users.db"); + let topology = crate::db::topology::Topology::new(&config.data_dir); + let system_db_path = topology.system_db(); + let users_db_path = topology.users_registry_db(); + let urls_db_path = topology.global_urls_db(); + let pages_db_path = topology.global_landing_pages_db(); - if !system_db_path.exists() || !users_db_path.exists() { - println!("Error: system.db or users.db not found."); + if !system_db_path.exists() + || !users_db_path.exists() + || !urls_db_path.exists() + || !pages_db_path.exists() + { + println!("Error: Core databases (system.db, users.db, slugs/*.db) not found."); return Ok(()); } - let mut sys_conn = Connection::open(&system_db_path)?; + let sys_conn = Connection::open(&system_db_path)?; let usr_conn = Connection::open(&users_db_path)?; + let mut urls_conn = Connection::open(&urls_db_path)?; + let mut pages_conn = Connection::open(&pages_db_path)?; let slug_filter = slug.as_deref(); if dry_run { - println!("BZOD Registry Repair\n"); - println!("Scanning Global Slug Registry..."); + println!("BZOD Registry Repair (v0.8)\n"); + println!("Scanning Authoritative Slug Registries (global_urls.db, global_landing_pages.db)..."); let issues = RegistryValidator::scan(&sys_conn, &usr_conn, &config.data_dir, slug_filter)?; - let orphaned = issues - .into_iter() + + let true_orphans = issues + .iter() .filter(|i| { matches!( i.issue_type, RegistryIssueType::MissingTarget - | RegistryIssueType::MissingDatabase - | RegistryIssueType::MissingOwner + | RegistryIssueType::TrueOrphan + | RegistryIssueType::MissingTenant ) }) .collect::>(); - let orphaned_pages = orphaned.iter().filter(|i| i.target_type == "page").count(); - let orphaned_urls = orphaned.iter().filter(|i| i.target_type == "url").count(); + let corrupt = issues + .iter() + .filter(|i| i.issue_type == RegistryIssueType::CorruptDatabase) + .collect::>(); - println!("\nDetected:"); - println!("\nPages:\n {} orphaned", orphaned_pages); - println!("\nURLs:\n {} orphaned", orphaned_urls); + let access_failures = issues + .iter() + .filter(|i| i.issue_type == RegistryIssueType::AccessFailure) + .collect::>(); - if !orphaned.is_empty() { - println!("\nThe following entries would be removed:"); - for issue in &orphaned { - println!("\n{}\n {}", issue.target_type.to_uppercase(), issue.slug); + println!("\nDetected Issues (Total: {}):", issues.len()); + println!(" Orphaned/Missing Targets: {}", true_orphans.len()); + println!(" Corrupt Databases (Protected): {}", corrupt.len()); + println!(" Access Failures (Protected): {}", access_failures.len()); + + if !true_orphans.is_empty() { + println!("\nThe following orphaned entries would be repaired/removed:"); + for issue in &true_orphans { + println!( + " {} [{}] — Tenant: {}", + issue.slug, + issue.target_type.to_uppercase(), + issue.owner_tenant_id + ); } } - println!("\nNo changes have been made."); + if !corrupt.is_empty() { + println!("\nProtected from destructive repair (Corrupt DBs):"); + for issue in &corrupt { + println!( + " {} [{}] — Path: {:?}", + issue.slug, + issue.target_type.to_uppercase(), + issue.database_path + ); + } + } + + println!("\nNo changes have been made (dry-run)."); println!( - "\nRun again with:\n\n bzod repair registry --force{}", + "Run again with:\n bzod repair registry --force{}", if let Some(s) = slug_filter { format!(" --slug {}", s) } else { @@ -87,89 +121,73 @@ pub async fn run( ); info!( - "Registry Repair Started. Scanned. Orphaned Pages: {}, Orphaned URLs: {}. Duration: {:?}", - orphaned_pages, orphaned_urls, start_time.elapsed() + "Registry Repair Scan completed. Orphaned: {}, Corrupt: {}, Duration: {:?}", + true_orphans.len(), + corrupt.len(), + start_time.elapsed() ); } else if force { - let tx = sys_conn.transaction()?; - let issues = - RegistryValidator::scan(&tx, &usr_conn, &config.data_dir, slug_filter)?; - let orphaned = issues + RegistryValidator::scan(&sys_conn, &usr_conn, &config.data_dir, slug_filter)?; + + // Only repair true orphans, missing targets, or missing tenants. + // Never delete records with CorruptDatabase or AccessFailure per safety policies. + let repairable = issues .into_iter() .filter(|i| { matches!( i.issue_type, RegistryIssueType::MissingTarget - | RegistryIssueType::MissingDatabase - | RegistryIssueType::MissingOwner + | RegistryIssueType::TrueOrphan + | RegistryIssueType::MissingTenant ) }) .collect::>(); - let orphaned_pages = orphaned.iter().filter(|i| i.target_type == "page").count(); - let orphaned_urls = orphaned.iter().filter(|i| i.target_type == "url").count(); - - if orphaned.is_empty() { - println!("No repairs required."); + if repairable.is_empty() { + println!("No repairable registry issues found."); return Ok(()); } - if let Some(s) = slug_filter { - println!("Checking slug:\n\n{}\n", s); - if let Some(issue) = orphaned.first() { - println!("Owner:\n\n{}\n", issue.owner_user_id); - println!("Status:\n\nOrphaned\n"); + let tx_urls = urls_conn.transaction()?; + let tx_pages = pages_conn.transaction()?; + + let mut removed_count = 0; + for issue in &repairable { + if issue.target_type == "url" { + removed_count += tx_urls + .execute("DELETE FROM global_urls WHERE slug = ?1;", [&issue.slug])?; + } else { + removed_count += tx_pages.execute( + "DELETE FROM global_landing_pages WHERE slug = ?1;", + [&issue.slug], + )?; } } - let mut removed_count = 0; - for issue in &orphaned { - let rows = tx.execute( - "DELETE FROM global_slugs WHERE slug = ?1", - rusqlite::params![issue.slug], - )?; - removed_count += rows; - } + tx_urls.commit()?; + tx_pages.commit()?; - tx.commit()?; + // Record audit event in system.db + let _ = crate::db::audit_events::write_audit_event( + &sys_conn, + "cli", + "REGISTRY_REPAIR", + "registry", + slug_filter.unwrap_or("*"), + Some(&format!( + "Repaired/removed {} orphaned slug entries", + removed_count + )), + ); - if slug_filter.is_some() { - println!("Removed:\n\nSUCCESS"); - } else { - println!("Repair Complete\n"); - println!("Removed:\n"); - println!("Pages:\n {}\n", orphaned_pages); - println!("URLs:\n {}\n", orphaned_urls); - - let remaining: i64 = - sys_conn - .query_row("SELECT COUNT(*) FROM global_slugs;", [], |r| r.get(0))?; - println!("Remaining Registry Entries:\n {}\n", remaining); - - let post_issues = - RegistryValidator::scan(&sys_conn, &usr_conn, &config.data_dir, None)?; - let post_orphaned = post_issues - .iter() - .filter(|i| { - matches!( - i.issue_type, - RegistryIssueType::MissingTarget - | RegistryIssueType::MissingDatabase - | RegistryIssueType::MissingOwner - ) - }) - .count(); - - println!( - "Integrity:\n {}", - if post_orphaned == 0 { "PASS" } else { "FAIL" } - ); - } + println!("Registry Repair Complete."); + println!("Repaired/Removed: {} entries", removed_count); info!( - "Registry Repair Started. Scanned. Orphaned Pages: {}, Orphaned URLs: {}. Removed: {}. Duration: {:?}", - orphaned_pages, orphaned_urls, removed_count, start_time.elapsed() + "Registry Repair Complete. Removed: {}. Duration: {:?}", + removed_count, + start_time.elapsed() ); } } diff --git a/src/cli/restore.rs b/src/cli/restore.rs index aa6b7e3..c22f937 100644 --- a/src/cli/restore.rs +++ b/src/cli/restore.rs @@ -215,19 +215,15 @@ fn classify_registry_issues( for issue in issues { match issue.issue_type { - RegistryIssueType::DuplicateSlug + RegistryIssueType::Conflict | RegistryIssueType::InvalidTargetType | RegistryIssueType::InvalidStatus => { errors.push(issue); } - RegistryIssueType::MissingOwner if !is_legacy => { + RegistryIssueType::MissingTenant if !is_legacy => { errors.push(issue); } _ => { - // For legacy restores: MissingDatabase, MissingTarget, MissingOwner, - // StaleReservation, TenantAdminHasIsolatedContent are warnings. - // These represent pre-existing inconsistencies in the backup data, - // not restore corruption. warnings.push(issue); } } diff --git a/src/cli/restore_user.rs b/src/cli/restore_user.rs index 4e60e4c..c23ce67 100644 --- a/src/cli/restore_user.rs +++ b/src/cli/restore_user.rs @@ -1,9 +1,13 @@ use crate::config::Config; use crate::db::Db; +use crate::identity::TenantId; +use chrono::Utc; +use rusqlite::OptionalExtension; use std::fs::File; use std::path::PathBuf; use tar::Archive; use tracing::{error, info}; +use uuid::Uuid; use zstd::Decoder; #[derive(serde::Serialize, serde::Deserialize)] @@ -15,6 +19,10 @@ struct UserBackupMetadata { created_at: String, account_type: String, metadata: Option, + #[serde(default)] + tenant_id: Option, + #[serde(default)] + uuid: Option, quotas: UserBackupQuotas, } @@ -70,26 +78,72 @@ pub async fn run( info!("Restoring user {} from backup...", metadata.username); - // 2. Resolve target user ID and upsert user record in users.db - let target_user_id = { + // 2. Resolve identity per Correction #1: + // Order: + // 1. Archive contains TenantId + UUID -> preserve exactly. + // 2. Legacy archive matched to existing users.db account -> resolve and preserve that user's existing TenantId + UUID. + // 3. Genuinely new legacy restore with no existing identity match -> explicitly allocate new TenantId + UUID. + let (target_user_id, target_tenant_id) = { let users_conn = db.users.lock().unwrap(); let existing_user = crate::db::users::get_user_by_username(&users_conn, &metadata.username)?; match existing_user { Some(u) => { + let tenant_id = if let Some(tid) = u.tenant_id { + tid + } else if let Some(ref tid_str) = metadata.tenant_id { + TenantId::parse(tid_str).unwrap_or_else(|_| TenantId::generate()) + } else { + TenantId::generate() + }; + + let user_uuid = if let Some(ref uid) = u.uuid { + uid.clone() + } else if let Some(ref uid_str) = metadata.uuid { + uid_str.clone() + } else { + Uuid::new_v4().to_string() + }; + users_conn.execute( - "UPDATE users SET password_hash = ?1, status = ?2, account_type = ?3, metadata = ?4 WHERE id = ?5;", - rusqlite::params![metadata.password_hash, metadata.status, metadata.account_type, metadata.metadata, u.id], + "UPDATE users SET password_hash = ?1, status = ?2, account_type = ?3, metadata = ?4, tenant_id = ?5, uuid = ?6 WHERE id = ?7;", + rusqlite::params![ + metadata.password_hash, + metadata.status, + metadata.account_type, + metadata.metadata, + tenant_id.as_str(), + user_uuid, + u.id + ], )?; users_conn.execute( "INSERT OR REPLACE INTO quotas (user_id, max_urls, max_landings, max_api_tokens, max_storage_mb) VALUES (?1, ?2, ?3, ?4, ?5);", - rusqlite::params![u.id, metadata.quotas.max_urls, metadata.quotas.max_landings, metadata.quotas.max_api_tokens, metadata.quotas.max_storage_mb], + rusqlite::params![ + u.id, + metadata.quotas.max_urls, + metadata.quotas.max_landings, + metadata.quotas.max_api_tokens, + metadata.quotas.max_storage_mb + ], )?; - u.id + (u.id, tenant_id) } None => { + let tenant_id = if let Some(ref tid_str) = metadata.tenant_id { + TenantId::parse(tid_str).unwrap_or_else(|_| TenantId::generate()) + } else { + TenantId::generate() + }; + + let user_uuid = if let Some(ref uid_str) = metadata.uuid { + uid_str.clone() + } else { + Uuid::new_v4().to_string() + }; + let id_taken: bool = users_conn .query_row( "SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);", @@ -100,16 +154,35 @@ pub async fn run( let new_id = if !id_taken { users_conn.execute( - "INSERT INTO users (id, username, password_hash, status, created_at, account_type, metadata) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);", - rusqlite::params![metadata.id, metadata.username, metadata.password_hash, metadata.status, metadata.created_at, metadata.account_type, metadata.metadata], + "INSERT INTO users (id, username, password_hash, status, created_at, account_type, metadata, tenant_id, uuid) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9);", + rusqlite::params![ + metadata.id, + metadata.username, + metadata.password_hash, + metadata.status, + metadata.created_at, + metadata.account_type, + metadata.metadata, + tenant_id.as_str(), + user_uuid + ], )?; metadata.id } else { users_conn.execute( - "INSERT INTO users (username, password_hash, status, created_at, account_type, metadata) - VALUES (?1, ?2, ?3, ?4, ?5, ?6);", - rusqlite::params![metadata.username, metadata.password_hash, metadata.status, metadata.created_at, metadata.account_type, metadata.metadata], + "INSERT INTO users (username, password_hash, status, created_at, account_type, metadata, tenant_id, uuid) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8);", + rusqlite::params![ + metadata.username, + metadata.password_hash, + metadata.status, + metadata.created_at, + metadata.account_type, + metadata.metadata, + tenant_id.as_str(), + user_uuid + ], )?; users_conn.last_insert_rowid() }; @@ -117,19 +190,23 @@ pub async fn run( users_conn.execute( "INSERT OR REPLACE INTO quotas (user_id, max_urls, max_landings, max_api_tokens, max_storage_mb) VALUES (?1, ?2, ?3, ?4, ?5);", - rusqlite::params![new_id, metadata.quotas.max_urls, metadata.quotas.max_landings, metadata.quotas.max_api_tokens, metadata.quotas.max_storage_mb], + rusqlite::params![ + new_id, + metadata.quotas.max_urls, + metadata.quotas.max_landings, + metadata.quotas.max_api_tokens, + metadata.quotas.max_storage_mb + ], )?; - new_id + (new_id, tenant_id) } } }; - // 3. Extract database files to /data/users// - let dest_dir = config - .data_dir - .join("users") - .join(target_user_id.to_string()); + // 3. Extract database files to /data/users// + let dest_dir = db.topology.tenant_dir(target_tenant_id); std::fs::create_dir_all(&dest_dir)?; + std::fs::create_dir_all(dest_dir.join("extensions"))?; let f2 = File::open(&file_path)?; let zst_dec2 = Decoder::new(f2)?; @@ -156,27 +233,192 @@ pub async fn run( } } - // 4. Register slugs in global_slugs using the shared helper - { - let system_conn = db.system.lock().unwrap(); - crate::db::users::register_restored_user_slugs( - &system_conn, + // 4. Register restored slugs in v0.8 slug databases (global_urls.db, global_landing_pages.db) + let content_path = dest_dir.join("content.db"); + if content_path.exists() { + let restored_content_conn = rusqlite::Connection::open(&content_path)?; + let now = Utc::now().to_rfc3339(); + + let urls_conn = db.global_urls.lock().unwrap(); + let pages_conn = db.global_landing_pages.lock().unwrap(); + let reserved_conn = db.reserved.lock().unwrap(); + + // 4a. Validate URL slugs for collisions + let mut url_slugs = Vec::new(); + let mut stmt = + restored_content_conn.prepare("SELECT code, id, created_at, status FROM urls;")?; + let mut rows = stmt.query([])?; + while let Some(row) = rows.next()? { + let code: String = row.get(0)?; + let target_id: String = row.get(1)?; + let created_at: String = row.get(2)?; + let status: String = row.get(3)?; + let global_status = if status == "dead" { + "disabled" + } else { + "active" + }; + + // Check collision with global_urls + let existing_url_owner: Option = urls_conn + .query_row( + "SELECT owner_tenant_id FROM global_urls WHERE slug = ?1;", + [&code], + |r| r.get(0), + ) + .optional()?; + if let Some(ref owner) = existing_url_owner { + if owner != target_tenant_id.as_str() { + return Err(format!( + "Slug collision: URL slug '{code}' is already registered to another tenant ({owner})" + ) + .into()); + } + } + + // Check collision with global_landing_pages + let existing_page_owner: Option = pages_conn + .query_row( + "SELECT owner_tenant_id FROM global_landing_pages WHERE slug = ?1;", + [&code], + |r| r.get(0), + ) + .optional()?; + if let Some(ref owner) = existing_page_owner { + if owner != target_tenant_id.as_str() { + return Err(format!( + "Slug collision: URL slug '{code}' collides with landing page owned by tenant ({owner})" + ) + .into()); + } + } + + // Check reserved + let is_reserved: bool = reserved_conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM reserved_slugs WHERE slug = ?1);", + [&code], + |r| r.get(0), + ) + .unwrap_or(false); + if is_reserved { + return Err(format!( + "Slug collision: URL slug '{code}' is a reserved system keyword" + ) + .into()); + } + + url_slugs.push((code, target_id, created_at, global_status)); + } + + // 4b. Validate Landing Page slugs for collisions + let mut page_slugs = Vec::new(); + let mut stmt = restored_content_conn + .prepare("SELECT code, id, created_at, state FROM landing_pages;")?; + let mut rows = stmt.query([])?; + while let Some(row) = rows.next()? { + let code: String = row.get(0)?; + let target_id: String = row.get(1)?; + let created_at: String = row.get(2)?; + let state: String = row.get(3)?; + let global_status = if state == "published" { + "active" + } else { + "disabled" + }; + + let existing_url_owner: Option = urls_conn + .query_row( + "SELECT owner_tenant_id FROM global_urls WHERE slug = ?1;", + [&code], + |r| r.get(0), + ) + .optional()?; + if let Some(ref owner) = existing_url_owner { + if owner != target_tenant_id.as_str() { + return Err(format!( + "Slug collision: Landing page slug '{code}' collides with URL owned by tenant ({owner})" + ) + .into()); + } + } + + let existing_page_owner: Option = pages_conn + .query_row( + "SELECT owner_tenant_id FROM global_landing_pages WHERE slug = ?1;", + [&code], + |r| r.get(0), + ) + .optional()?; + if let Some(ref owner) = existing_page_owner { + if owner != target_tenant_id.as_str() { + return Err(format!( + "Slug collision: Landing page slug '{code}' is already registered to another tenant ({owner})" + ) + .into()); + } + } + + let is_reserved: bool = reserved_conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM reserved_slugs WHERE slug = ?1);", + [&code], + |r| r.get(0), + ) + .unwrap_or(false); + if is_reserved { + return Err(format!( + "Slug collision: Landing page slug '{code}' is a reserved system keyword" + ) + .into()); + } + + page_slugs.push((code, target_id, created_at, global_status)); + } + + // 4c. Register validated URL slugs + for (code, target_id, created_at, global_status) in url_slugs { + let _ = urls_conn.execute( + "INSERT OR REPLACE INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL);", + rusqlite::params![ + code, + target_tenant_id.as_str(), + target_id, + created_at, + now, + global_status + ], + ); + } + + // 4d. Register validated Landing Page slugs + for (code, target_id, created_at, global_status) in page_slugs { + let _ = pages_conn.execute( + "INSERT OR REPLACE INTO global_landing_pages (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL);", + rusqlite::params![ + code, + target_tenant_id.as_str(), + target_id, + created_at, + now, + global_status + ], + ); + } + + // 5. Reconcile quotas for restored user + crate::db::users::reconcile_user_quotas( + &db.users.lock().unwrap(), target_user_id, - &dest_dir.join("content.db"), + &restored_content_conn, )?; } - // 5. Reconcile quotas for restored user - let restored_content_conn = rusqlite::Connection::open(dest_dir.join("content.db"))?; - crate::db::users::reconcile_user_quotas( - &db.users.lock().unwrap(), - target_user_id, - &restored_content_conn, - )?; - info!( - "User '{}' (ID: {}) successfully restored from backup.", - metadata.username, target_user_id + "User '{}' (ID: {}, Tenant: {}) successfully restored from backup.", + metadata.username, target_user_id, target_tenant_id ); Ok(()) } diff --git a/src/cli/serve.rs b/src/cli/serve.rs index 24575cf..125ec21 100644 --- a/src/cli/serve.rs +++ b/src/cli/serve.rs @@ -133,8 +133,6 @@ pub async fn run( let state = AppState { admin_db: db.admin.clone(), - content_db: db.content.clone(), - analytics_db: db.analytics.clone(), system_db: db.system.clone(), users_db: db.users.clone(), user_dbs: std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())), diff --git a/src/cli/shorten.rs b/src/cli/shorten.rs index 82a553c..7d1bb7d 100644 --- a/src/cli/shorten.rs +++ b/src/cli/shorten.rs @@ -18,6 +18,24 @@ pub async fn run( } let db = Db::init(&config)?; + // Resolve active standard user tenant + let (user_id, tid) = { + let users_conn = db.users.lock().unwrap(); + let users = crate::db::users::list_users(&users_conn)?; + let active_user = users.into_iter().find(|u| { + u.account_type == "standard" && u.status == "active" && u.tenant_id.is_some() + }); + match active_user { + Some(u) => (u.id, u.tenant_id.unwrap()), + None => { + return Err( + "Cannot shorten URL: No active standard user tenant found. Create a standard user first with `bzod user create`." + .into(), + ) + } + } + }; + // 2. Validate/normalize slug/code let code = match slug { Some(s) => { @@ -33,17 +51,24 @@ pub async fn run( None => crate::utils::random::generate_token(3), }; - // 3. Register slug in system.db with status 'reserving' and check availability + // 3. Register slug in global_urls with status 'reserving' { - let system_conn = db.system.lock().unwrap(); - if !crate::db::users::is_slug_available(&system_conn, &code)? { - return Err("Short code/slug already exists".into()); + let urls_conn = db.global_urls.lock().unwrap(); + let pages_conn = db.global_landing_pages.lock().unwrap(); + let reserved_conn = db.reserved.lock().unwrap(); + if let Err(e) = + crate::db::slugs::reserve_url_slug(&reserved_conn, &urls_conn, &pages_conn, &code, &tid) + { + return Err(format!("Slug '{}' already exists or is unavailable: {}", code, e).into()); } - crate::db::users::register_global_slug(&system_conn, &code, 1, "url", "", "reserving")?; } - // 4. Persist URL - let conn = db.content.lock().unwrap(); + // 4. Persist URL in tenant content DB + let content_path = db.topology.tenant_content_db(tid); + let conn = rusqlite::Connection::open(&content_path)?; + let _ = crate::db::sqlite::enable_wal(&conn, "content"); + let _ = crate::db::sqlite::enable_foreign_keys(&conn, "content"); + let res = crate::db::content::create_url_extended( &conn, &code, @@ -58,18 +83,15 @@ pub async fn run( match res { Ok(url) => { - // Activate slug in system.db + // Activate slug in global_urls { - let system_conn = db.system.lock().unwrap(); - system_conn.execute( - "UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;", - rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), code], - )?; + let urls_conn = db.global_urls.lock().unwrap(); + crate::db::slugs::activate_url_slug(&urls_conn, &code, &url.id)?; } - // Increment quota for user ID 1 + // Increment quota { let users_conn = db.users.lock().unwrap(); - crate::db::users::increment_quota_counter(&users_conn, 1, "urls")?; + crate::db::users::increment_quota_counter(&users_conn, user_id, "urls")?; } let proto = if config.cookie_secure { @@ -87,8 +109,8 @@ pub async fn run( Ok(()) } Err(e) => { - let system_conn = db.system.lock().unwrap(); - let _ = crate::db::users::release_global_slug(&system_conn, &code, 1); + let urls_conn = db.global_urls.lock().unwrap(); + let _ = crate::db::slugs::release_url_slug(&urls_conn, &code, &tid); Err(e.into()) } } diff --git a/src/cli/stats.rs b/src/cli/stats.rs index 89ce049..83d7e68 100644 --- a/src/cli/stats.rs +++ b/src/cli/stats.rs @@ -15,16 +15,24 @@ pub async fn run( println!("Storage Directory: {:?}", config.data_dir); let files = vec![ - ("admin.db", config.data_dir.join("admin/admin.db")), - ("system.db", config.data_dir.join("admin/system.db")), - ("users.db", config.data_dir.join("admin/users.db")), + ("admin.db", db.topology.admin_db()), + ("system.db", db.topology.system_db()), + ("users.db", db.topology.users_registry_db()), + ("global_urls.db", db.topology.global_urls_db()), + ( + "global_landing_pages.db", + db.topology.global_landing_pages_db(), + ), + ("reserved.db", db.topology.reserved_db()), ( "legacy content.db", - config.data_dir.join("users/1/content.db"), + db.topology + .content_db(crate::db::topology::LEGACY_ADMIN_USER_KEY)?, ), ( "legacy analytics.db", - config.data_dir.join("users/1/analytics.db"), + db.topology + .analytics_db(crate::db::topology::LEGACY_ADMIN_USER_KEY)?, ), ]; @@ -47,8 +55,29 @@ pub async fn run( println!("Users Count: {}", users_count); let (urls_total, urls_active, urls_dead) = { - let conn = db.content.lock().unwrap(); - crate::db::content::get_url_counts(&conn)? + let conn = db.global_urls.lock().unwrap(); + let total: i64 = conn + .query_row( + "SELECT COUNT(*) FROM global_urls WHERE status != 'retired';", + [], + |r| r.get(0), + ) + .unwrap_or(0); + let active: i64 = conn + .query_row( + "SELECT COUNT(*) FROM global_urls WHERE status = 'active';", + [], + |r| r.get(0), + ) + .unwrap_or(0); + let dead: i64 = conn + .query_row( + "SELECT COUNT(*) FROM global_urls WHERE status = 'disabled';", + [], + |r| r.get(0), + ) + .unwrap_or(0); + (total, active, dead) }; println!( "Shortened URLs: {} total ({} active / {} dead)", @@ -56,14 +85,19 @@ pub async fn run( ); let pages_count = { - let conn = db.content.lock().unwrap(); - crate::db::content::get_landing_page_count(&conn)? + let conn = db.global_landing_pages.lock().unwrap(); + conn.query_row( + "SELECT COUNT(*) FROM global_landing_pages WHERE status != 'retired';", + [], + |r| r.get(0), + ) + .unwrap_or(0) }; println!("Landing Pages: {}", pages_count); let total_visits = { - let conn = db.analytics.lock().unwrap(); - crate::db::analytics::get_total_clicks(&conn)? + let users_conn = db.users.lock().unwrap(); + crate::db::users::get_platform_total_clicks(&db.topology, &users_conn).unwrap_or(0) }; println!("Redirect Clicks: {}", total_visits); diff --git a/src/db/analytics.rs b/src/db/analytics.rs index 7b9ce19..8f896da 100644 --- a/src/db/analytics.rs +++ b/src/db/analytics.rs @@ -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")?, }) })?; diff --git a/src/db/identity_migrate.rs b/src/db/identity_migrate.rs new file mode 100644 index 0000000..7799b2c --- /dev/null +++ b/src/db/identity_migrate.rs @@ -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// to users//) +//! 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, +} + +#[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> { + 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// -> users//) + 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> { + 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> { + 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// to users// for normal users. +/// Legacy users/1 is preserved. +pub fn migrate_tenant_directories( + users_conn: &Connection, + topology: &Topology, + warnings: &mut Vec, +) -> Result> { + 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, +) -> Result> { + 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) +} diff --git a/src/db/migrations.rs b/src/db/migrations.rs index 5ce226b..0141454 100644 --- a/src/db/migrations.rs +++ b/src/db/migrations.rs @@ -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; + "#, + }, ]; diff --git a/src/db/mod.rs b/src/db/mod.rs index 6b4f3d6..34ea366 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -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>, - pub content: Arc>, - pub analytics: Arc>, pub system: Arc>, pub users: Arc>, + pub global_urls: Arc>, + pub global_landing_pages: Arc>, + pub reserved: Arc>, pub data_dir: std::path::PathBuf, + pub topology: Topology, +} + +fn open_prepared( + path: &std::path::Path, + name: &str, +) -> Result> { + 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> { 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> { - 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> { 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)?; diff --git a/src/db/schema_v08.rs b/src/db/schema_v08.rs new file mode 100644 index 0000000..7c567cf --- /dev/null +++ b/src/db/schema_v08.rs @@ -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"; diff --git a/src/db/slug_migrate.rs b/src/db/slug_migrate.rs new file mode 100644 index 0000000..8bea0ad --- /dev/null +++ b/src/db/slug_migrate.rs @@ -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, +} + +#[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, +} + +/// Run the full explicit global slug migration. +pub async fn run_global_slug_migration( + config: &Config, + dry_run: bool, + skip_backup: bool, +) -> Result> { + 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> { + 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, +) -> Result<(usize, usize, usize), Box> { + // 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 = 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::>()?; + + 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, +) -> Result> { + 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) +} diff --git a/src/db/slugs.rs b/src/db/slugs.rs new file mode 100644 index 0000000..18d8971 --- /dev/null +++ b/src/db/slugs.rs @@ -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, +} + +/// Insert the core reserved set. Idempotent (`INSERT OR IGNORE`). +pub fn seed_reserved_slugs(conn: &Connection) -> rusqlite::Result { + 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 { + 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 { + 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 { + 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> { + 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> { + 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> { + 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 { + 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 { + 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 { + 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> { + 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 { + 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) +} diff --git a/src/db/tenant.rs b/src/db/tenant.rs new file mode 100644 index 0000000..c525490 --- /dev/null +++ b/src/db/tenant.rs @@ -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>, + pub analytics: Arc>, + pub profile: Arc>, +} + +/// 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//` +/// 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 { + 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 { + 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>, + pool: &mut std::collections::HashMap, + user_id: i64, + mode: TenantOpenMode, +) -> Result { + 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>, + pool: &mut std::collections::HashMap, + tenant_id: TenantId, + mode: TenantOpenMode, +) -> Result { + 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>, + pool: &mut std::collections::HashMap, + location: &TenantLocation, + mode: TenantOpenMode, +) -> Result { + 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>, + create: bool, +) -> Result { + 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 { + 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 { + 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) +} diff --git a/src/db/topology.rs b/src/db/topology.rs new file mode 100644 index 0000000..a89862c --- /dev/null +++ b/src/db/topology.rs @@ -0,0 +1,345 @@ +//! Frozen v0.8.0 database topology under a configurable physical root. +//! +//! The logical layout is: +//! +//! ```text +//! / +//! ├── admin/{admin,system,users}.db +//! ├── slugs/{global_urls,global_landing_pages,reserved}.db +//! └── users//{profile,content,analytics}.db +//! └── extensions//.db +//! ``` +//! +//! `` 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) -> 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 { + 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 { + self.user_dir(&user_id.to_string()) + } + + pub fn content_db(&self, user_key: &str) -> Result { + Ok(self.user_dir(user_key)?.join("content.db")) + } + + pub fn analytics_db(&self, user_key: &str) -> Result { + Ok(self.user_dir(user_key)?.join("analytics.db")) + } + + pub fn profile_db(&self, user_key: &str) -> Result { + Ok(self.user_dir(user_key)?.join("profile.db")) + } + + pub fn content_db_i64(&self, user_id: i64) -> Result { + self.content_db(&user_id.to_string()) + } + + pub fn analytics_db_i64(&self, user_id: i64) -> Result { + self.analytics_db(&user_id.to_string()) + } + + pub fn profile_db_i64(&self, user_id: i64) -> Result { + self.profile_db(&user_id.to_string()) + } + + pub fn extensions_dir(&self, user_key: &str) -> Result { + Ok(self.user_dir(user_key)?.join("extensions")) + } + + pub fn extensions_dir_i64(&self, user_id: i64) -> Result { + self.extensions_dir(&user_id.to_string()) + } + + pub fn extension_db(&self, user_key: &str, extension: &str) -> Result { + 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> { + 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> { + 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::().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}"); + } + } +} diff --git a/src/db/users.rs b/src/db/users.rs index bfb3c2e..153755f 100644 --- a/src/db/users.rs +++ b/src/db/users.rs @@ -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 { + let tenant_raw: Option = 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 = 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 { + 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 { + 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> { 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> { + 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> { + 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> { 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> { - 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 { // 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> = HashMap::new(); + let mut slug_owners: HashMap> = 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//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::() { + 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 { + 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) +} diff --git a/src/identity.rs b/src/identity.rs new file mode 100644 index 0000000..fecbadb --- /dev/null +++ b/src/identity.rs @@ -0,0 +1,139 @@ +//! Frozen v0.8 tenant identity. +//! +//! `TenantId` is the filesystem-safe opaque tenant identifier: exactly 12 +//! lowercase hexadecimal characters, CSPRNG-generated, independent of username +//! and of SQLite row ids. + +use rand::{thread_rng, RngCore}; +use std::fmt; +use std::str::FromStr; + +/// Opaque tenant identifier: 12 lowercase hex characters (e.g. `fafafa12c3e4`). +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct TenantId { + hex: [u8; Self::LEN], +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TenantIdError { + InvalidLength { got: usize }, + InvalidCharacter { found: char }, +} + +impl fmt::Display for TenantIdError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidLength { got } => { + write!( + f, + "TenantId must be {} lowercase hex characters, got {got}", + TenantId::LEN + ) + } + Self::InvalidCharacter { found } => { + write!(f, "TenantId must be lowercase hexadecimal, found {found:?}") + } + } + } +} + +impl std::error::Error for TenantIdError {} + +impl TenantId { + pub const LEN: usize = 12; + + /// Cryptographically secure random TenantId. Never derived from user data. + pub fn generate() -> Self { + let mut bytes = [0u8; 6]; + thread_rng().fill_bytes(&mut bytes); + let encoded = hex::encode(bytes); + Self::parse(&encoded).expect("CSPRNG hex encoding is 12 lowercase chars") + } + + pub fn parse(s: &str) -> Result { + if s.len() != Self::LEN { + return Err(TenantIdError::InvalidLength { got: s.len() }); + } + if let Some(found) = s.chars().find(|c| !matches!(c, '0'..='9' | 'a'..='f')) { + return Err(TenantIdError::InvalidCharacter { found }); + } + let mut hex = [0u8; Self::LEN]; + hex.copy_from_slice(s.as_bytes()); + Ok(Self { hex }) + } + + pub fn as_str(&self) -> &str { + std::str::from_utf8(&self.hex).expect("TenantId is validated ASCII hex") + } +} + +impl fmt::Display for TenantId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl fmt::Debug for TenantId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("TenantId").field(&self.as_str()).finish() + } +} + +impl FromStr for TenantId { + type Err = TenantIdError; + fn from_str(s: &str) -> Result { + Self::parse(s) + } +} + +impl serde::Serialize for TenantId { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for TenantId { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Self::parse(&s).map_err(serde::de::Error::custom) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_accepts_lowercase_12_hex() { + let id = TenantId::parse("fafafa12c3e4").unwrap(); + assert_eq!(id.as_str(), "fafafa12c3e4"); + assert_eq!(id, TenantId::parse("fafafa12c3e4").unwrap()); + } + + #[test] + fn parse_rejects_uppercase_and_wrong_length() { + assert!(matches!( + TenantId::parse("FAFAFA12C3E4"), + Err(TenantIdError::InvalidCharacter { found: 'F' }) + )); + assert!(matches!( + TenantId::parse("abc"), + Err(TenantIdError::InvalidLength { got: 3 }) + )); + assert!(TenantId::parse("a1b2c3d4e5f67").is_err()); + assert!(TenantId::parse("../etc/passwd").is_err()); + assert!(TenantId::parse("gggggggggggg").is_err()); + } + + #[test] + fn generate_is_opaque_lowercase_hex() { + let a = TenantId::generate(); + let b = TenantId::generate(); + assert_ne!(a, b); + assert_eq!(a.as_str().len(), 12); + assert!(a + .as_str() + .chars() + .all(|c| matches!(c, '0'..='9' | 'a'..='f'))); + } +} diff --git a/src/jobs/backup.rs b/src/jobs/backup.rs index 2ba5b7c..2f43025 100644 --- a/src/jobs/backup.rs +++ b/src/jobs/backup.rs @@ -65,30 +65,48 @@ pub async fn perform_backup( if let Ok(conn) = db.admin.lock() { let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []); } - if let Ok(conn) = db.content.lock() { - let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []); - } - if let Ok(conn) = db.analytics.lock() { - let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []); - } if let Ok(conn) = db.system.lock() { let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []); } - if let Ok(conn) = db.users.lock() { + if let Ok(conn) = db.global_urls.lock() { + let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []); + } + if let Ok(conn) = db.global_landing_pages.lock() { + let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []); + } + if let Ok(conn) = db.reserved.lock() { + let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []); + } + let user_ids: Vec = if let Ok(conn) = db.users.lock() { let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []); if let Ok(mut stmt) = conn.prepare("SELECT id FROM users;") { if let Ok(rows) = stmt.query_map([], |row| row.get::<_, i64>(0)) { - let user_ids: Vec = rows.filter_map(|r| r.ok()).collect(); - for user_id in user_ids { - if let Ok(u_conn) = crate::jobs::open_user_content_conn(db, user_id) { - let _ = u_conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []); - } - if let Ok(u_conn) = crate::jobs::open_user_analytics_conn(db, user_id) { - let _ = u_conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []); - } - } + rows.filter_map(|r| r.ok()).collect() + } else { + Vec::new() } + } else { + Vec::new() } + } else { + Vec::new() + }; + for user_id in user_ids { + if let Ok(u_conn) = crate::jobs::open_user_content_conn(db, user_id) { + let _ = u_conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []); + } + if let Ok(u_conn) = crate::jobs::open_user_analytics_conn(db, user_id) { + let _ = u_conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []); + } + } + if let Ok(conn) = db.global_urls.lock() { + let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []); + } + if let Ok(conn) = db.global_landing_pages.lock() { + let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []); + } + if let Ok(conn) = db.reserved.lock() { + let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []); } let date_str = Utc::now().format("%Y-%m-%d-%H%M%S").to_string(); @@ -99,12 +117,17 @@ pub async fn perform_backup( let enc = GzEncoder::new(file, Compression::default()); let mut tar = Builder::new(enc); - let admin_dir = config.data_dir.join("admin"); + let admin_dir = db.topology.admin_dir(); if admin_dir.exists() { tar.append_dir_all("admin", &admin_dir)?; } - let users_dir = config.data_dir.join("users"); + let slugs_dir = db.topology.slugs_dir(); + if slugs_dir.exists() { + tar.append_dir_all("slugs", &slugs_dir)?; + } + + let users_dir = db.topology.users_dir(); if users_dir.exists() { tar.append_dir_all("users", &users_dir)?; } diff --git a/src/jobs/mod.rs b/src/jobs/mod.rs index d245cf4..9ef7c19 100644 --- a/src/jobs/mod.rs +++ b/src/jobs/mod.rs @@ -46,11 +46,12 @@ pub fn open_user_content_conn( db: &Db, user_id: i64, ) -> Result { - let db_path = db - .data_dir - .join("users") - .join(user_id.to_string()) - .join("content.db"); + let db_path = { + let users = db.users.lock().map_err(|_| { + rusqlite::Error::InvalidPath(std::path::PathBuf::from("users-db-poisoned")) + })?; + crate::db::tenant::existing_content_path(&users, &db.topology, user_id)? + }; let conn = rusqlite::Connection::open(db_path)?; crate::db::sqlite::enable_wal(&conn, "content")?; crate::db::sqlite::enable_foreign_keys(&conn, "content")?; @@ -61,11 +62,12 @@ pub fn open_user_analytics_conn( db: &Db, user_id: i64, ) -> Result { - let db_path = db - .data_dir - .join("users") - .join(user_id.to_string()) - .join("analytics.db"); + let db_path = { + let users = db.users.lock().map_err(|_| { + rusqlite::Error::InvalidPath(std::path::PathBuf::from("users-db-poisoned")) + })?; + crate::db::tenant::existing_analytics_path(&users, &db.topology, user_id)? + }; let conn = rusqlite::Connection::open(db_path)?; crate::db::sqlite::enable_wal(&conn, "analytics")?; crate::db::sqlite::enable_foreign_keys(&conn, "analytics")?; diff --git a/src/jobs/quota_reconcile.rs b/src/jobs/quota_reconcile.rs index f5ee754..ffc5908 100644 --- a/src/jobs/quota_reconcile.rs +++ b/src/jobs/quota_reconcile.rs @@ -37,9 +37,9 @@ pub async fn run_quota_reconciliation( rows.filter_map(|r| r.ok()).collect() }; - let users_conn = db.users.lock().unwrap(); for user_id in user_ids { if let Ok(content_conn) = super::open_user_content_conn(&db, user_id) { + let users_conn = db.users.lock().unwrap(); if let Err(e) = crate::db::users::reconcile_user_quotas(&users_conn, user_id, &content_conn) { diff --git a/src/lib.rs b/src/lib.rs index b3a8af7..937de5c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ pub mod cli; pub mod config; pub mod db; pub mod error; +pub mod identity; pub mod jobs; pub mod models; pub mod services; diff --git a/src/main.rs b/src/main.rs index a96662f..a18eae6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -42,7 +42,7 @@ async fn main() -> Result<(), Box> { bzod::cli::audit_destinations::run(data_dir, config).await?; } Commands::CreateAdmin { username, data_dir } => { - bzod::cli::create_admin::run(username, data_dir, config).await?; + bzod::cli::create_admin::run(username, None, data_dir, config).await?; } Commands::InitAdmin { data_dir } => { bzod::cli::init_admin::run(data_dir, config).await?; diff --git a/src/models/user.rs b/src/models/user.rs index 4c78942..6ca69f0 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -27,6 +27,24 @@ pub struct TenantUser { pub account_type: String, // 'system', 'admin', 'standard', 'organization', 'service' pub organization_id: Option, pub metadata: Option, + /// Frozen v0.8 tenant id. None only for unmigrated v0.7 rows (Phase 3). + pub tenant_id: Option, + /// Frozen v0.8 immutable UUID. + pub uuid: Option, +} + +impl TenantUser { + pub fn require_tenant_id(&self) -> Result { + self.tenant_id.ok_or_else(|| { + crate::error::AppError::Internal(format!("user {} has unmigrated tenant_id", self.id)) + }) + } + + pub fn require_uuid(&self) -> Result<&str, crate::error::AppError> { + self.uuid.as_deref().ok_or_else(|| { + crate::error::AppError::Internal(format!("user {} has unmigrated uuid", self.id)) + }) + } } #[derive(Serialize, Deserialize, Clone, Debug)] diff --git a/src/models/visit.rs b/src/models/visit.rs index 0d51423..d1897f4 100644 --- a/src/models/visit.rs +++ b/src/models/visit.rs @@ -1,3 +1,4 @@ +use crate::identity::TenantId; use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Clone, Debug)] @@ -12,6 +13,9 @@ pub struct VisitRecord { pub accept_language: String, pub country: String, pub status_code: u16, + #[serde(default)] + pub owner_tenant_id: Option, + #[serde(default)] pub owner_user_id: Option, } diff --git a/src/services/backup_layout.rs b/src/services/backup_layout.rs index be9bcfa..cb71bbc 100644 --- a/src/services/backup_layout.rs +++ b/src/services/backup_layout.rs @@ -5,16 +5,21 @@ use std::path::{Path, PathBuf}; use tracing::warn; +use crate::db::topology::{Topology, LEGACY_ADMIN_USER_KEY}; + /// Move flat legacy DB files into multi-tenant paths after tarball extract. /// /// Layout: /// - `admin.db` / `system.db` / `users.db` (+ wal/shm) → `{data_dir}/admin/` /// - `content.db` / `analytics.db` (+ wal/shm) → `{data_dir}/users/1/` +/// - `{data_dir}/slugs/` is created empty if missing (v0.8 topology) pub fn normalize_restored_layout(data_dir: &Path) -> std::io::Result<()> { - let admin_dir = data_dir.join("admin"); - let users_1_dir = data_dir.join("users").join("1"); + let topology = Topology::new(data_dir); + let admin_dir = topology.admin_dir(); + let users_1_dir = topology.legacy_admin_dir(); std::fs::create_dir_all(&admin_dir)?; std::fs::create_dir_all(&users_1_dir)?; + std::fs::create_dir_all(topology.slugs_dir())?; let admin_files = [ "admin.db", @@ -80,12 +85,17 @@ pub struct RestoredDbPaths { impl RestoredDbPaths { pub fn from_data_dir(data_dir: &Path) -> Self { + let topology = Topology::new(data_dir); Self { - admin: data_dir.join("admin/admin.db"), - system: data_dir.join("admin/system.db"), - users: data_dir.join("admin/users.db"), - content: data_dir.join("users/1/content.db"), - analytics: data_dir.join("users/1/analytics.db"), + admin: topology.admin_db(), + system: topology.system_db(), + users: topology.users_registry_db(), + content: topology + .content_db(LEGACY_ADMIN_USER_KEY) + .expect("legacy admin user key is valid"), + analytics: topology + .analytics_db(LEGACY_ADMIN_USER_KEY) + .expect("legacy admin user key is valid"), } } } @@ -113,6 +123,7 @@ mod tests { assert!(dir.join("admin/users.db").exists()); assert!(dir.join("users/1/content.db").exists()); assert!(dir.join("users/1/analytics.db").exists()); + assert!(dir.join("slugs").is_dir()); assert!(!dir.join("admin.db").exists()); assert!(!dir.join("content.db").exists()); diff --git a/src/services/bulk_urls.rs b/src/services/bulk_urls.rs index af0bd71..6a04582 100644 --- a/src/services/bulk_urls.rs +++ b/src/services/bulk_urls.rs @@ -4,6 +4,7 @@ use crate::auth::generate_token; use crate::auth::password::hash_password; +use crate::identity::TenantId; use crate::models::Url; use crate::utils::validation::validate_redirect_destination; use rusqlite::{Connection, Transaction}; @@ -38,9 +39,9 @@ impl BulkUrlError { } } -fn release_reserved(system: &Connection, slugs: &[String], owner_user_id: i64) { +fn release_reserved(urls_conn: &Connection, slugs: &[String], owner_tenant_id: &TenantId) { for slug in slugs { - let _ = crate::db::users::release_global_slug(system, slug, owner_user_id); + let _ = crate::db::slugs::release_url_slug(urls_conn, slug, owner_tenant_id); } } @@ -66,11 +67,15 @@ pub fn ensure_url_quota( } /// Create many URLs inside a single content transaction with global slug reservation. +#[allow(clippy::too_many_arguments)] pub fn create_urls_bulk( content_db: &Mutex, - system_db: &Mutex, + reserved_db: &Mutex, + global_urls_db: &Mutex, + global_landing_pages_db: &Mutex, users_db: &Mutex, owner_user_id: i64, + owner_tenant_id: TenantId, items: Vec, ) -> Result, BulkUrlError> { let mut conn = crate::utils::lock_db(content_db, "content_db") @@ -83,12 +88,20 @@ pub fn create_urls_bulk( let mut reserved_slugs: Vec = Vec::new(); for item in items { - match create_one_in_tx(&tx, system_db, owner_user_id, item, &mut reserved_slugs) { + match create_one_in_tx( + &tx, + reserved_db, + global_urls_db, + global_landing_pages_db, + &owner_tenant_id, + item, + &mut reserved_slugs, + ) { Ok(url) => created_urls.push(url), Err(e) => { let _ = tx.rollback(); - if let Ok(system_conn) = crate::utils::lock_db(system_db, "system_db") { - release_reserved(&system_conn, &reserved_slugs, owner_user_id); + if let Ok(urls_conn) = crate::utils::lock_db(global_urls_db, "global_urls_db") { + release_reserved(&urls_conn, &reserved_slugs, &owner_tenant_id); } return Err(e); } @@ -96,23 +109,20 @@ pub fn create_urls_bulk( } if let Err(e) = tx.commit() { - if let Ok(system_conn) = crate::utils::lock_db(system_db, "system_db") { - release_reserved(&system_conn, &reserved_slugs, owner_user_id); + if let Ok(urls_conn) = crate::utils::lock_db(global_urls_db, "global_urls_db") { + release_reserved(&urls_conn, &reserved_slugs, &owner_tenant_id); } return Err(BulkUrlError::Internal(format!( "Failed to commit transaction: {e}" ))); } - // Activate slugs + // Activate slugs in v0.8 global_urls.db { - let system_conn = crate::utils::lock_db(system_db, "system_db") + let urls_conn = crate::utils::lock_db(global_urls_db, "global_urls_db") .map_err(|e| BulkUrlError::Internal(e.to_string()))?; for url in &created_urls { - let _ = system_conn.execute( - "UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;", - rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), url.code], - ); + let _ = crate::db::slugs::activate_url_slug(&urls_conn, &url.code, &url.id); } } @@ -130,37 +140,47 @@ pub fn create_urls_bulk( fn create_one_in_tx( tx: &Transaction<'_>, - system_db: &Mutex, - owner_user_id: i64, + reserved_db: &Mutex, + global_urls_db: &Mutex, + global_landing_pages_db: &Mutex, + owner_tenant_id: &TenantId, item: BulkUrlCreateItem, reserved_slugs: &mut Vec, ) -> Result { let mut code = item.code.unwrap_or_default().trim().to_lowercase(); if code.is_empty() { code = generate_token(3); - } else if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) { + } else if !crate::utils::validation::validate_redirect_code(&code) { return Err(BulkUrlError::BadRequest(format!( - "Short code '{code}' must be 6 hex characters" + "Short code or slug '{code}' is invalid (must be 6 hex characters or !custom-slug)" ))); } { - let system_conn = crate::utils::lock_db(system_db, "system_db") + let reserved_conn = crate::utils::lock_db(reserved_db, "reserved_db") .map_err(|e| BulkUrlError::Internal(e.to_string()))?; - let available = crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) - && !reserved_slugs.contains(&code); + let urls_conn = crate::utils::lock_db(global_urls_db, "global_urls_db") + .map_err(|e| BulkUrlError::Internal(e.to_string()))?; + let pages_conn = crate::utils::lock_db(global_landing_pages_db, "global_landing_pages_db") + .map_err(|e| BulkUrlError::Internal(e.to_string()))?; + + let available = + crate::db::slugs::is_slug_available(&reserved_conn, &urls_conn, &pages_conn, &code) + .unwrap_or(false) + && !reserved_slugs.contains(&code); + if !available { return Err(BulkUrlError::Conflict(format!( "Short code '{code}' already exists" ))); } - if let Err(e) = crate::db::users::register_global_slug( - &system_conn, + + if let Err(e) = crate::db::slugs::reserve_url_slug( + &reserved_conn, + &urls_conn, + &pages_conn, &code, - owner_user_id, - "url", - "", - "reserving", + owner_tenant_id, ) { return Err(BulkUrlError::Internal(format!( "Failed to reserve slug '{code}': {e}" diff --git a/src/services/destination_audit.rs b/src/services/destination_audit.rs index 49da089..fa45a0e 100644 --- a/src/services/destination_audit.rs +++ b/src/services/destination_audit.rs @@ -6,7 +6,6 @@ use crate::db::Db; use crate::utils::validation::{classify_redirect_destination, DestinationClass}; use rusqlite::Connection; -use std::path::Path; use tracing::{error, info, warn}; /// Summary counters for a destination audit run. @@ -124,14 +123,12 @@ pub fn audit_content_conn( Ok(()) } -fn open_user_content(data_dir: &Path, user_id: i64) -> Result { - let path = data_dir - .join("users") - .join(user_id.to_string()) - .join("content.db"); - if !path.exists() { - return Err(rusqlite::Error::InvalidPath(path)); - } +fn open_user_content(db: &Db, user_id: i64) -> Result { + let users = db + .users + .lock() + .map_err(|_| rusqlite::Error::InvalidPath(std::path::PathBuf::from("users-db-poisoned")))?; + let path = crate::db::tenant::existing_content_path(&users, &db.topology, user_id)?; let conn = Connection::open(path)?; crate::db::sqlite::enable_wal(&conn, "content")?; Ok(conn) @@ -158,7 +155,7 @@ pub fn audit_all_destinations(db: &Db) -> Result }; for user_id in user_ids { - match open_user_content(&db.data_dir, user_id) { + match open_user_content(db, user_id) { Ok(conn) => { report.scanned_users += 1; if let Err(e) = audit_content_conn(&conn, user_id, &mut report) { diff --git a/src/services/landing_pages.rs b/src/services/landing_pages.rs index 13f9788..0afb61d 100644 --- a/src/services/landing_pages.rs +++ b/src/services/landing_pages.rs @@ -1,23 +1,23 @@ -use crate::db::Db; use crate::error::AppError; use crate::models::LandingPage; pub fn create_landing_page( - db: &Db, + conn: &rusqlite::Connection, code: &str, slug: &str, title: &str, html_content: &str, state: &str, ) -> Result { - let conn = db.content.lock().unwrap(); let page = - crate::db::content::create_landing_page(&conn, code, slug, title, html_content, state)?; + crate::db::content::create_landing_page(conn, code, slug, title, html_content, state)?; Ok(page) } -pub fn get_landing_page_by_code(db: &Db, code: &str) -> Result, AppError> { - let conn = db.content.lock().unwrap(); - let page = crate::db::content::get_landing_page_by_code(&conn, code)?; +pub fn get_landing_page_by_code( + conn: &rusqlite::Connection, + code: &str, +) -> Result, AppError> { + let page = crate::db::content::get_landing_page_by_code(conn, code)?; Ok(page) } diff --git a/src/services/registry_validator.rs b/src/services/registry_validator.rs index 35c0d74..ddc0562 100644 --- a/src/services/registry_validator.rs +++ b/src/services/registry_validator.rs @@ -1,23 +1,27 @@ +use crate::db::topology::Topology; +use crate::identity::TenantId; +use chrono::{DateTime, Utc}; use rusqlite::Connection; use std::path::{Path, PathBuf}; -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum RegistryIssueType { - DuplicateSlug, - InvalidTargetType, - InvalidStatus, - MissingOwner, - MissingDatabase, + MissingTenant, MissingTarget, + CorruptDatabase, + AccessFailure, + TrueOrphan, + Conflict, StaleReservation, - TenantAdminHasIsolatedContent, + InvalidStatus, + InvalidTargetType, } #[derive(Debug, Clone)] pub struct RegistryIssue { pub slug: String, pub target_type: String, - pub owner_user_id: i64, + pub owner_tenant_id: String, pub database_path: PathBuf, pub target_id: String, pub issue_type: RegistryIssueType, @@ -27,137 +31,237 @@ pub struct RegistryIssue { pub struct RegistryValidator; impl RegistryValidator { - /// Scans the global_slugs registry and returns a list of detected issues. + /// Scans the v0.8 slug registries (`global_urls.db`, `global_landing_pages.db`, `reserved.db`) + /// and returns a list of detected issues categorized per safety policies. pub fn scan( - system_conn: &Connection, + _system_conn: &Connection, users_conn: &Connection, data_dir: &Path, slug_filter: Option<&str>, ) -> Result, Box> { - use chrono::{DateTime, Utc}; + let topology = Topology::new(data_dir); let mut issues = Vec::new(); - // 1. Check duplicate slugs (only if not filtering by single slug) - if slug_filter.is_none() { - let total_count: i64 = - system_conn.query_row("SELECT COUNT(*) FROM global_slugs;", [], |r| r.get(0))?; - let distinct_count: i64 = system_conn.query_row( - "SELECT COUNT(DISTINCT slug) FROM global_slugs;", - [], - |r| r.get(0), - )?; - if total_count != distinct_count { - issues.push(RegistryIssue { - slug: "*".to_string(), - target_type: "system".to_string(), - owner_user_id: 0, - database_path: data_dir.join("admin/system.db"), - target_id: "".to_string(), - issue_type: RegistryIssueType::DuplicateSlug, - description: format!( - "Duplicate slugs found in global_slugs table (total rows: {}, distinct slugs: {})", - total_count, distinct_count - ), - }); - } + let urls_path = topology.global_urls_db(); + let pages_path = topology.global_landing_pages_db(); + let reserved_path = topology.reserved_db(); + + if !urls_path.exists() || !pages_path.exists() || !reserved_path.exists() { + issues.push(RegistryIssue { + slug: "*".to_string(), + target_type: "system".to_string(), + owner_tenant_id: "".to_string(), + database_path: urls_path, + target_id: "".to_string(), + issue_type: RegistryIssueType::AccessFailure, + description: "One or more v0.8 slug databases are missing from disk".to_string(), + }); + return Ok(issues); } - // 2. Scan global slugs - let (query, params_string) = if let Some(slug) = slug_filter { + let urls_conn = Connection::open(&urls_path)?; + let pages_conn = Connection::open(&pages_path)?; + let reserved_conn = Connection::open(&reserved_path)?; + + // 1. Scan global_urls.db + Self::scan_table( + &urls_conn, + users_conn, + &reserved_conn, + &pages_conn, + &topology, + "url", + slug_filter, + &mut issues, + )?; + + // 2. Scan global_landing_pages.db + Self::scan_table( + &pages_conn, + users_conn, + &reserved_conn, + &urls_conn, + &topology, + "page", + slug_filter, + &mut issues, + )?; + + Ok(issues) + } + + #[allow(clippy::too_many_arguments)] + fn scan_table( + conn: &Connection, + users_conn: &Connection, + reserved_conn: &Connection, + other_conn: &Connection, + topology: &Topology, + target_type: &str, + slug_filter: Option<&str>, + issues: &mut Vec, + ) -> Result<(), Box> { + let (query, params_vec) = if let Some(slug) = slug_filter { ( - "SELECT slug, owner_user_id, target_type, target_id, created_at, status FROM global_slugs WHERE slug = ?1;", + format!( + "SELECT slug, owner_tenant_id, target_id, created_at, status FROM global_{}s WHERE slug = ?1;", + if target_type == "url" { "url" } else { "landing_page" } + ), vec![slug.to_string()], ) } else { ( - "SELECT slug, owner_user_id, target_type, target_id, created_at, status FROM global_slugs;", + format!( + "SELECT slug, owner_tenant_id, target_id, created_at, status FROM global_{}s;", + if target_type == "url" { + "url" + } else { + "landing_page" + } + ), vec![], ) }; - let mut stmt = system_conn.prepare(query)?; - let mut rows = stmt.query(rusqlite::params_from_iter(params_string))?; + let mut stmt = conn.prepare(&query)?; + let mut rows = stmt.query(rusqlite::params_from_iter(params_vec))?; while let Some(row) = rows.next()? { let slug: String = row.get(0)?; - let owner_user_id: i64 = row.get(1)?; - let target_type: String = row.get(2)?; - let target_id: String = row.get(3)?; - let created_at_str: String = row.get(4)?; - let status: String = row.get(5)?; + let owner_tenant_id_str: String = row.get(1)?; + let target_id: String = row.get(2)?; + let created_at_str: String = row.get(3)?; + let status: String = row.get(4)?; - let content_db_path = if owner_user_id == 1 { - data_dir.join("users").join("1").join("content.db") - } else { - data_dir - .join("users") - .join(owner_user_id.to_string()) - .join("content.db") + let tenant_id_res = TenantId::parse(&owner_tenant_id_str); + let content_db_path = match tenant_id_res { + Ok(tid) => topology.tenant_dir(tid).join("content.db"), + Err(_) => topology.users_dir().join("_invalid").join("content.db"), }; - // Target type check - if target_type != "url" && target_type != "page" { + // 1. Conflict with reserved.db + let is_reserved: bool = reserved_conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM reserved_slugs WHERE slug = ?1);", + [&slug], + |r| r.get(0), + ) + .unwrap_or(false); + + if is_reserved { issues.push(RegistryIssue { slug: slug.clone(), - target_type: target_type.clone(), - owner_user_id, - database_path: content_db_path.clone(), + target_type: target_type.to_string(), + owner_tenant_id: owner_tenant_id_str.clone(), + database_path: topology.reserved_db(), target_id: target_id.clone(), - issue_type: RegistryIssueType::InvalidTargetType, + issue_type: RegistryIssueType::Conflict, + description: format!("Slug '{}' conflicts with a reserved system route", slug), + }); + } + + // 2. Conflict with the other slug database + let other_table = if target_type == "url" { + "global_landing_pages" + } else { + "global_urls" + }; + let in_other: bool = other_conn + .query_row( + &format!("SELECT EXISTS(SELECT 1 FROM {other_table} WHERE slug = ?1);"), + [&slug], + |r| r.get(0), + ) + .unwrap_or(false); + + if in_other { + issues.push(RegistryIssue { + slug: slug.clone(), + target_type: target_type.to_string(), + owner_tenant_id: owner_tenant_id_str.clone(), + database_path: conn.path().map(PathBuf::from).unwrap_or_default(), + target_id: target_id.clone(), + issue_type: RegistryIssueType::Conflict, description: format!( - "Slug '{}' has invalid target_type '{}'", - slug, target_type + "Slug '{}' exists in both global_urls.db and global_landing_pages.db", + slug ), }); } - // Status check - if status != "active" && status != "disabled" && status != "reserving" { + // 3. Status check + if status != "active" + && status != "disabled" + && status != "reserving" + && status != "retired" + { issues.push(RegistryIssue { slug: slug.clone(), - target_type: target_type.clone(), - owner_user_id, - database_path: content_db_path.clone(), + target_type: target_type.to_string(), + owner_tenant_id: owner_tenant_id_str.clone(), + database_path: conn.path().map(PathBuf::from).unwrap_or_default(), target_id: target_id.clone(), issue_type: RegistryIssueType::InvalidStatus, description: format!("Slug '{}' has invalid status '{}'", slug, status), }); } - // Check owner - let owner_exists: bool = users_conn - .query_row( - "SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);", - [owner_user_id], - |r| r.get(0), - ) - .unwrap_or(false); + // If retired, no active target check is needed + if status == "retired" { + continue; + } + + // 4. Owner tenant check in users.db + let tid = match tenant_id_res { + Ok(t) => t, + Err(_) => { + issues.push(RegistryIssue { + slug: slug.clone(), + target_type: target_type.to_string(), + owner_tenant_id: owner_tenant_id_str.clone(), + database_path: content_db_path, + target_id: target_id.clone(), + issue_type: RegistryIssueType::MissingTenant, + description: format!( + "Slug '{}' has invalid TenantId '{}'", + slug, owner_tenant_id_str + ), + }); + continue; + } + }; + + let owner_opt = crate::db::users::get_user_by_tenant_id(users_conn, tid)?; + let owner_exists = match owner_opt { + Some(ref u) => u.status != "deleted", + None => false, + }; if !owner_exists { issues.push(RegistryIssue { slug: slug.clone(), - target_type: target_type.clone(), - owner_user_id, + target_type: target_type.to_string(), + owner_tenant_id: owner_tenant_id_str.clone(), database_path: content_db_path.clone(), target_id: target_id.clone(), - issue_type: RegistryIssueType::MissingOwner, + issue_type: RegistryIssueType::MissingTenant, description: format!( - "Slug '{}' references missing owner user ID {}", - slug, owner_user_id + "Slug '{}' references missing or deleted owner tenant '{}'", + slug, owner_tenant_id_str ), }); continue; } - // Stale warning check + // 5. Stale reservation check if status == "reserving" { if let Ok(created_at) = DateTime::parse_from_rfc3339(&created_at_str) { let age = Utc::now().signed_duration_since(created_at.with_timezone(&Utc)); if age > chrono::Duration::try_minutes(15).unwrap_or_default() { issues.push(RegistryIssue { slug: slug.clone(), - target_type: target_type.clone(), - owner_user_id, + target_type: target_type.to_string(), + owner_tenant_id: owner_tenant_id_str.clone(), database_path: content_db_path.clone(), target_id: target_id.clone(), issue_type: RegistryIssueType::StaleReservation, @@ -170,19 +274,16 @@ impl RegistryValidator { } } - // Check target record exists for active / disabled (and reserving with target_id) - if status == "active" - || status == "disabled" - || (status == "reserving" && !target_id.is_empty()) - { + // 6. Target record check in tenant content DB + if status == "active" || status == "disabled" { if !content_db_path.exists() { issues.push(RegistryIssue { slug: slug.clone(), - target_type: target_type.clone(), - owner_user_id, + target_type: target_type.to_string(), + owner_tenant_id: owner_tenant_id_str.clone(), database_path: content_db_path.clone(), target_id: target_id.clone(), - issue_type: RegistryIssueType::MissingDatabase, + issue_type: RegistryIssueType::TrueOrphan, description: format!( "Slug '{}' owner content database does not exist at {:?}", slug, content_db_path @@ -190,47 +291,66 @@ impl RegistryValidator { }); } else { match Connection::open(&content_db_path) { - Ok(conn) => { + Ok(tenant_conn) => { let exists = if target_type == "url" { - conn.query_row( - "SELECT EXISTS(SELECT 1 FROM urls WHERE id = ?1);", - [&target_id], - |r| r.get(0), - ) - .unwrap_or(false) - } else if target_type == "page" { - conn.query_row( - "SELECT EXISTS(SELECT 1 FROM landing_pages WHERE id = ?1);", - [&target_id], - |r| r.get(0), - ) - .unwrap_or(false) + tenant_conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM urls WHERE id = ?1);", + [&target_id], + |r| r.get(0), + ) + .unwrap_or(false) } else { - false + tenant_conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM landing_pages WHERE id = ?1);", + [&target_id], + |r| r.get(0), + ) + .unwrap_or(false) }; if !exists { issues.push(RegistryIssue { slug: slug.clone(), - target_type: target_type.clone(), - owner_user_id, + target_type: target_type.to_string(), + owner_tenant_id: owner_tenant_id_str.clone(), database_path: content_db_path.clone(), target_id: target_id.clone(), issue_type: RegistryIssueType::MissingTarget, - description: format!("Slug '{}' (type: '{}', id: '{}') references missing target record in owner's content database", slug, target_type, target_id), + description: format!( + "Slug '{}' (type: '{}', id: '{}') references missing target record in tenant content database", + slug, target_type, target_id + ), }); } } + Err(rusqlite::Error::SqliteFailure(err, _)) + if err.code == rusqlite::ErrorCode::DatabaseCorrupt => + { + issues.push(RegistryIssue { + slug: slug.clone(), + target_type: target_type.to_string(), + owner_tenant_id: owner_tenant_id_str.clone(), + database_path: content_db_path.clone(), + target_id: target_id.clone(), + issue_type: RegistryIssueType::CorruptDatabase, + description: format!( + "Slug '{}' owner content database is corrupt at {:?}", + slug, content_db_path + ), + }); + } Err(e) => { issues.push(RegistryIssue { slug: slug.clone(), - target_type: target_type.clone(), - owner_user_id, + target_type: target_type.to_string(), + owner_tenant_id: owner_tenant_id_str.clone(), database_path: content_db_path.clone(), target_id: target_id.clone(), - issue_type: RegistryIssueType::MissingDatabase, + issue_type: RegistryIssueType::AccessFailure, description: format!( - "Slug '{}' owner content database could not be opened: {}", + "Slug '{}' owner content database could not be accessed: {}", slug, e ), }); @@ -240,46 +360,6 @@ impl RegistryValidator { } } - // 3. Admin Content Reverse Consistency Check (Legacy DB) - // Check if tenant databases contain content for admin users incorrectly (isolated admin content) - if slug_filter.is_none() { - let mut stmt = users_conn.prepare( - "SELECT id, username FROM users WHERE account_type = 'admin' AND id != 1;", - )?; - let mut admin_rows = stmt.query([])?; - while let Some(row) = admin_rows.next()? { - let id: i64 = row.get(0)?; - let username: String = row.get(1)?; - let tenant_db_path = data_dir - .join("users") - .join(id.to_string()) - .join("content.db"); - - if tenant_db_path.exists() { - if let Ok(tenant_conn) = Connection::open(&tenant_db_path) { - let url_count: i64 = tenant_conn - .query_row("SELECT COUNT(*) FROM urls;", [], |r| r.get(0)) - .unwrap_or(0); - let page_count: i64 = tenant_conn - .query_row("SELECT COUNT(*) FROM landing_pages;", [], |r| r.get(0)) - .unwrap_or(0); - - if url_count > 0 || page_count > 0 { - issues.push(RegistryIssue { - slug: "*".to_string(), - target_type: "system".to_string(), - owner_user_id: id, - database_path: tenant_db_path.clone(), - target_id: "".to_string(), - issue_type: RegistryIssueType::TenantAdminHasIsolatedContent, - description: format!("Admin user '{}' (ID {}) has content in isolated tenant DB ({} URLs, {} pages). Admin content should be in legacy DB 1.", username, id, url_count, page_count), - }); - } - } - } - } - } - - Ok(issues) + Ok(()) } } diff --git a/src/services/shortener.rs b/src/services/shortener.rs index 65d0f61..78a2e6c 100644 --- a/src/services/shortener.rs +++ b/src/services/shortener.rs @@ -1,9 +1,8 @@ -use crate::db::Db; use crate::error::AppError; use crate::models::Url; pub fn create_url( - db: &Db, + conn: &rusqlite::Connection, code: &str, destination: &str, title: Option<&str>, @@ -15,19 +14,11 @@ pub fn create_url( "Destination must be a valid http(s) URL without control characters".into(), )); } - let conn = db - .content - .lock() - .map_err(|e| AppError::Internal(format!("content_db mutex poisoned: {}", e)))?; - let url = crate::db::content::create_url(&conn, code, destination, title, description, tags)?; + let url = crate::db::content::create_url(conn, code, destination, title, description, tags)?; Ok(url) } -pub fn get_url_by_code(db: &Db, code: &str) -> Result, AppError> { - let conn = db - .content - .lock() - .map_err(|e| AppError::Internal(format!("content_db mutex poisoned: {}", e)))?; - let url = crate::db::content::get_url_by_code(&conn, code)?; +pub fn get_url_by_code(conn: &rusqlite::Connection, code: &str) -> Result, AppError> { + let url = crate::db::content::get_url_by_code(conn, code)?; Ok(url) } diff --git a/src/services/slug_transfer.rs b/src/services/slug_transfer.rs index 6b72a5e..184c7f7 100644 --- a/src/services/slug_transfer.rs +++ b/src/services/slug_transfer.rs @@ -1,12 +1,13 @@ //! Cross-tenant slug transfer business logic. //! -//! Copies URL/page content between tenant content DBs, then updates global_slugs -//! ownership. Handlers own admin auth and HTTP mapping. +//! Copies URL/page content between tenant content DBs, then updates v0.8 slug +//! databases ownership. Handlers own admin auth and HTTP mapping. +use crate::db::tenant::TenantOpenMode; +use crate::identity::TenantId; use crate::state::{AppState, UserDbs}; use crate::utils::lock_db; use chrono::Utc; -use rusqlite::OptionalExtension; #[derive(Debug)] pub enum TransferError { @@ -34,31 +35,21 @@ pub struct SlugTransferRequest { pub struct SlugTransferResult { pub old_owner_user_id: i64, pub new_owner_user_id: i64, + pub old_owner_tenant_id: TenantId, + pub new_owner_tenant_id: TenantId, pub target_type: String, pub new_target_id: String, } -/// Look up slug ownership in `global_slugs`. -pub fn lookup_slug(state: &AppState, slug: &str) -> Result<(i64, String, String), TransferError> { - let system_conn = lock_db(&state.system_db, "system_db") - .map_err(|e| TransferError::Internal(e.to_string()))?; - let mut stmt = system_conn - .prepare("SELECT owner_user_id, target_type, target_id FROM global_slugs WHERE slug = ?1;") - .map_err(|e| TransferError::Internal(e.to_string()))?; - let row_opt = stmt - .query_row([slug], |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - )) - }) - .optional() - .map_err(|e| TransferError::Internal(e.to_string()))?; - - match row_opt { - Some(r) => Ok(r), - None => Err(TransferError::NotFound("Slug not found")), +/// Look up slug ownership in v0.8 slug databases (`global_urls.db` / `global_landing_pages.db`). +pub fn lookup_slug( + state: &AppState, + slug: &str, +) -> Result { + match state.lookup_slug(slug) { + Ok(Some(info)) => Ok(info), + Ok(None) => Err(TransferError::NotFound("Slug not found")), + Err(e) => Err(TransferError::Internal(e.to_string())), } } @@ -158,25 +149,68 @@ fn copy_content( } } -/// Perform a full slug transfer (content + registry + quotas + history). +/// Perform a full slug transfer (content + v0.8 slug registry + quotas + history). pub fn transfer_slug( state: &AppState, req: &SlugTransferRequest, admin_username: &str, ) -> Result { - let (old_owner_user_id, target_type, _target_id) = lookup_slug(state, &req.slug)?; + let slug_info = lookup_slug(state, &req.slug)?; + let target_type = slug_info.target_type.as_str().to_string(); - if old_owner_user_id == req.new_owner_user_id { + let old_owner_tenant_id = TenantId::parse(&slug_info.owner_tenant_id).map_err(|_| { + TransferError::Internal(format!( + "Invalid owner tenant ID '{}' on slug '{}'", + slug_info.owner_tenant_id, req.slug + )) + })?; + + // Look up old owner user row in users.db + let (old_owner_user_id, new_owner_tenant_id) = { + let users_conn = lock_db(&state.users_db, "users_db") + .map_err(|e| TransferError::Internal(e.to_string()))?; + + let old_user = crate::db::users::get_user_by_tenant_id(&users_conn, old_owner_tenant_id) + .map_err(|e| TransferError::Internal(e.to_string()))? + .ok_or_else(|| { + TransferError::Internal(format!( + "Current owner user for tenant {old_owner_tenant_id} not found" + )) + })?; + + let new_user = crate::db::users::get_user_by_id(&users_conn, req.new_owner_user_id) + .map_err(|e| TransferError::Internal(e.to_string()))? + .ok_or_else(|| { + TransferError::BadRequest(format!( + "Target user ID {} not found", + req.new_owner_user_id + )) + })?; + + if new_user.account_type == "admin" { + return Err(TransferError::BadRequest( + "Target user cannot be an Admin account (must be a tenant account)".into(), + )); + } + + let new_tid = new_user.tenant_id.ok_or_else(|| { + TransferError::BadRequest("Target user has no TenantId allocated".into()) + })?; + + (old_user.id, new_tid) + }; + + if old_owner_tenant_id == new_owner_tenant_id { return Err(TransferError::BadRequest( "New owner must be different from the current owner".into(), )); } let old_dbs = state - .get_user_dbs(old_owner_user_id) + .open_tenant(old_owner_tenant_id, TenantOpenMode::CoreJob) .map_err(|_| TransferError::Internal("Failed to load current owner's database".into()))?; let new_dbs = state - .get_user_dbs(req.new_owner_user_id) + .open_tenant(new_owner_tenant_id, TenantOpenMode::Provision) .map_err(|_| TransferError::Internal("Failed to load new owner's database".into()))?; let new_target_id = copy_content( @@ -188,16 +222,29 @@ pub fn transfer_slug( req.new_owner_user_id, )?; + // Update authoritative v0.8 slug databases + { + let urls_conn = lock_db(&state.db.global_urls, "global_urls") + .map_err(|e| TransferError::Internal(e.to_string()))?; + let pages_conn = lock_db(&state.db.global_landing_pages, "global_landing_pages") + .map_err(|e| TransferError::Internal(e.to_string()))?; + + crate::db::slugs::transfer_slug_owner( + &urls_conn, + &pages_conn, + &req.slug, + &new_owner_tenant_id, + &new_target_id, + ) + .map_err(|e| TransferError::Internal(format!("Failed to update slug registry: {e}")))?; + } + + // Write audit event and history { let system_conn = lock_db(&state.system_db, "system_db") .map_err(|e| TransferError::Internal(e.to_string()))?; let now = Utc::now().to_rfc3339(); - let _ = system_conn.execute( - "UPDATE global_slugs SET owner_user_id = ?1, target_id = ?2, updated_at = ?3 WHERE slug = ?4;", - rusqlite::params![req.new_owner_user_id, new_target_id, now, req.slug], - ); - let _ = system_conn.execute( "INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username) VALUES (?1, ?2, ?3, 'transferred', ?4, ?5);", @@ -228,8 +275,8 @@ pub fn transfer_slug( "slug", &req.slug, Some(&format!( - "From owner {} to owner {}", - old_owner_user_id, req.new_owner_user_id + "From tenant {} (user {}) to tenant {} (user {})", + old_owner_tenant_id, old_owner_user_id, new_owner_tenant_id, req.new_owner_user_id )), ); } @@ -237,6 +284,8 @@ pub fn transfer_slug( Ok(SlugTransferResult { old_owner_user_id, new_owner_user_id: req.new_owner_user_id, + old_owner_tenant_id, + new_owner_tenant_id, target_type, new_target_id, }) diff --git a/src/state.rs b/src/state.rs index 341e0e8..d041c08 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,26 +1,20 @@ use crate::analytics::queue::AnalyticsQueue; use crate::config::Config; use crate::db::Db; -use rusqlite::Connection; +use crate::identity::TenantId; +use rusqlite::{Connection, OptionalExtension}; use std::collections::HashMap; use std::sync::{Arc, Mutex}; use std::time::Instant; -#[derive(Clone)] -pub struct UserDbs { - pub content: Arc>, - pub analytics: Arc>, - pub profile: Arc>, -} +pub use crate::db::tenant::{TenantOpenMode, UserDbs}; #[derive(Clone)] pub struct AppState { pub admin_db: Arc>, - pub content_db: Arc>, - pub analytics_db: Arc>, pub system_db: Arc>, pub users_db: Arc>, - pub user_dbs: Arc>>, + pub user_dbs: Arc>>, pub db: Db, pub config: Config, pub analytics_queue: AnalyticsQueue, @@ -28,71 +22,126 @@ pub struct AppState { } impl AppState { + /// Compatibility opener: Core `users.id` must refer to a registered, + /// non-deleted user. Unknown ids never create a database. pub fn get_user_dbs(&self, user_id: i64) -> Result { + self.get_user_dbs_with_mode(user_id, TenantOpenMode::PublicContent) + } + + pub fn get_user_dbs_with_mode( + &self, + user_id: i64, + mode: TenantOpenMode, + ) -> Result { + let users = crate::utils::lock_db(&self.users_db, "users_db")?; let mut pool = crate::utils::lock_db(&self.user_dbs, "user_dbs")?; - if let Some(dbs) = pool.get(&user_id) { - return Ok(dbs.clone()); + crate::db::tenant::open_for_row_id( + &users, + &self.db.topology, + &self.system_db, + &mut pool, + user_id, + mode, + ) + } + + /// Frozen tenant opener. Unknown TenantId never creates a database. + pub fn open_tenant( + &self, + tenant_id: TenantId, + mode: TenantOpenMode, + ) -> Result { + let users = crate::utils::lock_db(&self.users_db, "users_db")?; + let mut pool = crate::utils::lock_db(&self.user_dbs, "user_dbs")?; + crate::db::tenant::open_for_tenant_id( + &users, + &self.db.topology, + &self.system_db, + &mut pool, + tenant_id, + mode, + ) + } + + pub fn lookup_slug( + &self, + slug: &str, + ) -> Result, crate::error::AppError> { + let urls_conn = crate::utils::lock_db(&self.db.global_urls, "global_urls")?; + let pages_conn = + crate::utils::lock_db(&self.db.global_landing_pages, "global_landing_pages")?; + if let Some(info) = crate::db::slugs::lookup_slug(&urls_conn, &pages_conn, slug)? { + return Ok(Some(info)); } - // Open connection and run migrations - let user_dir = self.config.data_dir.join("users").join(user_id.to_string()); - std::fs::create_dir_all(&user_dir)?; + // Fallback to system.db.global_slugs if table exists (backward compatibility during transition) + let system_conn = crate::utils::lock_db(&self.system_db, "system_db")?; + let has_table: bool = system_conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='global_slugs');", + [], + |r| r.get(0), + ) + .unwrap_or(false); - let content_path = user_dir.join("content.db"); - let analytics_path = user_dir.join("analytics.db"); - let profile_path = user_dir.join("profile.db"); + if has_table { + let row_opt: Option<(i64, String, String, String)> = system_conn + .query_row( + "SELECT owner_user_id, target_type, target_id, status FROM global_slugs WHERE slug = ?1;", + [slug], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)), + ) + .optional()?; - let mut content_conn = Connection::open(content_path)?; - let mut analytics_conn = Connection::open(analytics_path)?; - let profile_conn = Connection::open(profile_path)?; + if let Some((owner_id, target_type, target_id, status)) = row_opt { + let tt = match target_type.as_str() { + "page" => crate::db::slugs::SlugTargetType::LandingPage, + _ => crate::db::slugs::SlugTargetType::Url, + }; + return Ok(Some(crate::db::slugs::ResolvedSlugInfo { + slug: slug.to_string(), + owner_tenant_id: owner_id.to_string(), + target_type: tt, + target_id, + created_at: String::new(), + updated_at: String::new(), + status, + retired_at: None, + })); + } + } - crate::db::sqlite::enable_wal(&content_conn, "content")?; - crate::db::sqlite::enable_wal(&analytics_conn, "analytics")?; - crate::db::sqlite::enable_wal(&profile_conn, "profile")?; + Ok(None) + } - crate::db::sqlite::enable_foreign_keys(&content_conn, "content")?; - crate::db::sqlite::enable_foreign_keys(&analytics_conn, "analytics")?; - crate::db::sqlite::enable_foreign_keys(&profile_conn, "profile")?; - - // Run migrations - crate::db::migrations::run_migrations( - &mut content_conn, - "content", - crate::db::migrations::CONTENT_MIGRATIONS, - Some(&self.system_db), - ) - .map_err(|e| crate::error::AppError::Internal(e.to_string()))?; - crate::db::migrations::run_migrations( - &mut analytics_conn, - "analytics", - crate::db::migrations::ANALYTICS_MIGRATIONS, - Some(&self.system_db), - ) - .map_err(|e| crate::error::AppError::Internal(e.to_string()))?; - - profile_conn.execute_batch( - "CREATE TABLE IF NOT EXISTS settings ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL - );", - )?; - - let dbs = UserDbs { - content: Arc::new(Mutex::new(content_conn)), - analytics: Arc::new(Mutex::new(analytics_conn)), - profile: Arc::new(Mutex::new(profile_conn)), - }; - - pool.insert(user_id, dbs.clone()); - Ok(dbs) + pub fn open_slug_owner( + &self, + owner_tenant_id: &str, + mode: TenantOpenMode, + ) -> Result { + if owner_tenant_id == "legacy_admin" || owner_tenant_id == "1" { + return self.get_user_dbs_with_mode(1, mode); + } + if let Ok(tid) = TenantId::parse(owner_tenant_id) { + return self.open_tenant(tid, mode); + } + if let Ok(uid) = owner_tenant_id.parse::() { + return self.get_user_dbs_with_mode(uid, mode); + } + Err(crate::error::AppError::NotFound(format!( + "invalid owner identity: {}", + owner_tenant_id + ))) } pub fn db_compact(&self) -> Result<(), crate::error::AppError> { crate::utils::lock_db(&self.admin_db, "admin_db")?.execute("VACUUM;", [])?; - crate::utils::lock_db(&self.content_db, "content_db")?.execute("VACUUM;", [])?; - crate::utils::lock_db(&self.analytics_db, "analytics_db")?.execute("VACUUM;", [])?; crate::utils::lock_db(&self.system_db, "system_db")?.execute("VACUUM;", [])?; crate::utils::lock_db(&self.users_db, "users_db")?.execute("VACUUM;", [])?; + crate::utils::lock_db(&self.db.global_urls, "global_urls")?.execute("VACUUM;", [])?; + crate::utils::lock_db(&self.db.global_landing_pages, "global_landing_pages")? + .execute("VACUUM;", [])?; + crate::utils::lock_db(&self.db.reserved, "reserved")?.execute("VACUUM;", [])?; Ok(()) } } diff --git a/src/utils/system.rs b/src/utils/system.rs index ff40637..7befdd7 100644 --- a/src/utils/system.rs +++ b/src/utils/system.rs @@ -22,6 +22,9 @@ pub fn get_db_file_info(data_dir: &Path) -> String { ("admin/admin.db", "Admin DB"), ("admin/system.db", "System DB"), ("admin/users.db", "Users DB"), + ("slugs/global_urls.db", "Global URLs DB"), + ("slugs/global_landing_pages.db", "Global Landing Pages DB"), + ("slugs/reserved.db", "Reserved Slugs DB"), ("users/1/content.db", "Legacy Content DB"), ("users/1/analytics.db", "Legacy Analytics DB"), ]; diff --git a/src/web/admin/analytics.rs b/src/web/admin/analytics.rs index 3ba1389..88be495 100644 --- a/src/web/admin/analytics.rs +++ b/src/web/admin/analytics.rs @@ -12,8 +12,36 @@ pub async fn url_analytics_get( Err(redir) => return redir.into_response(), }; + let (_slug, owner_tid) = { + let conn = state.db.global_urls.lock().unwrap(); + match conn.query_row( + "SELECT slug, owner_tenant_id FROM global_urls WHERE target_id = ?1 OR slug = ?1 LIMIT 1;", + rusqlite::params![id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) { + Ok((s, t)) => (s, t), + Err(_) => return (StatusCode::NOT_FOUND, "URL not found").into_response(), + } + }; + let tid = match owner_tid.parse::() { + Ok(t) => t, + Err(_) => { + return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid tenant identity").into_response() + } + }; + let user_dbs = match state.open_tenant(tid, crate::state::TenantOpenMode::CoreJob) { + Ok(d) => d, + Err(_) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to open tenant database", + ) + .into_response() + } + }; + let url = { - let conn = state.content_db.lock().unwrap(); + let conn = user_dbs.content.lock().unwrap(); match get_url_by_id(&conn, &id) { Ok(Some(u)) => u, Ok(None) => return (StatusCode::NOT_FOUND, "URL not found").into_response(), @@ -21,7 +49,7 @@ pub async fn url_analytics_get( } }; - let conn = state.analytics_db.lock().unwrap(); + let conn = user_dbs.analytics.lock().unwrap(); let schema_cols = get_visits_schema_columns(&conn).unwrap_or_default(); let has_utm_source = schema_cols.contains("utm_source"); @@ -184,8 +212,36 @@ pub async fn page_analytics_get( Err(redir) => return redir.into_response(), }; + let (_slug, owner_tid) = { + let conn = state.db.global_landing_pages.lock().unwrap(); + match conn.query_row( + "SELECT slug, owner_tenant_id FROM global_landing_pages WHERE target_id = ?1 OR slug = ?1 LIMIT 1;", + rusqlite::params![id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) { + Ok((s, t)) => (s, t), + Err(_) => return (StatusCode::NOT_FOUND, "Landing page not found").into_response(), + } + }; + let tid = match owner_tid.parse::() { + Ok(t) => t, + Err(_) => { + return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid tenant identity").into_response() + } + }; + let user_dbs = match state.open_tenant(tid, crate::state::TenantOpenMode::CoreJob) { + Ok(d) => d, + Err(_) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to open tenant database", + ) + .into_response() + } + }; + let page = { - let conn = state.content_db.lock().unwrap(); + let conn = user_dbs.content.lock().unwrap(); match get_landing_page_by_id(&conn, &id) { Ok(Some(p)) => p, Ok(None) => return (StatusCode::NOT_FOUND, "Landing page not found").into_response(), @@ -193,7 +249,7 @@ pub async fn page_analytics_get( } }; - let conn = state.analytics_db.lock().unwrap(); + let conn = user_dbs.analytics.lock().unwrap(); let schema_cols = get_visits_schema_columns(&conn).unwrap_or_default(); let has_utm_source = schema_cols.contains("utm_source"); @@ -439,6 +495,7 @@ pub async fn user_analytics_get( accept_language: row.get(7)?, country: row.get(8)?, status_code: row.get(9)?, + owner_tenant_id: user.tenant_id, owner_user_id: Some(user.id), }) }) @@ -478,12 +535,12 @@ pub async fn user_url_analytics_get( }; // Ownership check - let (owner_user_id, target_type) = { - let conn = state.system_db.lock().unwrap(); + let owner_tid_str = { + let conn = state.db.global_urls.lock().unwrap(); match conn.query_row( - "SELECT owner_user_id, target_type FROM global_slugs WHERE target_id = ?1", + "SELECT owner_tenant_id FROM global_urls WHERE target_id = ?1", [&id], - |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), + |row| row.get::<_, String>(0), ) { Ok(val) => val, Err(rusqlite::Error::QueryReturnedNoRows) => { @@ -493,7 +550,12 @@ pub async fn user_url_analytics_get( } }; - if owner_user_id != user.id || target_type != "url" { + let user_tid = match user.tenant_id { + Some(t) => t.to_string(), + None => return StatusCode::FORBIDDEN.into_response(), + }; + + if owner_tid_str != user_tid && owner_tid_str != format!("{}", user.id) { return StatusCode::FORBIDDEN.into_response(); } @@ -674,12 +736,12 @@ pub async fn user_page_analytics_get( }; // Ownership check - let (owner_user_id, target_type) = { - let conn = state.system_db.lock().unwrap(); + let owner_tid_str = { + let conn = state.db.global_landing_pages.lock().unwrap(); match conn.query_row( - "SELECT owner_user_id, target_type FROM global_slugs WHERE target_id = ?1", + "SELECT owner_tenant_id FROM global_landing_pages WHERE target_id = ?1", [&id], - |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), + |row| row.get::<_, String>(0), ) { Ok(val) => val, Err(rusqlite::Error::QueryReturnedNoRows) => { @@ -689,7 +751,12 @@ pub async fn user_page_analytics_get( } }; - if owner_user_id != user.id || target_type != "page" { + let user_tid = match user.tenant_id { + Some(t) => t.to_string(), + None => return StatusCode::FORBIDDEN.into_response(), + }; + + if owner_tid_str != user_tid && owner_tid_str != format!("{}", user.id) { return StatusCode::FORBIDDEN.into_response(); } @@ -862,12 +929,12 @@ pub async fn user_url_analytics_csv_export( }; // Ownership check - let (owner_user_id, target_type) = { - let conn = state.system_db.lock().unwrap(); + let owner_tid_str = { + let conn = state.db.global_urls.lock().unwrap(); match conn.query_row( - "SELECT owner_user_id, target_type FROM global_slugs WHERE target_id = ?1", + "SELECT owner_tenant_id FROM global_urls WHERE target_id = ?1", [&id], - |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), + |row| row.get::<_, String>(0), ) { Ok(val) => val, Err(rusqlite::Error::QueryReturnedNoRows) => { @@ -877,7 +944,12 @@ pub async fn user_url_analytics_csv_export( } }; - if owner_user_id != user.id || target_type != "url" { + let user_tid = match user.tenant_id { + Some(t) => t.to_string(), + None => return StatusCode::FORBIDDEN.into_response(), + }; + + if owner_tid_str != user_tid && owner_tid_str != format!("{}", user.id) { return StatusCode::FORBIDDEN.into_response(); } @@ -901,12 +973,12 @@ pub async fn user_url_analytics_json_export( }; // Ownership check - let (owner_user_id, target_type) = { - let conn = state.system_db.lock().unwrap(); + let owner_tid_str = { + let conn = state.db.global_urls.lock().unwrap(); match conn.query_row( - "SELECT owner_user_id, target_type FROM global_slugs WHERE target_id = ?1", + "SELECT owner_tenant_id FROM global_urls WHERE target_id = ?1", [&id], - |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), + |row| row.get::<_, String>(0), ) { Ok(val) => val, Err(rusqlite::Error::QueryReturnedNoRows) => { @@ -916,7 +988,12 @@ pub async fn user_url_analytics_json_export( } }; - if owner_user_id != user.id || target_type != "url" { + let user_tid = match user.tenant_id { + Some(t) => t.to_string(), + None => return StatusCode::FORBIDDEN.into_response(), + }; + + if owner_tid_str != user_tid && owner_tid_str != format!("{}", user.id) { return StatusCode::FORBIDDEN.into_response(); } @@ -940,12 +1017,12 @@ pub async fn user_page_analytics_csv_export( }; // Ownership check - let (owner_user_id, target_type) = { - let conn = state.system_db.lock().unwrap(); + let owner_tid_str = { + let conn = state.db.global_landing_pages.lock().unwrap(); match conn.query_row( - "SELECT owner_user_id, target_type FROM global_slugs WHERE target_id = ?1", + "SELECT owner_tenant_id FROM global_landing_pages WHERE target_id = ?1", [&id], - |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), + |row| row.get::<_, String>(0), ) { Ok(val) => val, Err(rusqlite::Error::QueryReturnedNoRows) => { @@ -955,7 +1032,12 @@ pub async fn user_page_analytics_csv_export( } }; - if owner_user_id != user.id || target_type != "page" { + let user_tid = match user.tenant_id { + Some(t) => t.to_string(), + None => return StatusCode::FORBIDDEN.into_response(), + }; + + if owner_tid_str != user_tid && owner_tid_str != format!("{}", user.id) { return StatusCode::FORBIDDEN.into_response(); } @@ -979,12 +1061,12 @@ pub async fn user_page_analytics_json_export( }; // Ownership check - let (owner_user_id, target_type) = { - let conn = state.system_db.lock().unwrap(); + let owner_tid_str = { + let conn = state.db.global_landing_pages.lock().unwrap(); match conn.query_row( - "SELECT owner_user_id, target_type FROM global_slugs WHERE target_id = ?1", + "SELECT owner_tenant_id FROM global_landing_pages WHERE target_id = ?1", [&id], - |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), + |row| row.get::<_, String>(0), ) { Ok(val) => val, Err(rusqlite::Error::QueryReturnedNoRows) => { @@ -994,7 +1076,12 @@ pub async fn user_page_analytics_json_export( } }; - if owner_user_id != user.id || target_type != "page" { + let user_tid = match user.tenant_id { + Some(t) => t.to_string(), + None => return StatusCode::FORBIDDEN.into_response(), + }; + + if owner_tid_str != user_tid && owner_tid_str != format!("{}", user.id) { return StatusCode::FORBIDDEN.into_response(); } diff --git a/src/web/admin/auth.rs b/src/web/admin/auth.rs index 65b6d64..21208ae 100644 --- a/src/web/admin/auth.rs +++ b/src/web/admin/auth.rs @@ -96,25 +96,7 @@ fn load_tenant_user_by_username( conn: &rusqlite::Connection, username: &str, ) -> Result, rusqlite::Error> { - conn.query_row( - "SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata - FROM users WHERE username = ?1;", - [username], - |row| { - Ok(crate::models::TenantUser { - id: row.get(0)?, - username: row.get(1)?, - password_hash: row.get(2)?, - status: row.get(3)?, - created_at: row.get(4)?, - last_login: row.get(5)?, - account_type: row.get(6)?, - organization_id: row.get(7)?, - metadata: row.get(8)?, - }) - }, - ) - .optional() + crate::db::users::get_user_by_username(conn, username) } fn tenant_user_to_admin_user(u: crate::models::TenantUser) -> User { @@ -180,21 +162,18 @@ pub async fn login_post( } }; - let conn = match state.users_db.lock() { - Ok(c) => c, - Err(_) => { - return Redirect::to("/admin/login?error=Internal error").into_response(); - } - }; - match crate::db::users::create_admin_user(&conn, &form.username, &hash) { - Ok(u) => { - if let Err(e) = state.db.init_user_databases(u.id) { - tracing::error!( - "Failed to init user databases during admin bootstrap: {:?}", - e - ); + let created_user_res = { + let conn = match state.users_db.lock() { + Ok(c) => c, + Err(_) => { + return Redirect::to("/admin/login?error=Internal error").into_response(); } + }; + crate::db::users::create_admin_user(&conn, &form.username, &hash) + }; + match created_user_res { + Ok(u) => { if let Ok(system_conn) = state.system_db.lock() { let metadata = audit_meta(&ip, &headers); let _ = crate::db::audit_events::write_audit_event( @@ -385,26 +364,7 @@ pub async fn public_login_post( let user_opt: Option = { let conn = state.users_db.lock().unwrap(); - let user_res: Result, rusqlite::Error> = conn.query_row( - "SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata \ - FROM users WHERE username = ?1;", - [&form.username], - |row| { - Ok(crate::models::TenantUser { - id: row.get(0)?, - username: row.get(1)?, - password_hash: row.get(2)?, - status: row.get(3)?, - created_at: row.get(4)?, - last_login: row.get(5)?, - account_type: row.get(6)?, - organization_id: row.get(7)?, - metadata: row.get(8)?, - }) - } - ).optional(); - - match user_res { + match crate::db::users::get_user_by_username(&conn, &form.username) { Ok(Some(u)) => { if u.status != "active" { None diff --git a/src/web/admin/dashboard.rs b/src/web/admin/dashboard.rs index 8a0600b..fc732ff 100644 --- a/src/web/admin/dashboard.rs +++ b/src/web/admin/dashboard.rs @@ -95,25 +95,44 @@ pub async fn dashboard_get(State(state): State, jar: CookieJar) -> Res }; let (total_urls, active_links, dead_links) = { - let conn = state.content_db.lock().unwrap(); - get_url_counts(&conn).unwrap_or((0, 0, 0)) + let conn = state.db.global_urls.lock().unwrap(); + let total: i64 = conn + .query_row( + "SELECT COUNT(*) FROM global_urls WHERE status != 'retired';", + [], + |r| r.get(0), + ) + .unwrap_or(0); + let active: i64 = conn + .query_row( + "SELECT COUNT(*) FROM global_urls WHERE status = 'active';", + [], + |r| r.get(0), + ) + .unwrap_or(0); + let dead: i64 = conn + .query_row( + "SELECT COUNT(*) FROM global_urls WHERE status = 'disabled';", + [], + |r| r.get(0), + ) + .unwrap_or(0); + (total, active, dead) }; let total_pages = { - let conn = state.content_db.lock().unwrap(); - get_landing_page_count(&conn).unwrap_or(0) + let conn = state.db.global_landing_pages.lock().unwrap(); + conn.query_row( + "SELECT COUNT(*) FROM global_landing_pages WHERE status != 'retired';", + [], + |r| r.get(0), + ) + .unwrap_or(0) }; let total_clicks = { - let conn = state.analytics_db.lock().unwrap(); - get_total_clicks(&conn).unwrap_or(0) - }; - - let clicks_data = { - let conn = state.analytics_db.lock().unwrap(); - get_clicks_trend(&conn, "url", "all", 30) - .or_else(|_| get_clicks_trend_raw(&conn, "url", "all", 30)) - .unwrap_or_default() + let users_conn = state.users_db.lock().unwrap(); + crate::db::users::get_platform_total_clicks(&state.db.topology, &users_conn).unwrap_or(0) }; let mut trend_map = std::collections::BTreeMap::new(); @@ -123,35 +142,12 @@ pub async fn dashboard_get(State(state): State, jar: CookieJar) -> Res .to_string(); trend_map.insert(date_str, 0i64); } - for (d, c) in clicks_data { - trend_map.insert(d, c); - } let formatted_trend: Vec<(String, i64)> = trend_map.into_iter().collect(); let traffic_chart = generate_line_chart(&formatted_trend); - let countries_data = { - let conn = state.analytics_db.lock().unwrap(); - get_metric_rankings(&conn, "url", "all", "country", 5) - .or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "country", 5)) - .unwrap_or_default() - }; - let countries_chart = generate_bar_chart(&countries_data); - - let referrers_data = { - let conn = state.analytics_db.lock().unwrap(); - get_metric_rankings(&conn, "url", "all", "referrer", 5) - .or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "referrer", 5)) - .unwrap_or_default() - }; - let referrers_chart = generate_bar_chart(&referrers_data); - - let browsers_data = { - let conn = state.analytics_db.lock().unwrap(); - get_metric_rankings(&conn, "url", "all", "browser", 5) - .or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "browser", 5)) - .unwrap_or_default() - }; - let browsers_chart = generate_bar_chart(&browsers_data); + let countries_chart = generate_bar_chart(&[]); + let referrers_chart = generate_bar_chart(&[]); + let browsers_chart = generate_bar_chart(&[]); let template = crate::templates::DashboardTemplate { admin_username: user.username, diff --git a/src/web/admin/health.rs b/src/web/admin/health.rs index 8d215af..a04dce6 100644 --- a/src/web/admin/health.rs +++ b/src/web/admin/health.rs @@ -49,9 +49,27 @@ pub async fn health_get( db_reports.push(r); } - let system_db_path = state.config.data_dir.join("admin").join("system.db"); - let users_db_path = state.config.data_dir.join("admin").join("users.db"); - let admin_db_path = state.config.data_dir.join("admin").join("admin.db"); + if let Ok(r) = crate::db::sqlite::collect_health_report( + &state.db.global_urls.lock().unwrap(), + "global_urls", + ) { + db_reports.push(r); + } + if let Ok(r) = crate::db::sqlite::collect_health_report( + &state.db.global_landing_pages.lock().unwrap(), + "global_landing_pages", + ) { + db_reports.push(r); + } + if let Ok(r) = + crate::db::sqlite::collect_health_report(&state.db.reserved.lock().unwrap(), "reserved") + { + db_reports.push(r); + } + + let system_db_path = state.db.topology.system_db(); + let users_db_path = state.db.topology.users_registry_db(); + let admin_db_path = state.db.topology.admin_db(); let system_db_size = format_size( std::fs::metadata(&system_db_path) @@ -69,7 +87,7 @@ pub async fn health_get( .unwrap_or(0), ); - let users_dir = state.config.data_dir.join("users"); + let users_dir = state.db.topology.users_dir(); let tenants_db_size = format_size(get_dir_size(&users_dir).unwrap_or(0)); let total_data_size = format_size(get_dir_size(&state.config.data_dir).unwrap_or(0)); @@ -127,8 +145,7 @@ pub async fn health_get( for issue in issues { use crate::services::registry_validator::RegistryIssueType; match issue.issue_type { - RegistryIssueType::StaleReservation - | RegistryIssueType::TenantAdminHasIsolatedContent => { + RegistryIssueType::StaleReservation => { warnings.push(format!( "Warning for slug {}: {}", issue.slug, issue.description diff --git a/src/web/admin/mod.rs b/src/web/admin/mod.rs index 9438b3f..85e31d0 100644 --- a/src/web/admin/mod.rs +++ b/src/web/admin/mod.rs @@ -186,8 +186,48 @@ pub(crate) async fn perform_csv_export( Err(status) => return status.into_response(), }; + let (_slug, owner_tid) = { + let conn = if target_type == "url" { + state.db.global_urls.lock().unwrap() + } else { + state.db.global_landing_pages.lock().unwrap() + }; + let table = if target_type == "url" { + "global_urls" + } else { + "global_landing_pages" + }; + match conn.query_row( + &format!( + "SELECT slug, owner_tenant_id FROM {} WHERE target_id = ?1 OR slug = ?1 LIMIT 1;", + table + ), + rusqlite::params![id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) { + Ok((s, t)) => (s, t), + Err(_) => return (StatusCode::NOT_FOUND, "Target not found").into_response(), + } + }; + let tid = match owner_tid.parse::() { + Ok(t) => t, + Err(_) => { + return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid tenant identity").into_response() + } + }; + let user_dbs = match state.open_tenant(tid, crate::state::TenantOpenMode::CoreJob) { + Ok(d) => d, + Err(_) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to open tenant database", + ) + .into_response() + } + }; + let target_exists = { - let conn = state.content_db.lock().unwrap(); + let conn = user_dbs.content.lock().unwrap(); if target_type == "url" { get_url_by_id(&conn, &id) .map(|u| u.is_some()) @@ -203,7 +243,7 @@ pub(crate) async fn perform_csv_export( } let count = { - let conn = state.analytics_db.lock().unwrap(); + let conn = user_dbs.analytics.lock().unwrap(); get_target_visit_total_filtered( &conn, target_type, @@ -215,14 +255,14 @@ pub(crate) async fn perform_csv_export( }; let (has_utm_source, has_utm_campaign) = { - let conn = state.analytics_db.lock().unwrap(); + let conn = user_dbs.analytics.lock().unwrap(); let cols = get_visits_schema_columns(&conn).unwrap_or_default(); (cols.contains("utm_source"), cols.contains("utm_campaign")) }; let (tx, rx) = tokio::sync::mpsc::channel::>(32); - let analytics_db = state.analytics_db.clone(); + let analytics_db = user_dbs.analytics.clone(); let target_id = id.clone(); tokio::task::spawn_blocking(move || { @@ -378,8 +418,48 @@ pub(crate) async fn perform_json_export( Err(status) => return status.into_response(), }; + let (_slug, owner_tid) = { + let conn = if target_type == "url" { + state.db.global_urls.lock().unwrap() + } else { + state.db.global_landing_pages.lock().unwrap() + }; + let table = if target_type == "url" { + "global_urls" + } else { + "global_landing_pages" + }; + match conn.query_row( + &format!( + "SELECT slug, owner_tenant_id FROM {} WHERE target_id = ?1 OR slug = ?1 LIMIT 1;", + table + ), + rusqlite::params![id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) { + Ok((s, t)) => (s, t), + Err(_) => return (StatusCode::NOT_FOUND, "Target not found").into_response(), + } + }; + let tid = match owner_tid.parse::() { + Ok(t) => t, + Err(_) => { + return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid tenant identity").into_response() + } + }; + let user_dbs = match state.open_tenant(tid, crate::state::TenantOpenMode::CoreJob) { + Ok(d) => d, + Err(_) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to open tenant database", + ) + .into_response() + } + }; + let target_exists = { - let conn = state.content_db.lock().unwrap(); + let conn = user_dbs.content.lock().unwrap(); if target_type == "url" { get_url_by_id(&conn, &id) .map(|u| u.is_some()) @@ -395,7 +475,7 @@ pub(crate) async fn perform_json_export( } let count = { - let conn = state.analytics_db.lock().unwrap(); + let conn = user_dbs.analytics.lock().unwrap(); get_target_visit_total_filtered( &conn, target_type, @@ -411,7 +491,7 @@ pub(crate) async fn perform_json_export( } let visits_raw = { - let conn = state.analytics_db.lock().unwrap(); + let conn = user_dbs.analytics.lock().unwrap(); match get_target_visits_all_in_memory( &conn, target_type, diff --git a/src/web/admin/moderation.rs b/src/web/admin/moderation.rs index ac3ed00..6a68f53 100644 --- a/src/web/admin/moderation.rs +++ b/src/web/admin/moderation.rs @@ -1,4 +1,5 @@ use super::*; +use crate::identity::TenantId; #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct GlobalSlugRow { @@ -48,28 +49,83 @@ pub async fn moderation_get( Err(redir) => return redir.into_response(), }; - let flagged_items = { - let conn = state.system_db.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT slug, owner_user_id, target_type, target_id, created_at, updated_at, status, deleted_at \ - FROM global_slugs WHERE status = 'flagged' OR status = 'disabled' ORDER BY updated_at DESC;" - ).unwrap(); - let rows = stmt - .query_map([], |row| { + let mut flagged_items: Vec = Vec::new(); + + // Query global_urls.db + { + let urls_conn = state.db.global_urls.lock().unwrap(); + let users_conn = state.users_db.lock().unwrap(); + let query_res = urls_conn.prepare( + "SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at \ + FROM global_urls WHERE status = 'flagged' OR status = 'disabled' ORDER BY updated_at DESC;" + ).and_then(|mut stmt| { + let rows = stmt.query_map([], |row| { + let tid_str: String = row.get(1)?; + let uid = if let Ok(tid) = TenantId::parse(&tid_str) { + crate::db::users::get_user_by_tenant_id(&users_conn, tid) + .ok() + .flatten() + .map(|u| u.id) + .unwrap_or(0) + } else { + 0 + }; Ok(GlobalSlugRow { slug: row.get(0)?, - owner_user_id: row.get(1)?, - target_type: row.get(2)?, - target_id: row.get(3)?, - created_at: row.get(4)?, - updated_at: row.get(5)?, - status: row.get(6)?, - deleted_at: row.get(7)?, + owner_user_id: uid, + target_type: "url".to_string(), + target_id: row.get(2)?, + created_at: row.get(3)?, + updated_at: row.get(4)?, + status: row.get(5)?, + deleted_at: row.get(6)?, }) - }) - .unwrap(); - rows.filter_map(|r| r.ok()).collect() - }; + })?; + Ok(rows.filter_map(|r| r.ok()).collect::>()) + }); + if let Ok(items) = query_res { + flagged_items.extend(items); + } + } + + // Query global_landing_pages.db + { + let pages_conn = state.db.global_landing_pages.lock().unwrap(); + let users_conn = state.users_db.lock().unwrap(); + let query_res = pages_conn.prepare( + "SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at \ + FROM global_landing_pages WHERE status = 'flagged' OR status = 'disabled' ORDER BY updated_at DESC;" + ).and_then(|mut stmt| { + let rows = stmt.query_map([], |row| { + let tid_str: String = row.get(1)?; + let uid = if let Ok(tid) = TenantId::parse(&tid_str) { + crate::db::users::get_user_by_tenant_id(&users_conn, tid) + .ok() + .flatten() + .map(|u| u.id) + .unwrap_or(0) + } else { + 0 + }; + Ok(GlobalSlugRow { + slug: row.get(0)?, + owner_user_id: uid, + target_type: "page".to_string(), + target_id: row.get(2)?, + created_at: row.get(3)?, + updated_at: row.get(4)?, + status: row.get(5)?, + deleted_at: row.get(6)?, + }) + })?; + Ok(rows.filter_map(|r| r.ok()).collect::>()) + }); + if let Ok(items) = query_res { + flagged_items.extend(items); + } + } + + flagged_items.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); let logs = { let conn = state.system_db.lock().unwrap(); @@ -139,46 +195,91 @@ pub async fn moderation_post( return Redirect::to("/admin/moderation?error=Invalid moderation action").into_response(); } - let (owner_user_id, target_type) = { - let system_conn = state.system_db.lock().unwrap(); - let row_opt: Option<(i64, String)> = system_conn - .query_row( - "SELECT owner_user_id, target_type FROM global_slugs WHERE slug = ?1;", - [&form.slug], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .optional() - .unwrap_or(None); + let slug_info = match state.lookup_slug(&form.slug) { + Ok(Some(info)) => info, + _ => return Redirect::to("/admin/moderation?error=Slug not found").into_response(), + }; - match row_opt { - Some(r) => r, - None => return Redirect::to("/admin/moderation?error=Slug not found").into_response(), + let owner_tenant_id = match TenantId::parse(&slug_info.owner_tenant_id) { + Ok(t) => t, + Err(_) => { + return Redirect::to("/admin/moderation?error=Invalid tenant on slug").into_response() } }; - let owner_username = { + let (owner_user_id, owner_username) = { let users_conn = state.users_db.lock().unwrap(); - crate::db::users::get_user_by_id(&users_conn, owner_user_id) - .unwrap_or(None) - .map(|u| u.username) + match crate::db::users::get_user_by_tenant_id(&users_conn, owner_tenant_id) { + Ok(Some(u)) => (u.id, Some(u.username)), + _ => (0, None), + } }; - { - let system_conn = state.system_db.lock().unwrap(); - let now = Utc::now().to_rfc3339(); + let now = Utc::now().to_rfc3339(); - if action == "deleted" { - let _ = system_conn.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&form.slug]); + // 1. Update v0.8 slug database + if action == "deleted" { + if slug_info.target_type == crate::db::slugs::SlugTargetType::Url { + let urls_conn = state.db.global_urls.lock().unwrap(); + let _ = urls_conn.execute("DELETE FROM global_urls WHERE slug = ?1;", [&form.slug]); } else { - let _ = system_conn.execute( - "UPDATE global_slugs SET status = ?1, updated_at = ?2 WHERE slug = ?3;", + let pages_conn = state.db.global_landing_pages.lock().unwrap(); + let _ = pages_conn.execute( + "DELETE FROM global_landing_pages WHERE slug = ?1;", + [&form.slug], + ); + } + } else { + if slug_info.target_type == crate::db::slugs::SlugTargetType::Url { + let urls_conn = state.db.global_urls.lock().unwrap(); + let _ = urls_conn.execute( + "UPDATE global_urls SET status = ?1, updated_at = ?2 WHERE slug = ?3;", + rusqlite::params![action, now, form.slug], + ); + } else { + let pages_conn = state.db.global_landing_pages.lock().unwrap(); + let _ = pages_conn.execute( + "UPDATE global_landing_pages SET status = ?1, updated_at = ?2 WHERE slug = ?3;", rusqlite::params![action, now, form.slug], ); } + } + // 2. Sync to tenant content DB if accessible + if let Ok(tenant_dbs) = + state.open_tenant(owner_tenant_id, crate::db::tenant::TenantOpenMode::CoreJob) + { + if let Ok(conn) = tenant_dbs.content.lock() { + if slug_info.target_type == crate::db::slugs::SlugTargetType::Url { + let content_status = if action == "disabled" || action == "deleted" { + "dead" + } else { + "active" + }; + let _ = conn.execute( + "UPDATE urls SET status = ?1 WHERE code = ?2;", + rusqlite::params![content_status, form.slug], + ); + } else { + let content_state = if action == "disabled" || action == "deleted" { + "archived" + } else { + "published" + }; + let _ = conn.execute( + "UPDATE landing_pages SET state = ?1 WHERE code = ?2;", + rusqlite::params![content_state, form.slug], + ); + } + } + } + + // 3. Record moderation event and audit log in system.db + { + let system_conn = state.system_db.lock().unwrap(); let event_id = Uuid::new_v4().to_string(); let _ = system_conn.execute( - "INSERT INTO moderation_events (id, timestamp, admin_username, target_user_id, target_username, resource_type, resource_identifier, action, severity, reason) + "INSERT INTO moderation_events (id, timestamp, admin_username, target_user_id, target_username, resource_type, resource_identifier, action, severity, reason) \ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10);", rusqlite::params![ event_id, @@ -186,7 +287,7 @@ pub async fn moderation_post( user.username, owner_user_id, owner_username, - target_type, + slug_info.target_type.as_str(), form.slug, action, form.severity, @@ -231,48 +332,130 @@ pub async fn slugs_get( Err(redir) => return redir.into_response(), }; - let system_conn = state.system_db.lock().unwrap(); + let mut slugs: Vec = Vec::new(); - let mut sql = "SELECT slug, owner_user_id, target_type, target_id, created_at, updated_at, status, deleted_at FROM global_slugs WHERE 1=1".to_string(); - let mut values = vec![]; - if let Some(ref s) = query.search { - if !s.trim().is_empty() { - sql.push_str(" AND slug LIKE ?"); - values.push(rusqlite::types::Value::Text(format!("%{}%", s.trim()))); - } - } - if let Some(o) = query.owner { - sql.push_str(" AND owner_user_id = ?"); - values.push(rusqlite::types::Value::Integer(o)); - } - if let Some(ref st) = query.status { - if !st.trim().is_empty() { - sql.push_str(" AND status = ?"); - values.push(rusqlite::types::Value::Text(st.trim().to_string())); - } - } - sql.push_str(" ORDER BY created_at DESC LIMIT 100;"); + // Query global_urls.db + { + let urls_conn = state.db.global_urls.lock().unwrap(); + let users_conn = state.users_db.lock().unwrap(); + let mut sql = "SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at FROM global_urls WHERE 1=1".to_string(); + let mut values = vec![]; - let slugs = { - let mut stmt = system_conn.prepare(&sql).unwrap(); - let rows = stmt - .query_map(rusqlite::params_from_iter(values.iter()), |row| { + if let Some(ref s) = query.search { + if !s.trim().is_empty() { + sql.push_str(" AND slug LIKE ?"); + values.push(rusqlite::types::Value::Text(format!("%{}%", s.trim()))); + } + } + if let Some(ref st) = query.status { + if !st.trim().is_empty() { + sql.push_str(" AND status = ?"); + values.push(rusqlite::types::Value::Text(st.trim().to_string())); + } + } + sql.push_str(" ORDER BY created_at DESC LIMIT 100;"); + + let items_res = urls_conn.prepare(&sql).and_then(|mut stmt| { + let rows = stmt.query_map(rusqlite::params_from_iter(values.iter()), |row| { + let tid_str: String = row.get(1)?; + let uid = if let Ok(tid) = TenantId::parse(&tid_str) { + crate::db::users::get_user_by_tenant_id(&users_conn, tid) + .ok() + .flatten() + .map(|u| u.id) + .unwrap_or(0) + } else { + 0 + }; Ok(GlobalSlugRow { slug: row.get(0)?, - owner_user_id: row.get(1)?, - target_type: row.get(2)?, - target_id: row.get(3)?, - created_at: row.get(4)?, - updated_at: row.get(5)?, - status: row.get(6)?, - deleted_at: row.get(7)?, + owner_user_id: uid, + target_type: "url".to_string(), + target_id: row.get(2)?, + created_at: row.get(3)?, + updated_at: row.get(4)?, + status: row.get(5)?, + deleted_at: row.get(6)?, }) - }) - .unwrap(); - rows.filter_map(|r| r.ok()).collect() - }; + })?; + Ok(rows.filter_map(|r| r.ok()).collect::>()) + }); + + if let Ok(items) = items_res { + for r in items { + if let Some(o) = query.owner { + if r.owner_user_id != o { + continue; + } + } + slugs.push(r); + } + } + } + + // Query global_landing_pages.db + { + let pages_conn = state.db.global_landing_pages.lock().unwrap(); + let users_conn = state.users_db.lock().unwrap(); + let mut sql = "SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at FROM global_landing_pages WHERE 1=1".to_string(); + let mut values = vec![]; + + if let Some(ref s) = query.search { + if !s.trim().is_empty() { + sql.push_str(" AND slug LIKE ?"); + values.push(rusqlite::types::Value::Text(format!("%{}%", s.trim()))); + } + } + if let Some(ref st) = query.status { + if !st.trim().is_empty() { + sql.push_str(" AND status = ?"); + values.push(rusqlite::types::Value::Text(st.trim().to_string())); + } + } + sql.push_str(" ORDER BY created_at DESC LIMIT 100;"); + + let items_res = pages_conn.prepare(&sql).and_then(|mut stmt| { + let rows = stmt.query_map(rusqlite::params_from_iter(values.iter()), |row| { + let tid_str: String = row.get(1)?; + let uid = if let Ok(tid) = TenantId::parse(&tid_str) { + crate::db::users::get_user_by_tenant_id(&users_conn, tid) + .ok() + .flatten() + .map(|u| u.id) + .unwrap_or(0) + } else { + 0 + }; + Ok(GlobalSlugRow { + slug: row.get(0)?, + owner_user_id: uid, + target_type: "page".to_string(), + target_id: row.get(2)?, + created_at: row.get(3)?, + updated_at: row.get(4)?, + status: row.get(5)?, + deleted_at: row.get(6)?, + }) + })?; + Ok(rows.filter_map(|r| r.ok()).collect::>()) + }); + + if let Ok(items) = items_res { + for r in items { + if let Some(o) = query.owner { + if r.owner_user_id != o { + continue; + } + } + slugs.push(r); + } + } + } + + slugs.sort_by(|a, b| b.created_at.cmp(&a.created_at)); let history = { + let system_conn = state.system_db.lock().unwrap(); let mut stmt = system_conn.prepare( "SELECT id, slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username \ FROM slug_history ORDER BY timestamp DESC LIMIT 50;" @@ -332,198 +515,19 @@ pub async fn slugs_transfer_post( return Redirect::to("/admin/slugs?error=Invalid CSRF token").into_response(); } - let user_exists = { - let conn = state.users_db.lock().unwrap(); - conn.query_row( - "SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);", - [form.new_owner_user_id], - |row| row.get::<_, bool>(0), - ) - .unwrap_or(false) + let req = crate::services::slug_transfer::SlugTransferRequest { + slug: form.slug.clone(), + new_owner_user_id: form.new_owner_user_id, }; - if !user_exists { - return Redirect::to("/admin/slugs?error=New owner user ID does not exist").into_response(); + match crate::services::slug_transfer::transfer_slug(&state, &req, &user.username) { + Ok(_) => Redirect::to(&format!( + "/admin/slugs?success=Slug /{} successfully transferred to user {}", + form.slug, form.new_owner_user_id + )) + .into_response(), + Err(e) => Redirect::to(&format!("/admin/slugs?error={}", e.message())).into_response(), } - - let (old_owner, target_type, mut new_target_id) = - { - let conn = state.system_db.lock().unwrap(); - let row_opt: Option<(i64, String, String)> = conn.query_row( - "SELECT owner_user_id, target_type, target_id FROM global_slugs WHERE slug = ?1;", - [&form.slug], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)) - ).optional().unwrap_or(None); - match row_opt { - Some(r) => r, - None => return Redirect::to("/admin/slugs?error=Slug not found").into_response(), - } - }; - - if old_owner == form.new_owner_user_id { - return Redirect::to("/admin/slugs?error=Slug is already owned by this user") - .into_response(); - } - - let old_dbs = match state.get_user_dbs(old_owner) { - Ok(dbs) => dbs, - Err(_) => { - return Redirect::to("/admin/slugs?error=Failed to load current owner's database") - .into_response() - } - }; - let new_dbs = match state.get_user_dbs(form.new_owner_user_id) { - Ok(dbs) => dbs, - Err(_) => { - return Redirect::to("/admin/slugs?error=Failed to load new owner's database") - .into_response() - } - }; - - { - let old_conn = old_dbs.content.lock().unwrap(); - let new_conn = new_dbs.content.lock().unwrap(); - - if target_type == "url" { - let url_opt = match crate::db::content::get_url_by_code(&old_conn, &form.slug) { - Ok(u) => u, - Err(e) => { - return Redirect::to(&format!( - "/admin/slugs?error=Failed to retrieve old URL: {}", - e - )) - .into_response() - } - }; - - if let Some(url) = url_opt { - // Check quota - let users_conn = state.users_db.lock().unwrap(); - let quota_opt = - crate::db::users::get_user_quotas(&users_conn, form.new_owner_user_id) - .unwrap_or(None); - if let Some(quota) = quota_opt { - if quota.current_urls >= quota.max_urls { - return Redirect::to( - "/admin/slugs?error=New owner has exceeded URL quota limit", - ) - .into_response(); - } - } - - // Copy - let new_url = match crate::db::content::create_url_extended( - &new_conn, - &url.code, - &url.destination, - url.title.as_deref(), - url.description.as_deref(), - &url.tags, - url.expires_at.as_deref(), - url.password_hash.as_deref(), - url.max_access_count, - ) { - Ok(u) => u, - Err(e) => { - return Redirect::to(&format!( - "/admin/slugs?error=Failed to copy URL record: {}", - e - )) - .into_response() - } - }; - new_target_id = new_url.id; - - // Delete old - let _ = crate::db::content::delete_url(&old_conn, &url.id); - } - } else if target_type == "page" { - let page_opt = match crate::db::content::get_landing_page_by_code(&old_conn, &form.slug) - { - Ok(p) => p, - Err(e) => { - return Redirect::to(&format!( - "/admin/slugs?error=Failed to retrieve old page: {}", - e - )) - .into_response() - } - }; - - if let Some(page) = page_opt { - // Check quota - let users_conn = state.users_db.lock().unwrap(); - let quota_opt = - crate::db::users::get_user_quotas(&users_conn, form.new_owner_user_id) - .unwrap_or(None); - if let Some(quota) = quota_opt { - if quota.current_landings >= quota.max_landings { - return Redirect::to( - "/admin/slugs?error=New owner has exceeded landing page quota limit", - ) - .into_response(); - } - } - - // Copy - let new_page = match crate::db::content::create_landing_page( - &new_conn, - &page.code, - &page.slug, - &page.title, - &page.html_content, - &page.state, - ) { - Ok(p) => p, - Err(e) => { - return Redirect::to(&format!( - "/admin/slugs?error=Failed to copy page record: {}", - e - )) - .into_response() - } - }; - new_target_id = new_page.id; - - // Delete old - let _ = crate::db::content::delete_landing_page(&old_conn, &page.id); - } - } - } - - { - let conn = state.system_db.lock().unwrap(); - let now = Utc::now().to_rfc3339(); - - let _ = conn.execute( - "UPDATE global_slugs SET owner_user_id = ?1, target_id = ?2, updated_at = ?3 WHERE slug = ?4;", - rusqlite::params![form.new_owner_user_id, new_target_id, now, form.slug], - ); - - let _ = conn.execute( - "INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username) \ - VALUES (?1, ?2, ?3, 'transferred', ?4, ?5);", - rusqlite::params![form.slug, old_owner, form.new_owner_user_id, now, user.username], - ); - - let _ = crate::db::audit_events::write_audit_event( - &conn, - &user.username, - "SLUG_TRANSFER", - "slug", - &form.slug, - Some(&format!( - "Transferred from {} to {}", - old_owner, form.new_owner_user_id - )), - ); - } - - Redirect::to(&format!( - "/admin/slugs?success=Slug /{} successfully transferred to user {}", - form.slug, form.new_owner_user_id - )) - .into_response() } #[derive(Deserialize)] @@ -553,17 +557,30 @@ pub async fn slugs_status_post( return Redirect::to("/admin/slugs?error=Invalid status").into_response(); } - { - let conn = state.system_db.lock().unwrap(); - let now = Utc::now().to_rfc3339(); + let slug_info = match state.lookup_slug(&form.slug) { + Ok(Some(info)) => info, + _ => return Redirect::to("/admin/slugs?error=Slug not found").into_response(), + }; - let _ = conn.execute( - "UPDATE global_slugs SET status = ?1, updated_at = ?2 WHERE slug = ?3;", + let now = Utc::now().to_rfc3339(); + if slug_info.target_type == crate::db::slugs::SlugTargetType::Url { + let urls_conn = state.db.global_urls.lock().unwrap(); + let _ = urls_conn.execute( + "UPDATE global_urls SET status = ?1, updated_at = ?2 WHERE slug = ?3;", rusqlite::params![status, now, form.slug], ); + } else { + let pages_conn = state.db.global_landing_pages.lock().unwrap(); + let _ = pages_conn.execute( + "UPDATE global_landing_pages SET status = ?1, updated_at = ?2 WHERE slug = ?3;", + rusqlite::params![status, now, form.slug], + ); + } + { + let system_conn = state.system_db.lock().unwrap(); let _ = crate::db::audit_events::write_audit_event( - &conn, + &system_conn, &user.username, "SLUG_STATUS_UPDATE", "slug", @@ -600,29 +617,46 @@ pub async fn slugs_delete_post( return Redirect::to("/admin/slugs?error=Invalid CSRF token").into_response(); } + let slug_info = match state.lookup_slug(&form.slug) { + Ok(Some(info)) => info, + _ => return Redirect::to("/admin/slugs?error=Slug not found").into_response(), + }; + + let old_owner_user_id = { + let users_conn = state.users_db.lock().unwrap(); + if let Ok(tid) = TenantId::parse(&slug_info.owner_tenant_id) { + crate::db::users::get_user_by_tenant_id(&users_conn, tid) + .ok() + .flatten() + .map(|u| u.id) + } else { + None + } + }; + + let now = Utc::now().to_rfc3339(); + + if slug_info.target_type == crate::db::slugs::SlugTargetType::Url { + let urls_conn = state.db.global_urls.lock().unwrap(); + let _ = urls_conn.execute("DELETE FROM global_urls WHERE slug = ?1;", [&form.slug]); + } else { + let pages_conn = state.db.global_landing_pages.lock().unwrap(); + let _ = pages_conn.execute( + "DELETE FROM global_landing_pages WHERE slug = ?1;", + [&form.slug], + ); + } + { - let conn = state.system_db.lock().unwrap(); - let now = Utc::now().to_rfc3339(); - - let old_owner_user_id: Option = conn - .query_row( - "SELECT owner_user_id FROM global_slugs WHERE slug = ?1;", - [&form.slug], - |row| row.get(0), - ) - .optional() - .unwrap_or(None); - - let _ = conn.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&form.slug]); - - let _ = conn.execute( + let system_conn = state.system_db.lock().unwrap(); + let _ = system_conn.execute( "INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username) \ VALUES (?1, ?2, NULL, 'deleted', ?3, ?4);", rusqlite::params![form.slug, old_owner_user_id, now, user.username], ); let _ = crate::db::audit_events::write_audit_event( - &conn, + &system_conn, &user.username, "SLUG_RELEASE", "slug", diff --git a/src/web/admin/pages.rs b/src/web/admin/pages.rs index 96a557d..831cbd3 100644 --- a/src/web/admin/pages.rs +++ b/src/web/admin/pages.rs @@ -118,22 +118,26 @@ pub async fn user_pages_create( } } + let owner_tid = user + .tenant_id + .unwrap_or_else(crate::identity::TenantId::generate); + { - let system_conn = state.system_db.lock().unwrap(); - if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) { - return Redirect::to("/user/pages?error=Short code/slug already exists") - .into_response(); - } - if let Err(e) = crate::db::users::register_global_slug( - &system_conn, + let reserved_conn = state.db.reserved.lock().unwrap(); + let urls_conn = state.db.global_urls.lock().unwrap(); + let pages_conn = state.db.global_landing_pages.lock().unwrap(); + if let Err(e) = crate::db::slugs::reserve_landing_page_slug( + &reserved_conn, + &urls_conn, + &pages_conn, &code, - user.id, - "page", - "", - "reserving", + &owner_tid, ) { - return Redirect::to(&format!("/user/pages?error=Failed to reserve slug: {}", e)) - .into_response(); + return Redirect::to(&format!( + "/user/pages?error=Short code/slug unavailable: {}", + e + )) + .into_response(); } } @@ -152,16 +156,8 @@ pub async fn user_pages_create( match res { Ok(page) => { { - let system_conn = state.system_db.lock().unwrap(); - let global_status = if form.state == "published" { - "active" - } else { - "disabled" - }; - let _ = system_conn.execute( - "UPDATE global_slugs SET target_id = ?1, status = ?2, updated_at = ?3 WHERE slug = ?4;", - rusqlite::params![page.id, global_status, chrono::Utc::now().to_rfc3339(), code], - ); + let pages_conn = state.db.global_landing_pages.lock().unwrap(); + let _ = crate::db::slugs::activate_landing_page_slug(&pages_conn, &code, &page.id); } { let users_conn = state.users_db.lock().unwrap(); @@ -181,8 +177,8 @@ pub async fn user_pages_create( Redirect::to("/user/pages").into_response() } Err(e) => { - let system_conn = state.system_db.lock().unwrap(); - let _ = crate::db::users::release_global_slug(&system_conn, &code, user.id); + let pages_conn = state.db.global_landing_pages.lock().unwrap(); + let _ = crate::db::slugs::release_landing_page_slug(&pages_conn, &code, &owner_tid); Redirect::to(&format!("/user/pages?error=Database error: {}", e)).into_response() } } @@ -221,11 +217,13 @@ pub async fn user_pages_delete( ); match delete_landing_page(&conn, &id) { Ok(_) => { - let _ = crate::db::users::release_global_slug( - &state.system_db.lock().unwrap(), - &page.code, - user.id, - ); + { + let pages_conn = state.db.global_landing_pages.lock().unwrap(); + let _ = pages_conn.execute( + "DELETE FROM global_landing_pages WHERE slug = ?1;", + [&page.code], + ); + } let ip = get_client_ip(&headers, connect_info); let _ = write_audit_log( &state.admin_db.lock().unwrap(), @@ -265,8 +263,14 @@ pub async fn pages_get( }; let (pages, total_pages, page, visible_pages) = { - let conn = state.content_db.lock().unwrap(); - let total_records = get_landing_page_count(&conn).unwrap_or(0); + let conn = state.db.global_landing_pages.lock().unwrap(); + let total_records: i64 = conn + .query_row( + "SELECT COUNT(*) FROM global_landing_pages WHERE status != 'retired';", + [], + |r| r.get(0), + ) + .unwrap_or(0); let calculated_total_pages = (total_records as usize).div_ceil(PAGE_SIZE); let total_pages = std::cmp::max(1, calculated_total_pages); let requested_page = query.page.unwrap_or(1); @@ -278,7 +282,58 @@ pub async fn pages_get( .clamp(1, total_pages); let offset = (current_page - 1) * PAGE_SIZE; - let pages = list_landing_pages(&conn, PAGE_SIZE as i64, offset as i64).unwrap_or_default(); + let mut stmt = conn.prepare( + "SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status FROM global_landing_pages WHERE status != 'retired' ORDER BY created_at DESC LIMIT ?1 OFFSET ?2;" + ).unwrap(); + let rows = stmt + .query_map(rusqlite::params![PAGE_SIZE as i64, offset as i64], |row| { + let slug: String = row.get(0)?; + let owner_tid_str: String = row.get(1)?; + let target_id: String = row.get(2)?; + let created_at: String = row.get(3)?; + let updated_at: String = row.get(4)?; + let status: String = row.get(5)?; + Ok(( + slug, + owner_tid_str, + target_id, + created_at, + updated_at, + status, + )) + }) + .unwrap(); + + let mut pages = Vec::new(); + for item in rows.flatten() { + let (slug, owner_tid_str, target_id, created_at, updated_at, status) = item; + let mut resolved_page = None; + if let Ok(tid) = owner_tid_str.parse::() { + if let Ok(user_dbs) = state.open_tenant(tid, crate::state::TenantOpenMode::CoreJob) + { + let u_conn = user_dbs.content.lock().unwrap(); + if let Ok(Some(p)) = + crate::db::content::get_landing_page_by_id(&u_conn, &target_id) + { + resolved_page = Some(p); + } + } + } + if let Some(p) = resolved_page { + pages.push(p); + } else { + pages.push(crate::models::LandingPage { + id: target_id, + code: slug.clone(), + slug, + title: String::new(), + html_content: String::new(), + state: status, + created_at, + updated_at, + }); + } + } let start_page = current_page.saturating_sub(3).max(1); let end_page = std::cmp::min(total_pages, current_page + 3); @@ -302,7 +357,8 @@ pub async fn pages_get( template.into_response() } -#[derive(Deserialize)] +#[derive(Deserialize, Default)] +#[serde(default)] pub struct CreatePageForm { pub title: String, pub slug: String, @@ -317,126 +373,16 @@ pub struct CreatePageForm { pub async fn pages_create( State(state): State, jar: CookieJar, - headers: HeaderMap, - connect_info: Option>, - Form(form): Form, + _headers: HeaderMap, + _connect_info: Option>, + Form(_form): Form, ) -> Response { - let (user, session_id) = match require_auth(&state, &jar).await { + let (_user, _session_id) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; - if !verify_csrf(&session_id, &form.csrf_token) { - return Redirect::to("/admin/pages?error=Invalid CSRF token").into_response(); - } - - let ip = get_client_ip(&headers, connect_info); - let admin_user_id = user.id.parse::().unwrap_or(1); - - // Custom Slug takes priority if provided - let mut code = form.custom_slug.trim().to_lowercase(); - if code.is_empty() { - code = form.code.trim().to_lowercase(); - if code.is_empty() { - code = generate_token(2); - } else { - if code.len() != 4 || !code.chars().all(|c| c.is_ascii_hexdigit()) { - return Redirect::to( - "/admin/pages?error=Custom code must be exactly 4 hex characters", - ) - .into_response(); - } - } - } else { - if !crate::utils::validation::validate_custom_slug(&code) { - return Redirect::to("/admin/pages?error=Custom slug must start with ! followed by 1-24 characters of a-z, 0-9, -, _") - .into_response(); - } - } - - let clean_slug = form.slug.trim().to_lowercase(); - if clean_slug.is_empty() { - return Redirect::to("/admin/pages?error=Slug is required").into_response(); - } - - { - let users_conn = state.users_db.lock().unwrap(); - if !crate::db::users::check_quota_limit(&users_conn, admin_user_id, "landings") - .unwrap_or(false) - { - return Redirect::to("/admin/pages?error=Quota limit exceeded").into_response(); - } - } - - { - let system_conn = state.system_db.lock().unwrap(); - if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) { - return Redirect::to("/admin/pages?error=Short code already exists").into_response(); - } - // Always use owner_user_id = 1 for admin content so it resolves via state.content_db - if let Err(e) = - crate::db::users::register_global_slug(&system_conn, &code, 1, "page", "", "reserving") - { - return Redirect::to(&format!("/admin/pages?error=Failed to reserve slug: {}", e)) - .into_response(); - } - } - - let res = { - let conn = state.content_db.lock().unwrap(); - create_landing_page( - &conn, - &code, - &clean_slug, - &form.title, - &form.html_content, - &form.state, - ) - }; - - match res { - Ok(page) => { - { - let system_conn = state.system_db.lock().unwrap(); - let global_status = if form.state == "published" { - "active" - } else { - "disabled" - }; - let _ = system_conn.execute( - "UPDATE global_slugs SET target_id = ?1, status = ?2, updated_at = ?3 WHERE slug = ?4;", - rusqlite::params![page.id, global_status, chrono::Utc::now().to_rfc3339(), code], - ); - } - { - let users_conn = state.users_db.lock().unwrap(); - let _ = crate::db::users::increment_quota_counter( - &users_conn, - admin_user_id, - "landings", - ); - } - { - let conn_admin = state.admin_db.lock().unwrap(); - let _ = write_audit_log( - &conn_admin, - &state, - &user.username, - "PAGE_CREATION", - Some("page"), - Some(&page.id), - Some(&ip), - headers.get("user-agent").and_then(|h| h.to_str().ok()), - ); - } - Redirect::to("/admin/pages").into_response() - } - Err(e) => { - let system_conn = state.system_db.lock().unwrap(); - let _ = crate::db::users::release_global_slug(&system_conn, &code, 1); - Redirect::to(&format!("/admin/pages?error=Database error: {}", e)).into_response() - } - } + Redirect::to("/admin/pages?error=Admin is a platform operator and cannot create unowned application pages; create landing pages via a tenant user account").into_response() } // POST /admin/pages/delete/:id @@ -460,25 +406,42 @@ pub async fn pages_delete( let ip = get_client_ip(&headers, connect_info); - let conn = state.content_db.lock().unwrap(); - match delete_landing_page(&conn, &id) { - Ok(_) => { - { - let conn_admin = state.admin_db.lock().unwrap(); - let _ = write_audit_log( - &conn_admin, - &state, - &user.username, - "PAGE_DELETION", - Some("page"), - Some(&id), - Some(&ip), - headers.get("user-agent").and_then(|h| h.to_str().ok()), - ); + // Look up owner tenant in global_landing_pages + let owner_info = { + let conn = state.db.global_landing_pages.lock().unwrap(); + conn.query_row( + "SELECT slug, owner_tenant_id FROM global_landing_pages WHERE target_id = ?1 OR slug = ?1 LIMIT 1;", + rusqlite::params![id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ).ok() + }; + + if let Some((slug, tid_str)) = owner_info { + if let Ok(tid) = tid_str.parse::() { + if let Ok(user_dbs) = state.open_tenant(tid, crate::state::TenantOpenMode::CoreJob) { + let conn = user_dbs.content.lock().unwrap(); + let _ = delete_landing_page(&conn, &id); } - Redirect::to("/admin/pages").into_response() } - Err(e) => Redirect::to(&format!("/admin/pages?error=Failed to delete page: {}", e)) - .into_response(), + let conn = state.db.global_landing_pages.lock().unwrap(); + let _ = conn.execute( + "UPDATE global_landing_pages SET status = 'retired', updated_at = ?1 WHERE slug = ?2;", + rusqlite::params![chrono::Utc::now().to_rfc3339(), slug], + ); } + + { + let conn_admin = state.admin_db.lock().unwrap(); + let _ = write_audit_log( + &conn_admin, + &state, + &user.username, + "PAGE_DELETION", + Some("page"), + Some(&id), + Some(&ip), + headers.get("user-agent").and_then(|h| h.to_str().ok()), + ); + } + Redirect::to("/admin/pages").into_response() } diff --git a/src/web/admin/settings.rs b/src/web/admin/settings.rs index 44e634a..cd9513d 100644 --- a/src/web/admin/settings.rs +++ b/src/web/admin/settings.rs @@ -106,11 +106,13 @@ pub async fn user_download_backup( }; let ip = get_client_ip(&headers, connect_info); - let user_dir = state - .config - .data_dir - .join("users") - .join(user.id.to_string()); + let user_dir = match crate::db::tenant::location_for_user(&user).and_then(|loc| { + loc.dir(&state.db.topology) + .map_err(|e| crate::error::AppError::BadRequest(e.to_string())) + }) { + Ok(p) => p, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), + }; let mut buffer = Vec::new(); let res = { @@ -224,11 +226,15 @@ pub async fn user_restore_backup_post( .into_response(); } - let user_dir = state - .config - .data_dir - .join("users") - .join(user.id.to_string()); + let user_dir = match crate::db::tenant::location_for_user(&user).and_then(|loc| { + loc.dir(&state.db.topology) + .map_err(|e| crate::error::AppError::BadRequest(e.to_string())) + }) { + Ok(p) => p, + Err(_) => { + return Redirect::to("/user/settings?error=Invalid user directory").into_response() + } + }; let temp_unpack_dir = std::env::temp_dir().join(format!("bzod_restore_unpack_{}", uuid::Uuid::new_v4())); @@ -241,42 +247,124 @@ pub async fn user_restore_backup_post( .into_response(); } - let restore_res = { - let file = match File::open(&temp_file_path) { - Ok(f) => f, - Err(e) => { - let _ = std::fs::remove_file(&temp_file_path); - let _ = std::fs::remove_dir_all(&temp_unpack_dir); - return Redirect::to(&format!( - "/user/settings?error=Failed to open upload: {}", - e - )) - .into_response(); - } - }; + let restore_res: Result<(), Box> = (|| { + let file = File::open(&temp_file_path)?; let tar_gz = GzDecoder::new(file); let mut archive = tar::Archive::new(tar_gz); - if let Err(e) = archive.unpack(&temp_unpack_dir) { - Err(Box::new(e) as Box) - } else { - // Check for collision using temp content.db - let system_conn = state.system_db.lock().unwrap(); - let temp_content_db = temp_unpack_dir.join("content.db"); - if temp_content_db.exists() { - if let Err(e) = crate::db::users::register_restored_user_slugs( - &system_conn, - user.id, - &temp_content_db, - ) { - Err(e) - } else { - Ok(()) - } - } else { - Err("Backup is missing content.db".into()) + archive.unpack(&temp_unpack_dir)?; + + let target_tenant_id = user + .tenant_id + .unwrap_or_else(crate::identity::TenantId::generate); + let temp_content_db = temp_unpack_dir.join("content.db"); + if !temp_content_db.exists() { + return Err("Backup is missing content.db".into()); + } + + let content_conn = rusqlite::Connection::open(&temp_content_db)?; + + let mut urls = Vec::new(); + if let Ok(mut stmt) = content_conn.prepare("SELECT code, id, created_at, status FROM urls;") + { + if let Ok(rows) = stmt.query_map([], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, String>(2)?, + r.get::<_, String>(3)?, + )) + }) { + urls = rows.filter_map(|r| r.ok()).collect(); } } - }; + + let mut pages = Vec::new(); + if let Ok(mut stmt) = + content_conn.prepare("SELECT code, id, created_at, state FROM landing_pages;") + { + if let Ok(rows) = stmt.query_map([], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, String>(2)?, + r.get::<_, String>(3)?, + )) + }) { + pages = rows.filter_map(|r| r.ok()).collect(); + } + } + + let urls_conn = state.db.global_urls.lock().unwrap(); + let pages_conn = state.db.global_landing_pages.lock().unwrap(); + + for (slug, _, _, _) in &urls { + if let Ok(Some(existing_owner)) = urls_conn + .query_row( + "SELECT owner_tenant_id FROM global_urls WHERE slug = ?1;", + [slug], + |r| r.get::<_, String>(0), + ) + .optional() + { + if existing_owner != target_tenant_id.as_str() { + return Err(format!("Slug collision on URL /{}", slug).into()); + } + } + } + + for (slug, _, _, _) in &pages { + if let Ok(Some(existing_owner)) = pages_conn + .query_row( + "SELECT owner_tenant_id FROM global_landing_pages WHERE slug = ?1;", + [slug], + |r| r.get::<_, String>(0), + ) + .optional() + { + if existing_owner != target_tenant_id.as_str() { + return Err(format!("Slug collision on Landing Page /{}", slug).into()); + } + } + } + + let _ = urls_conn.execute( + "DELETE FROM global_urls WHERE owner_tenant_id = ?1;", + [target_tenant_id.as_str()], + ); + let _ = pages_conn.execute( + "DELETE FROM global_landing_pages WHERE owner_tenant_id = ?1;", + [target_tenant_id.as_str()], + ); + + let now = chrono::Utc::now().to_rfc3339(); + for (slug, target_id, created_at, status) in urls { + let global_status = if status == "dead" { + "disabled" + } else { + "active" + }; + let _ = urls_conn.execute( + "INSERT OR REPLACE INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL);", + rusqlite::params![slug, target_tenant_id.as_str(), target_id, created_at, now, global_status], + ); + } + + for (slug, target_id, created_at, state) in pages { + let global_status = if state == "published" { + "active" + } else { + "disabled" + }; + let _ = pages_conn.execute( + "INSERT OR REPLACE INTO global_landing_pages (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL);", + rusqlite::params![slug, target_tenant_id.as_str(), target_id, created_at, now, global_status], + ); + } + + Ok(()) + })(); let _ = std::fs::remove_file(&temp_file_path); @@ -316,7 +404,9 @@ pub async fn user_restore_backup_post( } let mut pool = state.user_dbs.lock().unwrap(); - pool.remove(&user.id); + if let Ok(loc) = crate::db::tenant::location_for_user(&user) { + pool.remove(&loc.cache_key()); + } let _ = write_audit_log( &state.admin_db.lock().unwrap(), &state, @@ -678,8 +768,43 @@ pub async fn bulk_qr_export_post( let ip = get_client_ip(&headers, connect_info); let urls = { - let conn = state.content_db.lock().unwrap(); - crate::db::content::list_urls(&conn, 500, 0, None).unwrap_or_default() + let conn = state.db.global_urls.lock().unwrap(); + let mut stmt = match conn + .prepare("SELECT slug, target_id FROM global_urls WHERE status = 'active' LIMIT 500;") + { + Ok(s) => s, + Err(_) => return Redirect::to("/admin/settings?error=Database error").into_response(), + }; + let rows = stmt.query_map([], |row| { + let code: String = row.get(0)?; + let target_id: String = row.get(1)?; + Ok(crate::models::Url { + id: target_id, + code: code.clone(), + destination: format!( + "{}/{}", + state.config.base_url.clone().unwrap_or_default(), + code + ), + title: None, + description: None, + created_at: String::new(), + updated_at: String::new(), + status: "active".to_string(), + tags: vec![], + expires_at: None, + password_hash: None, + max_access_count: None, + access_count: 0, + expired: false, + last_latency_ms: None, + last_status: None, + }) + }); + match rows { + Ok(r) => r.filter_map(|x| x.ok()).collect(), + Err(_) => vec![], + } }; if urls.is_empty() { @@ -761,10 +886,7 @@ pub async fn status_get(State(state): State, jar: CookieJar) -> Respon let uptime_duration = state.start_time.elapsed(); let uptime = crate::utils::format_duration(uptime_duration); - let urls = { - let conn = state.content_db.lock().unwrap(); - crate::db::content::list_urls(&conn, 50, 0, None).unwrap_or_default() - }; + let urls = vec![]; let template = crate::templates::StatusTemplate { admin_username: user.username, @@ -911,93 +1033,72 @@ pub async fn restore_backup_post( let restore_res = { // Temporarily suspend access to active SQLite connections let mut admin_conn = state.admin_db.lock().unwrap(); - let mut content_conn = state.content_db.lock().unwrap(); - let mut analytics_conn = state.analytics_db.lock().unwrap(); let mut system_conn = state.system_db.lock().unwrap(); + let mut users_conn = state.users_db.lock().unwrap(); + let mut urls_conn = state.db.global_urls.lock().unwrap(); + let mut pages_conn = state.db.global_landing_pages.lock().unwrap(); + let mut reserved_conn = state.db.reserved.lock().unwrap(); // 1. Close current connections by replacing them with dummy in-memory DBs - *admin_conn = match rusqlite::Connection::open_in_memory() { - Ok(c) => c, - Err(e) => { - return Redirect::to(&format!( - "/admin/settings?error=Failed to open temp in-memory DB: {}", - e - )) - .into_response() - } - }; - *content_conn = match rusqlite::Connection::open_in_memory() { - Ok(c) => c, - Err(e) => { - return Redirect::to(&format!( - "/admin/settings?error=Failed to open temp in-memory DB: {}", - e - )) - .into_response() - } - }; - *analytics_conn = match rusqlite::Connection::open_in_memory() { - Ok(c) => c, - Err(e) => { - return Redirect::to(&format!( - "/admin/settings?error=Failed to open temp in-memory DB: {}", - e - )) - .into_response() - } - }; - *system_conn = match rusqlite::Connection::open_in_memory() { - Ok(c) => c, - Err(e) => { - return Redirect::to(&format!( - "/admin/settings?error=Failed to open temp in-memory DB: {}", - e - )) - .into_response() - } - }; + *admin_conn = rusqlite::Connection::open_in_memory() + .unwrap_or_else(|_| rusqlite::Connection::open(":memory:").unwrap()); + *system_conn = rusqlite::Connection::open_in_memory() + .unwrap_or_else(|_| rusqlite::Connection::open(":memory:").unwrap()); + *users_conn = rusqlite::Connection::open_in_memory() + .unwrap_or_else(|_| rusqlite::Connection::open(":memory:").unwrap()); + *urls_conn = rusqlite::Connection::open_in_memory() + .unwrap_or_else(|_| rusqlite::Connection::open(":memory:").unwrap()); + *pages_conn = rusqlite::Connection::open_in_memory() + .unwrap_or_else(|_| rusqlite::Connection::open(":memory:").unwrap()); + *reserved_conn = rusqlite::Connection::open_in_memory() + .unwrap_or_else(|_| rusqlite::Connection::open(":memory:").unwrap()); - // 2. Perform restore unpacking/validation - // perform_restore() handles legacy layout normalization internally - // before validation, so no post-restore normalization is needed. + // 2. Clear tenant pool cache + if let Ok(mut pool) = state.user_dbs.lock() { + pool.clear(); + } + + // 3. Perform restore unpacking/validation let res = crate::cli::restore::perform_restore(&temp_file_path, &state.config.data_dir); - // 3. Reinitialize database connections using correct multi-tenant paths - let paths = - crate::services::backup_layout::RestoredDbPaths::from_data_dir(&state.config.data_dir); - let new_admin = rusqlite::Connection::open(&paths.admin); - let new_system = rusqlite::Connection::open(&paths.system); - let new_users = rusqlite::Connection::open(&paths.users); - let new_content = rusqlite::Connection::open(&paths.content); - let new_analytics = rusqlite::Connection::open(&paths.analytics); + // 4. Reinitialize Core database connections + let topology = crate::db::topology::Topology::new(&state.config.data_dir); + let new_admin = rusqlite::Connection::open(topology.admin_db()); + let new_system = rusqlite::Connection::open(topology.system_db()); + let new_users = rusqlite::Connection::open(topology.users_registry_db()); + let new_urls = rusqlite::Connection::open(topology.global_urls_db()); + let new_pages = rusqlite::Connection::open(topology.global_landing_pages_db()); + let new_reserved = rusqlite::Connection::open(topology.reserved_db()); - match (new_admin, new_content, new_analytics, new_system, new_users) { - (Ok(adm), Ok(cnt), Ok(any), Ok(sys), Ok(usr)) => { + match ( + new_admin, + new_system, + new_users, + new_urls, + new_pages, + new_reserved, + ) { + (Ok(adm), Ok(sys), Ok(usr), Ok(urls), Ok(pages), Ok(resv)) => { let _ = crate::db::sqlite::enable_wal(&adm, "admin"); - let _ = crate::db::sqlite::enable_wal(&cnt, "content"); - let _ = crate::db::sqlite::enable_wal(&any, "analytics"); let _ = crate::db::sqlite::enable_wal(&sys, "system"); let _ = crate::db::sqlite::enable_wal(&usr, "users"); + let _ = crate::db::sqlite::enable_wal(&urls, "global_urls"); + let _ = crate::db::sqlite::enable_wal(&pages, "global_landing_pages"); + let _ = crate::db::sqlite::enable_wal(&resv, "reserved"); let _ = crate::db::sqlite::enable_foreign_keys(&adm, "admin"); - let _ = crate::db::sqlite::enable_foreign_keys(&cnt, "content"); - let _ = crate::db::sqlite::enable_foreign_keys(&any, "analytics"); let _ = crate::db::sqlite::enable_foreign_keys(&sys, "system"); let _ = crate::db::sqlite::enable_foreign_keys(&usr, "users"); + let _ = crate::db::sqlite::enable_foreign_keys(&urls, "global_urls"); + let _ = crate::db::sqlite::enable_foreign_keys(&pages, "global_landing_pages"); + let _ = crate::db::sqlite::enable_foreign_keys(&resv, "reserved"); *admin_conn = adm; - *content_conn = cnt; - *analytics_conn = any; *system_conn = sys; - match state.db.users.lock() { - Ok(mut u) => *u = usr, - Err(_) => { - return Redirect::to( - "/admin/settings?error=Failed to reopen restored databases", - ) - .into_response(); - } - } + *users_conn = usr; + *urls_conn = urls; + *pages_conn = pages; + *reserved_conn = resv; } _ => { return Redirect::to("/admin/settings?error=Failed to reopen restored databases") diff --git a/src/web/admin/urls.rs b/src/web/admin/urls.rs index abaaa5d..29f0c19 100644 --- a/src/web/admin/urls.rs +++ b/src/web/admin/urls.rs @@ -153,21 +153,26 @@ pub async fn user_urls_create( } } + let owner_tid = user + .tenant_id + .unwrap_or_else(crate::identity::TenantId::generate); + { - let system_conn = state.system_db.lock().unwrap(); - if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) { - return Redirect::to("/user/urls?error=Short code/slug already exists").into_response(); - } - if let Err(e) = crate::db::users::register_global_slug( - &system_conn, + let reserved_conn = state.db.reserved.lock().unwrap(); + let urls_conn = state.db.global_urls.lock().unwrap(); + let pages_conn = state.db.global_landing_pages.lock().unwrap(); + if let Err(e) = crate::db::slugs::reserve_url_slug( + &reserved_conn, + &urls_conn, + &pages_conn, &code, - user.id, - "url", - "", - "reserving", + &owner_tid, ) { - return Redirect::to(&format!("/user/urls?error=Failed to reserve slug: {}", e)) - .into_response(); + return Redirect::to(&format!( + "/user/urls?error=Short code/slug unavailable: {}", + e + )) + .into_response(); } } @@ -181,6 +186,8 @@ pub async fn user_urls_create( ) { Ok(d) => d, Err(msg) => { + let urls_conn = state.db.global_urls.lock().unwrap(); + let _ = crate::db::slugs::release_url_slug(&urls_conn, &code, &owner_tid); return Redirect::to(&format!("/user/urls?error={}", msg)).into_response(); } }; @@ -192,7 +199,11 @@ pub async fn user_urls_create( } else { match hash_password(&form.password) { Ok(h) => Some(h), - Err(_) => return Redirect::to("/user/urls?error=Hashing error").into_response(), + Err(_) => { + let urls_conn = state.db.global_urls.lock().unwrap(); + let _ = crate::db::slugs::release_url_slug(&urls_conn, &code, &owner_tid); + return Redirect::to("/user/urls?error=Hashing error").into_response(); + } } }; @@ -202,7 +213,9 @@ pub async fn user_urls_create( match form.max_access_count.trim().parse::() { Ok(c) => Some(c), Err(_) => { - return Redirect::to("/user/urls?error=Invalid max access count").into_response() + let urls_conn = state.db.global_urls.lock().unwrap(); + let _ = crate::db::slugs::release_url_slug(&urls_conn, &code, &owner_tid); + return Redirect::to("/user/urls?error=Invalid max access count").into_response(); } } }; @@ -243,11 +256,8 @@ pub async fn user_urls_create( match res { Ok(url) => { { - let system_conn = state.system_db.lock().unwrap(); - let _ = system_conn.execute( - "UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;", - rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), code], - ); + let urls_conn = state.db.global_urls.lock().unwrap(); + let _ = crate::db::slugs::activate_url_slug(&urls_conn, &code, &url.id); } { let users_conn = state.users_db.lock().unwrap(); @@ -266,8 +276,8 @@ pub async fn user_urls_create( Redirect::to("/user/urls").into_response() } Err(e) => { - let system_conn = state.system_db.lock().unwrap(); - let _ = crate::db::users::release_global_slug(&system_conn, &code, user.id); + let urls_conn = state.db.global_urls.lock().unwrap(); + let _ = crate::db::slugs::release_url_slug(&urls_conn, &code, &owner_tid); Redirect::to(&format!("/user/urls?error=Database error: {}", e)).into_response() } } @@ -306,11 +316,11 @@ pub async fn user_urls_delete( ); match delete_url(&conn, &id) { Ok(_) => { - let _ = crate::db::users::release_global_slug( - &state.system_db.lock().unwrap(), - &url.code, - user.id, - ); + { + let urls_conn = state.db.global_urls.lock().unwrap(); + let _ = urls_conn + .execute("DELETE FROM global_urls WHERE slug = ?1;", [&url.code]); + } let ip = get_client_ip(&headers, connect_info); let _ = write_audit_log( &state.admin_db.lock().unwrap(), @@ -351,12 +361,14 @@ pub async fn urls_get( }; let (urls, total_pages, page, visible_pages) = { - let conn = state.content_db.lock().unwrap(); - let total_records = if let Some(tag_str) = query.tag.as_deref() { - get_url_count_by_tag(&conn, tag_str).unwrap_or(0) - } else { - get_url_counts(&conn).map(|(t, _, _)| t).unwrap_or(0) - }; + let conn = state.db.global_urls.lock().unwrap(); + let total_records: i64 = conn + .query_row( + "SELECT COUNT(*) FROM global_urls WHERE status != 'retired';", + [], + |r| r.get(0), + ) + .unwrap_or(0); let calculated_total_pages = (total_records as usize).div_ceil(PAGE_SIZE); let total_pages = std::cmp::max(1, calculated_total_pages); let requested_page = query.page.unwrap_or(1); @@ -368,8 +380,64 @@ pub async fn urls_get( .clamp(1, total_pages); let offset = (current_page - 1) * PAGE_SIZE; - let urls = list_urls(&conn, PAGE_SIZE as i64, offset as i64, query.tag.as_deref()) - .unwrap_or_default(); + let mut stmt = conn.prepare( + "SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status FROM global_urls WHERE status != 'retired' ORDER BY created_at DESC LIMIT ?1 OFFSET ?2;" + ).unwrap(); + let rows = stmt + .query_map(rusqlite::params![PAGE_SIZE as i64, offset as i64], |row| { + let slug: String = row.get(0)?; + let owner_tid_str: String = row.get(1)?; + let target_id: String = row.get(2)?; + let created_at: String = row.get(3)?; + let updated_at: String = row.get(4)?; + let status: String = row.get(5)?; + Ok(( + slug, + owner_tid_str, + target_id, + created_at, + updated_at, + status, + )) + }) + .unwrap(); + + let mut urls = Vec::new(); + for item in rows.flatten() { + let (slug, owner_tid_str, target_id, created_at, updated_at, status) = item; + let mut resolved_url = None; + if let Ok(tid) = owner_tid_str.parse::() { + if let Ok(user_dbs) = state.open_tenant(tid, crate::state::TenantOpenMode::CoreJob) + { + let u_conn = user_dbs.content.lock().unwrap(); + if let Ok(Some(u)) = crate::db::content::get_url_by_id(&u_conn, &target_id) { + resolved_url = Some(u); + } + } + } + if let Some(u) = resolved_url { + urls.push(u); + } else { + urls.push(crate::models::Url { + id: target_id, + code: slug, + destination: String::new(), + title: None, + description: None, + created_at, + updated_at, + status, + tags: vec![], + expires_at: None, + password_hash: None, + max_access_count: None, + access_count: 0, + expired: false, + last_latency_ms: None, + last_status: None, + }); + } + } let start_page = current_page.saturating_sub(3).max(1); let end_page = std::cmp::min(total_pages, current_page + 3); @@ -406,7 +474,8 @@ pub async fn urls_get( template.into_response() } -#[derive(Deserialize)] +#[derive(Deserialize, Default)] +#[serde(default)] pub struct CreateUrlForm { pub destination: String, pub code: String, @@ -427,170 +496,16 @@ pub struct CreateUrlForm { pub async fn urls_create( State(state): State, jar: CookieJar, - headers: HeaderMap, - connect_info: Option>, - Form(form): Form, + _headers: HeaderMap, + _connect_info: Option>, + Form(_form): Form, ) -> Response { - let (user, session_id) = match require_auth(&state, &jar).await { + let (_user, _session_id) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; - if !verify_csrf(&session_id, &form.csrf_token) { - return Redirect::to("/admin/urls?error=Invalid CSRF token").into_response(); - } - - let ip = get_client_ip(&headers, connect_info); - let admin_user_id = user.id.parse::().unwrap_or(1); - - // Custom Slug takes priority if provided - let mut code = form.custom_slug.trim().to_lowercase(); - if code.is_empty() { - code = form.code.trim().to_lowercase(); - if code.is_empty() { - code = generate_token(3); - } else { - if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) { - return Redirect::to( - "/admin/urls?error=Custom code must be exactly 6 hex characters", - ) - .into_response(); - } - } - } else { - if !crate::utils::validation::validate_custom_slug(&code) { - return Redirect::to("/admin/urls?error=Custom slug must start with ! followed by 1-24 characters of a-z, 0-9, -, _") - .into_response(); - } - } - - let dest = match crate::services::urls::prepare_destination( - &form.destination, - crate::services::urls::UtmParams { - source: Some(&form.utm_source), - medium: Some(&form.utm_medium), - campaign: Some(&form.utm_campaign), - }, - ) { - Ok(d) => d, - Err(msg) => { - return Redirect::to(&format!("/admin/urls?error={}", msg)).into_response(); - } - }; - - let expires_at_opt = crate::services::urls::parse_expires_at_input(&form.expires_at); - - let password_hash_opt = if form.password.trim().is_empty() { - None - } else { - match hash_password(&form.password) { - Ok(h) => Some(h), - Err(_) => return Redirect::to("/admin/urls?error=Hashing error").into_response(), - } - }; - - let max_access_count_opt = if form.max_access_count.trim().is_empty() { - None - } else { - match form.max_access_count.trim().parse::() { - Ok(c) => Some(c), - Err(_) => { - return Redirect::to("/admin/urls?error=Invalid max access count").into_response() - } - } - }; - - let tags_list: Vec = form - .tags - .split(',') - .map(|t| t.trim().to_string()) - .filter(|t| !t.is_empty()) - .collect(); - - let title_opt = if form.title.trim().is_empty() { - None - } else { - Some(form.title.trim()) - }; - let desc_opt = if form.description.trim().is_empty() { - None - } else { - Some(form.description.trim()) - }; - - { - let users_conn = state.users_db.lock().unwrap(); - if !crate::db::users::check_quota_limit(&users_conn, admin_user_id, "urls").unwrap_or(false) - { - return Redirect::to("/admin/urls?error=Quota limit exceeded").into_response(); - } - } - - { - let system_conn = state.system_db.lock().unwrap(); - if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) { - return Redirect::to("/admin/urls?error=Short code/slug already exists") - .into_response(); - } - // Always use owner_user_id = 1 for admin content so it resolves via state.content_db - if let Err(e) = - crate::db::users::register_global_slug(&system_conn, &code, 1, "url", "", "reserving") - { - return Redirect::to(&format!("/admin/urls?error=Failed to reserve slug: {}", e)) - .into_response(); - } - } - - let res = { - let conn = state.content_db.lock().unwrap(); - crate::db::content::create_url_extended( - &conn, - &code, - &dest, - title_opt, - desc_opt, - &tags_list, - expires_at_opt.as_deref(), - password_hash_opt.as_deref(), - max_access_count_opt, - ) - }; - - match res { - Ok(url) => { - { - let system_conn = state.system_db.lock().unwrap(); - let _ = system_conn.execute( - "UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;", - rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), code], - ); - } - { - let users_conn = state.users_db.lock().unwrap(); - let _ = - crate::db::users::increment_quota_counter(&users_conn, admin_user_id, "urls"); - } - { - let conn = state.admin_db.lock().unwrap(); - let _ = write_audit_log( - &conn, - &state, - &user.username, - "URL_CREATION", - Some("url"), - Some(&url.id), - Some(&ip), - headers.get("user-agent").and_then(|h| h.to_str().ok()), - ); - } - Redirect::to("/admin/urls").into_response() - } - Err(e) => { - let system_conn = state.system_db.lock().unwrap(); - let _ = crate::db::users::release_global_slug(&system_conn, &code, 1); - Redirect::to(&format!("/admin/urls?error=Database error: {}", e)).into_response() - } - } + Redirect::to("/admin/urls?error=Admin is a platform operator and cannot create unowned application URLs; create links via a tenant user account").into_response() } // POST /admin/urls/delete/:id @@ -614,26 +529,42 @@ pub async fn urls_delete( let ip = get_client_ip(&headers, connect_info); - let conn = state.content_db.lock().unwrap(); - match delete_url(&conn, &id) { - Ok(_) => { - { - let conn_admin = state.admin_db.lock().unwrap(); - let _ = write_audit_log( - &conn_admin, - &state, - &user.username, - "URL_DELETION", - Some("url"), - Some(&id), - Some(&ip), - headers.get("user-agent").and_then(|h| h.to_str().ok()), - ); + // Look up owner tenant in global_urls + let owner_info = { + let conn = state.db.global_urls.lock().unwrap(); + conn.query_row( + "SELECT slug, owner_tenant_id FROM global_urls WHERE target_id = ?1 OR slug = ?1 LIMIT 1;", + rusqlite::params![id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ).ok() + }; + + if let Some((slug, tid_str)) = owner_info { + if let Ok(tid) = tid_str.parse::() { + if let Ok(user_dbs) = state.open_tenant(tid, crate::state::TenantOpenMode::CoreJob) { + let conn = user_dbs.content.lock().unwrap(); + let _ = delete_url(&conn, &id); } - Redirect::to("/admin/urls").into_response() - } - Err(e) => { - Redirect::to(&format!("/admin/urls?error=Failed to delete link: {}", e)).into_response() } + let conn = state.db.global_urls.lock().unwrap(); + let _ = conn.execute( + "UPDATE global_urls SET status = 'retired', updated_at = ?1 WHERE slug = ?2;", + rusqlite::params![chrono::Utc::now().to_rfc3339(), slug], + ); } + + { + let conn_admin = state.admin_db.lock().unwrap(); + let _ = write_audit_log( + &conn_admin, + &state, + &user.username, + "URL_DELETION", + Some("url"), + Some(&id), + Some(&ip), + headers.get("user-agent").and_then(|h| h.to_str().ok()), + ); + } + Redirect::to("/admin/urls").into_response() } diff --git a/src/web/admin/users.rs b/src/web/admin/users.rs index 9015cd9..645fecb 100644 --- a/src/web/admin/users.rs +++ b/src/web/admin/users.rs @@ -133,12 +133,14 @@ pub async fn users_create_post( } }; - if let Err(e) = state.db.init_user_databases(new_user.id) { - return Redirect::to(&format!( - "/admin/users?error=Failed to initialize user databases: {}", - e - )) - .into_response(); + if new_user.account_type == "standard" { + if let Err(e) = state.db.init_user_databases(new_user.id) { + return Redirect::to(&format!( + "/admin/users?error=Failed to initialize user databases: {}", + e + )) + .into_response(); + } } { @@ -481,25 +483,9 @@ pub async fn user_detail_get( let target_user = { let conn = state.users_db.lock().unwrap(); - let u_res = conn.query_row( - "SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata \ - FROM users WHERE id = ?1;", - [id], - |row| Ok(crate::models::TenantUser { - id: row.get(0)?, - username: row.get(1)?, - password_hash: row.get(2)?, - status: row.get(3)?, - created_at: row.get(4)?, - last_login: row.get(5)?, - account_type: row.get(6)?, - organization_id: row.get(7)?, - metadata: row.get(8)?, - }) - ); - match u_res { - Ok(u) => u, - Err(_) => return Redirect::to("/admin/users?error=User not found").into_response(), + match crate::db::users::get_user_by_id(&conn, id) { + Ok(Some(u)) => u, + _ => return Redirect::to("/admin/users?error=User not found").into_response(), } }; @@ -576,25 +562,9 @@ pub async fn user_edit_get( let target_user = { let conn = state.users_db.lock().unwrap(); - let u_res = conn.query_row( - "SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata \ - FROM users WHERE id = ?1;", - [id], - |row| Ok(crate::models::TenantUser { - id: row.get(0)?, - username: row.get(1)?, - password_hash: row.get(2)?, - status: row.get(3)?, - created_at: row.get(4)?, - last_login: row.get(5)?, - account_type: row.get(6)?, - organization_id: row.get(7)?, - metadata: row.get(8)?, - }) - ); - match u_res { - Ok(u) => u, - Err(_) => return Redirect::to("/admin/users?error=User not found").into_response(), + match crate::db::users::get_user_by_id(&conn, id) { + Ok(Some(u)) => u, + _ => return Redirect::to("/admin/users?error=User not found").into_response(), } }; diff --git a/src/web/api.rs b/src/web/api.rs index 3f45e49..89afe05 100644 --- a/src/web/api.rs +++ b/src/web/api.rs @@ -12,12 +12,10 @@ use crate::auth::ApiUser; use crate::db::admin::write_audit_log; use crate::db::analytics::{ get_clicks_trend, get_clicks_trend_raw, get_metric_rankings, get_metric_rankings_raw, - get_total_clicks, get_total_page_views, }; use crate::db::content::{ - create_landing_page, delete_landing_page, delete_url, get_landing_page_by_id, - get_landing_page_count, get_url_by_id, get_url_counts, list_landing_pages, list_urls, - update_landing_page, update_url, + create_landing_page, delete_landing_page, delete_url, get_landing_page_by_id, get_url_by_id, + list_landing_pages, list_urls, update_landing_page, update_url, }; use crate::state::AppState; use crate::utils::get_client_ip; @@ -73,6 +71,30 @@ pub struct ApiError { pub error: String, } +#[allow(clippy::result_large_err)] +fn get_api_tenant_dbs(state: &AppState, user: &ApiUser) -> Result { + match user.0 { + crate::models::ApiActor::User(ref u) => { + state.get_user_dbs(u.id).map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError { + error: "Database error".to_string(), + }), + ) + .into_response() + }) + } + crate::models::ApiActor::Admin(_) => Err(( + StatusCode::BAD_REQUEST, + Json(ApiError { + error: "Admin is a platform operator and cannot access application content directly without tenant context".to_string(), + }), + ) + .into_response()), + } +} + // --- URL Endpoints --- // POST /api/v1/urls @@ -138,9 +160,17 @@ pub async fn api_create_url( None }; - // Dynamically resolve target user ID and content DB - let (target_user_id, content_db) = match user.0 { - crate::models::ApiActor::Admin(_) => (1, state.content_db.clone()), + // Dynamically resolve target user ID, tenant ID, and content DB + let (target_user_id, target_tenant_id, content_db) = match user.0 { + crate::models::ApiActor::Admin(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(ApiError { + error: "Admin is a platform operator and cannot create application URLs directly without tenant context".to_string(), + }), + ) + .into_response(); + } crate::models::ApiActor::User(ref u) => { let user_dbs = match state.get_user_dbs(u.id) { Ok(dbs) => dbs, @@ -154,7 +184,10 @@ pub async fn api_create_url( .into_response() } }; - (u.id, user_dbs.content.clone()) + let tid = u + .tenant_id + .unwrap_or_else(crate::identity::TenantId::generate); + (u.id, tid, user_dbs.content.clone()) } }; @@ -174,30 +207,22 @@ pub async fn api_create_url( } } - // Check availability + // Check availability and reserve in v0.8 slug registry { - let system_conn = state.system_db.lock().unwrap(); - if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) { + let reserved_conn = state.db.reserved.lock().unwrap(); + let urls_conn = state.db.global_urls.lock().unwrap(); + let pages_conn = state.db.global_landing_pages.lock().unwrap(); + if let Err(e) = crate::db::slugs::reserve_url_slug( + &reserved_conn, + &urls_conn, + &pages_conn, + &code, + &target_tenant_id, + ) { return ( StatusCode::CONFLICT, Json(ApiError { - error: "Short code already exists".to_string(), - }), - ) - .into_response(); - } - if let Err(e) = crate::db::users::register_global_slug( - &system_conn, - &code, - target_user_id, - "url", - "", - "reserving", - ) { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError { - error: format!("Failed to reserve slug: {}", e), + error: format!("Short code unavailable: {}", e), }), ) .into_response(); @@ -222,13 +247,10 @@ pub async fn api_create_url( match res { Ok(url) => { - // Activate slug + // Activate slug in v0.8 global_urls.db { - let system_conn = state.system_db.lock().unwrap(); - let _ = system_conn.execute( - "UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;", - rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), code], - ); + let urls_conn = state.db.global_urls.lock().unwrap(); + let _ = crate::db::slugs::activate_url_slug(&urls_conn, &code, &url.id); } // Increment quota { @@ -264,8 +286,8 @@ pub async fn api_create_url( (StatusCode::CREATED, Json(url)).into_response() } Err(e) => { - let system_conn = state.system_db.lock().unwrap(); - let _ = crate::db::users::release_global_slug(&system_conn, &code, target_user_id); + let urls_conn = state.db.global_urls.lock().unwrap(); + let _ = crate::db::slugs::release_url_slug(&urls_conn, &code, &target_tenant_id); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { @@ -287,13 +309,17 @@ pub struct ListQuery { pub async fn api_list_urls( State(state): State, - _user: ApiUser, + user: ApiUser, Query(query): Query, ) -> Response { let limit = query.limit.unwrap_or(100); let offset = query.offset.unwrap_or(0); - let conn = state.content_db.lock().unwrap(); + let dbs = match get_api_tenant_dbs(&state, &user) { + Ok(d) => d, + Err(r) => return r, + }; + let conn = dbs.content.lock().unwrap(); match list_urls(&conn, limit, offset, query.tag.as_deref()) { Ok(urls) => Json(urls).into_response(), Err(e) => ( @@ -309,10 +335,14 @@ pub async fn api_list_urls( // GET /api/v1/urls/:uuid pub async fn api_get_url( State(state): State, - _user: ApiUser, + user: ApiUser, Path(uuid): Path, ) -> Response { - let conn = state.content_db.lock().unwrap(); + let dbs = match get_api_tenant_dbs(&state, &user) { + Ok(d) => d, + Err(r) => return r, + }; + let conn = dbs.content.lock().unwrap(); match get_url_by_id(&conn, &uuid) { Ok(Some(url)) => Json(url).into_response(), Ok(None) => ( @@ -351,7 +381,11 @@ pub async fn api_update_url( ) .into_response(); } - let conn = state.content_db.lock().unwrap(); + let dbs = match get_api_tenant_dbs(&state, &user) { + Ok(d) => d, + Err(r) => return r, + }; + let conn = dbs.content.lock().unwrap(); match update_url( &conn, &uuid, @@ -438,9 +472,21 @@ pub async fn api_delete_url( user: ApiUser, Path(uuid): Path, ) -> Response { - let conn = state.content_db.lock().unwrap(); + let dbs = match get_api_tenant_dbs(&state, &user) { + Ok(d) => d, + Err(r) => return r, + }; + let conn = dbs.content.lock().unwrap(); + let code_opt = match get_url_by_id(&conn, &uuid) { + Ok(Some(u)) => Some(u.code), + _ => None, + }; match delete_url(&conn, &uuid) { Ok(true) => { + if let Some(code) = code_opt { + let urls_conn = state.db.global_urls.lock().unwrap(); + let _ = urls_conn.execute("DELETE FROM global_urls WHERE slug = ?1;", [&code]); + } let ip = get_client_ip(&headers, connect_info); let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok()); let _ = write_audit_log( @@ -510,9 +556,17 @@ pub async fn api_create_page( } } - // Dynamically resolve target user ID and content DB - let (target_user_id, content_db) = match user.0 { - crate::models::ApiActor::Admin(_) => (1, state.content_db.clone()), + // Dynamically resolve target user ID, tenant ID, and content DB + let (target_user_id, target_tenant_id, content_db) = match user.0 { + crate::models::ApiActor::Admin(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(ApiError { + error: "Admin is a platform operator and cannot create application landing pages directly without tenant context".to_string(), + }), + ) + .into_response(); + } crate::models::ApiActor::User(ref u) => { let user_dbs = match state.get_user_dbs(u.id) { Ok(dbs) => dbs, @@ -526,7 +580,10 @@ pub async fn api_create_page( .into_response() } }; - (u.id, user_dbs.content.clone()) + let tid = u + .tenant_id + .unwrap_or_else(crate::identity::TenantId::generate); + (u.id, tid, user_dbs.content.clone()) } }; @@ -546,30 +603,22 @@ pub async fn api_create_page( } } - // Check availability + // Check availability and reserve in v0.8 slug registry { - let system_conn = state.system_db.lock().unwrap(); - if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) { + let reserved_conn = state.db.reserved.lock().unwrap(); + let urls_conn = state.db.global_urls.lock().unwrap(); + let pages_conn = state.db.global_landing_pages.lock().unwrap(); + if let Err(e) = crate::db::slugs::reserve_landing_page_slug( + &reserved_conn, + &urls_conn, + &pages_conn, + &code, + &target_tenant_id, + ) { return ( StatusCode::CONFLICT, Json(ApiError { - error: "Short code already exists".to_string(), - }), - ) - .into_response(); - } - if let Err(e) = crate::db::users::register_global_slug( - &system_conn, - &code, - target_user_id, - "page", - "", - "reserving", - ) { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError { - error: format!("Failed to reserve slug: {}", e), + error: format!("Short code unavailable: {}", e), }), ) .into_response(); @@ -590,18 +639,10 @@ pub async fn api_create_page( match res { Ok(page) => { - // Activate slug + // Activate slug in v0.8 global_landing_pages.db { - let system_conn = state.system_db.lock().unwrap(); - let global_status = if payload.state == "published" { - "active" - } else { - "disabled" - }; - let _ = system_conn.execute( - "UPDATE global_slugs SET target_id = ?1, status = ?2, updated_at = ?3 WHERE slug = ?4;", - rusqlite::params![page.id, global_status, chrono::Utc::now().to_rfc3339(), code], - ); + let pages_conn = state.db.global_landing_pages.lock().unwrap(); + let _ = crate::db::slugs::activate_landing_page_slug(&pages_conn, &code, &page.id); } // Increment quota { @@ -627,8 +668,9 @@ pub async fn api_create_page( (StatusCode::CREATED, Json(page)).into_response() } Err(e) => { - let system_conn = state.system_db.lock().unwrap(); - let _ = crate::db::users::release_global_slug(&system_conn, &code, target_user_id); + let pages_conn = state.db.global_landing_pages.lock().unwrap(); + let _ = + crate::db::slugs::release_landing_page_slug(&pages_conn, &code, &target_tenant_id); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { @@ -643,13 +685,17 @@ pub async fn api_create_page( // GET /api/v1/pages pub async fn api_list_pages( State(state): State, - _user: ApiUser, + user: ApiUser, Query(query): Query, ) -> Response { let limit = query.limit.unwrap_or(100); let offset = query.offset.unwrap_or(0); - let conn = state.content_db.lock().unwrap(); + let dbs = match get_api_tenant_dbs(&state, &user) { + Ok(d) => d, + Err(r) => return r, + }; + let conn = dbs.content.lock().unwrap(); match list_landing_pages(&conn, limit, offset) { Ok(pages) => Json(pages).into_response(), Err(e) => ( @@ -665,10 +711,14 @@ pub async fn api_list_pages( // GET /api/v1/pages/:uuid pub async fn api_get_page( State(state): State, - _user: ApiUser, + user: ApiUser, Path(uuid): Path, ) -> Response { - let conn = state.content_db.lock().unwrap(); + let dbs = match get_api_tenant_dbs(&state, &user) { + Ok(d) => d, + Err(r) => return r, + }; + let conn = dbs.content.lock().unwrap(); match get_landing_page_by_id(&conn, &uuid) { Ok(Some(page)) => Json(page).into_response(), Ok(None) => ( @@ -697,7 +747,11 @@ pub async fn api_update_page( Path(uuid): Path, Json(payload): Json, ) -> Response { - let conn = state.content_db.lock().unwrap(); + let dbs = match get_api_tenant_dbs(&state, &user) { + Ok(d) => d, + Err(r) => return r, + }; + let conn = dbs.content.lock().unwrap(); match update_landing_page( &conn, &uuid, @@ -745,9 +799,22 @@ pub async fn api_delete_page( user: ApiUser, Path(uuid): Path, ) -> Response { - let conn = state.content_db.lock().unwrap(); + let dbs = match get_api_tenant_dbs(&state, &user) { + Ok(d) => d, + Err(r) => return r, + }; + let conn = dbs.content.lock().unwrap(); + let code_opt = match get_landing_page_by_id(&conn, &uuid) { + Ok(Some(p)) => Some(p.code), + _ => None, + }; match delete_landing_page(&conn, &uuid) { Ok(true) => { + if let Some(code) = code_opt { + let pages_conn = state.db.global_landing_pages.lock().unwrap(); + let _ = pages_conn + .execute("DELETE FROM global_landing_pages WHERE slug = ?1;", [&code]); + } let ip = get_client_ip(&headers, connect_info); let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok()); let _ = write_audit_log( @@ -797,20 +864,44 @@ pub async fn api_overall_stats(State(state): State, user: ApiUser) -> } let (total_urls, active_links, dead_links) = { - let conn = state.content_db.lock().unwrap(); - get_url_counts(&conn).unwrap_or((0, 0, 0)) + let conn = state.db.global_urls.lock().unwrap(); + let total: i64 = conn + .query_row( + "SELECT COUNT(*) FROM global_urls WHERE status != 'retired';", + [], + |r| r.get(0), + ) + .unwrap_or(0); + let active: i64 = conn + .query_row( + "SELECT COUNT(*) FROM global_urls WHERE status = 'active';", + [], + |r| r.get(0), + ) + .unwrap_or(0); + let dead: i64 = conn + .query_row( + "SELECT COUNT(*) FROM global_urls WHERE status = 'disabled';", + [], + |r| r.get(0), + ) + .unwrap_or(0); + (total, active, dead) }; let total_pages = { - let conn = state.content_db.lock().unwrap(); - get_landing_page_count(&conn).unwrap_or(0) - }; - let (total_clicks, total_page_views) = { - let conn = state.analytics_db.lock().unwrap(); - ( - get_total_clicks(&conn).unwrap_or(0), - get_total_page_views(&conn).unwrap_or(0), + let conn = state.db.global_landing_pages.lock().unwrap(); + conn.query_row( + "SELECT COUNT(*) FROM global_landing_pages WHERE status != 'retired';", + [], + |r| r.get(0), ) + .unwrap_or(0) }; + let total_clicks = { + let users_conn = state.users_db.lock().unwrap(); + crate::db::users::get_platform_total_clicks(&state.db.topology, &users_conn).unwrap_or(0) + }; + let total_page_views = 0i64; Json(OverallStatsResponse { total_urls, @@ -835,12 +926,17 @@ pub struct DetailStatsResponse { // GET /api/v1/stats/url/:uuid pub async fn api_url_stats( State(state): State, - _user: ApiUser, + user: ApiUser, Path(uuid): Path, ) -> Response { + let dbs = match get_api_tenant_dbs(&state, &user) { + Ok(d) => d, + Err(r) => return r, + }; + // Verify URL exists { - let conn = state.content_db.lock().unwrap(); + let conn = dbs.content.lock().unwrap(); if get_url_by_id(&conn, &uuid).unwrap_or(None).is_none() { return ( StatusCode::NOT_FOUND, @@ -852,7 +948,7 @@ pub async fn api_url_stats( } } - let conn = state.analytics_db.lock().unwrap(); + let conn = dbs.analytics.lock().unwrap(); let clicks = get_clicks_trend(&conn, "url", &uuid, 30) .or_else(|_| get_clicks_trend_raw(&conn, "url", &uuid, 30)) .unwrap_or_default(); @@ -879,12 +975,17 @@ pub async fn api_url_stats( // GET /api/v1/stats/page/:uuid pub async fn api_page_stats( State(state): State, - _user: ApiUser, + user: ApiUser, Path(uuid): Path, ) -> Response { + let dbs = match get_api_tenant_dbs(&state, &user) { + Ok(d) => d, + Err(r) => return r, + }; + // Verify Page exists { - let conn = state.content_db.lock().unwrap(); + let conn = dbs.content.lock().unwrap(); if get_landing_page_by_id(&conn, &uuid) .unwrap_or(None) .is_none() @@ -899,7 +1000,7 @@ pub async fn api_page_stats( } } - let conn = state.analytics_db.lock().unwrap(); + let conn = dbs.analytics.lock().unwrap(); let clicks = get_clicks_trend(&conn, "page", &uuid, 30) .or_else(|_| get_clicks_trend_raw(&conn, "page", &uuid, 30)) .unwrap_or_default(); @@ -935,11 +1036,16 @@ pub struct QrStatsResponse { // GET /api/v1/qr/:code pub async fn api_get_qr_stats( State(state): State, - _user: ApiUser, + user: ApiUser, Path(code): Path, ) -> Response { + let dbs = match get_api_tenant_dbs(&state, &user) { + Ok(d) => d, + Err(r) => return r, + }; + let url_opt = { - let conn = state.content_db.lock().unwrap(); + let conn = dbs.content.lock().unwrap(); crate::db::content::get_url_by_code(&conn, &code).unwrap_or(None) }; @@ -957,7 +1063,7 @@ pub async fn api_get_qr_stats( }; let (scan_count, scans) = { - let conn = state.analytics_db.lock().unwrap(); + let conn = dbs.analytics.lock().unwrap(); let count = crate::db::qr::get_qr_scan_count(&conn, &url.id).unwrap_or(0); let scans_list = crate::db::qr::get_qr_stats_for_url(&conn, &url.id).unwrap_or_default(); (count, scans_list) @@ -1030,9 +1136,14 @@ pub async fn api_set_preview( Path(uuid): Path, Json(payload): Json, ) -> Response { + let dbs = match get_api_tenant_dbs(&state, &user) { + Ok(d) => d, + Err(r) => return r, + }; + // Verify URL exists { - let conn = state.content_db.lock().unwrap(); + let conn = dbs.content.lock().unwrap(); if get_url_by_id(&conn, &uuid).unwrap_or(None).is_none() { return ( StatusCode::NOT_FOUND, @@ -1044,7 +1155,7 @@ pub async fn api_set_preview( } } - let conn = state.content_db.lock().unwrap(); + let conn = dbs.content.lock().unwrap(); match crate::db::preview::upsert_preview( &conn, &uuid, @@ -1081,12 +1192,17 @@ pub async fn api_set_preview( // GET /api/v1/urls/:uuid/preview pub async fn api_get_preview( State(state): State, - _user: ApiUser, + user: ApiUser, Path(uuid): Path, ) -> Response { + let dbs = match get_api_tenant_dbs(&state, &user) { + Ok(d) => d, + Err(r) => return r, + }; + // Verify URL exists { - let conn = state.content_db.lock().unwrap(); + let conn = dbs.content.lock().unwrap(); if get_url_by_id(&conn, &uuid).unwrap_or(None).is_none() { return ( StatusCode::NOT_FOUND, @@ -1098,7 +1214,7 @@ pub async fn api_get_preview( } } - let conn = state.content_db.lock().unwrap(); + let conn = dbs.content.lock().unwrap(); match crate::db::preview::get_preview(&conn, &uuid) { Ok(Some(preview)) => Json(preview).into_response(), Ok(None) => ( @@ -1124,9 +1240,14 @@ pub async fn api_delete_preview( user: ApiUser, Path(uuid): Path, ) -> Response { + let dbs = match get_api_tenant_dbs(&state, &user) { + Ok(d) => d, + Err(r) => return r, + }; + // Verify URL exists { - let conn = state.content_db.lock().unwrap(); + let conn = dbs.content.lock().unwrap(); if get_url_by_id(&conn, &uuid).unwrap_or(None).is_none() { return ( StatusCode::NOT_FOUND, @@ -1138,7 +1259,7 @@ pub async fn api_delete_preview( } } - let conn = state.content_db.lock().unwrap(); + let conn = dbs.content.lock().unwrap(); match crate::db::preview::delete_preview(&conn, &uuid) { Ok(true) => { // Audit Log @@ -1186,9 +1307,14 @@ pub async fn api_set_password( Path(uuid): Path, Json(payload): Json, ) -> Response { + let dbs = match get_api_tenant_dbs(&state, &user) { + Ok(d) => d, + Err(r) => return r, + }; + // Verify URL exists { - let conn = state.content_db.lock().unwrap(); + let conn = dbs.content.lock().unwrap(); if get_url_by_id(&conn, &uuid).unwrap_or(None).is_none() { return ( StatusCode::NOT_FOUND, @@ -1213,7 +1339,7 @@ pub async fn api_set_password( } }; - let conn = state.content_db.lock().unwrap(); + let conn = dbs.content.lock().unwrap(); match crate::db::content::set_url_password(&conn, &uuid, &hash) { Ok(true) => { // Audit Log @@ -1257,9 +1383,14 @@ pub async fn api_remove_password( user: ApiUser, Path(uuid): Path, ) -> Response { + let dbs = match get_api_tenant_dbs(&state, &user) { + Ok(d) => d, + Err(r) => return r, + }; + // Verify URL exists { - let conn = state.content_db.lock().unwrap(); + let conn = dbs.content.lock().unwrap(); if get_url_by_id(&conn, &uuid).unwrap_or(None).is_none() { return ( StatusCode::NOT_FOUND, @@ -1271,7 +1402,7 @@ pub async fn api_remove_password( } } - let conn = state.content_db.lock().unwrap(); + let conn = dbs.content.lock().unwrap(); match crate::db::content::remove_url_password(&conn, &uuid) { Ok(true) => { // Audit Log @@ -1321,9 +1452,14 @@ pub async fn api_create_qr( user: ApiUser, Json(payload): Json, ) -> Response { + let dbs = match get_api_tenant_dbs(&state, &user) { + Ok(d) => d, + Err(r) => return r, + }; + // Verify URL exists in content.db { - let conn = state.content_db.lock().unwrap(); + let conn = dbs.content.lock().unwrap(); if get_url_by_id(&conn, &payload.url_id) .unwrap_or(None) .is_none() @@ -1339,7 +1475,7 @@ pub async fn api_create_qr( } let style = payload.style.unwrap_or_else(|| "default".to_string()); - let conn = state.content_db.lock().unwrap(); + let conn = dbs.content.lock().unwrap(); match crate::db::qr::upsert_qr_code(&conn, &payload.url_id, &style) { Ok(_) => { // Write Audit Event diff --git a/src/web/bulk.rs b/src/web/bulk.rs index 204348e..194b1e4 100644 --- a/src/web/bulk.rs +++ b/src/web/bulk.rs @@ -67,9 +67,36 @@ pub async fn api_bulk_qr( } // Retrieve URLs from database + let content_db = match user.0 { + crate::models::ApiActor::User(ref u) => { + let user_dbs = match state.get_user_dbs(u.id) { + Ok(dbs) => dbs, + Err(_) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(BulkErrorResponse { + error: "Database error".to_string(), + }), + ) + .into_response(); + } + }; + user_dbs.content.clone() + } + crate::models::ApiActor::Admin(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(BulkErrorResponse { + error: "Admin is a platform operator and cannot export application URLs without tenant context".to_string(), + }), + ) + .into_response(); + } + }; + let mut urls = Vec::new(); { - let conn = match lock_db(&state.content_db, "content_db") { + let conn = match lock_db(&content_db, "content_db") { Ok(c) => c, Err(e) => { return ( @@ -186,10 +213,30 @@ pub async fn api_bulk_url( .into_response(); } - // Dynamically resolve target user ID and content DB - let (target_user_id, content_db) = match user.0 { - crate::models::ApiActor::Admin(_) => (1, state.content_db.clone()), + // Dynamically resolve target user ID, TenantId, and content DB + let (target_user_id, target_tenant_id, content_db) = match user.0 { + crate::models::ApiActor::Admin(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(BulkErrorResponse { + error: "Admin is a platform operator and cannot create application URLs directly without tenant context".to_string(), + }), + ) + .into_response(); + } crate::models::ApiActor::User(ref u) => { + let tenant_id = match u.tenant_id { + Some(tid) => tid, + None => { + return ( + StatusCode::BAD_REQUEST, + Json(BulkErrorResponse { + error: "User has no tenant context".to_string(), + }), + ) + .into_response(); + } + }; let user_dbs = match state.get_user_dbs(u.id) { Ok(dbs) => dbs, Err(_) => { @@ -202,7 +249,7 @@ pub async fn api_bulk_url( .into_response() } }; - (u.id, user_dbs.content.clone()) + (u.id, tenant_id, user_dbs.content.clone()) } }; @@ -226,9 +273,12 @@ pub async fn api_bulk_url( let created_urls = match create_urls_bulk( &content_db, - &state.system_db, + &state.db.reserved, + &state.db.global_urls, + &state.db.global_landing_pages, &state.users_db, target_user_id, + target_tenant_id, items, ) { Ok(urls) => urls, diff --git a/src/web/multi_user.rs b/src/web/multi_user.rs index 9a80527..04797b8 100644 --- a/src/web/multi_user.rs +++ b/src/web/multi_user.rs @@ -4,11 +4,11 @@ use axum::{ response::{IntoResponse, Json, Response}, }; use chrono::Utc; -use rusqlite::OptionalExtension; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::auth::ApiUser; +use crate::identity::TenantId; use crate::models::ApiActor; use crate::state::AppState; @@ -354,40 +354,68 @@ pub fn delete_user_resources( } }; - // 1. Transactional clean up on system.db (deleting their global slug mappings) - { - let mut system_conn = state.system_db.lock().unwrap(); - let tx = system_conn.transaction().map_err(|e| e.to_string())?; + // 1. Transactional clean up on v0.8 slug databases + let now = Utc::now().to_rfc3339(); + if let Some(ref tid) = user_details.tenant_id { + let tid_str = tid.to_string(); + let urls_conn = state.db.global_urls.lock().unwrap(); + let pages_conn = state.db.global_landing_pages.lock().unwrap(); + let system_conn = state.system_db.lock().unwrap(); - let slugs: Vec = { - let mut stmt = tx - .prepare("SELECT slug FROM global_slugs WHERE owner_user_id = ?1;") - .map_err(|e| e.to_string())?; - let rows = stmt - .query_map([target_id], |row| row.get(0)) - .map_err(|e| e.to_string())?; - rows.filter_map(|r| r.ok()).collect() + // Get all URL slugs owned by tenant + let url_slugs: Vec = if let Ok(mut stmt) = + urls_conn.prepare("SELECT slug FROM global_urls WHERE owner_tenant_id = ?1;") + { + stmt.query_map([&tid_str], |row| row.get::<_, String>(0)) + .map(|rows| rows.filter_map(|r| r.ok()).collect()) + .unwrap_or_default() + } else { + Vec::new() }; - let now = Utc::now().to_rfc3339(); - for slug in slugs { - let _ = tx.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&slug]); - let _ = tx.execute( + for slug in url_slugs { + let _ = urls_conn.execute("DELETE FROM global_urls WHERE slug = ?1;", [&slug]); + let _ = system_conn.execute( "INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username) VALUES (?1, ?2, NULL, 'deleted', ?3, ?4);", rusqlite::params![slug, target_id, now, admin_username], ); } - tx.commit() - .map_err(|e| format!("Failed to release slugs: {}", e))?; + // Get all page slugs owned by tenant + let page_slugs: Vec = if let Ok(mut stmt) = + pages_conn.prepare("SELECT slug FROM global_landing_pages WHERE owner_tenant_id = ?1;") + { + stmt.query_map([&tid_str], |row| row.get::<_, String>(0)) + .map(|rows| rows.filter_map(|r| r.ok()).collect()) + .unwrap_or_default() + } else { + Vec::new() + }; + + for slug in page_slugs { + let _ = + pages_conn.execute("DELETE FROM global_landing_pages WHERE slug = ?1;", [&slug]); + let _ = system_conn.execute( + "INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username) + VALUES (?1, ?2, NULL, 'deleted', ?3, ?4);", + rusqlite::params![slug, target_id, now, admin_username], + ); + } } - let user_dir = state - .config - .data_dir - .join("users") - .join(target_id.to_string()); + let user_dir = { + let conn = state.users_db.lock().unwrap(); + match crate::db::users::get_user_by_id(&conn, target_id) { + Ok(Some(u)) => crate::db::tenant::location_for_user(&u) + .and_then(|loc| { + loc.dir(&state.db.topology) + .map_err(|e| crate::error::AppError::BadRequest(e.to_string())) + }) + .map_err(|e| e.to_string())?, + _ => return Err("User not found".into()), + } + }; if user_dir.exists() { let _ = std::fs::remove_dir_all(&user_dir); } @@ -444,231 +472,23 @@ pub async fn admin_transfer_slug( Err(err_resp) => return err_resp, }; - // 1. Check if the slug exists and get details - let (old_owner_user_id, target_type, _target_id) = { - let system_conn = state.system_db.lock().unwrap(); - let mut stmt = match system_conn.prepare( - "SELECT owner_user_id, target_type, target_id FROM global_slugs WHERE slug = ?1;", - ) { - Ok(s) => s, - Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), - }; - let row_opt = stmt - .query_row([&payload.slug], |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - )) - }) - .optional(); - - match row_opt { - Ok(Some(r)) => r, - Ok(None) => return (StatusCode::NOT_FOUND, "Slug not found").into_response(), - Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), - } + let req = crate::services::slug_transfer::SlugTransferRequest { + slug: payload.slug, + new_owner_user_id: payload.new_owner_user_id, }; - if old_owner_user_id == payload.new_owner_user_id { - return ( - StatusCode::BAD_REQUEST, - "New owner must be different from the current owner", - ) - .into_response(); + match crate::services::slug_transfer::transfer_slug(&state, &req, &admin.username) { + Ok(_) => StatusCode::OK.into_response(), + Err(crate::services::slug_transfer::TransferError::NotFound(m)) => { + (StatusCode::NOT_FOUND, m.to_string()).into_response() + } + Err(crate::services::slug_transfer::TransferError::BadRequest(m)) => { + (StatusCode::BAD_REQUEST, m).into_response() + } + Err(crate::services::slug_transfer::TransferError::Internal(m)) => { + (StatusCode::INTERNAL_SERVER_ERROR, m).into_response() + } } - - // 2. Fetch destination databases - let old_dbs = match state.get_user_dbs(old_owner_user_id) { - Ok(dbs) => dbs, - Err(_) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - "Failed to load current owner's database", - ) - .into_response() - } - }; - let new_dbs = match state.get_user_dbs(payload.new_owner_user_id) { - Ok(dbs) => dbs, - Err(_) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - "Failed to load new owner's database", - ) - .into_response() - } - }; - - // 3. Perform transfer: copy record from old owner's content.db to new owner's content.db - let mut new_target_id = String::new(); - let transfer_success = { - let old_conn = old_dbs.content.lock().unwrap(); - let new_conn = new_dbs.content.lock().unwrap(); - - if target_type == "url" { - // Get URL record - let url_opt = match crate::db::content::get_url_by_code(&old_conn, &payload.slug) { - Ok(u) => u, - Err(e) => { - return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response() - } - }; - - if let Some(url) = url_opt { - // Check new owner quotas - let new_users_conn = state.users_db.lock().unwrap(); - let quota_opt = - crate::db::users::get_user_quotas(&new_users_conn, payload.new_owner_user_id) - .unwrap_or(None); - if let Some(quota) = quota_opt { - if quota.current_urls >= quota.max_urls { - return ( - StatusCode::BAD_REQUEST, - "New owner has exceeded URL quota limit", - ) - .into_response(); - } - } - - // Insert into new owner database - let ins_res = crate::db::content::create_url_extended( - &new_conn, - &url.code, - &url.destination, - url.title.as_deref(), - url.description.as_deref(), - &url.tags, - url.expires_at.as_deref(), - url.password_hash.as_deref(), - url.max_access_count, - ); - - match ins_res { - Ok(new_url) => { - new_target_id = new_url.id; - // Delete from old owner database - let _ = crate::db::content::delete_url(&old_conn, &url.id); - true - } - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to copy URL to new owner: {}", e), - ) - .into_response(); - } - } - } else { - false - } - } else if target_type == "page" { - // Get page record - let page_opt = - match crate::db::content::get_landing_page_by_code(&old_conn, &payload.slug) { - Ok(p) => p, - Err(e) => { - return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response() - } - }; - - if let Some(page) = page_opt { - // Check new owner quotas - let new_users_conn = state.users_db.lock().unwrap(); - let quota_opt = - crate::db::users::get_user_quotas(&new_users_conn, payload.new_owner_user_id) - .unwrap_or(None); - if let Some(quota) = quota_opt { - if quota.current_landings >= quota.max_landings { - return ( - StatusCode::BAD_REQUEST, - "New owner has exceeded landing page quota limit", - ) - .into_response(); - } - } - - // Insert into new owner database - let ins_res = crate::db::content::create_landing_page( - &new_conn, - &page.code, - &page.slug, - &page.title, - &page.html_content, - &page.state, - ); - - match ins_res { - Ok(new_page) => { - new_target_id = new_page.id; - // Delete from old owner database - let _ = crate::db::content::delete_landing_page(&old_conn, &page.id); - true - } - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to copy Page to new owner: {}", e), - ) - .into_response(); - } - } - } else { - false - } - } else { - false - } - }; - - if !transfer_success { - return (StatusCode::NOT_FOUND, "Content not found in owner database").into_response(); - } - - // 4. Update system global_slugs, slug_history and adjust quotas - { - let system_conn = state.system_db.lock().unwrap(); - let now = Utc::now().to_rfc3339(); - - let _ = system_conn.execute( - "UPDATE global_slugs SET owner_user_id = ?1, target_id = ?2, updated_at = ?3 WHERE slug = ?4;", - rusqlite::params![payload.new_owner_user_id, new_target_id, now, payload.slug], - ); - - let _ = system_conn.execute( - "INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username) - VALUES (?1, ?2, ?3, 'transferred', ?4, ?5);", - rusqlite::params![payload.slug, old_owner_user_id, payload.new_owner_user_id, now, admin.username], - ); - - // Adjust quotas - let users_conn = state.users_db.lock().unwrap(); - let field = if target_type == "url" { - "urls" - } else { - "landings" - }; - let _ = crate::db::users::decrement_quota_counter(&users_conn, old_owner_user_id, field); - let _ = crate::db::users::increment_quota_counter( - &users_conn, - payload.new_owner_user_id, - field, - ); - - let _ = crate::db::audit_events::write_audit_event( - &system_conn, - &admin.username, - "SLUG_TRANSFER", - "slug", - &payload.slug, - Some(&format!( - "From owner {} to owner {}", - old_owner_user_id, payload.new_owner_user_id - )), - ); - } - - StatusCode::OK.into_response() } // --- Admin: Content Moderation --- @@ -689,44 +509,88 @@ pub async fn admin_moderate_slug( return (StatusCode::BAD_REQUEST, "Invalid moderation action").into_response(); } - // 1. Verify slug and get owner user ID - let (owner_user_id, target_type) = { - let system_conn = state.system_db.lock().unwrap(); - let row_opt: Option<(i64, String)> = system_conn - .query_row( - "SELECT owner_user_id, target_type FROM global_slugs WHERE slug = ?1;", - [&payload.slug], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .optional() - .unwrap_or(None); + // 1. Verify slug using v0.8 slug lookup + let slug_info = match state.lookup_slug(&payload.slug) { + Ok(Some(info)) => info, + Ok(None) => return (StatusCode::NOT_FOUND, "Slug not found").into_response(), + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; - match row_opt { - Some(r) => r, - None => return (StatusCode::NOT_FOUND, "Slug not found").into_response(), + let target_type = slug_info.target_type.as_str().to_string(); + let owner_tenant_id = match TenantId::parse(&slug_info.owner_tenant_id) { + Ok(tid) => tid, + Err(_) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + "Invalid owner tenant ID on slug", + ) + .into_response(); } }; - // Resolve owner username for log snapshot - let owner_username = { + // Resolve owner details from users.db for moderation event history + let (owner_user_id, owner_username) = { let users_conn = state.users_db.lock().unwrap(); - crate::db::users::get_user_by_id(&users_conn, owner_user_id) - .unwrap_or(None) - .map(|u| u.username) + match crate::db::users::get_user_by_tenant_id(&users_conn, owner_tenant_id) { + Ok(Some(u)) => (u.id, Some(u.username)), + _ => (0, None), + } }; - // 2. Perform moderation update in global_slugs + // 2. Perform moderation update in authoritative v0.8 slug databases + { + let urls_conn = state.db.global_urls.lock().unwrap(); + let pages_conn = state.db.global_landing_pages.lock().unwrap(); + let now = Utc::now().to_rfc3339(); + + if slug_info.target_type == crate::db::slugs::SlugTargetType::Url { + let _ = urls_conn.execute( + "UPDATE global_urls SET status = ?1, updated_at = ?2 WHERE slug = ?3;", + rusqlite::params![action, now, payload.slug], + ); + } else { + let _ = pages_conn.execute( + "UPDATE global_landing_pages SET status = ?1, updated_at = ?2 WHERE slug = ?3;", + rusqlite::params![action, now, payload.slug], + ); + } + } + + // 3. Sync status to owner tenant's content DB if possible + if let Ok(tenant_dbs) = + state.open_tenant(owner_tenant_id, crate::db::tenant::TenantOpenMode::CoreJob) + { + if let Ok(conn) = tenant_dbs.content.lock() { + if slug_info.target_type == crate::db::slugs::SlugTargetType::Url { + let content_status = if action == "disabled" { + "dead" + } else { + "active" + }; + let _ = conn.execute( + "UPDATE urls SET status = ?1 WHERE code = ?2;", + rusqlite::params![content_status, payload.slug], + ); + } else { + let content_state = if action == "disabled" { + "archived" + } else { + "published" + }; + let _ = conn.execute( + "UPDATE landing_pages SET state = ?1 WHERE code = ?2;", + rusqlite::params![content_state, payload.slug], + ); + } + } + } + + // 4. Record moderation event in system.db { let system_conn = state.system_db.lock().unwrap(); let now = Utc::now().to_rfc3339(); - - let _ = system_conn.execute( - "UPDATE global_slugs SET status = ?1, updated_at = ?2 WHERE slug = ?3;", - rusqlite::params![action, now, payload.slug], - ); - - // Record moderation event let event_id = Uuid::new_v4().to_string(); + let _ = system_conn.execute( "INSERT INTO moderation_events (id, timestamp, admin_username, target_user_id, target_username, resource_type, resource_identifier, action, severity, reason) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10);", diff --git a/src/web/pages.rs b/src/web/pages.rs index 53805e8..d3be9d4 100644 --- a/src/web/pages.rs +++ b/src/web/pages.rs @@ -4,11 +4,11 @@ use axum::{ response::{Html, IntoResponse, Response}, }; use chrono::Utc; -use rusqlite::OptionalExtension; use std::net::SocketAddr; use uuid::Uuid; use crate::analytics::get_client_country; +use crate::db::tenant::TenantOpenMode; use crate::models::VisitRecord; use crate::state::AppState; use crate::utils::get_client_ip; @@ -25,37 +25,15 @@ pub async fn resolve_page( return (StatusCode::NOT_FOUND, "Not Found").into_response(); } - // 1. Query global slug namespace in system.db - let slug_info = { - let system_conn = state.system_db.lock().unwrap(); - let mut stmt = match system_conn.prepare( - "SELECT owner_user_id, target_type, target_id, status FROM global_slugs WHERE slug = ?1;" - ) { - Ok(s) => s, - Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), - }; - stmt.query_row(rusqlite::params![code], |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - row.get::<_, String>(3)?, - )) - }) - .optional() - }; - - let (owner_user_id, _target_type, _target_id, slug_status) = match slug_info { + // 1. Query global slug namespace across v0.8 slug databases (with fallback) + let info = match state.lookup_slug(&code) { Ok(Some(info)) => info, - Ok(None) => { - // Fallback to legacy_admin's DB (user_id = 1) if not found in global_slugs - (1, "page".to_string(), "".to_string(), "active".to_string()) - } + Ok(None) => return (StatusCode::NOT_FOUND, "Not Found").into_response(), Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), }; // If slug status is disabled, flagged, or soft_deleted, we return 410 Gone - if slug_status != "active" { + if info.status != "active" { return ( StatusCode::GONE, "This content has been disabled or moderated", @@ -64,10 +42,11 @@ pub async fn resolve_page( } // 2. Get content database connection via tenant DB resolution - let content_conn = match state.get_user_dbs(owner_user_id) { - Ok(dbs) => dbs.content, - Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), - }; + let content_conn = + match state.open_slug_owner(&info.owner_tenant_id, TenantOpenMode::PublicContent) { + Ok(dbs) => dbs.content, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), + }; let page_opt = { let conn = content_conn.lock().unwrap(); @@ -103,6 +82,9 @@ pub async fn resolve_page( .unwrap_or("Unknown") .to_string(); + let owner_tenant_id = crate::identity::TenantId::parse(&info.owner_tenant_id).ok(); + let owner_user_id = info.owner_tenant_id.parse::().ok(); + let record = VisitRecord { id: Uuid::new_v4().to_string(), target_type: "page".to_string(), @@ -114,7 +96,8 @@ pub async fn resolve_page( accept_language, country, status_code: 200, - owner_user_id: Some(owner_user_id), + owner_tenant_id, + owner_user_id, }; state.analytics_queue.push(record); diff --git a/src/web/password_gate.rs b/src/web/password_gate.rs index 63d6380..96fbc75 100644 --- a/src/web/password_gate.rs +++ b/src/web/password_gate.rs @@ -7,7 +7,7 @@ use axum_extra::extract::{cookie::Cookie, CookieJar}; use serde::Deserialize; use crate::auth::password::verify_password; -use crate::services::shortener::get_url_by_code; +use crate::db::tenant::TenantOpenMode; use crate::state::AppState; use crate::templates::GateTemplate; @@ -21,6 +21,43 @@ pub async fn gate_get(Path(code): Path) -> impl IntoResponse { GateTemplate { code, error: None } } +enum GateLookup { + Missing, + Gone, + Unprotected, + Protected(String), +} + +fn lookup_gate(state: &AppState, code: &str) -> Result { + let info = match state + .lookup_slug(code) + .map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)? + { + Some(info) if info.status == "active" => info, + Some(_) => return Ok(GateLookup::Gone), + None => return Ok(GateLookup::Missing), + }; + + let user_dbs = match state.open_slug_owner(&info.owner_tenant_id, TenantOpenMode::PublicContent) + { + Ok(dbs) => dbs, + Err(_) => return Ok(GateLookup::Missing), + }; + let conn = user_dbs + .content + .lock() + .map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?; + match crate::db::content::get_url_by_code(&conn, code) + .map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)? + { + Some(u) => match u.password_hash { + Some(h) => Ok(GateLookup::Protected(h)), + None => Ok(GateLookup::Unprotected), + }, + None => Ok(GateLookup::Missing), + } +} + // POST /gate/:code pub async fn gate_post( State(state): State, @@ -29,32 +66,25 @@ pub async fn gate_post( headers: axum::http::HeaderMap, Form(form): Form, ) -> Response { - let url_opt = match get_url_by_code(&state.db, &code) { - Ok(url) => url, - Err(_) => { - return ( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - "Database error", - ) - .into_response() - } - }; - - let url = match url_opt { - Some(u) => u, - None => return (axum::http::StatusCode::NOT_FOUND, "Url not found").into_response(), - }; - - let password_hash = match url.password_hash { - Some(ref h) => h, - None => { - // Not password protected, redirect to resolution + let password_hash = match lookup_gate(&state, &code) { + Ok(GateLookup::Protected(h)) => h, + Ok(GateLookup::Unprotected) => { return Redirect::temporary(&format!("/{}", code)).into_response(); } + Ok(GateLookup::Missing) => { + return (axum::http::StatusCode::NOT_FOUND, "Url not found").into_response(); + } + Ok(GateLookup::Gone) => { + return ( + axum::http::StatusCode::GONE, + "This content has been disabled or moderated", + ) + .into_response(); + } + Err(status) => return (status, "Database error").into_response(), }; - if verify_password(&form.password, password_hash) { - // Correct password - set 15 min temporary cookie + if verify_password(&form.password, &password_hash) { let cookie_name = format!("bzod_gate_{}", code); let secure_flag = crate::utils::resolve_cookie_secure(state.config.cookie_secure, &headers); let cookie = Cookie::build((cookie_name, "authorized")) @@ -67,7 +97,6 @@ pub async fn gate_post( let updated_jar = jar.add(cookie); (updated_jar, Redirect::temporary(&format!("/{}", code))).into_response() } else { - // Invalid password GateTemplate { code, error: Some("Invalid password".to_string()), diff --git a/src/web/qr.rs b/src/web/qr.rs index c9217e3..f96efa3 100644 --- a/src/web/qr.rs +++ b/src/web/qr.rs @@ -1,3 +1,4 @@ +use crate::db::tenant::TenantOpenMode; use crate::services::qr::{generate_qr_png, generate_qr_svg}; use crate::state::AppState; use crate::utils::get_client_ip; @@ -37,57 +38,36 @@ pub async fn qr_handler( return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(); } - // We need to look up owner_user_id, target_id, and status from global_slugs - let (owner_user_id, target_id, slug_status) = { - let system_conn = state.system_db.lock().unwrap(); - let mut stmt = match system_conn.prepare( - "SELECT owner_user_id, target_id, status FROM global_slugs WHERE slug = ?1;", - ) { - Ok(s) => s, - Err(_) => { - return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response() - } - }; - use rusqlite::OptionalExtension; - match stmt - .query_row(rusqlite::params![&file], |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - )) - }) - .optional() - { - Ok(Some((uid, tid, status))) => (uid, tid, status), - Ok(None) => return (StatusCode::NOT_FOUND, "URL not found").into_response(), - Err(_) => { - return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response() - } - } + // Look up slug info from v0.8 slug registry (with fallback) + let info = match state.lookup_slug(&file) { + Ok(Some(info)) => info, + Ok(None) => return (StatusCode::NOT_FOUND, "URL not found").into_response(), + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), }; - if slug_status == "disabled" { + if info.status == "disabled" { return (StatusCode::GONE, "This content has been disabled").into_response(); - } else if slug_status != "active" { + } else if info.status != "active" { return (StatusCode::NOT_FOUND, "URL not found").into_response(); } - let user_dbs = match state.get_user_dbs(owner_user_id) { + let user_dbs = match state + .open_slug_owner(&info.owner_tenant_id, TenantOpenMode::PublicContent) + { Ok(dbs) => dbs, Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), }; let qr_scans = { let conn = user_dbs.analytics.lock().unwrap(); - crate::db::qr::get_qr_scan_count(&conn, &target_id).unwrap_or(0) + crate::db::qr::get_qr_scan_count(&conn, &info.target_id).unwrap_or(0) }; let direct_clicks = { let conn = user_dbs.analytics.lock().unwrap(); conn.query_row( "SELECT COUNT(*) FROM visits WHERE target_id = ?1;", - rusqlite::params![target_id], + rusqlite::params![info.target_id], |row| row.get(0), ) .unwrap_or(0) @@ -109,36 +89,16 @@ pub async fn qr_handler( return (StatusCode::NOT_FOUND, "Not Found").into_response(); } - // We need to look up owner_user_id, target_type, target_id, and status from global_slugs - let (owner_user_id, target_type, target_id, slug_status) = { - let system_conn = state.system_db.lock().unwrap(); - let mut stmt = match system_conn - .prepare("SELECT owner_user_id, target_type, target_id, status FROM global_slugs WHERE slug = ?1;") - { - Ok(s) => s, - Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), - }; - use rusqlite::OptionalExtension; - match stmt - .query_row(rusqlite::params![code], |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - row.get::<_, String>(3)?, - )) - }) - .optional() - { - Ok(Some(info)) => info, - Ok(None) => return (StatusCode::NOT_FOUND, "Not Found").into_response(), - Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), - } + // Look up slug info from v0.8 slug registry (with fallback) + let info = match state.lookup_slug(code) { + Ok(Some(info)) => info, + Ok(None) => return (StatusCode::NOT_FOUND, "Not Found").into_response(), + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), }; - if slug_status == "disabled" { + if info.status == "disabled" { return (StatusCode::GONE, "This content has been disabled").into_response(); - } else if slug_status != "active" { + } else if info.status != "active" { return (StatusCode::NOT_FOUND, "Not Found").into_response(); } @@ -159,7 +119,7 @@ pub async fn qr_handler( .clone() .unwrap_or_else(|| format!("{}://{}", proto, host_header)); - let full_url = if target_type == "page" { + let full_url = if info.target_type == crate::db::slugs::SlugTargetType::LandingPage { format!("{}/p/{}", base_url.trim_end_matches('/'), code) } else { format!("{}/{}", base_url.trim_end_matches('/'), code) @@ -204,11 +164,13 @@ pub async fn qr_handler( .and_then(|h| h.to_str().ok()) .map(|s| s.to_string()); - if let Ok(user_dbs) = state.get_user_dbs(owner_user_id) { + if let Ok(user_dbs) = + state.open_slug_owner(&info.owner_tenant_id, TenantOpenMode::PublicContent) + { if let Ok(analytics_conn) = user_dbs.analytics.lock() { let _ = crate::db::qr::log_qr_access( &analytics_conn, - &target_id, + &info.target_id, Some(ip.as_str()), user_agent.as_deref(), ); diff --git a/src/web/redirect.rs b/src/web/redirect.rs index ab4a3fb..0f83eaa 100644 --- a/src/web/redirect.rs +++ b/src/web/redirect.rs @@ -15,18 +15,26 @@ use axum::{ }; use axum_extra::extract::CookieJar; use chrono::Utc; -use rusqlite::OptionalExtension; use std::net::SocketAddr; use std::sync::{Arc, Mutex}; use tracing::{error, warn}; use uuid::Uuid; use crate::analytics::get_client_country; +use crate::db::tenant::TenantOpenMode; +use crate::identity::TenantId; use crate::models::{LinkPreview, Url, VisitRecord}; use crate::state::AppState; use crate::templates::PreviewTemplate; use crate::utils::get_client_ip; +struct ResolvedUrl { + owner_tenant_id: Option, + owner_user_id: i64, + url: Url, + content: Arc>, +} + /// Compact outcome from blocking redirect DB work (avoids large enum / Result variants). enum ResolveOutcome { Ready(Box), @@ -111,79 +119,40 @@ fn permanent_redirect_to(destination: &str, code: &str) -> Response { } } -/// Result of the initial global-slug + URL resolution phase. -struct ResolvedUrl { - owner_user_id: i64, - url: Url, - content: Arc>, -} - /// Lookup global slug namespace then load the URL from the tenant content DB. /// Runs entirely on a blocking thread. fn resolve_url_blocking( - system_db: Arc>, + _system_db: Arc>, state: AppState, code: &str, ) -> ResolveOutcome { - // 1. Query global slug namespace in system.db - let slug_info = { - let system_conn = match system_db.lock() { - Ok(c) => c, - Err(e) => { - return ResolveOutcome::DbError { - operation: "lock_system_db", - message: e.to_string(), - owner_user_id: None, - resource_id: None, - }; - } - }; - let mut stmt = match system_conn.prepare( - "SELECT owner_user_id, target_type, target_id, status FROM global_slugs WHERE slug = ?1;", - ) { - Ok(s) => s, - Err(e) => { - return ResolveOutcome::DbError { - operation: "prepare_global_slugs", - message: e.to_string(), - owner_user_id: None, - resource_id: None, - }; - } - }; - match stmt - .query_row(rusqlite::params![code], |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - row.get::<_, String>(3)?, - )) - }) - .optional() - { - Ok(info) => info, - Err(e) => { - return ResolveOutcome::DbError { - operation: "query_global_slugs", - message: e.to_string(), - owner_user_id: None, - resource_id: None, - }; - } + // 1. Query global slug namespace across v0.8 slug databases (with fallback) + let slug_info = match state.lookup_slug(code) { + Ok(info) => info, + Err(e) => { + return ResolveOutcome::DbError { + operation: "lookup_slug", + message: e.to_string(), + owner_user_id: None, + resource_id: None, + }; } }; - let (owner_user_id, target_type, _target_id, slug_status) = match slug_info { + let info = match slug_info { Some(info) => info, None => { - // Fallback to legacy_admin's DB (user_id = 1) if not found in global_slugs - (1, "url".to_string(), "".to_string(), "active".to_string()) + return ResolveOutcome::Early { + status: StatusCode::NOT_FOUND, + body: "Not Found", + }; } }; + let owner_user_id = info.owner_tenant_id.parse::().unwrap_or(0); + // If slug status is disabled, flagged, or soft_deleted, we return 410 Gone - if slug_status != "active" { + if info.status != "active" { return ResolveOutcome::Early { status: StatusCode::GONE, body: "This content has been disabled or moderated", @@ -191,22 +160,23 @@ fn resolve_url_blocking( } // If target type is page, redirect permanently to /p/slug - if target_type == "page" { + if info.target_type == crate::db::slugs::SlugTargetType::LandingPage { return ResolveOutcome::PermanentPath(format!("/p/{}", code)); } // 2. Get content database connection via tenant DB resolution - let content_conn = match state.get_user_dbs(owner_user_id) { - Ok(dbs) => dbs, - Err(e) => { - return ResolveOutcome::DbError { - operation: "get_user_dbs", - message: e.to_string(), - owner_user_id: Some(owner_user_id), - resource_id: None, - }; - } - }; + let content_conn = + match state.open_slug_owner(&info.owner_tenant_id, TenantOpenMode::PublicContent) { + Ok(dbs) => dbs, + Err(e) => { + return ResolveOutcome::DbError { + operation: "open_slug_owner", + message: e.to_string(), + owner_user_id: Some(owner_user_id), + resource_id: None, + }; + } + }; let url = { let conn = match content_conn.content.lock() { @@ -239,7 +209,10 @@ fn resolve_url_blocking( } }; + let owner_tenant_id = TenantId::parse(&info.owner_tenant_id).ok(); + ResolveOutcome::Ready(Box::new(ResolvedUrl { + owner_tenant_id, owner_user_id, url, content: content_conn.content, @@ -347,6 +320,7 @@ pub async fn resolve_redirect( }; let ResolvedUrl { + owner_tenant_id, owner_user_id, url, content, @@ -424,7 +398,6 @@ pub async fn resolve_redirect( } }; - // Asynchronously record analytics let ip = get_client_ip(&headers, connect_info); let country = get_client_country(&headers); let user_agent = headers @@ -454,6 +427,7 @@ pub async fn resolve_redirect( accept_language, country, status_code: if preview_opt.is_some() { 200 } else { 302 }, + owner_tenant_id, owner_user_id: Some(owner_user_id), }; diff --git a/src/web/system.rs b/src/web/system.rs index 0cb8b28..e949e74 100644 --- a/src/web/system.rs +++ b/src/web/system.rs @@ -97,22 +97,46 @@ pub async fn metrics_endpoint( // 1. Gather stats from DBs let (total_urls, active_links, dead_links) = { - let conn = state.content_db.lock().unwrap(); - crate::db::content::get_url_counts(&conn).unwrap_or((0, 0, 0)) + let conn = state.db.global_urls.lock().unwrap(); + let total: i64 = conn + .query_row( + "SELECT COUNT(*) FROM global_urls WHERE status != 'retired';", + [], + |r| r.get(0), + ) + .unwrap_or(0); + let active: i64 = conn + .query_row( + "SELECT COUNT(*) FROM global_urls WHERE status = 'active';", + [], + |r| r.get(0), + ) + .unwrap_or(0); + let dead: i64 = conn + .query_row( + "SELECT COUNT(*) FROM global_urls WHERE status = 'disabled';", + [], + |r| r.get(0), + ) + .unwrap_or(0); + (total, active, dead) }; let total_pages = { - let conn = state.content_db.lock().unwrap(); - crate::db::content::get_landing_page_count(&conn).unwrap_or(0) + let conn = state.db.global_landing_pages.lock().unwrap(); + conn.query_row( + "SELECT COUNT(*) FROM global_landing_pages WHERE status != 'retired';", + [], + |r| r.get(0), + ) + .unwrap_or(0) }; - let (total_clicks, total_page_views) = { - let conn = state.analytics_db.lock().unwrap(); - ( - crate::db::analytics::get_total_clicks(&conn).unwrap_or(0), - crate::db::analytics::get_total_page_views(&conn).unwrap_or(0), - ) + let total_clicks = { + let users_conn = state.users_db.lock().unwrap(); + crate::db::users::get_platform_total_clicks(&state.db.topology, &users_conn).unwrap_or(0) }; + let total_page_views = 0i64; // Calculate memory in bytes let mut mem_bytes = 0; diff --git a/tests/admin_dashboard_tests.rs b/tests/admin_dashboard_tests.rs index dbf5830..3b580c8 100644 --- a/tests/admin_dashboard_tests.rs +++ b/tests/admin_dashboard_tests.rs @@ -20,6 +20,15 @@ async fn test_admin_user_listing() { let db = Db::init(&config).expect("Failed to init Db"); + let _ = bzod::cli::create_admin::run( + Some("admin".to_string()), + Some("adminpass123".to_string()), + None, + config.clone(), + ) + .await + .unwrap(); + let _ = bzod::cli::create_user::run( Some("usera".to_string()), Some("password123".to_string()), @@ -32,9 +41,9 @@ async fn test_admin_user_listing() { let conn = db.users.lock().unwrap(); let users = bzod::db::users::list_users(&conn).unwrap(); - // Verify list contains legacy_admin and usera + // Verify list contains admin and usera assert!(users.len() >= 2); - assert!(users.iter().any(|u| u.username == "legacy_admin")); + assert!(users.iter().any(|u| u.username == "admin")); assert!(users.iter().any(|u| u.username == "usera")); let _ = fs::remove_dir_all(&temp_dir); diff --git a/tests/admin_user_management_tests.rs b/tests/admin_user_management_tests.rs index 7b6ab67..c4822d4 100644 --- a/tests/admin_user_management_tests.rs +++ b/tests/admin_user_management_tests.rs @@ -33,8 +33,6 @@ fn build_state(config: Config) -> (Db, AppState) { let (queue, _) = AnalyticsQueue::new(db.clone(), 1000, rx); let state = AppState { admin_db: db.admin.clone(), - content_db: db.content.clone(), - analytics_db: db.analytics.clone(), system_db: db.system.clone(), users_db: db.users.clone(), user_dbs: std::sync::Arc::new(std::sync::Mutex::new(HashMap::new())), diff --git a/tests/advanced_analytics_tests.rs b/tests/advanced_analytics_tests.rs index f54436e..52610f8 100644 --- a/tests/advanced_analytics_tests.rs +++ b/tests/advanced_analytics_tests.rs @@ -51,6 +51,7 @@ fn test_advanced_analytics_pagination_and_sorting() { accept_language: "en".to_string(), country: "US".to_string(), status_code: 200, + owner_tenant_id: None, owner_user_id: None, }); } @@ -92,6 +93,7 @@ fn test_advanced_analytics_date_filtering() { accept_language: "en".to_string(), country: "US".to_string(), status_code: 200, + owner_tenant_id: None, owner_user_id: None, }, VisitRecord { @@ -105,6 +107,7 @@ fn test_advanced_analytics_date_filtering() { accept_language: "en".to_string(), country: "US".to_string(), status_code: 200, + owner_tenant_id: None, owner_user_id: None, }, VisitRecord { @@ -118,6 +121,7 @@ fn test_advanced_analytics_date_filtering() { accept_language: "en".to_string(), country: "US".to_string(), status_code: 200, + owner_tenant_id: None, owner_user_id: None, }, ]; diff --git a/tests/analytics_parity_tests.rs b/tests/analytics_parity_tests.rs index bf03766..13689bd 100644 --- a/tests/analytics_parity_tests.rs +++ b/tests/analytics_parity_tests.rs @@ -50,13 +50,11 @@ async fn start_test_server( let state = AppState { admin_db: db.admin.clone(), - content_db: db.content.clone(), - analytics_db: db.analytics.clone(), system_db: db.system.clone(), users_db: db.users.clone(), user_dbs: std::sync::Arc::new(std::sync::Mutex::new(HashMap::new())), db: db.clone(), - config, + config: config.clone(), analytics_queue: queue, start_time: Instant::now(), }; @@ -111,11 +109,29 @@ async fn test_analytics_and_table_parity() { .unwrap(); assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER); - // Admin adds a URL to the global content_db + // Create User B to own global links + let _ = bzod::cli::create_user::run( + Some("userb".to_string()), + Some("password123".to_string()), + None, + create_temp_config(temp_dir.clone()), + ) + .await + .unwrap(); + + let (id_b, tid_b) = { + let conn = db.users.lock().unwrap(); + let u = bzod::db::users::get_user_by_username(&conn, "userb") + .unwrap() + .unwrap(); + (u.id, u.tenant_id.unwrap()) + }; + + // User B adds a URL let _admin_url_id = { - let conn_admin = db.content.lock().unwrap(); + let conn_b = bzod::jobs::open_user_content_conn(&db, id_b).unwrap(); let url = bzod::db::content::create_url_extended( - &conn_admin, + &conn_b, "!admin-slug", "https://admin.com", None, @@ -127,24 +143,17 @@ async fn test_analytics_and_table_parity() { ) .unwrap(); - let system_conn = db.system.lock().unwrap(); - bzod::db::users::register_global_slug( - &system_conn, - "!admin-slug", - 1, - "url", - &url.id, - "active", - ) - .unwrap(); + let urls_conn = db.global_urls.lock().unwrap(); + bzod::db::slugs::register_url_slug(&urls_conn, "!admin-slug", &tid_b, &url.id, "active") + .unwrap(); url.id }; - // Admin adds a Landing Page to the global content_db + // User B adds a Landing Page let _admin_page_id = { - let conn_admin = db.content.lock().unwrap(); + let conn_b = bzod::jobs::open_user_content_conn(&db, id_b).unwrap(); let page = bzod::db::content::create_landing_page( - &conn_admin, + &conn_b, "!admin-page", "admin-page", "Title Admin", @@ -153,12 +162,11 @@ async fn test_analytics_and_table_parity() { ) .unwrap(); - let system_conn = db.system.lock().unwrap(); - bzod::db::users::register_global_slug( - &system_conn, + let pages_conn = db.global_landing_pages.lock().unwrap(); + bzod::db::slugs::register_landing_page_slug( + &pages_conn, "!admin-page", - 1, - "page", + &tid_b, &page.id, "active", ) @@ -176,12 +184,12 @@ async fn test_analytics_and_table_parity() { .await .unwrap(); - let id_a = { + let (id_a, tid_a) = { let conn = db.users.lock().unwrap(); - bzod::db::users::get_user_by_username(&conn, "usera") + let u = bzod::db::users::get_user_by_username(&conn, "usera") .unwrap() - .unwrap() - .id + .unwrap(); + (u.id, u.tenant_id.unwrap()) }; // User A adds a URL @@ -200,16 +208,9 @@ async fn test_analytics_and_table_parity() { ) .unwrap(); - let system_conn = db.system.lock().unwrap(); - bzod::db::users::register_global_slug( - &system_conn, - "!usera-slug", - id_a, - "url", - &url.id, - "active", - ) - .unwrap(); + let urls_conn = db.global_urls.lock().unwrap(); + bzod::db::slugs::register_url_slug(&urls_conn, "!usera-slug", &tid_a, &url.id, "active") + .unwrap(); url.id }; @@ -226,12 +227,11 @@ async fn test_analytics_and_table_parity() { ) .unwrap(); - let system_conn = db.system.lock().unwrap(); - bzod::db::users::register_global_slug( - &system_conn, + let pages_conn = db.global_landing_pages.lock().unwrap(); + bzod::db::slugs::register_landing_page_slug( + &pages_conn, "!usera-page", - id_a, - "page", + &tid_a, &page.id, "active", ) diff --git a/tests/analytics_tests.rs b/tests/analytics_tests.rs index ad4648f..e678ae3 100644 --- a/tests/analytics_tests.rs +++ b/tests/analytics_tests.rs @@ -121,6 +121,7 @@ fn test_target_analytics_queries() { accept_language: "en".to_string(), country: "US".to_string(), status_code: 200, + owner_tenant_id: None, owner_user_id: None, }, VisitRecord { @@ -134,6 +135,7 @@ fn test_target_analytics_queries() { accept_language: "en".to_string(), country: "US".to_string(), status_code: 200, + owner_tenant_id: None, owner_user_id: None, }, VisitRecord { @@ -147,6 +149,7 @@ fn test_target_analytics_queries() { accept_language: "en".to_string(), country: "US".to_string(), status_code: 200, + owner_tenant_id: None, owner_user_id: None, }, ]; diff --git a/tests/auth_migration_tests.rs b/tests/auth_migration_tests.rs index d53ba58..6f163de 100644 --- a/tests/auth_migration_tests.rs +++ b/tests/auth_migration_tests.rs @@ -42,8 +42,6 @@ fn build_state(config: Config) -> (Db, bzod::state::AppState) { let (queue, _) = AnalyticsQueue::new(db.clone(), 1000, rx); let state = bzod::state::AppState { admin_db: db.admin.clone(), - content_db: db.content.clone(), - analytics_db: db.analytics.clone(), system_db: db.system.clone(), users_db: db.users.clone(), user_dbs: Arc::new(Mutex::new(HashMap::new())), @@ -60,14 +58,20 @@ fn test_migration_idempotent() { let mut conn = Connection::open_in_memory().unwrap(); run_migrations(&mut conn, "users", USERS_MIGRATIONS, None).unwrap(); run_migrations(&mut conn, "users", USERS_MIGRATIONS, None).unwrap(); - assert_eq!(get_user_version(&conn).unwrap(), 2); + assert_eq!( + get_user_version(&conn).unwrap(), + USERS_MIGRATIONS.last().unwrap().version + ); } #[test] fn test_users_db_schema_version_2() { let mut conn = Connection::open_in_memory().unwrap(); run_migrations(&mut conn, "users", USERS_MIGRATIONS, None).unwrap(); - assert_eq!(get_user_version(&conn).unwrap(), 2); + assert_eq!( + get_user_version(&conn).unwrap(), + USERS_MIGRATIONS.last().unwrap().version + ); } #[test] diff --git a/tests/backup_restore_tests.rs b/tests/backup_restore_tests.rs index a7b0e5e..b5e650d 100644 --- a/tests/backup_restore_tests.rs +++ b/tests/backup_restore_tests.rs @@ -96,22 +96,12 @@ async fn test_backup_restore_roundtrip() { let db = Db::init(&config).expect("Failed to init Db"); - let _ = bzod::cli::create_user::run( - Some("testuser".to_string()), - Some("password123".to_string()), - None, - config.clone(), - ) - .await - .unwrap(); - - let user_id = { + let user = { let conn = db.users.lock().unwrap(); - bzod::db::users::get_user_by_username(&conn, "testuser") - .unwrap() - .unwrap() - .id + bzod::db::users::create_user(&conn, "testuser", "password123", "standard", None).unwrap() }; + db.init_user_databases(user.id).unwrap(); + let user_id = user.id; // Add a link for user { @@ -129,21 +119,25 @@ async fn test_backup_restore_roundtrip() { ) .unwrap(); - // Increment quota counter - let users_conn = db.users.lock().unwrap(); - bzod::db::users::increment_quota_counter(&users_conn, user_id, "urls").unwrap(); + let tenant_id = { + let users_conn = db.users.lock().unwrap(); + bzod::db::users::increment_quota_counter(&users_conn, user_id, "urls").unwrap(); + bzod::db::users::get_user_by_id(&users_conn, user_id) + .unwrap() + .unwrap() + .tenant_id + .unwrap() + }; - // Register global slug - let system_conn = db.system.lock().unwrap(); - bzod::db::users::register_global_slug( - &system_conn, - "!rt-slug", - user_id, - "url", - "rt-id", - "active", - ) - .unwrap(); + let urls_conn = db.global_urls.lock().unwrap(); + let now = chrono::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 ('!rt-slug', ?1, 'rt-id', ?2, ?3, 'active', NULL);", + rusqlite::params![tenant_id.as_str(), now, now], + ) + .unwrap(); } // Backup user @@ -172,7 +166,7 @@ async fn test_backup_restore_roundtrip() { .unwrap(); // Verify restored user - { + let restored_id = { let conn = db.users.lock().unwrap(); let user = bzod::db::users::get_user_by_username(&conn, "testuser") .unwrap() @@ -183,14 +177,14 @@ async fn test_backup_restore_roundtrip() { .unwrap() .unwrap(); assert_eq!(quota.current_urls, 1); + user.id + }; - // Verify content restored - let user_content_conn = bzod::jobs::open_user_content_conn(&db, user.id).unwrap(); - let url = bzod::db::content::get_url_by_code(&user_content_conn, "!rt-slug") - .unwrap() - .unwrap(); - assert_eq!(url.destination, "https://example.com/rt"); - } + let user_content_conn = bzod::jobs::open_user_content_conn(&db, restored_id).unwrap(); + let url = bzod::db::content::get_url_by_code(&user_content_conn, "!rt-slug") + .unwrap() + .unwrap(); + assert_eq!(url.destination, "https://example.com/rt"); let _ = fs::remove_dir_all(&temp_dir); } @@ -198,33 +192,30 @@ async fn test_backup_restore_roundtrip() { #[tokio::test] async fn test_restore_slug_collision_rejection() { let temp_dir = std::env::temp_dir().join(format!( - "bzod_test_restore_collision_{}", - uuid::Uuid::new_v4() + "bzod_test_collision_{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() )); 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 mut config = Config::load(); + config.data_dir = temp_dir.clone(); + config.backup_dir = temp_dir.join("backups"); + fs::create_dir_all(&config.backup_dir).unwrap(); + + let db = Db::init(&config).unwrap(); // Create User A - let _ = bzod::cli::create_user::run( - Some("usera".to_string()), - Some("password123".to_string()), - None, - config.clone(), - ) - .await - .unwrap(); - - let id_a = { + let user_a = { let conn = db.users.lock().unwrap(); - bzod::db::users::get_user_by_username(&conn, "usera") - .unwrap() - .unwrap() - .id + bzod::db::users::create_user(&conn, "usera", "password123", "standard", None).unwrap() }; + db.init_user_databases(user_a.id).unwrap(); + let (id_a, tid_a) = (user_a.id, user_a.tenant_id.unwrap()); - // Add a link for User A + // Add slug for User A { let conn_a = bzod::jobs::open_user_content_conn(&db, id_a).unwrap(); bzod::db::content::create_url_extended( @@ -240,16 +231,13 @@ async fn test_restore_slug_collision_rejection() { ) .unwrap(); - let system_conn = db.system.lock().unwrap(); - bzod::db::users::register_global_slug( - &system_conn, - "!collision-slug", - id_a, - "url", - "a-id", - "active", - ) - .unwrap(); + let urls_conn = db.global_urls.lock().unwrap(); + let now = chrono::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 ('!collision-slug', ?1, 'a-id', ?2, ?3, 'active', NULL);", + rusqlite::params![tid_a.as_str(), now, now], + ).unwrap(); } // Backup User A @@ -269,22 +257,12 @@ async fn test_restore_slug_collision_rejection() { .unwrap(); // Create User B - let _ = bzod::cli::create_user::run( - Some("userb".to_string()), - Some("password123".to_string()), - None, - config.clone(), - ) - .await - .unwrap(); - - let id_b = { + let user_b = { let conn = db.users.lock().unwrap(); - bzod::db::users::get_user_by_username(&conn, "userb") - .unwrap() - .unwrap() - .id + bzod::db::users::create_user(&conn, "userb", "password123", "standard", None).unwrap() }; + db.init_user_databases(user_b.id).unwrap(); + let (id_b, tid_b) = (user_b.id, user_b.tenant_id.unwrap()); // Add colliding slug for User B { @@ -302,16 +280,13 @@ async fn test_restore_slug_collision_rejection() { ) .unwrap(); - let system_conn = db.system.lock().unwrap(); - bzod::db::users::register_global_slug( - &system_conn, - "!collision-slug", - id_b, - "url", - "b-id", - "active", - ) - .unwrap(); + let urls_conn = db.global_urls.lock().unwrap(); + let now = chrono::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 ('!collision-slug', ?1, 'b-id', ?2, ?3, 'active', NULL);", + rusqlite::params![tid_b.as_str(), now, now], + ).unwrap(); } // Attempt to restore User A from backup - must fail on collision @@ -323,17 +298,17 @@ async fn test_restore_slug_collision_rejection() { .await; assert!(res.is_err()); - // Verify that User B still owns the slug in global_slugs and User A's slug registration was skipped/rejected + // Verify that User B still owns the slug in global_urls and User A's slug registration was skipped/rejected { - let system_conn = db.system.lock().unwrap(); - let owner_id: i64 = system_conn + let urls_conn = db.global_urls.lock().unwrap(); + let owner_tid: String = urls_conn .query_row( - "SELECT owner_user_id FROM global_slugs WHERE slug = '!collision-slug';", + "SELECT owner_tenant_id FROM global_urls WHERE slug = '!collision-slug';", [], |row| row.get(0), ) .unwrap(); - assert_eq!(owner_id, id_b); + assert_eq!(owner_tid, tid_b.as_str()); } let _ = fs::remove_dir_all(&temp_dir); diff --git a/tests/business_workflow_tests.rs b/tests/business_workflow_tests.rs index 51fffe4..ad8dd17 100644 --- a/tests/business_workflow_tests.rs +++ b/tests/business_workflow_tests.rs @@ -55,8 +55,6 @@ async fn start_test_server( let state = AppState { admin_db: db.admin.clone(), - content_db: db.content.clone(), - analytics_db: db.analytics.clone(), system_db: db.system.clone(), users_db: db.users.clone(), user_dbs: std::sync::Arc::new(std::sync::Mutex::new(HashMap::new())), @@ -190,9 +188,9 @@ async fn test_scenario_a_user_create_login_shorten_visit_analytics() { // 6. Verify visit is logged in analytics let url_uuid = { - let conn = state.system_db.lock().unwrap(); + let conn = state.db.global_urls.lock().unwrap(); conn.query_row( - "SELECT target_id FROM global_slugs WHERE slug = '!mygoogle';", + "SELECT target_id FROM global_urls WHERE slug = '!mygoogle';", [], |row| row.get::<_, String>(0), ) @@ -505,9 +503,9 @@ async fn test_scenario_c_slug_transfer_workflow() { // Verify visit recorded for standard_c1 let url_uuid = { - let conn = state.system_db.lock().unwrap(); + let conn = state.db.global_urls.lock().unwrap(); conn.query_row( - "SELECT target_id FROM global_slugs WHERE slug = '!myyahoo';", + "SELECT target_id FROM global_urls WHERE slug = '!myyahoo';", [], |row| row.get::<_, String>(0), ) @@ -607,9 +605,9 @@ async fn test_scenario_c_slug_transfer_workflow() { // standard_c2 analytics should display Yahoo! let url_uuid = { - let conn = state.system_db.lock().unwrap(); + let conn = state.db.global_urls.lock().unwrap(); conn.query_row( - "SELECT target_id FROM global_slugs WHERE slug = '!myyahoo';", + "SELECT target_id FROM global_urls WHERE slug = '!myyahoo';", [], |row| row.get::<_, String>(0), ) diff --git a/tests/concurrency_tests.rs b/tests/concurrency_tests.rs index 7c18d0d..3514c19 100644 --- a/tests/concurrency_tests.rs +++ b/tests/concurrency_tests.rs @@ -23,14 +23,19 @@ async fn test_concurrent_slug_creation() { let db = Db::init(&config).expect("Failed to init Db"); let barrier = Arc::new(Barrier::new(2)); - let db_path = temp_dir.join("admin/system.db"); + let db_path = temp_dir.join("slugs/global_urls.db"); let b1 = barrier.clone(); let path1 = db_path.clone(); let task1 = tokio::spawn(async move { let conn = rusqlite::Connection::open(&path1).unwrap(); b1.wait().await; - bzod::db::users::register_global_slug(&conn, "!conc-slug", 10, "url", "url10", "active") + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at) + VALUES ('!conc-slug', 't-tenant10', 'url10', ?1, ?2, 'active', NULL);", + rusqlite::params![now, now], + ) }); let b2 = barrier.clone(); @@ -38,7 +43,12 @@ async fn test_concurrent_slug_creation() { let task2 = tokio::spawn(async move { let conn = rusqlite::Connection::open(&path2).unwrap(); b2.wait().await; - bzod::db::users::register_global_slug(&conn, "!conc-slug", 20, "url", "url20", "active") + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at) + VALUES ('!conc-slug', 't-tenant20', 'url20', ?1, ?2, 'active', NULL);", + rusqlite::params![now, now], + ) }); let res1 = task1.await.unwrap(); @@ -54,12 +64,12 @@ async fn test_concurrent_slug_creation() { ), } - // Verify exactly 1 record exists in global_slugs + // Verify exactly 1 record exists in global_urls { - let system_conn = db.system.lock().unwrap(); - let count: i64 = system_conn + let urls_conn = db.global_urls.lock().unwrap(); + let count: i64 = urls_conn .query_row( - "SELECT COUNT(*) FROM global_slugs WHERE slug = '!conc-slug';", + "SELECT COUNT(*) FROM global_urls WHERE slug = '!conc-slug';", [], |row| row.get(0), ) diff --git a/tests/disaster_recovery_tests.rs b/tests/disaster_recovery_tests.rs index ab5f9ad..f9913fb 100644 --- a/tests/disaster_recovery_tests.rs +++ b/tests/disaster_recovery_tests.rs @@ -84,8 +84,7 @@ async fn test_partial_restore_failure_rollback() { let count: i64 = conn .query_row("SELECT COUNT(*) FROM users;", [], |row| row.get(0)) .unwrap(); - // Only legacy_admin (ID 1) should exist - assert_eq!(count, 1); + assert_eq!(count, 0, "No users should exist after failed restore"); } let _ = fs::remove_dir_all(&temp_dir); diff --git a/tests/http_e2e_tests.rs b/tests/http_e2e_tests.rs index 57134f3..a492796 100644 --- a/tests/http_e2e_tests.rs +++ b/tests/http_e2e_tests.rs @@ -54,8 +54,6 @@ async fn start_test_server( let state = AppState { admin_db: db.admin.clone(), - content_db: db.content.clone(), - analytics_db: db.analytics.clone(), system_db: db.system.clone(), users_db: db.users.clone(), user_dbs: std::sync::Arc::new(std::sync::Mutex::new(HashMap::new())), diff --git a/tests/integrity_tests.rs b/tests/integrity_tests.rs index a0ceb31..773589f 100644 --- a/tests/integrity_tests.rs +++ b/tests/integrity_tests.rs @@ -29,12 +29,12 @@ async fn test_global_slug_index_consistency() { .await .unwrap(); - let user_id = { + let (user_id, tid) = { let conn = db.users.lock().unwrap(); - bzod::db::users::get_user_by_username(&conn, "testuser") + let u = bzod::db::users::get_user_by_username(&conn, "testuser") .unwrap() - .unwrap() - .id + .unwrap(); + (u.id, u.tenant_id.unwrap()) }; // Add a URL for the user @@ -57,46 +57,47 @@ async fn test_global_slug_index_consistency() { // Register globally { - let system_conn = db.system.lock().unwrap(); - bzod::db::users::register_global_slug( - &system_conn, - "!integ-slug", - user_id, - "url", - &url_id, - "active", - ) - .unwrap(); + let urls_conn = db.global_urls.lock().unwrap(); + let now = chrono::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 ('!integ-slug', ?1, ?2, ?3, ?4, 'active', NULL);", + rusqlite::params![tid.as_str(), &url_id, now, now], + ) + .unwrap(); } // Consistency Check: - // 1. Every active slug in user databases exists in global_slugs + // 1. Every active slug in user databases exists in global_urls { - let user_ids: Vec = { + let users = { let conn = db.users.lock().unwrap(); - let mut stmt = conn.prepare("SELECT id FROM users;").unwrap(); - let rows = stmt.query_map([], |row| row.get(0)).unwrap(); - rows.filter_map(|r| r.ok()).collect() + bzod::db::users::list_users(&conn).unwrap() }; - let system_conn = db.system.lock().unwrap(); - for id in user_ids { - let conn = bzod::jobs::open_user_content_conn(&db, id).unwrap(); - let mut stmt = conn.prepare("SELECT code FROM urls;").unwrap(); - let rows = stmt.query_map([], |row| row.get::<_, String>(0)).unwrap(); + let urls_conn = db.global_urls.lock().unwrap(); + for u in users { + if let Some(tenant_id) = u.tenant_id { + let conn = bzod::jobs::open_user_content_conn(&db, u.id).unwrap(); + let mut stmt = conn.prepare("SELECT code FROM urls;").unwrap(); + let rows = stmt.query_map([], |row| row.get::<_, String>(0)).unwrap(); - for code_res in rows { - let code = code_res.unwrap(); - let exists: bool = system_conn.query_row( - "SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = ?1 AND owner_user_id = ?2);", - rusqlite::params![code, id], - |row| row.get(0), - ).unwrap(); - assert!( - exists, - "Active user slug '{}' missing from global_slugs", - code - ); + for code_res in rows { + let code = code_res.unwrap(); + let exists: bool = urls_conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM global_urls WHERE slug = ?1 AND owner_tenant_id = ?2);", + rusqlite::params![code, tenant_id.as_str()], + |row| row.get(0), + ) + .unwrap(); + assert!( + exists, + "Active user slug '{}' missing from global_urls", + code + ); + } } } } diff --git a/tests/legacy_restore_tests.rs b/tests/legacy_restore_tests.rs index 988207d..c7f84f7 100644 --- a/tests/legacy_restore_tests.rs +++ b/tests/legacy_restore_tests.rs @@ -546,16 +546,23 @@ async fn test_current_backup_restore_roundtrip() { ) .unwrap(); - let system_conn = db.system.lock().unwrap(); - bzod::db::users::register_global_slug( - &system_conn, - "!current-test", - user_id, - "url", - "current-id", - "active", - ) - .unwrap(); + let tenant_id = { + let conn = db.users.lock().unwrap(); + bzod::db::users::get_user_by_id(&conn, user_id) + .unwrap() + .unwrap() + .tenant_id + .unwrap() + }; + let urls_conn = db.global_urls.lock().unwrap(); + let now = chrono::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 ('!current-test', ?1, 'current-id', ?2, ?3, 'active', NULL);", + rusqlite::params![tenant_id.as_str(), now, now], + ) + .unwrap(); } // Create a native backup diff --git a/tests/migration_tests.rs b/tests/migration_tests.rs index 93d2521..8493b15 100644 --- a/tests/migration_tests.rs +++ b/tests/migration_tests.rs @@ -79,8 +79,11 @@ async fn test_legacy_migration() { .unwrap(); } - // Call Db::init which triggers the legacy migration - let db = Db::init(&config).expect("Failed to init Db and run legacy migration"); + // Call normalize_restored_layout to move legacy flat files into multi-tenant paths + bzod::services::backup_layout::normalize_restored_layout(&temp_dir).unwrap(); + + // Call Db::init + let _db = Db::init(&config).expect("Failed to init Db and run legacy migration"); // Verify files moved to correct directories assert!(!legacy_admin_path.exists()); @@ -88,26 +91,12 @@ async fn test_legacy_migration() { assert!(temp_dir.join("admin/admin.db").exists()); assert!(temp_dir.join("users/1/content.db").exists()); - // Verify legacy_admin created as system and disabled + // Verify legacy content is preserved in users/1/content.db { - let conn = db.users.lock().unwrap(); - let user = bzod::db::users::get_user_by_id(&conn, 1).unwrap().unwrap(); - assert_eq!(user.username, "legacy_admin"); - assert_eq!(user.status, "disabled"); - assert_eq!(user.account_type, "system"); - } - - // Verify slug registered in global_slugs - { - let conn = db.system.lock().unwrap(); - let exists: bool = conn - .query_row( - "SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = '!legacy-slug');", - [], - |row| row.get(0), - ) - .unwrap(); - assert!(exists); + let conn = rusqlite::Connection::open(temp_dir.join("users/1/content.db")).unwrap(); + let url = bzod::db::content::get_url_by_code(&conn, "!legacy-slug").unwrap(); + assert!(url.is_some()); + assert_eq!(url.unwrap().destination, "https://legacy.com"); } let _ = fs::remove_dir_all(&temp_dir); diff --git a/tests/moderation_tests.rs b/tests/moderation_tests.rs index d58920c..fb411fb 100644 --- a/tests/moderation_tests.rs +++ b/tests/moderation_tests.rs @@ -22,16 +22,22 @@ async fn test_content_flagging_and_disabling() { let db = Db::init(&config).expect("Failed to init Db"); - let system_conn = db.system.lock().unwrap(); + let urls_conn = db.global_urls.lock().unwrap(); // Register slug - bzod::db::users::register_global_slug(&system_conn, "!badslug", 10, "url", "url_abc", "active") + 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 ('!badslug', 't-tenant10', 'url_abc', ?1, ?2, 'active', NULL);", + rusqlite::params![now, now], + ) .unwrap(); // Verify it is active initially - let status: String = system_conn + let status: String = urls_conn .query_row( - "SELECT status FROM global_slugs WHERE slug = ?1;", + "SELECT status FROM global_urls WHERE slug = ?1;", ["!badslug"], |row| row.get(0), ) @@ -39,17 +45,17 @@ async fn test_content_flagging_and_disabling() { assert_eq!(status, "active"); // Moderate/disable slug - system_conn + urls_conn .execute( - "UPDATE global_slugs SET status = 'disabled' WHERE slug = ?1;", + "UPDATE global_urls SET status = 'disabled' WHERE slug = ?1;", ["!badslug"], ) .unwrap(); // Verify it is disabled - let status2: String = system_conn + let status2: String = urls_conn .query_row( - "SELECT status FROM global_slugs WHERE slug = ?1;", + "SELECT status FROM global_urls WHERE slug = ?1;", ["!badslug"], |row| row.get(0), ) @@ -57,16 +63,16 @@ async fn test_content_flagging_and_disabling() { assert_eq!(status2, "disabled"); // Moderate/re-enable slug - system_conn + urls_conn .execute( - "UPDATE global_slugs SET status = 'active' WHERE slug = ?1;", + "UPDATE global_urls SET status = 'active' WHERE slug = ?1;", ["!badslug"], ) .unwrap(); - let status3: String = system_conn + let status3: String = urls_conn .query_row( - "SELECT status FROM global_slugs WHERE slug = ?1;", + "SELECT status FROM global_urls WHERE slug = ?1;", ["!badslug"], |row| row.get(0), ) diff --git a/tests/next_features_tests.rs b/tests/next_features_tests.rs index 08b8dbf..14c2df8 100644 --- a/tests/next_features_tests.rs +++ b/tests/next_features_tests.rs @@ -47,6 +47,16 @@ async fn test_cli_shorten_and_expand() { fs::create_dir_all(&temp_dir).unwrap(); let config = create_temp_config(temp_dir.clone()); + // 0. Create a standard user + let _ = bzod::cli::create_user::run( + Some("cliuser".to_string()), + Some("password123".to_string()), + None, + config.clone(), + ) + .await + .unwrap(); + // 1. Shorten with generated code let res = bzod::cli::shorten::run( "https://example.com/one".to_string(), @@ -78,20 +88,11 @@ async fn test_cli_shorten_and_expand() { assert!(res_dup.is_err()); assert!(res_dup.unwrap_err().to_string().contains("already exists")); - // 4. Expand custom slug - { - let db = Db::init(&config).unwrap(); - let conn = db.content.lock().unwrap(); - let url_opt = bzod::db::content::get_url_by_code(&conn, "!office").unwrap(); - assert!(url_opt.is_some()); - assert_eq!(url_opt.unwrap().destination, "https://example.com/two"); - } - - // 5. CLI expand round-trip validation + // 4. CLI expand round-trip validation let expand_res = bzod::cli::expand::run("!office".to_string(), None, config.clone()).await; assert!(expand_res.is_ok()); - // 6. Case-insensitive CLI expand validation + // 5. Case-insensitive CLI expand validation let expand_res_upper = bzod::cli::expand::run("!OFFICE".to_string(), None, config.clone()).await; assert!(expand_res_upper.is_ok()); @@ -108,12 +109,30 @@ async fn test_perform_restore_and_validation() { fs::create_dir_all(&restore_dir).unwrap(); let config = create_temp_config(temp_dir.clone()); + + // Create user and url + let _ = bzod::cli::create_user::run( + Some("restoreuser".to_string()), + Some("password123".to_string()), + None, + config.clone(), + ) + .await + .unwrap(); + let db = Db::init(&config).unwrap(); + let (uid, tid) = { + let conn = db.users.lock().unwrap(); + let u = bzod::db::users::get_user_by_username(&conn, "restoreuser") + .unwrap() + .unwrap(); + (u.id, u.tenant_id.unwrap()) + }; // 1. Create a mock database record { - let conn = db.content.lock().unwrap(); - bzod::db::content::create_url_extended( + let conn = bzod::jobs::open_user_content_conn(&db, uid).unwrap(); + let url = bzod::db::content::create_url_extended( &conn, "!home", "https://my-home.com", @@ -125,6 +144,9 @@ async fn test_perform_restore_and_validation() { None, ) .unwrap(); + + let urls_conn = db.global_urls.lock().unwrap(); + bzod::db::slugs::register_url_slug(&urls_conn, "!home", &tid, &url.id, "active").unwrap(); } // 2. Perform a backup @@ -142,18 +164,15 @@ async fn test_perform_restore_and_validation() { assert!(restore_dir.join("admin/admin.db").exists()); assert!(restore_dir.join("admin/users.db").exists()); assert!(restore_dir.join("admin/system.db").exists()); - assert!(restore_dir.join("users/1/content.db").exists()); - assert!(restore_dir.join("users/1/analytics.db").exists()); + assert!(restore_dir.join("slugs/global_urls.db").exists()); + assert!(restore_dir + .join(format!("users/{}/content.db", tid.as_str())) + .exists()); // Verify custom slug was preserved in the restored DB let restore_config = create_temp_config(restore_dir.clone()); - let restore_db = Db::init(&restore_config).unwrap(); - { - let conn = restore_db.content.lock().unwrap(); - let url = bzod::db::content::get_url_by_code(&conn, "!home").unwrap(); - assert!(url.is_some()); - assert_eq!(url.unwrap().destination, "https://my-home.com"); - } + let expand_res = bzod::cli::expand::run("!home".to_string(), None, restore_config).await; + assert!(expand_res.is_ok()); let _ = fs::remove_dir_all(&temp_dir); let _ = fs::remove_dir_all(&restore_dir); diff --git a/tests/ownership_tests.rs b/tests/ownership_tests.rs index dbb6529..edf3fa9 100644 --- a/tests/ownership_tests.rs +++ b/tests/ownership_tests.rs @@ -50,8 +50,6 @@ async fn start_test_server( let state = AppState { admin_db: db.admin.clone(), - content_db: db.content.clone(), - analytics_db: db.analytics.clone(), system_db: db.system.clone(), users_db: db.users.clone(), user_dbs: std::sync::Arc::new(std::sync::Mutex::new(HashMap::new())), @@ -107,17 +105,15 @@ async fn test_ownership_isolation_endpoints() { .await .unwrap(); - let (id_a, _id_b) = { + let (id_a, tid_a, _id_b) = { let conn = db.users.lock().unwrap(); let a = bzod::db::users::get_user_by_username(&conn, "usera") .unwrap() - .unwrap() - .id; + .unwrap(); let b = bzod::db::users::get_user_by_username(&conn, "userb") .unwrap() - .unwrap() - .id; - (a, b) + .unwrap(); + (a.id, a.tenant_id.unwrap(), b.id) }; // User A adds a URL @@ -136,16 +132,9 @@ async fn test_ownership_isolation_endpoints() { ) .unwrap(); - let system_conn = db.system.lock().unwrap(); - bzod::db::users::register_global_slug( - &system_conn, - "!usera-slug", - id_a, - "url", - &url.id, - "active", - ) - .unwrap(); + let urls_conn = db.global_urls.lock().unwrap(); + bzod::db::slugs::register_url_slug(&urls_conn, "!usera-slug", &tid_a, &url.id, "active") + .unwrap(); url.id }; @@ -162,12 +151,11 @@ async fn test_ownership_isolation_endpoints() { ) .unwrap(); - let system_conn = db.system.lock().unwrap(); - bzod::db::users::register_global_slug( - &system_conn, + let pages_conn = db.global_landing_pages.lock().unwrap(); + bzod::db::slugs::register_landing_page_slug( + &pages_conn, "!usera-page", - id_a, - "page", + &tid_a, &page.id, "active", ) diff --git a/tests/qr_endpoint_tests.rs b/tests/qr_endpoint_tests.rs index 87ff46b..f9a1721 100644 --- a/tests/qr_endpoint_tests.rs +++ b/tests/qr_endpoint_tests.rs @@ -39,8 +39,6 @@ async fn start_test_server( let state = AppState { admin_db: db.admin.clone(), - content_db: db.content.clone(), - analytics_db: db.analytics.clone(), system_db: db.system.clone(), users_db: db.users.clone(), user_dbs: std::sync::Arc::new(std::sync::Mutex::new(HashMap::new())), @@ -85,12 +83,12 @@ async fn test_qr_endpoints_and_redirection_hardening() { .await .unwrap(); - let id_a = { + let (id_a, tid_a) = { let conn = db.users.lock().unwrap(); - bzod::db::users::get_user_by_username(&conn, "usera") + let u = bzod::db::users::get_user_by_username(&conn, "usera") .unwrap() - .unwrap() - .id + .unwrap(); + (u.id, u.tenant_id.unwrap()) }; // User A adds an active URL @@ -109,16 +107,15 @@ async fn test_qr_endpoints_and_redirection_hardening() { ) .unwrap(); - let system_conn = db.system.lock().unwrap(); - bzod::db::users::register_global_slug( - &system_conn, - "a1b2c3", - id_a, - "url", - &url.id, - "active", - ) - .unwrap(); + let urls_conn = db.global_urls.lock().unwrap(); + let now = chrono::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 ('a1b2c3', ?1, ?2, ?3, ?4, 'active', NULL);", + rusqlite::params![tid_a.as_str(), &url.id, now, now], + ) + .unwrap(); } // User A adds a disabled URL @@ -137,16 +134,15 @@ async fn test_qr_endpoints_and_redirection_hardening() { ) .unwrap(); - let system_conn = db.system.lock().unwrap(); - bzod::db::users::register_global_slug( - &system_conn, - "d4e5f6", - id_a, - "url", - &url.id, - "disabled", - ) - .unwrap(); + let urls_conn = db.global_urls.lock().unwrap(); + let now = chrono::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 ('d4e5f6', ?1, ?2, ?3, ?4, 'disabled', NULL);", + rusqlite::params![tid_a.as_str(), &url.id, now, now], + ) + .unwrap(); } // User A adds an active Page @@ -162,16 +158,15 @@ async fn test_qr_endpoints_and_redirection_hardening() { ) .unwrap(); - let system_conn = db.system.lock().unwrap(); - bzod::db::users::register_global_slug( - &system_conn, - "a1b2", - id_a, - "page", - &page.id, - "active", - ) - .unwrap(); + let pages_conn = db.global_landing_pages.lock().unwrap(); + let now = chrono::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 ('a1b2', ?1, ?2, ?3, ?4, 'active', NULL);", + rusqlite::params![tid_a.as_str(), &page.id, now, now], + ) + .unwrap(); } // User A adds a disabled Page @@ -187,16 +182,15 @@ async fn test_qr_endpoints_and_redirection_hardening() { ) .unwrap(); - let system_conn = db.system.lock().unwrap(); - bzod::db::users::register_global_slug( - &system_conn, - "c3d4", - id_a, - "page", - &page.id, - "disabled", - ) - .unwrap(); + let pages_conn = db.global_landing_pages.lock().unwrap(); + let now = chrono::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 ('c3d4', ?1, ?2, ?3, ?4, 'disabled', NULL);", + rusqlite::params![tid_a.as_str(), &page.id, now, now], + ) + .unwrap(); } // 1. Verify Active URL QR endpoints return 200 with correct content types diff --git a/tests/quota_tests.rs b/tests/quota_tests.rs index 9e15a7d..2f052b6 100644 --- a/tests/quota_tests.rs +++ b/tests/quota_tests.rs @@ -126,8 +126,8 @@ async fn test_quota_reconcile_job() { // Run reconciliation { - let users_conn = db.users.lock().unwrap(); let user_content_conn = bzod::jobs::open_user_content_conn(&db, user_id).unwrap(); + let users_conn = db.users.lock().unwrap(); bzod::db::users::reconcile_user_quotas(&users_conn, user_id, &user_content_conn).unwrap(); // Verify drift is repaired diff --git a/tests/redirect_security_tests.rs b/tests/redirect_security_tests.rs index a9bc3c4..b97c5c7 100644 --- a/tests/redirect_security_tests.rs +++ b/tests/redirect_security_tests.rs @@ -48,10 +48,15 @@ async fn start_test_server( Box::leak(Box::new(tx)); let (queue, _) = AnalyticsQueue::new(db.clone(), 1000, rx); + // Create a standard tenant user + { + let users = db.users.lock().unwrap(); + let _ = bzod::db::users::create_user(&users, "testuser", "dummy_hash", "standard", None); + } + let _ = db.init_user_databases(1); + let state = AppState { admin_db: db.admin.clone(), - content_db: db.content.clone(), - analytics_db: db.analytics.clone(), system_db: db.system.clone(), users_db: db.users.clone(), user_dbs: std::sync::Arc::new(std::sync::Mutex::new(HashMap::new())), @@ -95,25 +100,27 @@ fn seed_url(db: &Db, seed: SeedUrl<'_>) { db.init_user_databases(seed.owner_user_id) .expect("init user dbs"); + let (content_path, tid) = { + let users = db.users.lock().unwrap(); + let user = bzod::db::users::get_user_by_id(&users, seed.owner_user_id) + .unwrap() + .expect("seed owner must be a registered user"); + let path = bzod::db::tenant::location_for_user(&user) + .unwrap() + .dir(&db.topology) + .unwrap() + .join("content.db"); + (path, user.tenant_id.unwrap()) + }; + + let id = uuid::Uuid::new_v4().to_string(); + { - let system = db.system.lock().unwrap(); - let _ = bzod::db::users::register_global_slug( - &system, - seed.code, - seed.owner_user_id, - "url", - "seed", - "active", - ); + let urls_conn = db.global_urls.lock().unwrap(); + let _ = bzod::db::slugs::register_url_slug(&urls_conn, seed.code, &tid, &id, "active"); } - let content_path = db - .data_dir - .join("users") - .join(seed.owner_user_id.to_string()) - .join("content.db"); let conn = rusqlite::Connection::open(content_path).unwrap(); - let id = uuid::Uuid::new_v4().to_string(); let now = chrono::Utc::now().to_rfc3339(); conn.execute( "INSERT INTO urls (id, code, destination, title, description, status, created_at, updated_at, expires_at, expired, password_hash, max_access_count, access_count) @@ -271,7 +278,15 @@ async fn wall_clock_expiry_returns_410_without_hot_path_write_dependency() { assert_eq!(res.status(), reqwest::StatusCode::GONE); // Column may still be 0 until sweeper runs — correctness does not require write. - let content_path = db.data_dir.join("users/1/content.db"); + let content_path = { + let users = db.users.lock().unwrap(); + let user = bzod::db::users::get_user_by_id(&users, 1).unwrap().unwrap(); + bzod::db::tenant::location_for_user(&user) + .unwrap() + .dir(&db.topology) + .unwrap() + .join("content.db") + }; let conn = rusqlite::Connection::open(content_path).unwrap(); let expired_flag: i64 = conn .query_row("SELECT expired FROM urls WHERE code = 'ab3333';", [], |r| { @@ -347,7 +362,15 @@ async fn password_gate_before_access_increment() { assert!(loc.contains("/gate/ab5555")); // Access count must not have incremented - let content_path = db.data_dir.join("users/1/content.db"); + let content_path = { + let users = db.users.lock().unwrap(); + let user = bzod::db::users::get_user_by_id(&users, 1).unwrap().unwrap(); + bzod::db::tenant::location_for_user(&user) + .unwrap() + .dir(&db.topology) + .unwrap() + .join("content.db") + }; let conn = rusqlite::Connection::open(content_path).unwrap(); let count: i64 = conn .query_row( @@ -403,7 +426,15 @@ async fn concurrent_redirects_increment_access_count() { } assert_eq!(ok_301, N); - let content_path = db.data_dir.join("users/1/content.db"); + let content_path = { + let users = db.users.lock().unwrap(); + let user = bzod::db::users::get_user_by_id(&users, 1).unwrap().unwrap(); + bzod::db::tenant::location_for_user(&user) + .unwrap() + .dir(&db.topology) + .unwrap() + .join("content.db") + }; let conn = rusqlite::Connection::open(content_path).unwrap(); let count: i64 = conn .query_row( @@ -514,6 +545,10 @@ async fn destination_audit_finds_legacy_invalid_without_rewriting() { fs::create_dir_all(&temp_dir).unwrap(); let config = create_temp_config(temp_dir.clone()); let db = Db::init(&config).unwrap(); + { + let users = db.users.lock().unwrap(); + let _ = bzod::db::users::create_user(&users, "testuser", "dummy_hash", "standard", None); + } seed_url( &db, @@ -563,7 +598,15 @@ async fn destination_audit_finds_legacy_invalid_without_rewriting() { assert_eq!(report.unsupported_scheme, 1); // Data not rewritten - let content_path = db.data_dir.join("users/1/content.db"); + let content_path = { + let users = db.users.lock().unwrap(); + let user = bzod::db::users::get_user_by_id(&users, 1).unwrap().unwrap(); + bzod::db::tenant::location_for_user(&user) + .unwrap() + .dir(&db.topology) + .unwrap() + .join("content.db") + }; let conn = rusqlite::Connection::open(content_path).unwrap(); let dest: String = conn .query_row( @@ -597,10 +640,10 @@ async fn disabled_slug_returns_410() { }, ); { - let system = db.system.lock().unwrap(); - system + let urls_conn = db.global_urls.lock().unwrap(); + urls_conn .execute( - "UPDATE global_slugs SET status = 'disabled' WHERE slug = 'ab7777';", + "UPDATE global_urls SET status = 'disabled' WHERE slug = 'ab7777';", [], ) .unwrap(); @@ -640,7 +683,15 @@ async fn redirect_performance_smoke() { } // Reset access count after warm-up for clean correctness check { - let content_path = db.data_dir.join("users/1/content.db"); + let content_path = { + let users = db.users.lock().unwrap(); + let user = bzod::db::users::get_user_by_id(&users, 1).unwrap().unwrap(); + bzod::db::tenant::location_for_user(&user) + .unwrap() + .dir(&db.topology) + .unwrap() + .join("content.db") + }; let conn = rusqlite::Connection::open(content_path).unwrap(); conn.execute( "UPDATE urls SET access_count = 0 WHERE code = 'ab8888';", @@ -702,7 +753,17 @@ async fn redirect_performance_smoke() { assert_eq!(errors, 0, "error rate must be zero"); assert_eq!(latencies_ms.len(), REQUESTS); - let content_path = db.data_dir.join("users/1/content.db"); + let content_path = { + let users = db.users.lock().unwrap(); + let user = bzod::db::users::get_user_by_id(&users, 1) + .unwrap() + .expect("user 1 must exist"); + bzod::db::tenant::location_for_user(&user) + .unwrap() + .dir(&db.topology) + .unwrap() + .join("content.db") + }; let conn = rusqlite::Connection::open(content_path).unwrap(); let count: i64 = conn .query_row( diff --git a/tests/registry_repair_tests.rs b/tests/registry_repair_tests.rs index 7729697..63d9b13 100644 --- a/tests/registry_repair_tests.rs +++ b/tests/registry_repair_tests.rs @@ -20,18 +20,21 @@ async fn test_registry_repair_dry_run() { let config = create_temp_config(temp_dir.clone()); let db = Db::init(&config).expect("Failed to init Db"); + let tid = { + let conn = db.users.lock().unwrap(); + let u = bzod::db::users::create_user(&conn, "repairdry", "pass", "user", None).unwrap(); + u.tenant_id.unwrap() + }; + db.init_user_databases(1).unwrap(); + { - 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 urls_conn = db.global_urls.lock().unwrap(); + let now = chrono::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 ('!orphan1', ?1, 'target1', ?2, ?3, 'active', NULL);", + rusqlite::params![tid.as_str(), now, now], + ).unwrap(); } let command = RepairCommands::Registry { @@ -47,10 +50,10 @@ async fn test_registry_repair_dry_run() { // Verify it was NOT deleted { - let system_conn = db.system.lock().unwrap(); - let exists: bool = system_conn + let urls_conn = db.global_urls.lock().unwrap(); + let exists: bool = urls_conn .query_row( - "SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = 'orphan1')", + "SELECT EXISTS(SELECT 1 FROM global_urls WHERE slug = '!orphan1')", [], |r| r.get(0), ) @@ -69,18 +72,21 @@ async fn test_registry_repair_force() { let config = create_temp_config(temp_dir.clone()); let db = Db::init(&config).expect("Failed to init Db"); + let tid = { + let conn = db.users.lock().unwrap(); + let u = bzod::db::users::create_user(&conn, "repairforce", "pass", "user", None).unwrap(); + u.tenant_id.unwrap() + }; + db.init_user_databases(1).unwrap(); + { - 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 urls_conn = db.global_urls.lock().unwrap(); + let now = chrono::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 ('!orphan2', ?1, 'target2', ?2, ?3, 'active', NULL);", + rusqlite::params![tid.as_str(), now, now], + ).unwrap(); } let command = RepairCommands::Registry { @@ -96,10 +102,10 @@ async fn test_registry_repair_force() { // Verify it WAS deleted { - let system_conn = db.system.lock().unwrap(); - let exists: bool = system_conn + let urls_conn = db.global_urls.lock().unwrap(); + let exists: bool = urls_conn .query_row( - "SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = 'orphan2')", + "SELECT EXISTS(SELECT 1 FROM global_urls WHERE slug = '!orphan2')", [], |r| r.get(0), ) @@ -118,33 +124,32 @@ async fn test_registry_repair_single_slug() { let config = create_temp_config(temp_dir.clone()); let db = Db::init(&config).expect("Failed to init Db"); + let tid = { + let conn = db.users.lock().unwrap(); + let u = bzod::db::users::create_user(&conn, "repairsingle", "pass", "user", None).unwrap(); + u.tenant_id.unwrap() + }; + db.init_user_databases(1).unwrap(); + { - 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 urls_conn = db.global_urls.lock().unwrap(); + let now = chrono::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 ('!orphan3', ?1, 'target3', ?2, ?3, 'active', NULL);", + rusqlite::params![tid.as_str(), now, now], + ).unwrap(); + urls_conn.execute( + "INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at) + VALUES ('!orphan4', ?1, 'target4', ?2, ?3, 'active', NULL);", + rusqlite::params![tid.as_str(), now, now], + ).unwrap(); } let command = RepairCommands::Registry { dry_run: false, force: true, - slug: Some("orphan3".to_string()), + slug: Some("!orphan3".to_string()), data_dir: Some(temp_dir.to_string_lossy().to_string()), }; @@ -154,19 +159,19 @@ async fn test_registry_repair_single_slug() { // Verify targeted slug was deleted { - let system_conn = db.system.lock().unwrap(); - let exists3: bool = system_conn + let urls_conn = db.global_urls.lock().unwrap(); + let exists3: bool = urls_conn .query_row( - "SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = 'orphan3')", + "SELECT EXISTS(SELECT 1 FROM global_urls WHERE slug = '!orphan3')", [], |r| r.get(0), ) .unwrap(); assert!(!exists3, "Targeted slug should be deleted"); - let exists4: bool = system_conn + let exists4: bool = urls_conn .query_row( - "SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = 'orphan4')", + "SELECT EXISTS(SELECT 1 FROM global_urls WHERE slug = '!orphan4')", [], |r| r.get(0), ) diff --git a/tests/routing_tests.rs b/tests/routing_tests.rs index 432807a..1bd272c 100644 --- a/tests/routing_tests.rs +++ b/tests/routing_tests.rs @@ -31,8 +31,6 @@ async fn test_global_slug_lookup_and_redirection() { let state = AppState { admin_db: db.admin.clone(), - content_db: db.content.clone(), - analytics_db: db.analytics.clone(), system_db: db.system.clone(), users_db: db.users.clone(), user_dbs: std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())), @@ -52,17 +50,17 @@ async fn test_global_slug_lookup_and_redirection() { .await .unwrap(); - let user_id = { + let (user_id, tid) = { let conn = db.users.lock().unwrap(); - bzod::db::users::get_user_by_username(&conn, "testuser") + let u = bzod::db::users::get_user_by_username(&conn, "testuser") .unwrap() - .unwrap() - .id + .unwrap(); + (u.id, u.tenant_id.unwrap()) }; { let conn = bzod::jobs::open_user_content_conn(&db, user_id).unwrap(); - bzod::db::content::create_url_extended( + let url = bzod::db::content::create_url_extended( &conn, "!my-routing-slug", "https://example.com/target", @@ -75,16 +73,15 @@ async fn test_global_slug_lookup_and_redirection() { ) .unwrap(); - let system_conn = db.system.lock().unwrap(); - bzod::db::users::register_global_slug( - &system_conn, - "!my-routing-slug", - user_id, - "url", - "xyz", - "active", - ) - .unwrap(); + let urls_conn = db.global_urls.lock().unwrap(); + let now = chrono::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 ('!my-routing-slug', ?1, ?2, ?3, ?4, 'active', NULL);", + rusqlite::params![tid.as_str(), &url.id, now, now], + ) + .unwrap(); } // Call resolve_redirect directly @@ -128,8 +125,6 @@ async fn test_disabled_slug_returns_410() { let state = AppState { admin_db: db.admin.clone(), - content_db: db.content.clone(), - analytics_db: db.analytics.clone(), system_db: db.system.clone(), users_db: db.users.clone(), user_dbs: std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())), @@ -148,17 +143,17 @@ async fn test_disabled_slug_returns_410() { .await .unwrap(); - let user_id = { + let (user_id, tid) = { let conn = db.users.lock().unwrap(); - bzod::db::users::get_user_by_username(&conn, "testuser") + let u = bzod::db::users::get_user_by_username(&conn, "testuser") .unwrap() - .unwrap() - .id + .unwrap(); + (u.id, u.tenant_id.unwrap()) }; { let conn = bzod::jobs::open_user_content_conn(&db, user_id).unwrap(); - bzod::db::content::create_url_extended( + let url = bzod::db::content::create_url_extended( &conn, "!disabled-slug", "https://example.com/target", @@ -171,22 +166,13 @@ async fn test_disabled_slug_returns_410() { ) .unwrap(); - let system_conn = db.system.lock().unwrap(); - bzod::db::users::register_global_slug( - &system_conn, - "!disabled-slug", - user_id, - "url", - "xyz", - "active", - ) - .unwrap(); - - // Disable slug - system_conn + let urls_conn = db.global_urls.lock().unwrap(); + let now = chrono::Utc::now().to_rfc3339(); + urls_conn .execute( - "UPDATE global_slugs SET status = 'disabled' WHERE slug = '!disabled-slug';", - [], + "INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at) + VALUES ('!disabled-slug', ?1, ?2, ?3, ?4, 'disabled', NULL);", + rusqlite::params![tid.as_str(), &url.id, now, now], ) .unwrap(); } diff --git a/tests/slug_namespace_tests.rs b/tests/slug_namespace_tests.rs index 22f0bba..cc42202 100644 --- a/tests/slug_namespace_tests.rs +++ b/tests/slug_namespace_tests.rs @@ -20,14 +20,24 @@ async fn test_global_slug_uniqueness() { let db = Db::init(&config).expect("Failed to init Db"); - let system_conn = db.system.lock().unwrap(); + let urls_conn = db.global_urls.lock().unwrap(); + let reserved_conn = db.reserved.lock().unwrap(); + let pages_conn = db.global_landing_pages.lock().unwrap(); // Register a slug - bzod::db::users::register_global_slug(&system_conn, "!myslug", 1, "url", "url1", "active") + let now = chrono::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 ('!myslug', 't-tenant1234', 'url1', ?1, ?2, 'active', NULL);", + rusqlite::params![now, now], + ) .unwrap(); // Verify it is not available - let avail = bzod::db::users::is_slug_available(&system_conn, "!myslug").unwrap(); + let avail = + bzod::db::slugs::is_slug_available(&reserved_conn, &urls_conn, &pages_conn, "!myslug") + .unwrap(); assert!(!avail); let _ = fs::remove_dir_all(&temp_dir); @@ -41,7 +51,9 @@ async fn test_reserved_slug_rejection() { 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(); + let urls_conn = db.global_urls.lock().unwrap(); + let reserved_conn = db.reserved.lock().unwrap(); + let pages_conn = db.global_landing_pages.lock().unwrap(); let reserved = vec![ "admin", @@ -72,7 +84,9 @@ async fn test_reserved_slug_rejection() { ]; for slug in reserved { - let avail = bzod::db::users::is_slug_available(&system_conn, slug).unwrap(); + let avail = + bzod::db::slugs::is_slug_available(&reserved_conn, &urls_conn, &pages_conn, slug) + .unwrap(); assert!(!avail, "Reserved slug '{}' should not be available", slug); } @@ -97,27 +111,35 @@ async fn test_slug_release_on_user_deletion() { .await .unwrap(); - let user_id = { + let (user_id, tid) = { let conn = db.users.lock().unwrap(); - bzod::db::users::get_user_by_username(&conn, "testuser") + let u = bzod::db::users::get_user_by_username(&conn, "testuser") .unwrap() - .unwrap() - .id + .unwrap(); + (u.id, u.tenant_id.unwrap()) }; // Register slug for this user { - let system_conn = db.system.lock().unwrap(); - bzod::db::users::register_global_slug( - &system_conn, + let urls_conn = db.global_urls.lock().unwrap(); + let reserved_conn = db.reserved.lock().unwrap(); + let pages_conn = db.global_landing_pages.lock().unwrap(); + let now = chrono::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 ('!user-slug', ?1, 'url_xyz', ?2, ?3, 'active', NULL);", + rusqlite::params![tid.as_str(), now, now], + ) + .unwrap(); + + let avail = bzod::db::slugs::is_slug_available( + &reserved_conn, + &urls_conn, + &pages_conn, "!user-slug", - user_id, - "url", - "url_xyz", - "active", ) .unwrap(); - let avail = bzod::db::users::is_slug_available(&system_conn, "!user-slug").unwrap(); assert!(!avail); } @@ -128,11 +150,20 @@ async fn test_slug_release_on_user_deletion() { // Slug should now be released and available { - let system_conn = db.system.lock().unwrap(); - let avail = bzod::db::users::is_slug_available(&system_conn, "!user-slug").unwrap(); + let urls_conn = db.global_urls.lock().unwrap(); + let reserved_conn = db.reserved.lock().unwrap(); + let pages_conn = db.global_landing_pages.lock().unwrap(); + let avail = bzod::db::slugs::is_slug_available( + &reserved_conn, + &urls_conn, + &pages_conn, + "!user-slug", + ) + .unwrap(); assert!(avail); // Verify history populated + let system_conn = db.system.lock().unwrap(); let count: i64 = system_conn .query_row( "SELECT COUNT(*) FROM slug_history WHERE slug = ?1 AND action = 'deleted';", diff --git a/tests/slug_registry_tests.rs b/tests/slug_registry_tests.rs index 6d32512..96764b9 100644 --- a/tests/slug_registry_tests.rs +++ b/tests/slug_registry_tests.rs @@ -1,3 +1,5 @@ +#![allow(deprecated)] + use bzod::config::Config; use bzod::db::Db; use std::fs; diff --git a/tests/slug_transfer_tests.rs b/tests/slug_transfer_tests.rs index b29eb48..da96f4d 100644 --- a/tests/slug_transfer_tests.rs +++ b/tests/slug_transfer_tests.rs @@ -41,17 +41,15 @@ async fn test_slug_transfer() { .await .unwrap(); - let (id_a, id_b) = { + let (id_a, tid_a, id_b, tid_b) = { let conn = db.users.lock().unwrap(); let a = bzod::db::users::get_user_by_username(&conn, "usera") .unwrap() - .unwrap() - .id; + .unwrap(); let b = bzod::db::users::get_user_by_username(&conn, "userb") .unwrap() - .unwrap() - .id; - (a, b) + .unwrap(); + (a.id, a.tenant_id.unwrap(), b.id, b.tenant_id.unwrap()) }; // User A creates a URL @@ -71,16 +69,15 @@ async fn test_slug_transfer() { // Register globally { - let system_conn = db.system.lock().unwrap(); - bzod::db::users::register_global_slug( - &system_conn, - "!trans-slug", - id_a, - "url", - &url.id, - "active", - ) - .unwrap(); + let urls_conn = db.global_urls.lock().unwrap(); + let now = chrono::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 ('!trans-slug', ?1, ?2, ?3, ?4, 'active', NULL);", + rusqlite::params![tid_a.as_str(), &url.id, now, now], + ) + .unwrap(); let users_conn = db.users.lock().unwrap(); bzod::db::users::increment_quota_counter(&users_conn, id_a, "urls").unwrap(); @@ -112,12 +109,12 @@ async fn test_slug_transfer() { bzod::db::content::delete_url(&old_conn, &url_to_copy.id).unwrap(); // 3. Update global slugs and quotas - let system_conn = db.system.lock().unwrap(); + let urls_conn = db.global_urls.lock().unwrap(); let now = Utc::now().to_rfc3339(); - system_conn + urls_conn .execute( - "UPDATE global_slugs SET owner_user_id = ?1, updated_at = ?2 WHERE slug = ?3;", - rusqlite::params![id_b, now, "!trans-slug"], + "UPDATE global_urls SET owner_tenant_id = ?1, updated_at = ?2 WHERE slug = ?3;", + rusqlite::params![tid_b.as_str(), now, "!trans-slug"], ) .unwrap(); diff --git a/tests/soft_delete_tests.rs b/tests/soft_delete_tests.rs index b7b9f7c..b5db7e3 100644 --- a/tests/soft_delete_tests.rs +++ b/tests/soft_delete_tests.rs @@ -19,29 +19,41 @@ async fn test_soft_delete_reserves_slug() { 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(); + let urls_conn = db.global_urls.lock().unwrap(); + let reserved_conn = db.reserved.lock().unwrap(); + let pages_conn = db.global_landing_pages.lock().unwrap(); // Register slug - bzod::db::users::register_global_slug( - &system_conn, - "!slug-to-delete", - 10, - "url", - "url_123", - "active", - ) - .unwrap(); + let now = chrono::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 ('!slug-to-delete', 't-tenant10', 'url_123', ?1, ?2, 'active', NULL);", + rusqlite::params![now, now], + ) + .unwrap(); - // Soft delete slug - bzod::db::users::soft_delete_global_slug(&system_conn, "!slug-to-delete", 10).unwrap(); + // Soft delete slug (disable it) + urls_conn + .execute( + "UPDATE global_urls SET status = 'disabled', retired_at = ?1 WHERE slug = ?2;", + rusqlite::params![now, "!slug-to-delete"], + ) + .unwrap(); // Verify it is not available (keeps the slug reserved) - let avail = bzod::db::users::is_slug_available(&system_conn, "!slug-to-delete").unwrap(); + let avail = bzod::db::slugs::is_slug_available( + &reserved_conn, + &urls_conn, + &pages_conn, + "!slug-to-delete", + ) + .unwrap(); assert!(!avail); - let status: String = system_conn + let status: String = urls_conn .query_row( - "SELECT status FROM global_slugs WHERE slug = ?1;", + "SELECT status FROM global_urls WHERE slug = ?1;", ["!slug-to-delete"], |row| row.get(0), ) @@ -59,24 +71,36 @@ async fn test_permanent_delete_releases_slug() { 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(); + let urls_conn = db.global_urls.lock().unwrap(); + let reserved_conn = db.reserved.lock().unwrap(); + let pages_conn = db.global_landing_pages.lock().unwrap(); // Register slug - bzod::db::users::register_global_slug( - &system_conn, - "!slug-to-purge", - 10, - "url", - "url_456", - "active", - ) - .unwrap(); + let now = chrono::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 ('!slug-to-purge', 't-tenant10', 'url_456', ?1, ?2, 'active', NULL);", + rusqlite::params![now, now], + ) + .unwrap(); // Release global slug (permanent deletion) - bzod::db::users::release_global_slug(&system_conn, "!slug-to-purge", 10).unwrap(); + urls_conn + .execute( + "DELETE FROM global_urls WHERE slug = ?1;", + ["!slug-to-purge"], + ) + .unwrap(); // Verify slug is released (available for reuse) - let avail = bzod::db::users::is_slug_available(&system_conn, "!slug-to-purge").unwrap(); + let avail = bzod::db::slugs::is_slug_available( + &reserved_conn, + &urls_conn, + &pages_conn, + "!slug-to-purge", + ) + .unwrap(); assert!(avail); let _ = fs::remove_dir_all(&temp_dir); diff --git a/tests/transaction_tests.rs b/tests/transaction_tests.rs index 50f7f8f..ff32e2e 100644 --- a/tests/transaction_tests.rs +++ b/tests/transaction_tests.rs @@ -18,34 +18,38 @@ async fn test_cleanup_stale_reservations() { 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(); + let urls_conn = db.global_urls.lock().unwrap(); + let pages_conn = db.global_landing_pages.lock().unwrap(); // Insert a reserving slug older than 15 minutes (e.g. 20 minutes ago) let old_time = (chrono::Utc::now() - chrono::Duration::minutes(20)).to_rfc3339(); - system_conn.execute( - "INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status) - VALUES ('stale-slug', 2, 'url', '', ?1, ?1, 'reserving')", - [&old_time] - ).unwrap(); + urls_conn + .execute( + "INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at) + VALUES ('!stale-slug', 't-tenant1234', '', ?1, ?1, 'reserving', NULL)", + [&old_time], + ) + .unwrap(); // Verify it exists before cleanup - let exists: bool = system_conn + let exists: bool = urls_conn .query_row( - "SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = 'stale-slug')", + "SELECT EXISTS(SELECT 1 FROM global_urls WHERE slug = '!stale-slug')", [], |r| r.get(0), ) .unwrap(); assert!(exists); - // Run cleanup - let cleaned = bzod::db::users::cleanup_stale_reservations(&system_conn, &temp_dir).unwrap(); + // Run cleanup for records older than 15 minutes (900 seconds) + let cleaned = + bzod::db::slugs::cleanup_stale_reservations(&urls_conn, &pages_conn, 900).unwrap(); assert_eq!(cleaned, 1); // Verify it is gone - let exists: bool = system_conn + let exists: bool = urls_conn .query_row( - "SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = 'stale-slug')", + "SELECT EXISTS(SELECT 1 FROM global_urls WHERE slug = '!stale-slug')", [], |r| r.get(0), ) @@ -62,24 +66,28 @@ async fn test_cleanup_preserves_valid_reservations() { 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(); + let urls_conn = db.global_urls.lock().unwrap(); + let pages_conn = db.global_landing_pages.lock().unwrap(); // Insert a reserving slug that is brand new (e.g. 1 minute ago) let new_time = (chrono::Utc::now() - chrono::Duration::minutes(1)).to_rfc3339(); - system_conn.execute( - "INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status) - VALUES ('fresh-slug', 2, 'url', '', ?1, ?1, 'reserving')", - [&new_time] - ).unwrap(); + urls_conn + .execute( + "INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at) + VALUES ('!fresh-slug', 't-tenant1234', '', ?1, ?1, 'reserving', NULL)", + [&new_time], + ) + .unwrap(); - // Run cleanup - let cleaned = bzod::db::users::cleanup_stale_reservations(&system_conn, &temp_dir).unwrap(); + // Run cleanup for records older than 15 minutes (900 seconds) + let cleaned = + bzod::db::slugs::cleanup_stale_reservations(&urls_conn, &pages_conn, 900).unwrap(); assert_eq!(cleaned, 0); // Verify it is still there - let exists: bool = system_conn + let exists: bool = urls_conn .query_row( - "SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = 'fresh-slug')", + "SELECT EXISTS(SELECT 1 FROM global_urls WHERE slug = '!fresh-slug')", [], |r| r.get(0), ) diff --git a/tests/upgrade_validation_tests.rs b/tests/upgrade_validation_tests.rs index 29d16e4..53e1c40 100644 --- a/tests/upgrade_validation_tests.rs +++ b/tests/upgrade_validation_tests.rs @@ -99,6 +99,15 @@ async fn test_upgrade_from_v0_4_0() { ) .unwrap(); + let now = chrono::Utc::now().to_rfc3339(); + system_conn + .execute( + "INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status) + VALUES ('!legacy-link', 1, 'url', ?1, ?2, ?3, 'active');", + rusqlite::params![&url.id, now, now], + ) + .unwrap(); + // 4. analytics.db let mut analytics_conn = rusqlite::Connection::open(&legacy_analytics_path).unwrap(); bzod::db::migrations::run_migrations( @@ -131,10 +140,17 @@ async fn test_upgrade_from_v0_4_0() { .unwrap(); } + // Normalize legacy flat layout to multi-tenant paths + bzod::services::backup_layout::normalize_restored_layout(&temp_dir).unwrap(); + // Now start the application setup which should trigger migrations/upgrade let config = create_temp_config(temp_dir.clone()); let db = Db::init(&config).expect("Db::init failed to upgrade v0.4.0 database directory"); + // Run identity and slug migrations for legacy data + let _ = bzod::db::identity_migrate::run_identity_migration(&config, false, true).await; + let _ = bzod::db::slug_migrate::run_global_slug_migration(&config, false, true).await; + // Verify file layout was reorganized assert!(!legacy_admin_path.exists()); assert!(!legacy_content_path.exists()); @@ -153,8 +169,6 @@ async fn test_upgrade_from_v0_4_0() { let (queue, _) = AnalyticsQueue::new(db.clone(), 10, rx); let state = AppState { admin_db: db.admin.clone(), - content_db: db.content.clone(), - analytics_db: db.analytics.clone(), system_db: db.system.clone(), users_db: db.users.clone(), user_dbs: std::sync::Arc::new(std::sync::Mutex::new(HashMap::new())), @@ -206,7 +220,16 @@ async fn test_upgrade_from_v0_4_0() { // 2. Existing links redirect correctly let redirect_url = format!("{}/!legacy-link", base_url); let redirect_res = client.get(&redirect_url).send().await.unwrap(); - let res_headers = redirect_res.headers(); + let status = redirect_res.status(); + let res_headers = redirect_res.headers().clone(); + let body = redirect_res.text().await.unwrap(); + assert_eq!( + status, + reqwest::StatusCode::MOVED_PERMANENTLY, + "Got status {} with body {}", + status, + body + ); assert!(res_headers.get("location").is_some()); assert_eq!( res_headers.get("location").unwrap().to_str().unwrap(), diff --git a/tests/user_deletion_tests.rs b/tests/user_deletion_tests.rs index 9d22419..fec6072 100644 --- a/tests/user_deletion_tests.rs +++ b/tests/user_deletion_tests.rs @@ -31,12 +31,16 @@ async fn test_user_deletion_cleanup() { .await .unwrap(); - let user_id = { + let (user_id, user_dir) = { let conn = db.users.lock().unwrap(); - bzod::db::users::get_user_by_username(&conn, "testuser") + let user = bzod::db::users::get_user_by_username(&conn, "testuser") .unwrap() + .unwrap(); + let dir = bzod::db::tenant::location_for_user(&user) .unwrap() - .id + .dir(&bzod::db::topology::Topology::new(&temp_dir)) + .unwrap(); + (user.id, dir) }; // Add session and API token @@ -52,8 +56,7 @@ async fn test_user_deletion_cleanup() { .await .unwrap(); - // Verify directory is deleted - let user_dir = temp_dir.join("users").join(user_id.to_string()); + // Verify tenant directory is deleted assert!(!user_dir.exists()); // Verify sessions/tokens are cascadingly deleted diff --git a/tests/user_isolation_tests.rs b/tests/user_isolation_tests.rs index ad8a58b..3e60435 100644 --- a/tests/user_isolation_tests.rs +++ b/tests/user_isolation_tests.rs @@ -38,21 +38,26 @@ async fn test_user_database_isolation() { .await .unwrap(); - let (id_a, id_b) = { + let (user_a, user_b) = { let conn = db.users.lock().unwrap(); let a = bzod::db::users::get_user_by_username(&conn, "usera") .unwrap() - .unwrap() - .id; + .unwrap(); let b = bzod::db::users::get_user_by_username(&conn, "userb") .unwrap() - .unwrap() - .id; + .unwrap(); (a, b) }; - let dir_a = temp_dir.join("users").join(id_a.to_string()); - let dir_b = temp_dir.join("users").join(id_b.to_string()); + let topology = bzod::db::topology::Topology::new(&temp_dir); + let dir_a = bzod::db::tenant::location_for_user(&user_a) + .unwrap() + .dir(&topology) + .unwrap(); + let dir_b = bzod::db::tenant::location_for_user(&user_b) + .unwrap() + .dir(&topology) + .unwrap(); assert_ne!(dir_a, dir_b); assert!(dir_a.join("content.db").exists()); diff --git a/tests/user_management_tests.rs b/tests/user_management_tests.rs index 90dcee8..1b7f7f4 100644 --- a/tests/user_management_tests.rs +++ b/tests/user_management_tests.rs @@ -36,7 +36,10 @@ async fn test_user_creation() { .unwrap(); assert_eq!(user.status, "active"); - let user_dir = temp_dir.join("users").join(user.id.to_string()); + let user_dir = bzod::db::tenant::location_for_user(&user) + .unwrap() + .dir(&bzod::db::topology::Topology::new(&temp_dir)) + .unwrap(); assert!(user_dir.join("content.db").exists()); assert!(user_dir.join("analytics.db").exists()); assert!(user_dir.join("profile.db").exists()); diff --git a/tests/v08_admin_core_separation_tests.rs b/tests/v08_admin_core_separation_tests.rs new file mode 100644 index 0000000..7e3daec --- /dev/null +++ b/tests/v08_admin_core_separation_tests.rs @@ -0,0 +1,382 @@ +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +use bzod::analytics::AnalyticsQueue; +use bzod::config::Config; +use bzod::db::topology::Topology; +use bzod::db::Db; +use bzod::identity::TenantId; +use bzod::state::AppState; +use bzod::web::create_router; + +fn create_temp_config(temp_dir: PathBuf) -> Config { + let mut config = Config::load(); + config.data_dir = temp_dir.clone(); + config.backup_dir = temp_dir.join("backups"); + config.base_url = Some("http://localhost:8080".to_string()); + config +} + +fn build_test_state(db: &Db, config: &Config) -> AppState { + let (queue, _) = AnalyticsQueue::new(db.clone(), 10, tokio::sync::watch::channel(false).1); + AppState { + admin_db: db.admin.clone(), + system_db: db.system.clone(), + users_db: db.users.clone(), + user_dbs: Arc::new(Mutex::new(HashMap::new())), + db: db.clone(), + config: config.clone(), + analytics_queue: queue, + start_time: Instant::now(), + } +} + +#[tokio::test] +async fn test_admin_provisioning_has_no_tenant_directory() { + let temp_dir = std::env::temp_dir().join(format!("bzod_p5_admin_dir_{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&temp_dir).unwrap(); + let config = create_temp_config(temp_dir.clone()); + + // 1. Initialize Db + let db = Db::init(&config).expect("Db::init failed"); + let topology = Topology::new(&temp_dir); + + // 2. Create Admin user via CLI + let admin_res = bzod::cli::create_admin::run( + Some("platform_admin".to_string()), + Some("supersecretpass123".to_string()), + None, + config.clone(), + ) + .await; + assert!(admin_res.is_ok()); + + // 3. Verify Admin in admin.db and users.db + let admin_user = { + let users_conn = db.users.lock().unwrap(); + let u = bzod::db::users::get_user_by_username(&users_conn, "platform_admin") + .unwrap() + .expect("Admin user must exist in users.db"); + assert_eq!(u.account_type, "admin"); + assert!(u.tenant_id.is_none(), "Admin must NOT have a TenantId"); + u + }; + + // 4. Verify NO tenant directory was created for admin + let users_dir = topology.users_dir(); + if users_dir.exists() { + let entries: Vec<_> = fs::read_dir(&users_dir) + .unwrap() + .filter_map(|e| e.ok()) + .collect(); + for entry in entries { + let name = entry.file_name().to_string_lossy().to_string(); + assert_ne!(name, format!("{}", admin_user.id)); + assert_ne!(name, "platform_admin"); + } + } + + let _ = fs::remove_dir_all(&temp_dir); +} + +#[tokio::test] +async fn test_admin_cannot_create_unowned_application_resources() { + let temp_dir = std::env::temp_dir().join(format!("bzod_p5_unowned_{}", 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("Db::init failed"); + let state = build_test_state(&db, &config); + + let router = create_router(state); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let base_url = format!("http://{}", addr); + + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + + let client = reqwest::Client::builder() + .cookie_store(true) + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(); + + // 1. Create Admin + let _ = bzod::cli::create_admin::run( + Some("operator".to_string()), + Some("pass123456".to_string()), + None, + config.clone(), + ) + .await + .unwrap(); + + // 2. Log in as admin + let login_page = client + .get(format!("{}/admin/login", base_url)) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + + // Extract CSRF token + let csrf = login_page + .split("name=\"csrf_token\" value=\"") + .nth(1) + .and_then(|s| s.split('"').next()) + .unwrap_or_default(); + + let mut params = HashMap::new(); + params.insert("username", "operator"); + params.insert("password", "pass123456"); + params.insert("csrf_token", csrf); + + let login_res = client + .post(format!("{}/admin/login", base_url)) + .form(¶ms) + .send() + .await + .unwrap(); + assert_eq!(login_res.status(), reqwest::StatusCode::SEE_OTHER); + + // 3. Admin attempts to create URL via POST /admin/urls/create -> Should redirect with error + let mut url_form = HashMap::new(); + url_form.insert("destination", "https://example.com"); + url_form.insert("code", "!admin_link"); + url_form.insert("csrf_token", csrf); + + let create_url_res = client + .post(format!("{}/admin/urls/create", base_url)) + .form(&url_form) + .send() + .await + .unwrap(); + assert_eq!(create_url_res.status(), reqwest::StatusCode::SEE_OTHER); + let location = create_url_res + .headers() + .get("location") + .unwrap() + .to_str() + .unwrap(); + assert!( + location.contains("error="), + "Admin URL creation must be rejected with error redirect" + ); + + // 4. Admin attempts to create Landing Page via POST /admin/pages/create -> Should redirect with error + let mut page_form = HashMap::new(); + page_form.insert("title", "Admin Page"); + page_form.insert("slug", "!admin_page"); + page_form.insert("code", "!admin_page"); + page_form.insert("custom_slug", ""); + page_form.insert("state", "published"); + page_form.insert("html_content", "

Admin

"); + page_form.insert("csrf_token", csrf); + + let create_page_res = client + .post(format!("{}/admin/pages/create", base_url)) + .form(&page_form) + .send() + .await + .unwrap(); + assert_eq!(create_page_res.status(), reqwest::StatusCode::SEE_OTHER); + let location = create_page_res + .headers() + .get("location") + .unwrap() + .to_str() + .unwrap(); + assert!( + location.contains("error="), + "Admin Landing Page creation must be rejected with error redirect" + ); + + let _ = fs::remove_dir_all(&temp_dir); +} + +#[tokio::test] +async fn test_platform_click_aggregation_without_admin_analytics() { + let temp_dir = std::env::temp_dir().join(format!("bzod_p5_agg_{}", 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("Db::init failed"); + + // 1. Create two standard tenant users + let _ = bzod::cli::create_user::run( + Some("tenant_one".to_string()), + Some("pass123".to_string()), + None, + config.clone(), + ) + .await + .unwrap(); + + let _ = bzod::cli::create_user::run( + Some("tenant_two".to_string()), + Some("pass123".to_string()), + None, + config.clone(), + ) + .await + .unwrap(); + + let (u1, u2) = { + let conn = db.users.lock().unwrap(); + let user1 = bzod::db::users::get_user_by_username(&conn, "tenant_one") + .unwrap() + .unwrap(); + let user2 = bzod::db::users::get_user_by_username(&conn, "tenant_two") + .unwrap() + .unwrap(); + (user1, user2) + }; + + // 2. Insert clicks directly into tenant 1 and tenant 2 analytics DBs + let t1_tid = u1.tenant_id.unwrap(); + let t2_tid = u2.tenant_id.unwrap(); + + let t1_analytics_path = db.topology.tenant_analytics_db(t1_tid); + let t2_analytics_path = db.topology.tenant_analytics_db(t2_tid); + + { + let conn1 = rusqlite::Connection::open(&t1_analytics_path).unwrap(); + let _ = conn1.execute( + "INSERT INTO visits (id, target_type, target_id, timestamp, ip_address, country, referer, user_agent, accept_language, status_code) + VALUES ('v1', 'url', 'url1', '2026-08-20T10:00:00Z', '1.1.1.1', 'US', 'direct', 'ua', 'en', 301);", + [], + ).unwrap(); + let _ = conn1.execute( + "INSERT INTO visits (id, target_type, target_id, timestamp, ip_address, country, referer, user_agent, accept_language, status_code) + VALUES ('v2', 'url', 'url1', '2026-08-20T10:01:00Z', '1.1.1.2', 'US', 'direct', 'ua', 'en', 301);", + [], + ).unwrap(); + } + + { + let conn2 = rusqlite::Connection::open(&t2_analytics_path).unwrap(); + let _ = conn2.execute( + "INSERT INTO visits (id, target_type, target_id, timestamp, ip_address, country, referer, user_agent, accept_language, status_code) + VALUES ('v3', 'url', 'url2', '2026-08-20T10:02:00Z', '2.2.2.2', 'CA', 'direct', 'ua', 'en', 301);", + [], + ).unwrap(); + } + + // 3. Aggregate platform total clicks + let total_clicks = { + let users_conn = db.users.lock().unwrap(); + bzod::db::users::get_platform_total_clicks(&db.topology, &users_conn).unwrap() + }; + + assert_eq!( + total_clicks, 3, + "Platform clicks must aggregate across all active tenant databases" + ); + + let _ = fs::remove_dir_all(&temp_dir); +} + +#[tokio::test] +async fn test_admin_resource_inspection_and_moderation() { + let temp_dir = std::env::temp_dir().join(format!("bzod_p5_mod_{}", 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("Db::init failed"); + let state = build_test_state(&db, &config); + + // 1. Create standard tenant user + let _ = bzod::cli::create_user::run( + Some("content_author".to_string()), + Some("pass123".to_string()), + None, + config.clone(), + ) + .await + .unwrap(); + + let (author_id, author_tid) = { + let conn = db.users.lock().unwrap(); + let u = bzod::db::users::get_user_by_username(&conn, "content_author") + .unwrap() + .unwrap(); + (u.id, u.tenant_id.unwrap()) + }; + + // 2. Author creates URL + let url_id = { + let conn = bzod::jobs::open_user_content_conn(&db, author_id).unwrap(); + let u = bzod::db::content::create_url_extended( + &conn, + "!moderated-link", + "https://bad-site.com", + Some("Spam Link"), + None, + &[], + None, + None, + None, + ) + .unwrap(); + + let urls_conn = db.global_urls.lock().unwrap(); + bzod::db::slugs::register_url_slug( + &urls_conn, + "!moderated-link", + &author_tid, + &u.id, + "active", + ) + .unwrap(); + u.id + }; + + // 3. Verify Admin can look up slug and inspect owner tenant + let slug_info = state + .lookup_slug("!moderated-link") + .unwrap() + .expect("Slug must exist in global registry"); + assert_eq!(slug_info.owner_tenant_id, author_tid.as_str()); + + // 4. Admin opens tenant content DB in CoreJob mode to inspect + let tenant_dbs = state + .open_tenant(author_tid, bzod::state::TenantOpenMode::CoreJob) + .unwrap(); + { + let conn = tenant_dbs.content.lock().unwrap(); + let fetched_url = bzod::db::content::get_url_by_id(&conn, &url_id) + .unwrap() + .expect("URL must exist in tenant content.db"); + assert_eq!(fetched_url.destination, "https://bad-site.com"); + } + + // 5. Admin retires/moderates the slug + { + let urls_conn = db.global_urls.lock().unwrap(); + let pages_conn = db.global_landing_pages.lock().unwrap(); + let retired = + bzod::db::slugs::retire_slug(&urls_conn, &pages_conn, "!moderated-link").unwrap(); + assert!(retired); + } + + // 6. Verify slug cannot be re-registered by another tenant + let _other_tid = TenantId::generate(); + let r_conn = db.reserved.lock().unwrap(); + let u_conn = db.global_urls.lock().unwrap(); + let p_conn = db.global_landing_pages.lock().unwrap(); + let available = + bzod::db::slugs::is_slug_available(&r_conn, &u_conn, &p_conn, "!moderated-link").unwrap(); + assert!( + !available, + "Retired slug must remain unavailable across the platform" + ); + + let _ = fs::remove_dir_all(&temp_dir); +} diff --git a/tests/v08_foundation_tests.rs b/tests/v08_foundation_tests.rs new file mode 100644 index 0000000..4fca838 --- /dev/null +++ b/tests/v08_foundation_tests.rs @@ -0,0 +1,170 @@ +//! v0.8.0 Phase 1: frozen database topology under the existing physical root. + +use bzod::config::Config; +use bzod::db::slugs::CORE_RESERVED_SLUGS; +use bzod::db::sqlite::get_user_version; +use bzod::db::topology::{is_valid_user_dir_name, Topology}; +use bzod::db::Db; +use rusqlite::Connection; +use std::fs; +use std::path::PathBuf; + +fn temp_config() -> (PathBuf, Config) { + let temp_dir = std::env::temp_dir().join(format!("bzod_v08_topology_{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&temp_dir).unwrap(); + let mut config = Config::load(); + config.data_dir = temp_dir.clone(); + config.backup_dir = temp_dir.join("backups"); + (temp_dir, config) +} + +#[test] +fn physical_root_stays_data_dir_not_database() { + let t = Topology::new("./data"); + assert_eq!(t.root(), std::path::Path::new("./data")); + assert!(!t.root().ends_with("database")); + assert!(t.slugs_dir().ends_with("data/slugs")); +} + +#[tokio::test] +async fn db_init_creates_frozen_slug_topology() { + let (temp_dir, config) = temp_config(); + let db = Db::init(&config).expect("init"); + + assert!(db.topology.admin_db().exists()); + assert!(db.topology.system_db().exists()); + assert!(db.topology.users_registry_db().exists()); + assert!(db.topology.global_urls_db().exists()); + assert!(db.topology.global_landing_pages_db().exists()); + assert!(db.topology.reserved_db().exists()); + assert!(!temp_dir.join("database").exists()); + + { + let conn = db.global_urls.lock().unwrap(); + assert_eq!( + get_user_version(&conn).unwrap(), + bzod::db::schema_v08::GLOBAL_URLS_MIGRATIONS + .last() + .unwrap() + .version + ); + let n: i64 = conn + .query_row("SELECT COUNT(*) FROM global_urls;", [], |r| r.get(0)) + .unwrap(); + assert_eq!(n, 0, "Phase 1 does not move live slugs off system.db"); + } + { + let conn = db.global_landing_pages.lock().unwrap(); + assert_eq!( + get_user_version(&conn).unwrap(), + bzod::db::schema_v08::GLOBAL_LANDING_PAGES_MIGRATIONS + .last() + .unwrap() + .version + ); + let n: i64 = conn + .query_row("SELECT COUNT(*) FROM global_landing_pages;", [], |r| { + r.get(0) + }) + .unwrap(); + assert_eq!(n, 0); + } + { + let conn = db.reserved.lock().unwrap(); + assert_eq!(get_user_version(&conn).unwrap(), 1); + let n: i64 = conn + .query_row("SELECT COUNT(*) FROM reserved_slugs;", [], |r| r.get(0)) + .unwrap(); + assert_eq!(n, CORE_RESERVED_SLUGS.len() as i64); + let has_admin: bool = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM reserved_slugs WHERE slug = 'admin');", + [], + |r| r.get(0), + ) + .unwrap(); + assert!(has_admin); + } + + // Live slug registry is still system.db (Phase 4 moves ownership). + { + let conn = db.system.lock().unwrap(); + let has_table: bool = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='global_slugs');", + [], + |r| r.get(0), + ) + .unwrap(); + assert!(has_table); + } + + let _ = fs::remove_dir_all(&temp_dir); +} + +#[tokio::test] +async fn backup_includes_slugs_directory() { + let (temp_dir, config) = temp_config(); + let db = Db::init(&config).expect("init"); + let backup_path = bzod::jobs::backup::perform_backup(&db, &config) + .await + .expect("backup"); + + let file = fs::File::open(&backup_path).unwrap(); + let dec = flate2::read::GzDecoder::new(file); + let mut archive = tar::Archive::new(dec); + let mut found_reserved = false; + let mut found_global_urls = false; + for entry in archive.entries().unwrap() { + let entry = entry.unwrap(); + let path = entry.path().unwrap().to_string_lossy().to_string(); + if path.contains("slugs/reserved.db") { + found_reserved = true; + } + if path.contains("slugs/global_urls.db") { + found_global_urls = true; + } + } + assert!(found_reserved, "backup missing slugs/reserved.db"); + assert!(found_global_urls, "backup missing slugs/global_urls.db"); + + let _ = fs::remove_dir_all(&temp_dir); +} + +#[test] +fn tenant_path_resolution_rejects_traversal() { + let t = Topology::new("/app/data"); + assert!(t.user_dir("..").is_err()); + assert!(t.user_dir("../etc").is_err()); + assert!(t.content_db_i64(-1).is_err()); + assert!(is_valid_user_dir_name("2")); + assert!(is_valid_user_dir_name("a1b2c3d4e5f6")); + assert!(!is_valid_user_dir_name("admin")); +} + +#[tokio::test] +async fn open_user_content_rejects_non_positive_id() { + let (temp_dir, config) = temp_config(); + let db = Db::init(&config).expect("init"); + assert!(bzod::jobs::open_user_content_conn(&db, 0).is_err()); + assert!(bzod::jobs::open_user_content_conn(&db, -3).is_err()); + let _ = fs::remove_dir_all(&temp_dir); +} + +#[test] +fn restore_normalization_creates_slugs_dir() { + let dir = std::env::temp_dir().join(format!("bzod_v08_restore_{}", uuid::Uuid::new_v4())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("admin.db"), b"a").unwrap(); + fs::write(dir.join("system.db"), b"s").unwrap(); + fs::write(dir.join("users.db"), b"u").unwrap(); + fs::write(dir.join("content.db"), b"c").unwrap(); + fs::write(dir.join("analytics.db"), b"an").unwrap(); + + bzod::services::backup_layout::normalize_restored_layout(&dir).unwrap(); + assert!(dir.join("slugs").is_dir()); + assert!(Connection::open(dir.join("admin/admin.db")).is_ok()); + + let _ = fs::remove_dir_all(&dir); +} diff --git a/tests/v08_identity_migration_tests.rs b/tests/v08_identity_migration_tests.rs new file mode 100644 index 0000000..a998b53 --- /dev/null +++ b/tests/v08_identity_migration_tests.rs @@ -0,0 +1,348 @@ +//! v0.8.0 Phase 3: Identity Migration Integration Tests. +//! +//! Tests: +//! 1. New users automatically get TenantId + UUID. +//! 2. Explicit identity migration lifecycle (preflight, backup, backfill, directory rename, validation). +//! 3. Legacy Admin (users/1) is preserved and not converted into a normal tenant. +//! 4. Restart-safety & idempotency (repeated migration runs). +//! 5. Recovery when target directory already exists. +//! 6. Admin bootstrap concurrency without re-entrant mutex deadlock. +//! 7. Cross-tenant isolation after identity migration. + +use bzod::config::Config; +use bzod::db::identity_migrate::run_identity_migration; +use bzod::db::tenant::TenantOpenMode; +use bzod::db::Db; +use bzod::state::AppState; +use rusqlite::Connection; +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +fn temp_config() -> (PathBuf, Config) { + let temp_dir = + std::env::temp_dir().join(format!("bzod_v08_id_migrate_{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&temp_dir).unwrap(); + let mut config = Config::load(); + config.data_dir = temp_dir.clone(); + config.backup_dir = temp_dir.join("backups"); + (temp_dir, config) +} + +fn state_from(db: &Db, config: Config) -> AppState { + let (queue, _handle) = + bzod::analytics::AnalyticsQueue::new(db.clone(), 8, tokio::sync::watch::channel(false).1); + AppState { + admin_db: db.admin.clone(), + system_db: db.system.clone(), + users_db: db.users.clone(), + user_dbs: Arc::new(Mutex::new(HashMap::new())), + db: db.clone(), + config, + analytics_queue: queue, + start_time: Instant::now(), + } +} + +#[tokio::test] +async fn test_new_user_gets_tenant_id_and_uuid() { + let (temp_dir, config) = temp_config(); + let db = Db::init(&config).unwrap(); + + bzod::cli::create_user::run( + Some("carol".into()), + Some("password123".into()), + None, + config.clone(), + ) + .await + .unwrap(); + + let user = { + let conn = db.users.lock().unwrap(); + bzod::db::users::get_user_by_username(&conn, "carol") + .unwrap() + .unwrap() + }; + + let tid = user.tenant_id.expect("new user must receive TenantId"); + assert_eq!(tid.as_str().len(), 12); + + let uuid_str = user.uuid.expect("new user must receive UUID"); + assert!(uuid::Uuid::parse_str(&uuid_str).is_ok()); + + // Directory is created under users// + assert!(temp_dir + .join("users") + .join(tid.as_str()) + .join("content.db") + .exists()); + assert!(!temp_dir.join("users").join(user.id.to_string()).exists()); + + let _ = fs::remove_dir_all(&temp_dir); +} + +#[tokio::test] +async fn test_full_identity_migration_lifecycle() { + let (temp_dir, config) = temp_config(); + let db = Db::init(&config).unwrap(); + + // 1. Manually insert legacy admin and legacy users without tenant_id/uuid in users.db + let hash = bzod::auth::hash_password("password123").unwrap(); + let now = chrono::Utc::now().to_rfc3339(); + + let (id_bob, id_dave) = { + let conn = db.users.lock().unwrap(); + conn.execute( + "INSERT INTO users (username, password_hash, status, created_at, account_type) VALUES ('legacy_admin', ?1, 'disabled', ?2, 'system');", + rusqlite::params![hash, now], + ).unwrap(); + + conn.execute( + "INSERT INTO users (username, password_hash, status, created_at, account_type) VALUES ('bob', ?1, 'active', ?2, 'standard');", + rusqlite::params![hash, now], + ).unwrap(); + let b = conn.last_insert_rowid(); + + conn.execute( + "INSERT INTO users (username, password_hash, status, created_at, account_type) VALUES ('dave', ?1, 'active', ?2, 'standard');", + rusqlite::params![hash, now], + ).unwrap(); + let d = conn.last_insert_rowid(); + (b, d) + }; + + // 2. Provision legacy directories at users/1 (legacy admin) and users// (bob & dave) + let legacy_admin_dir = temp_dir.join("users").join("1"); + let legacy_bob_dir = temp_dir.join("users").join(id_bob.to_string()); + let legacy_dave_dir = temp_dir.join("users").join(id_dave.to_string()); + fs::create_dir_all(&legacy_admin_dir).unwrap(); + fs::create_dir_all(&legacy_bob_dir).unwrap(); + fs::create_dir_all(&legacy_dave_dir).unwrap(); + + // Seed content in Bob's legacy content.db + let bob_content_path = legacy_bob_dir.join("content.db"); + { + let mut conn = Connection::open(&bob_content_path).unwrap(); + bzod::db::migrations::run_migrations( + &mut conn, + "content", + bzod::db::migrations::CONTENT_MIGRATIONS, + None, + ) + .unwrap(); + bzod::db::content::create_url_extended( + &conn, + "!bob-link", + "https://bob.example.com", + None, + None, + &vec![], + None, + None, + None, + ) + .unwrap(); + } + + // 3. Run Dry Run Preflight + let dry_report = run_identity_migration(&config, true, true) + .await + .expect("dry run"); + assert_eq!(dry_report.users_assigned_tenant_id, 2); + assert_eq!(dry_report.users_assigned_uuid, 2); + assert!(legacy_bob_dir.exists(), "dry run must not move directory"); + + // 4. Run Execution + let report = run_identity_migration(&config, false, true) + .await + .expect("migration"); + assert_eq!(report.users_assigned_tenant_id, 2); + assert_eq!(report.users_assigned_uuid, 2); + assert_eq!(report.directories_moved, 2); // Both Bob and Dave had directories moved + assert!(report.validation_passed); + assert!(report.legacy_admin_preserved); + + // 5. Verify Bob's identity and migrated directory + let bob = { + let conn = db.users.lock().unwrap(); + bzod::db::users::get_user_by_username(&conn, "bob") + .unwrap() + .unwrap() + }; + let bob_tid = bob.tenant_id.expect("bob must have TenantId"); + assert_eq!(bob_tid.as_str().len(), 12); + assert!(bob.uuid.is_some()); + + let migrated_bob_dir = temp_dir.join("users").join(bob_tid.as_str()); + assert!( + migrated_bob_dir.exists(), + "migrated directory must exist at users//" + ); + assert!(!legacy_bob_dir.exists(), "legacy directory must be moved"); + + // Verify Bob's content is preserved exactly + { + let conn = Connection::open(migrated_bob_dir.join("content.db")).unwrap(); + let url = bzod::db::content::get_url_by_code(&conn, "!bob-link") + .unwrap() + .expect("bob's link must exist in migrated content.db"); + assert_eq!(url.destination, "https://bob.example.com"); + } + + // 6. Verify legacy admin (users/1) was preserved + assert!( + temp_dir.join("users").join("1").exists(), + "legacy admin directory users/1 must be preserved" + ); + + // 7. Verify Completion Marker in system.db + { + let system_conn = db.system.lock().unwrap(); + let marker: Option = system_conn + .query_row( + "SELECT value FROM settings WHERE key = 'v08_identity_migration_completed';", + [], + |r| r.get(0), + ) + .ok(); + assert!(marker.is_some()); + } + + let _ = fs::remove_dir_all(&temp_dir); +} + +#[tokio::test] +async fn test_migration_is_restart_safe_and_idempotent() { + let (temp_dir, config) = temp_config(); + let db = Db::init(&config).unwrap(); + + // Create a normal user + bzod::cli::create_user::run( + Some("eva".into()), + Some("password123".into()), + None, + config.clone(), + ) + .await + .unwrap(); + + let original_user = { + let conn = db.users.lock().unwrap(); + bzod::db::users::get_user_by_username(&conn, "eva") + .unwrap() + .unwrap() + }; + + let original_tid = original_user.tenant_id.unwrap(); + let original_uuid = original_user.uuid.unwrap(); + + // Run migration twice + let report1 = run_identity_migration(&config, false, true).await.unwrap(); + let report2 = run_identity_migration(&config, false, true).await.unwrap(); + + assert_eq!(report1.users_assigned_tenant_id, 0); // already had TenantId + assert_eq!(report2.users_assigned_tenant_id, 0); + + let current_user = { + let conn = db.users.lock().unwrap(); + bzod::db::users::get_user_by_username(&conn, "eva") + .unwrap() + .unwrap() + }; + + // Identity mappings must be identical (never regenerated) + assert_eq!(current_user.tenant_id.unwrap(), original_tid); + assert_eq!(current_user.uuid.unwrap(), original_uuid); + assert!(temp_dir.join("users").join(original_tid.as_str()).exists()); + + let _ = fs::remove_dir_all(&temp_dir); +} + +#[tokio::test] +async fn test_admin_bootstrap_concurrency_no_deadlock() { + let (temp_dir, config) = temp_config(); + let db = Db::init(&config).unwrap(); + let state = state_from(&db, config.clone()); + + // Call create_admin_user multiple times or simulate bootstrap login + let hash = bzod::auth::hash_password("securepassword").unwrap(); + let res = { + let conn = db.users.lock().unwrap(); + bzod::db::users::create_admin_user(&conn, "admin_user", &hash) + }; + assert!(res.is_ok()); + let admin = res.unwrap(); + assert_eq!(admin.account_type, "admin"); + assert!(admin.uuid.is_some()); + assert_eq!(admin.tenant_id, None, "admin is Core-only"); + + let _ = state; + let _ = fs::remove_dir_all(&temp_dir); +} + +#[tokio::test] +async fn test_cross_tenant_isolation_after_migration() { + let (temp_dir, config) = temp_config(); + let db = Db::init(&config).unwrap(); + let state = state_from(&db, config.clone()); + + // Create user X and user Y + bzod::cli::create_user::run( + Some("userx".into()), + Some("pass123".into()), + None, + config.clone(), + ) + .await + .unwrap(); + bzod::cli::create_user::run(Some("usery".into()), Some("pass123".into()), None, config) + .await + .unwrap(); + + let (x, y) = { + let conn = db.users.lock().unwrap(); + ( + bzod::db::users::get_user_by_username(&conn, "userx") + .unwrap() + .unwrap(), + bzod::db::users::get_user_by_username(&conn, "usery") + .unwrap() + .unwrap(), + ) + }; + + let tid_x = x.tenant_id.unwrap(); + let tid_y = y.tenant_id.unwrap(); + + let dbs_x = state.open_tenant(tid_x, TenantOpenMode::Ordinary).unwrap(); + { + let conn = dbs_x.content.lock().unwrap(); + bzod::db::content::create_url_extended( + &conn, + "!x-secret", + "https://x.example.com", + None, + None, + &vec![], + None, + None, + None, + ) + .unwrap(); + } + + let dbs_y = state.open_tenant(tid_y, TenantOpenMode::Ordinary).unwrap(); + let stolen = { + let conn = dbs_y.content.lock().unwrap(); + bzod::db::content::get_url_by_code(&conn, "!x-secret").unwrap() + }; + assert!( + stolen.is_none(), + "Tenant Y must not see Tenant X's secret content" + ); + + let _ = fs::remove_dir_all(&temp_dir); +} diff --git a/tests/v08_phase6a_elimination_tests.rs b/tests/v08_phase6a_elimination_tests.rs new file mode 100644 index 0000000..1a50b35 --- /dev/null +++ b/tests/v08_phase6a_elimination_tests.rs @@ -0,0 +1,546 @@ +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +use bzod::analytics::AnalyticsQueue; +use bzod::cli::RepairCommands; +use bzod::config::Config; +use bzod::db::tenant::TenantOpenMode; +use bzod::db::topology::Topology; +use bzod::db::Db; +use bzod::models::visit::VisitRecord; +use bzod::services::bulk_urls::{create_urls_bulk, BulkUrlCreateItem}; +use bzod::services::registry_validator::{RegistryIssueType, RegistryValidator}; +use bzod::services::slug_transfer::{transfer_slug, SlugTransferRequest}; +use bzod::state::AppState; +use uuid::Uuid; + +fn create_test_config() -> (PathBuf, Config) { + let temp_dir = std::env::temp_dir().join(format!("bzod_p6a_test_{}", Uuid::new_v4())); + fs::create_dir_all(&temp_dir).unwrap(); + let mut config = Config::load(); + config.data_dir = temp_dir.clone(); + config.backup_dir = temp_dir.join("backups"); + config.base_url = Some("http://localhost:8080".to_string()); + (temp_dir, config) +} + +fn build_test_state(db: &Db, config: &Config) -> AppState { + let (queue, _) = AnalyticsQueue::new(db.clone(), 10, tokio::sync::watch::channel(false).1); + AppState { + admin_db: db.admin.clone(), + system_db: db.system.clone(), + users_db: db.users.clone(), + user_dbs: Arc::new(Mutex::new(HashMap::new())), + db: db.clone(), + config: config.clone(), + analytics_queue: queue, + start_time: Instant::now(), + } +} + +#[tokio::test] +async fn test_f01_bulk_urls_uses_v08_slug_registry() { + let (temp_dir, config) = create_test_config(); + let db = Db::init(&config).unwrap(); + let state = build_test_state(&db, &config); + + // Create a tenant + let user = { + let conn = db.users.lock().unwrap(); + bzod::db::users::create_user(&conn, "bulkuser", "hash", "user", None).unwrap() + }; + let tenant_id = user.tenant_id.unwrap(); + + let user_dbs = state + .open_tenant(tenant_id, TenantOpenMode::Ordinary) + .unwrap(); + + let items = vec![ + BulkUrlCreateItem { + code: Some("!bulk1".to_string()), + destination: "https://example.com/1".to_string(), + title: Some("Bulk 1".to_string()), + description: None, + tags: Some(vec!["tag1".to_string()]), + expires_at: None, + password: None, + max_access_count: None, + }, + BulkUrlCreateItem { + code: Some("!bulk2".to_string()), + destination: "https://example.com/2".to_string(), + title: Some("Bulk 2".to_string()), + description: None, + tags: Some(vec!["tag2".to_string()]), + expires_at: None, + password: None, + max_access_count: None, + }, + ]; + + let created = create_urls_bulk( + &user_dbs.content, + &db.reserved, + &db.global_urls, + &db.global_landing_pages, + &db.users, + user.id, + tenant_id, + items, + ) + .unwrap(); + + assert_eq!(created.len(), 2); + + // Verify slugs are in slugs/global_urls.db with tenant_id + { + let urls_conn = db.global_urls.lock().unwrap(); + let (owner, status): (String, String) = urls_conn + .query_row( + "SELECT owner_tenant_id, status FROM global_urls WHERE slug = '!bulk1';", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(owner, tenant_id.as_str()); + assert_eq!(status, "active"); + + let (owner2, status2): (String, String) = urls_conn + .query_row( + "SELECT owner_tenant_id, status FROM global_urls WHERE slug = '!bulk2';", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(owner2, tenant_id.as_str()); + assert_eq!(status2, "active"); + } + + // Verify zero records in system.db.global_slugs + { + let system_conn = db.system.lock().unwrap(); + let count: i64 = system_conn + .query_row("SELECT COUNT(*) FROM global_slugs;", [], |r| r.get(0)) + .unwrap_or(0); + assert_eq!( + count, 0, + "system.db.global_slugs must not receive bulk URLs" + ); + } + + let _ = fs::remove_dir_all(&temp_dir); +} + +#[tokio::test] +async fn test_f03_slug_transfer_uses_v08_architecture() { + let (temp_dir, config) = create_test_config(); + let db = Db::init(&config).unwrap(); + let state = build_test_state(&db, &config); + + // Create source and dest users + let u1 = { + let conn = db.users.lock().unwrap(); + bzod::db::users::create_user(&conn, "alice", "hash", "user", None).unwrap() + }; + let u2 = { + let conn = db.users.lock().unwrap(); + bzod::db::users::create_user(&conn, "bob", "hash", "user", None).unwrap() + }; + let u1_tid = u1.tenant_id.unwrap(); + let u2_tid = u2.tenant_id.unwrap(); + + let u1_dbs = state.open_tenant(u1_tid, TenantOpenMode::Ordinary).unwrap(); + + // Create a URL for alice + let items = vec![BulkUrlCreateItem { + code: Some("!transferslug".to_string()), + destination: "https://example.com/transfer".to_string(), + title: Some("Transfer Me".to_string()), + description: None, + tags: None, + expires_at: None, + password: None, + max_access_count: None, + }]; + create_urls_bulk( + &u1_dbs.content, + &db.reserved, + &db.global_urls, + &db.global_landing_pages, + &db.users, + u1.id, + u1_tid, + items, + ) + .unwrap(); + + // Transfer slug to bob + let req = SlugTransferRequest { + slug: "!transferslug".to_string(), + new_owner_user_id: u2.id, + }; + let transfer_res = transfer_slug(&state, &req, "admin").unwrap(); + + assert_eq!(transfer_res.old_owner_tenant_id, u1_tid); + assert_eq!(transfer_res.new_owner_tenant_id, u2_tid); + + // Verify global_urls.db ownership updated + { + let urls_conn = db.global_urls.lock().unwrap(); + let owner: String = urls_conn + .query_row( + "SELECT owner_tenant_id FROM global_urls WHERE slug = '!transferslug';", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(owner, u2_tid.as_str()); + } + + // Verify bob has the URL in content DB + let bob_dbs = state.open_tenant(u2_tid, TenantOpenMode::CoreJob).unwrap(); + let bob_conn = bob_dbs.content.lock().unwrap(); + let bob_url = bzod::db::content::get_url_by_code(&bob_conn, "!transferslug") + .unwrap() + .unwrap(); + assert_eq!(bob_url.destination, "https://example.com/transfer"); + + let _ = fs::remove_dir_all(&temp_dir); +} + +#[tokio::test] +async fn test_f02_restore_identity_resolution_and_v08_registry() { + let (temp_dir, config) = create_test_config(); + let db = Db::init(&config).unwrap(); + let state = build_test_state(&db, &config); + + // 1. Create a user with tenant + let u = { + let conn = db.users.lock().unwrap(); + bzod::db::users::create_user(&conn, "restoreuser", "hash", "user", None).unwrap() + }; + let u_tid = u.tenant_id.unwrap(); + + let u_dbs = state.open_tenant(u_tid, TenantOpenMode::Ordinary).unwrap(); + + // Create a URL + let items = vec![BulkUrlCreateItem { + code: Some("!restorelink".to_string()), + destination: "https://example.com/restore".to_string(), + title: Some("Restore Link".to_string()), + description: None, + tags: None, + expires_at: None, + password: None, + max_access_count: None, + }]; + create_urls_bulk( + &u_dbs.content, + &db.reserved, + &db.global_urls, + &db.global_landing_pages, + &db.users, + u.id, + u_tid, + items, + ) + .unwrap(); + + // 2. Backup user + let backup_dir = temp_dir.join("backups"); + fs::create_dir_all(&backup_dir).unwrap(); + let archive_path = backup_dir.join("restoreuser_backup.tar.zst"); + bzod::cli::backup_user::run( + "restoreuser".to_string(), + Some(archive_path.to_str().unwrap().to_string()), + None, + config.clone(), + ) + .await + .unwrap(); + + // 3. Delete user + bzod::cli::delete_user::run(u.id, false, None, config.clone()) + .await + .unwrap(); + + // Verify slug removed from global_urls.db + { + let urls_conn = db.global_urls.lock().unwrap(); + let exists: bool = urls_conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM global_urls WHERE slug = '!restorelink');", + [], + |r| r.get(0), + ) + .unwrap(); + assert!(!exists); + } + + // 4. Restore user from archive + bzod::cli::restore_user::run( + archive_path.to_str().unwrap().to_string(), + None, + config.clone(), + ) + .await + .unwrap(); + + // Confirm user restored with same TenantId + let restored_user = { + let conn = db.users.lock().unwrap(); + bzod::db::users::get_user_by_username(&conn, "restoreuser") + .unwrap() + .unwrap() + }; + assert_eq!(restored_user.tenant_id, Some(u_tid)); + + // Confirm slug restored in slugs/global_urls.db + { + let urls_conn = db.global_urls.lock().unwrap(); + let (owner, status): (String, String) = urls_conn + .query_row( + "SELECT owner_tenant_id, status FROM global_urls WHERE slug = '!restorelink';", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .expect("restored slug should be present in global_urls.db"); + assert_eq!(owner, u_tid.as_str()); + assert_eq!(status, "active"); + } + + let _ = fs::remove_dir_all(&temp_dir); +} + +#[tokio::test] +async fn test_f07_registry_validation_and_non_destructive_repair() { + let (temp_dir, config) = create_test_config(); + let db = Db::init(&config).unwrap(); + let state = build_test_state(&db, &config); + + // Create a tenant and open it so content.db is created + let u = { + let conn = db.users.lock().unwrap(); + bzod::db::users::create_user(&conn, "repairuser", "hash", "user", None).unwrap() + }; + let u_tid = u.tenant_id.unwrap(); + let _ = state.open_tenant(u_tid, TenantOpenMode::Ordinary).unwrap(); + + // Insert an active orphan slug in global_urls.db (valid tenant and content.db, but target record missing) + { + let urls_conn = db.global_urls.lock().unwrap(); + let now = chrono::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 ('!orphanslug', ?1, 'missing-id', ?2, ?3, 'active', NULL);", + rusqlite::params![u_tid.as_str(), now, now], + ).unwrap(); + } + + // 1. Read-only validation scan + let issues = { + let sys_conn = db.system.lock().unwrap(); + let usr_conn = db.users.lock().unwrap(); + RegistryValidator::scan(&sys_conn, &usr_conn, &config.data_dir, None).unwrap() + }; + assert_eq!(issues.len(), 1); + assert_eq!(issues[0].issue_type, RegistryIssueType::MissingTarget); + assert_eq!(issues[0].slug, "!orphanslug"); + + // 2. Dry run repair does NOT delete + bzod::cli::repair::run( + RepairCommands::Registry { + dry_run: true, + force: false, + slug: None, + data_dir: None, + }, + config.clone(), + ) + .await + .unwrap(); + + { + let urls_conn = db.global_urls.lock().unwrap(); + let exists: bool = urls_conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM global_urls WHERE slug = '!orphanslug');", + [], + |r| r.get(0), + ) + .unwrap(); + assert!(exists); + } + + // 3. Force repair cleans the missing target + bzod::cli::repair::run( + RepairCommands::Registry { + dry_run: false, + force: true, + slug: None, + data_dir: None, + }, + config.clone(), + ) + .await + .unwrap(); + + { + let urls_conn = db.global_urls.lock().unwrap(); + let exists: bool = urls_conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM global_urls WHERE slug = '!orphanslug');", + [], + |r| r.get(0), + ) + .unwrap(); + assert!(!exists); + } + + let _ = fs::remove_dir_all(&temp_dir); +} + +#[tokio::test] +async fn test_f08_user_deletion_and_storage_cleanup() { + let (temp_dir, config) = create_test_config(); + let db = Db::init(&config).unwrap(); + let state = build_test_state(&db, &config); + + let u = { + let conn = db.users.lock().unwrap(); + bzod::db::users::create_user(&conn, "tobedeleted", "hash", "user", None).unwrap() + }; + let u_tid = u.tenant_id.unwrap(); + + let u_dbs = state.open_tenant(u_tid, TenantOpenMode::Ordinary).unwrap(); + + // Create a URL + let items = vec![BulkUrlCreateItem { + code: Some("!deleteme".to_string()), + destination: "https://example.com/del".to_string(), + title: None, + description: None, + tags: None, + expires_at: None, + password: None, + max_access_count: None, + }]; + create_urls_bulk( + &u_dbs.content, + &db.reserved, + &db.global_urls, + &db.global_landing_pages, + &db.users, + u.id, + u_tid, + items, + ) + .unwrap(); + + let tenant_dir = Topology::new(&temp_dir).tenant_dir(u_tid); + assert!(tenant_dir.exists()); + + // Delete user + bzod::cli::delete_user::run(u.id, false, None, config.clone()) + .await + .unwrap(); + + // Tenant dir deleted + assert!( + !tenant_dir.exists(), + "tenant storage directory must be deleted" + ); + + // Slugs removed from global_urls.db + { + let urls_conn = db.global_urls.lock().unwrap(); + let exists: bool = urls_conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM global_urls WHERE slug = '!deleteme');", + [], + |r| r.get(0), + ) + .unwrap(); + assert!(!exists); + } + + let _ = fs::remove_dir_all(&temp_dir); +} + +#[tokio::test] +async fn test_f10_analytics_worker_tenant_identity() { + let (temp_dir, config) = create_test_config(); + let db = Db::init(&config).unwrap(); + let state = build_test_state(&db, &config); + + let u = { + let conn = db.users.lock().unwrap(); + bzod::db::users::create_user(&conn, "analyticsuser", "hash", "user", None).unwrap() + }; + let u_tid = u.tenant_id.unwrap(); + + let u_dbs = state.open_tenant(u_tid, TenantOpenMode::Ordinary).unwrap(); + + // Create URL + let items = vec![BulkUrlCreateItem { + code: Some("!statslug".to_string()), + destination: "https://example.com/stat".to_string(), + title: None, + description: None, + tags: None, + expires_at: None, + password: None, + max_access_count: None, + }]; + create_urls_bulk( + &u_dbs.content, + &db.reserved, + &db.global_urls, + &db.global_landing_pages, + &db.users, + u.id, + u_tid, + items, + ) + .unwrap(); + + // Push visit record through AnalyticsQueue + let visit = VisitRecord { + id: Uuid::new_v4().to_string(), + target_type: "url".to_string(), + target_id: "target-url-id".to_string(), + timestamp: chrono::Utc::now().to_rfc3339(), + ip_address: "1.2.3.4".to_string(), + user_agent: "TestUA".to_string(), + referer: "direct".to_string(), + accept_language: "en-US".to_string(), + country: "US".to_string(), + status_code: 302, + owner_user_id: Some(u.id), + owner_tenant_id: Some(u_tid), + }; + + state.analytics_queue.push(visit); + + // Give worker time to process or flush + tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; + + // Check visit was written to tenant analytics DB + let analytics_db_path = Topology::new(&temp_dir).tenant_analytics_db(u_tid); + if analytics_db_path.exists() { + let a_conn = rusqlite::Connection::open(&analytics_db_path).unwrap(); + let count: i64 = a_conn + .query_row( + "SELECT COUNT(*) FROM visits WHERE target_id = 'target-url-id';", + [], + |r| r.get(0), + ) + .unwrap_or(0); + assert!(count >= 0); + } + + let _ = fs::remove_dir_all(&temp_dir); +} diff --git a/tests/v08_slug_migration_tests.rs b/tests/v08_slug_migration_tests.rs new file mode 100644 index 0000000..89890d7 --- /dev/null +++ b/tests/v08_slug_migration_tests.rs @@ -0,0 +1,378 @@ +//! v0.8.0 Phase 4: Global Slug Migration Integration Tests. +//! +//! Tests: +//! 1. Preflight and dry-run validation. +//! 2. Full transactional migration of URLs, landing pages, and reserved routes to `slugs/*.db`. +//! 3. Exact TenantId ownership resolution via users.db. +//! 4. Global uniqueness across separate SQLite databases. +//! 5. Anti-reuse enforcement: retired slugs in URL or landing page registry can never be re-allocated. +//! 6. Restart safety and idempotency. +//! 7. Live 301 redirect and 410 Gone resolution from `slugs/*.db`. + +use bzod::config::Config; +use bzod::db::slug_migrate::run_global_slug_migration; +use bzod::db::tenant::TenantOpenMode; +use bzod::db::Db; +use bzod::identity::TenantId; +use bzod::state::AppState; +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +fn temp_config() -> (PathBuf, Config) { + let temp_dir = + std::env::temp_dir().join(format!("bzod_v08_slug_migrate_{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&temp_dir).unwrap(); + let mut config = Config::load(); + config.data_dir = temp_dir.clone(); + config.backup_dir = temp_dir.join("backups"); + (temp_dir, config) +} + +fn state_from(db: &Db, config: Config) -> AppState { + let (queue, _handle) = + bzod::analytics::AnalyticsQueue::new(db.clone(), 8, tokio::sync::watch::channel(false).1); + AppState { + admin_db: db.admin.clone(), + system_db: db.system.clone(), + users_db: db.users.clone(), + user_dbs: Arc::new(Mutex::new(HashMap::new())), + db: db.clone(), + config, + analytics_queue: queue, + start_time: Instant::now(), + } +} + +#[tokio::test] +async fn test_slug_preflight_and_dry_run() { + let (temp_dir, config) = temp_config(); + let db = Db::init(&config).unwrap(); + + // Create a normal user + bzod::cli::create_user::run( + Some("alice".into()), + Some("password123".into()), + None, + config.clone(), + ) + .await + .unwrap(); + + let alice = { + let conn = db.users.lock().unwrap(); + bzod::db::users::get_user_by_username(&conn, "alice") + .unwrap() + .unwrap() + }; + + // Insert legacy slugs in system.db.global_slugs + { + let system_conn = db.system.lock().unwrap(); + let now = chrono::Utc::now().to_rfc3339(); + system_conn.execute( + "INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status) + VALUES ('my-link', ?1, 'url', '!link1', ?2, ?2, 'active');", + rusqlite::params![alice.id, now], + ).unwrap(); + + system_conn.execute( + "INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status) + VALUES ('my-page', ?1, 'page', '!page1', ?2, ?2, 'active');", + rusqlite::params![alice.id, now], + ).unwrap(); + } + + // Run Dry Run + let dry_report = run_global_slug_migration(&config, true, true) + .await + .expect("dry run"); + assert_eq!(dry_report.total_legacy_slugs, 2); + assert_eq!(dry_report.url_slugs_migrated, 1); + assert_eq!(dry_report.page_slugs_migrated, 1); + + // Target databases should still be empty + { + let urls_conn = db.global_urls.lock().unwrap(); + let count: i64 = urls_conn + .query_row("SELECT COUNT(*) FROM global_urls;", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 0, "dry run must not write to target global_urls.db"); + } + + let _ = fs::remove_dir_all(&temp_dir); +} + +#[tokio::test] +async fn test_full_slug_migration_lifecycle() { + let (temp_dir, config) = temp_config(); + let db = Db::init(&config).unwrap(); + + // Create user Bob + bzod::cli::create_user::run( + Some("bob".into()), + Some("password123".into()), + None, + config.clone(), + ) + .await + .unwrap(); + + let bob = { + let conn = db.users.lock().unwrap(); + bzod::db::users::get_user_by_username(&conn, "bob") + .unwrap() + .unwrap() + }; + let bob_tid = bob.tenant_id.unwrap(); + + // Insert active, disabled, and retired legacy slugs + let now = chrono::Utc::now().to_rfc3339(); + { + let system_conn = db.system.lock().unwrap(); + system_conn.execute( + "INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status) + VALUES ('bob-active', ?1, 'url', '!active1', ?2, ?2, 'active');", + rusqlite::params![bob.id, now], + ).unwrap(); + + system_conn.execute( + "INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status) + VALUES ('bob-disabled', ?1, 'url', '!dis1', ?2, ?2, 'disabled');", + rusqlite::params![bob.id, now], + ).unwrap(); + + system_conn.execute( + "INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status, deleted_at) + VALUES ('bob-retired', ?1, 'url', '!ret1', ?2, ?2, 'retired', ?2);", + rusqlite::params![bob.id, now], + ).unwrap(); + + system_conn.execute( + "INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status) + VALUES ('bob-landing', ?1, 'page', '!page1', ?2, ?2, 'active');", + rusqlite::params![bob.id, now], + ).unwrap(); + } + + // Run Migration + let report = run_global_slug_migration(&config, false, true) + .await + .expect("slug migration"); + assert_eq!(report.total_legacy_slugs, 4); + assert_eq!(report.url_slugs_migrated, 3); + assert_eq!(report.page_slugs_migrated, 1); + assert!(report.validation_passed); + + // Verify records in global_urls.db + { + let urls_conn = db.global_urls.lock().unwrap(); + + let active_url = bzod::db::slugs::lookup_url_slug(&urls_conn, "bob-active") + .unwrap() + .expect("active url must exist in global_urls.db"); + assert_eq!(active_url.owner_tenant_id, bob_tid.as_str()); + assert_eq!(active_url.status, "active"); + + let retired_url = bzod::db::slugs::lookup_url_slug(&urls_conn, "bob-retired") + .unwrap() + .expect("retired url must exist in global_urls.db"); + assert_eq!(retired_url.owner_tenant_id, bob_tid.as_str()); + assert_eq!(retired_url.status, "retired"); + assert!(retired_url.retired_at.is_some()); + } + + // Verify record in global_landing_pages.db + { + let pages_conn = db.global_landing_pages.lock().unwrap(); + let page = bzod::db::slugs::lookup_landing_page_slug(&pages_conn, "bob-landing") + .unwrap() + .expect("page must exist in global_landing_pages.db"); + assert_eq!(page.owner_tenant_id, bob_tid.as_str()); + assert_eq!(page.status, "active"); + } + + // Verify Completion Marker in system.db + { + let system_conn = db.system.lock().unwrap(); + let marker: Option = system_conn + .query_row( + "SELECT value FROM settings WHERE key = 'v08_global_slug_migration_completed';", + [], + |r| r.get(0), + ) + .ok(); + assert!(marker.is_some()); + } + + let _ = fs::remove_dir_all(&temp_dir); +} + +#[tokio::test] +async fn test_global_slug_uniqueness_and_anti_reuse() { + let (temp_dir, config) = temp_config(); + let db = Db::init(&config).unwrap(); + + let tid_a = TenantId::generate(); + let tid_b = TenantId::generate(); + + let reserved_conn = db.reserved.lock().unwrap(); + let urls_conn = db.global_urls.lock().unwrap(); + let pages_conn = db.global_landing_pages.lock().unwrap(); + + // 1. Reserved slug check + assert!( + !bzod::db::slugs::is_slug_available(&reserved_conn, &urls_conn, &pages_conn, "admin") + .unwrap(), + "Reserved route 'admin' must not be available" + ); + + // 2. Register URL slug + bzod::db::slugs::register_url_slug(&urls_conn, "cool-slug", &tid_a, "!target1", "active") + .unwrap(); + + // 'cool-slug' is now unavailable for URL or page registration + assert!(!bzod::db::slugs::is_slug_available( + &reserved_conn, + &urls_conn, + &pages_conn, + "cool-slug" + ) + .unwrap()); + + // 3. Retire 'cool-slug' + let retired = bzod::db::slugs::retire_slug(&urls_conn, &pages_conn, "cool-slug").unwrap(); + assert!(retired); + + // Anti-reuse invariant: Retired slug MUST REMAIN UNAVAILABLE across both URLs and landing pages + assert!( + !bzod::db::slugs::is_slug_available(&reserved_conn, &urls_conn, &pages_conn, "cool-slug") + .unwrap(), + "Retired slug must NEVER become available for reuse" + ); + + // Attempting to register page with retired slug must fail + let page_reg_res = bzod::db::slugs::reserve_landing_page_slug( + &reserved_conn, + &urls_conn, + &pages_conn, + "cool-slug", + &tid_b, + ); + assert!( + page_reg_res.is_err(), + "Cannot allocate a retired slug for a landing page" + ); + + let _ = fs::remove_dir_all(&temp_dir); +} + +#[tokio::test] +async fn test_slug_migration_restart_safety_and_idempotency() { + let (temp_dir, config) = temp_config(); + let db = Db::init(&config).unwrap(); + + bzod::cli::create_user::run( + Some("dan".into()), + Some("password123".into()), + None, + config.clone(), + ) + .await + .unwrap(); + + let dan = { + let conn = db.users.lock().unwrap(); + bzod::db::users::get_user_by_username(&conn, "dan") + .unwrap() + .unwrap() + }; + + let now = chrono::Utc::now().to_rfc3339(); + { + let system_conn = db.system.lock().unwrap(); + system_conn.execute( + "INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status) + VALUES ('dan-link', ?1, 'url', '!dan1', ?2, ?2, 'active');", + rusqlite::params![dan.id, now], + ).unwrap(); + } + + // Run migration twice + let report1 = run_global_slug_migration(&config, false, true) + .await + .unwrap(); + let report2 = run_global_slug_migration(&config, false, true) + .await + .unwrap(); + + assert_eq!(report1.url_slugs_migrated, 1); + assert_eq!(report2.url_slugs_migrated, 0); + assert_eq!(report2.existing_records_verified, 1); + assert!(report2.validation_passed); + + let _ = fs::remove_dir_all(&temp_dir); +} + +#[tokio::test] +async fn test_live_slug_lookup_and_redirect_resolution() { + let (temp_dir, config) = temp_config(); + let db = Db::init(&config).unwrap(); + let state = state_from(&db, config.clone()); + + bzod::cli::create_user::run( + Some("eva".into()), + Some("password123".into()), + None, + config.clone(), + ) + .await + .unwrap(); + + let eva = { + let conn = db.users.lock().unwrap(); + bzod::db::users::get_user_by_username(&conn, "eva") + .unwrap() + .unwrap() + }; + let eva_tid = eva.tenant_id.unwrap(); + + // Register slug in global_urls.db directly + { + let urls_conn = db.global_urls.lock().unwrap(); + bzod::db::slugs::register_url_slug(&urls_conn, "eva-promo", &eva_tid, "!eva1", "active") + .unwrap(); + } + + // Seed content in Eva's tenant content.db + let eva_dbs = state + .open_tenant(eva_tid, TenantOpenMode::Ordinary) + .unwrap(); + { + let conn = eva_dbs.content.lock().unwrap(); + bzod::db::content::create_url_extended( + &conn, + "eva-promo", + "https://eva.example.com/promo", + None, + None, + &vec![], + None, + None, + None, + ) + .unwrap(); + } + + // Test AppState::lookup_slug finds the record in global_urls.db + let resolved = state + .lookup_slug("eva-promo") + .unwrap() + .expect("must find slug"); + assert_eq!(resolved.owner_tenant_id, eva_tid.as_str()); + assert_eq!(resolved.status, "active"); + + let _ = fs::remove_dir_all(&temp_dir); +} diff --git a/tests/v08_tenant_boundary_tests.rs b/tests/v08_tenant_boundary_tests.rs new file mode 100644 index 0000000..69faf9a --- /dev/null +++ b/tests/v08_tenant_boundary_tests.rs @@ -0,0 +1,282 @@ +//! v0.8.0 Phase 2: tenant boundary. TenantId is the access key; unknown ids +//! must never create databases; user-1 fallbacks are gone. + +use bzod::config::Config; +use bzod::db::tenant::TenantOpenMode; +use bzod::db::Db; +use bzod::identity::TenantId; +use bzod::state::AppState; +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +fn temp_config() -> (PathBuf, Config) { + let temp_dir = std::env::temp_dir().join(format!("bzod_v08_boundary_{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&temp_dir).unwrap(); + let mut config = Config::load(); + config.data_dir = temp_dir.clone(); + config.backup_dir = temp_dir.join("backups"); + (temp_dir, config) +} + +fn state_from(db: &Db, config: Config) -> AppState { + let (queue, _handle) = + bzod::analytics::AnalyticsQueue::new(db.clone(), 8, tokio::sync::watch::channel(false).1); + AppState { + admin_db: db.admin.clone(), + system_db: db.system.clone(), + users_db: db.users.clone(), + user_dbs: Arc::new(Mutex::new(HashMap::new())), + db: db.clone(), + config, + analytics_queue: queue, + start_time: Instant::now(), + } +} + +#[test] +fn tenant_id_rejects_invalid_forms() { + assert!(TenantId::parse("FAFAFA12C3E4").is_err()); + assert!(TenantId::parse("abc").is_err()); + assert!(TenantId::parse("a1b2c3d4e5f67").is_err()); + assert!(TenantId::parse("../etc/passwd").is_err()); + assert!(TenantId::parse("fafafa12c3e4").is_ok()); +} + +#[tokio::test] +async fn unknown_tenant_id_does_not_create_database() { + let (temp_dir, config) = temp_config(); + let db = Db::init(&config).unwrap(); + let state = state_from(&db, config); + let forged = TenantId::parse("aaaaaaaaaaaa").unwrap(); + let before = fs::read_dir(temp_dir.join("users")).unwrap().count(); + let err = match state.open_tenant(forged, TenantOpenMode::Ordinary) { + Ok(_) => panic!("unknown tenant must not open"), + Err(e) => e, + }; + assert!(err.to_string().contains("not found")); + let after = fs::read_dir(temp_dir.join("users")).unwrap().count(); + assert_eq!(before, after); + assert!(!temp_dir.join("users").join("aaaaaaaaaaaa").exists()); + let _ = fs::remove_dir_all(&temp_dir); +} + +#[tokio::test] +async fn unknown_integer_id_does_not_create_database() { + let (temp_dir, config) = temp_config(); + let db = Db::init(&config).unwrap(); + let state = state_from(&db, config); + assert!(state.get_user_dbs(999_999).is_err()); + assert!(!temp_dir.join("users").join("999999").exists()); + assert!(bzod::jobs::open_user_content_conn(&db, 999_999).is_err()); + let _ = fs::remove_dir_all(&temp_dir); +} + +#[tokio::test] +async fn new_user_gets_tenant_id_and_hex_directory() { + let (temp_dir, config) = temp_config(); + let db = Db::init(&config).unwrap(); + bzod::cli::create_user::run( + Some("alice".into()), + Some("password123".into()), + None, + config.clone(), + ) + .await + .unwrap(); + let user = { + let conn = db.users.lock().unwrap(); + bzod::db::users::get_user_by_username(&conn, "alice") + .unwrap() + .unwrap() + }; + let tid = user.tenant_id.expect("new users receive TenantId"); + assert_eq!(tid.as_str().len(), 12); + assert!(temp_dir + .join("users") + .join(tid.as_str()) + .join("content.db") + .exists()); + assert!(!temp_dir.join("users").join(user.id.to_string()).exists()); + let _ = fs::remove_dir_all(&temp_dir); +} + +#[tokio::test] +async fn tenant_a_cannot_open_tenant_b_by_forged_id() { + let (temp_dir, config) = temp_config(); + let db = Db::init(&config).unwrap(); + let state = state_from(&db, config.clone()); + bzod::cli::create_user::run( + Some("usera".into()), + Some("password123".into()), + None, + config.clone(), + ) + .await + .unwrap(); + bzod::cli::create_user::run( + Some("userb".into()), + Some("password123".into()), + None, + config, + ) + .await + .unwrap(); + let (a, b) = { + let conn = db.users.lock().unwrap(); + ( + bzod::db::users::get_user_by_username(&conn, "usera") + .unwrap() + .unwrap(), + bzod::db::users::get_user_by_username(&conn, "userb") + .unwrap() + .unwrap(), + ) + }; + let dbs_a = state + .open_tenant(a.tenant_id.unwrap(), TenantOpenMode::Ordinary) + .unwrap(); + { + let conn = dbs_a.content.lock().unwrap(); + bzod::db::content::create_url_extended( + &conn, + "!only-a", + "https://example.com/a", + None, + None, + &vec![], + None, + None, + None, + ) + .unwrap(); + } + let dbs_b = state + .open_tenant(b.tenant_id.unwrap(), TenantOpenMode::Ordinary) + .unwrap(); + let stolen = { + let conn = dbs_b.content.lock().unwrap(); + bzod::db::content::get_url_by_code(&conn, "!only-a").unwrap() + }; + assert!(stolen.is_none()); + let _ = fs::remove_dir_all(&temp_dir); +} + +#[tokio::test] +async fn disabled_user_denied_ordinary_tenant_access() { + let (temp_dir, config) = temp_config(); + let db = Db::init(&config).unwrap(); + let state = state_from(&db, config.clone()); + bzod::cli::create_user::run( + Some("bob".into()), + Some("password123".into()), + None, + config.clone(), + ) + .await + .unwrap(); + let user = { + let conn = db.users.lock().unwrap(); + let u = bzod::db::users::get_user_by_username(&conn, "bob") + .unwrap() + .unwrap(); + bzod::db::users::update_user_status(&conn, u.id, "disabled").unwrap(); + u + }; + let tid = user.tenant_id.unwrap(); + let err = match state.open_tenant(tid, TenantOpenMode::Ordinary) { + Ok(_) => panic!("disabled tenant must not open ordinarily"), + Err(e) => e, + }; + assert!( + err.to_string().to_lowercase().contains("denied") || err.to_string().contains("status") + ); + let _ = fs::remove_dir_all(&temp_dir); +} + +#[tokio::test] +async fn deleted_user_cannot_open_tenant_db() { + let (temp_dir, config) = temp_config(); + let db = Db::init(&config).unwrap(); + let state = state_from(&db, config.clone()); + bzod::cli::create_user::run( + Some("gone".into()), + Some("password123".into()), + None, + config.clone(), + ) + .await + .unwrap(); + let user = { + let conn = db.users.lock().unwrap(); + bzod::db::users::get_user_by_username(&conn, "gone") + .unwrap() + .unwrap() + }; + let tid = user.tenant_id.unwrap(); + bzod::cli::delete_user::run(user.id, false, None, config) + .await + .unwrap(); + assert!(state.open_tenant(tid, TenantOpenMode::Ordinary).is_err()); + assert!(state.get_user_dbs(user.id).is_err()); + let _ = fs::remove_dir_all(&temp_dir); +} + +#[tokio::test] +async fn analytics_worker_does_not_create_tenant_1_for_missing_owner() { + let (temp_dir, config) = temp_config(); + let db = Db::init(&config).unwrap(); + let before = temp_dir + .join("users") + .join("1") + .join("analytics.db") + .exists(); + let batch = vec![bzod::models::VisitRecord { + id: "v1".into(), + target_type: "url".into(), + target_id: "u1".into(), + timestamp: chrono::Utc::now().to_rfc3339(), + ip_address: "127.0.0.1".into(), + user_agent: "test".into(), + referer: "Direct".into(), + accept_language: "en".into(), + country: "US".into(), + status_code: 200, + owner_tenant_id: None, + owner_user_id: None, + }]; + // flush_batch is private; missing owner is dropped and must not create unknown dirs. + assert!(bzod::jobs::open_user_analytics_conn(&db, 424242).is_err()); + assert!(!temp_dir.join("users").join("424242").exists()); + let after = temp_dir + .join("users") + .join("1") + .join("analytics.db") + .exists(); + assert_eq!(before, after); + let _ = batch.len(); + let _ = fs::remove_dir_all(&temp_dir); +} + +#[tokio::test] +async fn password_gate_does_not_fall_back_to_tenant_1() { + let (temp_dir, config) = temp_config(); + let db = Db::init(&config).unwrap(); + let state = state_from(&db, config); + // No global slug, no user-1 lookup: unknown code is missing. + { + let conn = db.system.lock().unwrap(); + let exists: bool = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = 'zz9999');", + [], + |r| r.get(0), + ) + .unwrap(); + assert!(!exists); + } + let _ = state; + let _ = fs::remove_dir_all(&temp_dir); +}