From 2761863c14ade808e4f77bb7d7751a16ee5388d7 Mon Sep 17 00:00:00 2001 From: Sunil Thakare Date: Mon, 29 Jun 2026 16:11:43 +0530 Subject: [PATCH] Release v0.5.2 - Add admin content consistency diagnostics - Add admin-migrate CLI - Harden RBAC for API endpoints - Normalize multi-tenant storage paths - Improve backup and restore compatibility - Fix administrator routing consistency - Improve doctor and stats commands --- Cargo.toml | 2 +- src/auth/middleware.rs | 31 ++++++++ src/cli/admin_migrate.rs | 152 +++++++++++++++++++++++++++++++++++++++ src/cli/doctor.rs | 6 +- src/cli/mod.rs | 14 ++++ src/cli/stats.rs | 24 +++++-- src/db/mod.rs | 10 +-- src/db/users.rs | 79 ++++++++++++++++++-- src/main.rs | 9 +++ src/utils/system.rs | 9 +-- src/web/admin.rs | 138 +++++++++++++++++++++++++++-------- src/web/api.rs | 12 +++- src/web/pages.rs | 13 ++-- src/web/redirect.rs | 13 ++-- 14 files changed, 437 insertions(+), 75 deletions(-) create mode 100644 src/cli/admin_migrate.rs diff --git a/Cargo.toml b/Cargo.toml index 853c62a..ead2e68 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "bzod" description = "Self-hosted multi-user URL management, landing page and QR analytics platform" -version = "0.5.1" +version = "0.5.2" edition = "2021" license = "MIT OR Apache-2.0" repository = "https://github.com/thakares/nx9-url-shortener" diff --git a/src/auth/middleware.rs b/src/auth/middleware.rs index 6f9736c..423d583 100644 --- a/src/auth/middleware.rs +++ b/src/auth/middleware.rs @@ -4,11 +4,42 @@ use crate::state::AppState; use axum::{ extract::{FromRef, FromRequestParts}, http::{request::Parts, StatusCode}, + Json, }; // Extractor: Authenticate API requests using Bearer token pub struct ApiUser(pub ApiActor); +impl ApiUser { + pub fn require_admin( + &self, + ) -> Result<&crate::models::User, (StatusCode, Json)> { + match &self.0 { + ApiActor::Admin(u) => Ok(u), + _ => Err(( + StatusCode::FORBIDDEN, + Json(crate::web::api::ApiError { + error: "Admin privileges required".to_string(), + }), + )), + } + } + + pub fn require_tenant( + &self, + ) -> Result<&crate::models::TenantUser, (StatusCode, Json)> { + match &self.0 { + ApiActor::User(u) => Ok(u), + _ => Err(( + StatusCode::FORBIDDEN, + Json(crate::web::api::ApiError { + error: "Tenant privileges required".to_string(), + }), + )), + } + } +} + #[axum::async_trait] impl FromRequestParts for ApiUser where diff --git a/src/cli/admin_migrate.rs b/src/cli/admin_migrate.rs new file mode 100644 index 0000000..e18c68e --- /dev/null +++ b/src/cli/admin_migrate.rs @@ -0,0 +1,152 @@ +use crate::config::Config; +use crate::db::Db; +use rusqlite::Connection; +use std::path::PathBuf; +use tracing::{error, info}; + +pub async fn run( + target_admin_id: i64, + data_dir: Option, + dry_run: bool, + force: bool, + mut config: Config, +) -> Result<(), Box> { + if let Some(d) = data_dir { + config.data_dir = PathBuf::from(d); + } + let db = Db::init(&config)?; + + // 1. Verify target admin exists and is an admin + let target_user = { + let conn = db.users.lock().unwrap(); + crate::db::users::get_user_by_id(&conn, target_admin_id)? + }; + + let target_user = match target_user { + Some(u) => u, + None => { + error!("Target admin ID {} not found", target_admin_id); + return Ok(()); + } + }; + + if target_user.account_type != "admin" { + error!( + "Target user '{}' (ID {}) is not an admin account.", + target_user.username, target_admin_id + ); + return Ok(()); + } + + if target_admin_id == 1 { + error!("Target admin ID cannot be 1 (legacy admin)."); + return Ok(()); + } + + // 2. Open databases + let legacy_content_path = config.data_dir.join("users").join("1").join("content.db"); + + if !legacy_content_path.exists() { + info!( + "No legacy admin content database found at {:?}", + legacy_content_path + ); + return Ok(()); + } + + 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 mut legacy_conn = Connection::open(&legacy_content_path)?; + let mut target_conn = Connection::open(&target_content_path)?; + let mut system_conn = db.system.lock().unwrap(); + + println!("Scanning legacy admin content database..."); + + // 3. Count items + let urls = { + let mut stmt = legacy_conn.prepare("SELECT * FROM urls;")?; + let mut rows = stmt.query([])?; + let mut data = Vec::new(); + while let Ok(Some(_)) = rows.next() { + data.push(1); + } + data + }; + let url_count = urls.len(); + + let pages = { + let mut stmt = legacy_conn.prepare("SELECT * FROM landing_pages;")?; + let mut rows = stmt.query([])?; + let mut data = Vec::new(); + while let Ok(Some(_)) = rows.next() { + data.push(1); + } + data + }; + let page_count = pages.len(); + + println!( + "Found {} URLs and {} Landing Pages owned by legacy admin (ID 1).", + url_count, page_count + ); + + if dry_run { + println!("Dry run mode enabled. No changes will be made."); + return Ok(()); + } + + if !force { + println!("Migration requires the --force flag to execute. Aborting."); + return Ok(()); + } + + println!( + "Starting migration to Admin '{}' (ID {})...", + target_user.username, target_admin_id + ); + + // 4. Perform Migration (using ATTACH DATABASE for fast copy) + // We attach the legacy db to the target db to do INSERT INTO ... SELECT * FROM + target_conn.execute( + "ATTACH DATABASE ?1 AS legacy;", + rusqlite::params![legacy_content_path.to_string_lossy()], + )?; + + let tx = target_conn.transaction()?; + tx.execute("INSERT OR IGNORE INTO urls SELECT * FROM legacy.urls;", [])?; + tx.execute( + "INSERT OR IGNORE INTO landing_pages SELECT * FROM legacy.landing_pages;", + [], + )?; + tx.commit()?; + + target_conn.execute("DETACH DATABASE legacy;", [])?; + + // 5. Update global registry + let sys_tx = system_conn.transaction()?; + let updated_slugs = sys_tx.execute( + "UPDATE global_slugs SET owner_user_id = ?1 WHERE owner_user_id = 1;", + rusqlite::params![target_admin_id], + )?; + sys_tx.commit()?; + + // 6. Delete from legacy + let legacy_tx = legacy_conn.transaction()?; + legacy_tx.execute("DELETE FROM urls;", [])?; + legacy_tx.execute("DELETE FROM landing_pages;", [])?; + legacy_tx.commit()?; + + println!("Migration Complete!"); + println!("-------------------"); + println!("Migrated {} URLs.", url_count); + println!("Migrated {} Landing Pages.", page_count); + println!("Updated {} slugs in global registry.", updated_slugs); + println!("Cleared legacy content database."); + + Ok(()) +} diff --git a/src/cli/doctor.rs b/src/cli/doctor.rs index 32d6341..ee8841d 100644 --- a/src/cli/doctor.rs +++ b/src/cli/doctor.rs @@ -26,12 +26,14 @@ pub async fn run( // 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 dbs = vec![ ("admin", admin_dir.join("admin.db")), ("system", admin_dir.join("system.db")), ("users", admin_dir.join("users.db")), - ("legacy content", config.data_dir.join("content.db")), - ("legacy analytics", config.data_dir.join("analytics.db")), + ("legacy content", legacy_user_dir.join("content.db")), + ("legacy analytics", legacy_user_dir.join("analytics.db")), ]; for (db_name, db_path) in dbs { diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 293417e..ad2d422 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -11,6 +11,7 @@ pub mod shorten; pub mod stats; pub mod validate; +pub mod admin_migrate; pub mod backup_user; pub mod create_user; pub mod delete_user; @@ -165,4 +166,17 @@ pub enum Commands { #[arg(long)] data_dir: Option, }, + /// FUTURE: Migrate legacy admin content to a specific admin tenant database + AdminMigrate { + /// Target Admin ID + target_admin_id: i64, + #[arg(long)] + data_dir: Option, + /// Preview what would be moved without making changes + #[arg(long)] + dry_run: bool, + /// Force the migration to execute + #[arg(long)] + force: bool, + }, } diff --git a/src/cli/stats.rs b/src/cli/stats.rs index f6a6e5f..89ce049 100644 --- a/src/cli/stats.rs +++ b/src/cli/stats.rs @@ -14,14 +14,26 @@ pub async fn run( println!("=== BZOD Database Stats ==="); println!("Storage Directory: {:?}", config.data_dir); - let files = vec!["admin.db", "content.db", "analytics.db", "system.db"]; - for f in files { - let p = config.data_dir.join(f); - if p.exists() { - let sz = std::fs::metadata(&p)?.len(); + 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")), + ( + "legacy content.db", + config.data_dir.join("users/1/content.db"), + ), + ( + "legacy analytics.db", + config.data_dir.join("users/1/analytics.db"), + ), + ]; + + for (name, path) in files { + if path.exists() { + let sz = std::fs::metadata(&path)?.len(); println!( " File: {} - Size: {} bytes ({:.2} MB)", - f, + name, sz, sz as f64 / 1_048_576.0 ); diff --git a/src/db/mod.rs b/src/db/mod.rs index 81a6331..e07449c 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -50,7 +50,7 @@ impl Db { // 1. If legacy admin.db exists at root, move admin/system DBs to config.data_dir/admin/ if legacy_admin_db.exists() { - info!("Legacy admin.db found at root. Moving administrative databases to admin/ subfolder..."); + tracing::warn!("LEGACY DETECTED: admin.db found at root. Moving administrative databases to multi-tenant admin/ subfolder..."); let files = vec![ "admin.db", "admin.db-wal", @@ -219,7 +219,7 @@ impl Db { fs::create_dir_all(&legacy_user_dir)?; if legacy_content_db.exists() || legacy_analytics_db.exists() { - info!("Legacy content/analytics databases found at root. Moving to user ID 1 directory..."); + 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); @@ -461,11 +461,7 @@ impl Db { for user_id in user_ids { let user_dir = config.data_dir.join("users").join(user_id.to_string()); - let content_path = if user_id == 1 { - config.data_dir.join("content.db") // legacy admin content db path - } else { - user_dir.join("content.db") - }; + let content_path = user_dir.join("content.db"); if content_path.exists() { let content_conn = Connection::open(&content_path)?; diff --git a/src/db/users.rs b/src/db/users.rs index 0b1cefc..cec9991 100644 --- a/src/db/users.rs +++ b/src/db/users.rs @@ -808,12 +808,7 @@ pub fn verify_global_slug_registry_integrity( || (status == "reserving" && !target_id.is_empty()) { let content_db_path = if owner_user_id == 1 { - let p1 = data_dir.join("users").join("1").join("content.db"); - if p1.exists() { - p1 - } else { - data_dir.join("content.db") - } + data_dir.join("users").join("1").join("content.db") } else { data_dir .join("users") @@ -862,6 +857,78 @@ pub fn verify_global_slug_registry_integrity( } } + // 3. Admin Content Reverse Consistency Check (Legacy DB) + let admin_content_db_path = data_dir.join("users").join("1").join("content.db"); + + if admin_content_db_path.exists() { + if let Ok(admin_content_conn) = Connection::open(&admin_content_db_path) { + // Check URLs + if let Ok(mut stmt) = admin_content_conn.prepare("SELECT code, id FROM urls;") { + if let Ok(mut rows) = stmt.query([]) { + while let Ok(Some(row)) = rows.next() { + let code: String = row.get(0).unwrap_or_default(); + let id: String = row.get(1).unwrap_or_default(); + let exists: bool = system_conn.query_row( + "SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = ?1 AND owner_user_id = 1 AND target_id = ?2);", + rusqlite::params![code, id], + |r| r.get(0) + ).unwrap_or(false); + if !exists { + warnings.push(format!("Orphaned admin URL detected in legacy content DB: code='{}', id='{}' is missing from global_slugs", code, id)); + } + } + } + } + // Check Landing Pages + if let Ok(mut stmt) = admin_content_conn.prepare("SELECT code, id FROM landing_pages;") + { + if let Ok(mut rows) = stmt.query([]) { + while let Ok(Some(row)) = rows.next() { + let code: String = row.get(0).unwrap_or_default(); + let id: String = row.get(1).unwrap_or_default(); + let exists: bool = system_conn.query_row( + "SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = ?1 AND owner_user_id = 1 AND target_id = ?2);", + rusqlite::params![code, id], + |r| r.get(0) + ).unwrap_or(false); + if !exists { + warnings.push(format!("Orphaned admin Landing Page detected in legacy content DB: code='{}', id='{}' is missing from global_slugs", code, id)); + } + } + } + } + } + } + + // 4. Check for admin content in non-legacy tenant DBs + if let Ok(mut stmt) = users_conn + .prepare("SELECT id, username FROM users WHERE account_type = 'admin' AND id != 1;") + { + if let Ok(mut rows) = stmt.query([]) { + while let Ok(Some(row)) = rows.next() { + let id: i64 = row.get(0).unwrap_or(0); + let username: String = row.get(1).unwrap_or_default(); + let tenant_db_path = data_dir + .join("users") + .join(id.to_string()) + .join("content.db"); + if tenant_db_path.exists() { + if let Ok(conn) = Connection::open(&tenant_db_path) { + let url_count: i64 = conn + .query_row("SELECT COUNT(*) FROM urls;", [], |r| r.get(0)) + .unwrap_or(0); + let page_count: i64 = conn + .query_row("SELECT COUNT(*) FROM landing_pages;", [], |r| r.get(0)) + .unwrap_or(0); + if url_count > 0 || page_count > 0 { + warnings.push(format!("Admin user '{}' (ID {}) has content in isolated tenant DB ({} URLs, {} pages). Admin content should be in legacy DB 1.", username, id, url_count, page_count)); + } + } + } + } + } + } + Ok((errors, warnings)) } diff --git a/src/main.rs b/src/main.rs index 75b62cb..3877117 100644 --- a/src/main.rs +++ b/src/main.rs @@ -94,6 +94,15 @@ async fn main() -> Result<(), Box> { Commands::RestoreUser { file, data_dir } => { bzod::cli::restore_user::run(file, data_dir, config).await?; } + Commands::AdminMigrate { + target_admin_id, + data_dir, + dry_run, + force, + } => { + bzod::cli::admin_migrate::run(target_admin_id, data_dir, dry_run, force, config) + .await?; + } } Ok(()) diff --git a/src/utils/system.rs b/src/utils/system.rs index 416a042..ff40637 100644 --- a/src/utils/system.rs +++ b/src/utils/system.rs @@ -19,10 +19,11 @@ pub fn get_memory_usage() -> String { pub fn get_db_file_info(data_dir: &Path) -> String { let mut stats = String::new(); let files = vec![ - ("admin.db", "Admin DB"), - ("content.db", "Content DB"), - ("analytics.db", "Analytics DB"), - ("system.db", "System DB"), + ("admin/admin.db", "Admin DB"), + ("admin/system.db", "System DB"), + ("admin/users.db", "Users DB"), + ("users/1/content.db", "Legacy Content DB"), + ("users/1/analytics.db", "Legacy Analytics DB"), ]; for (f, name) in files { diff --git a/src/web/admin.rs b/src/web/admin.rs index 64c6a67..effd330 100644 --- a/src/web/admin.rs +++ b/src/web/admin.rs @@ -1872,14 +1872,10 @@ pub async fn urls_create( return Redirect::to("/admin/urls?error=Short code/slug already exists") .into_response(); } - if let Err(e) = crate::db::users::register_global_slug( - &system_conn, - &code, - admin_user_id, - "url", - "", - "reserving", - ) { + // 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(); } @@ -1931,7 +1927,7 @@ pub async fn urls_create( } Err(e) => { let system_conn = state.system_db.lock().unwrap(); - let _ = crate::db::users::release_global_slug(&system_conn, &code, admin_user_id); + let _ = crate::db::users::release_global_slug(&system_conn, &code, 1); Redirect::to(&format!("/admin/urls?error=Database error: {}", e)).into_response() } } @@ -2444,14 +2440,10 @@ pub async fn pages_create( 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(); } - if let Err(e) = crate::db::users::register_global_slug( - &system_conn, - &code, - admin_user_id, - "page", - "", - "reserving", - ) { + // 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(); } @@ -2508,7 +2500,7 @@ pub async fn pages_create( } Err(e) => { let system_conn = state.system_db.lock().unwrap(); - let _ = crate::db::users::release_global_slug(&system_conn, &code, admin_user_id); + let _ = crate::db::users::release_global_slug(&system_conn, &code, 1); Redirect::to(&format!("/admin/pages?error=Database error: {}", e)).into_response() } } @@ -2781,15 +2773,48 @@ pub async fn download_backup( let enc = GzEncoder::new(&mut buffer, Compression::default()); let mut tar = Builder::new(enc); - let files = vec!["admin.db", "content.db", "analytics.db", "system.db"]; + let files = vec![ + ("admin.db", state.config.data_dir.join("admin/admin.db")), + ("system.db", state.config.data_dir.join("admin/system.db")), + ("users.db", state.config.data_dir.join("admin/users.db")), + ( + "content.db", + state.config.data_dir.join("users/1/content.db"), + ), + ( + "analytics.db", + state.config.data_dir.join("users/1/analytics.db"), + ), + ]; + let mut add_err = None; - for f in files { - let path = state.config.data_dir.join(f); + let mut manifest_files = Vec::new(); + + for (name, path) in files { if path.exists() { - if let Err(e) = tar.append_path_with_name(&path, f) { + if let Err(e) = tar.append_path_with_name(&path, name) { add_err = Some(e); break; } + manifest_files.push(name.to_string()); + } + } + + if add_err.is_none() { + let manifest = serde_json::json!({ + "created_at": chrono::Utc::now().to_rfc3339(), + "type": "legacy_flat_backup", + "files_included": manifest_files, + "note": "Multi-tenant databases flattened for backward compatibility.", + }); + let manifest_str = manifest.to_string(); + let mut header = tar::Header::new_gnu(); + header.set_size(manifest_str.len() as u64); + header.set_cksum(); + if let Err(e) = + tar.append_data(&mut header, "backup_manifest.json", manifest_str.as_bytes()) + { + add_err = Some(e); } } @@ -3372,28 +3397,75 @@ pub async fn restore_backup_post( // 2. Perform restore unpacking/validation let res = crate::cli::restore::perform_restore(&temp_file_path, &state.config.data_dir); - // 3. Reinitialize database connections - let new_admin = rusqlite::Connection::open(state.config.data_dir.join("admin.db")); - let new_content = rusqlite::Connection::open(state.config.data_dir.join("content.db")); - let new_analytics = rusqlite::Connection::open(state.config.data_dir.join("analytics.db")); - let new_system = rusqlite::Connection::open(state.config.data_dir.join("system.db")); + // 3. Post-Restore Path Normalization (Move flat files to multi-tenant structure) + let admin_dir = state.config.data_dir.join("admin"); + let users_1_dir = state.config.data_dir.join("users").join("1"); + let _ = std::fs::create_dir_all(&admin_dir); + let _ = std::fs::create_dir_all(&users_1_dir); - match (new_admin, new_content, new_analytics, new_system) { - (Ok(adm), Ok(cnt), Ok(any), Ok(sys)) => { + let admin_files = vec![ + "admin.db", + "admin.db-wal", + "admin.db-shm", + "system.db", + "system.db-wal", + "system.db-shm", + "users.db", + "users.db-wal", + "users.db-shm", + ]; + for f in admin_files { + let src = state.config.data_dir.join(f); + if src.exists() { + let _ = std::fs::rename(&src, admin_dir.join(f)); + } + } + + let content_files = vec![ + "content.db", + "content.db-wal", + "content.db-shm", + "analytics.db", + "analytics.db-wal", + "analytics.db-shm", + ]; + for f in content_files { + let src = state.config.data_dir.join(f); + if src.exists() { + let _ = std::fs::rename(&src, users_1_dir.join(f)); + } + } + + // 4. Reinitialize database connections using correct multi-tenant paths + let new_admin = rusqlite::Connection::open(state.config.data_dir.join("admin/admin.db")); + let new_system = rusqlite::Connection::open(state.config.data_dir.join("admin/system.db")); + + let new_users = rusqlite::Connection::open(state.config.data_dir.join("admin/users.db")); + + let new_content = + rusqlite::Connection::open(state.config.data_dir.join("users/1/content.db")); + let new_analytics = + rusqlite::Connection::open(state.config.data_dir.join("users/1/analytics.db")); + + match (new_admin, new_content, new_analytics, new_system, new_users) { + (Ok(adm), Ok(cnt), Ok(any), Ok(sys), Ok(usr)) => { 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_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"); *admin_conn = adm; *content_conn = cnt; *analytics_conn = any; *system_conn = sys; + *state.db.users.lock().unwrap() = usr; } _ => { return Redirect::to("/admin/settings?error=Failed to reopen restored databases") @@ -3422,6 +3494,14 @@ pub async fn restore_backup_post( headers.get("user-agent").and_then(|h| h.to_str().ok()), ); } + + // Run doctor check to verify integrity after restore (spawn in background since we can't easily await here) + tracing::info!("Running post-restore diagnostics..."); + let config_clone = state.config.clone(); + tokio::spawn(async move { + let _ = crate::cli::doctor::run(None, config_clone).await; + }); + Redirect::to("/admin/login").into_response() } Err(e) => { diff --git a/src/web/api.rs b/src/web/api.rs index b26fc77..48901fe 100644 --- a/src/web/api.rs +++ b/src/web/api.rs @@ -793,7 +793,11 @@ pub struct OverallStatsResponse { } // GET /api/v1/stats -pub async fn api_overall_stats(State(state): State, _user: ApiUser) -> Response { +pub async fn api_overall_stats(State(state): State, user: ApiUser) -> Response { + if let Err(err) = user.require_admin() { + return err.into_response(); + } + let (total_urls, active_links, dead_links) = { let conn = state.content_db.lock().unwrap(); get_url_counts(&conn).unwrap_or((0, 0, 0)) @@ -982,9 +986,13 @@ pub struct AuditQuery { // GET /api/v1/audit pub async fn api_list_audit( State(state): State, - _user: ApiUser, + user: ApiUser, Query(query): Query, ) -> Response { + if let Err(err) = user.require_admin() { + return err.into_response(); + } + let limit = query.limit.unwrap_or(50); let offset = query.offset.unwrap_or(0); diff --git a/src/web/pages.rs b/src/web/pages.rs index 095f660..3ce9872 100644 --- a/src/web/pages.rs +++ b/src/web/pages.rs @@ -63,15 +63,10 @@ pub async fn resolve_page( .into_response(); } - // 2. Get content database connection - admin (user_id=1) uses legacy content_db, - // tenant users use per-user content databases - let content_conn = if owner_user_id == 1 { - state.content_db.clone() - } else { - match state.get_user_dbs(owner_user_id) { - Ok(dbs) => dbs.content, - Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), - } + // 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 page_opt = { diff --git a/src/web/redirect.rs b/src/web/redirect.rs index 8204eae..3a5be36 100644 --- a/src/web/redirect.rs +++ b/src/web/redirect.rs @@ -74,15 +74,10 @@ pub async fn resolve_redirect( return Redirect::permanent(&format!("/p/{}", code)).into_response(); } - // 2. Get content database connection - admin (user_id=1) uses legacy content_db, - // tenant users use per-user content databases - let content_conn = if owner_user_id == 1 { - state.content_db.clone() - } else { - match state.get_user_dbs(owner_user_id) { - Ok(dbs) => dbs.content, - Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), - } + // 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 url_opt = {