use super::*; #[derive(Deserialize)] pub struct UserSettingsQuery { pub success: Option, pub error: Option, } pub async fn user_settings_get( State(state): State, jar: CookieJar, Query(query): Query, ) -> Response { let (user, session_id) = match require_user_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let csrf_token = generate_csrf_token(&session_id); let template = crate::templates::UserSettingsTemplate { admin_username: user.username.clone(), username: user.username, csrf_token, success: query.success, error: query.error, }; template.into_response() } pub async fn user_change_password_post( State(state): State, jar: CookieJar, headers: HeaderMap, connect_info: Option>, Form(form): Form, ) -> Response { let (user, session_id) = match require_user_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; if !verify_csrf(&session_id, &form.csrf_token) { return Redirect::to("/user/settings?error=Invalid CSRF token").into_response(); } if form.current_password.trim().is_empty() || form.new_password.trim().len() < 8 { return Redirect::to("/user/settings?error=Password must be at least 8 characters") .into_response(); } let conn = state.users_db.lock().unwrap(); if !verify_password(&form.current_password, &user.password_hash) { let _ = write_audit_log( &state.admin_db.lock().unwrap(), &state, &user.username, "USER_PASSWORD_CHANGE_FAIL", Some("user"), Some(&user.id.to_string()), Some(&get_client_ip(&headers, connect_info)), headers.get("user-agent").and_then(|h| h.to_str().ok()), ); return Redirect::to("/user/settings?error=Incorrect current password").into_response(); } let new_hash = match hash_password(&form.new_password) { Ok(h) => h, Err(_) => return Redirect::to("/user/settings?error=Hashing error").into_response(), }; match conn.execute( "UPDATE users SET password_hash = ?1 WHERE id = ?2;", params![new_hash, user.id], ) { Ok(_) => { let _ = write_audit_log( &state.admin_db.lock().unwrap(), &state, &user.username, "USER_PASSWORD_CHANGED", Some("user"), Some(&user.id.to_string()), Some(&get_client_ip(&headers, connect_info)), headers.get("user-agent").and_then(|h| h.to_str().ok()), ); Redirect::to("/user/settings?success=Password updated successfully").into_response() } Err(e) => Redirect::to(&format!( "/user/settings?error=Failed to update password: {}", e )) .into_response(), } } pub async fn user_download_backup( State(state): State, jar: CookieJar, headers: HeaderMap, connect_info: Option>, ) -> Response { let (user, _) = match require_user_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let ip = get_client_ip(&headers, connect_info); 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 = { let enc = GzEncoder::new(&mut buffer, Compression::default()); let mut tar = Builder::new(enc); let files = vec!["content.db", "analytics.db", "profile.db"]; let mut add_err = None; for f in files { let path = user_dir.join(f); if path.exists() { if let Err(e) = tar.append_path_with_name(&path, f) { add_err = Some(e); break; } } } match add_err { Some(e) => Err(e), None => match tar.into_inner().and_then(|encoder| encoder.finish()) { Ok(_) => Ok(()), Err(e) => Err(e), }, } }; match res { Ok(_) => { let _ = write_audit_log( &state.admin_db.lock().unwrap(), &state, &user.username, "USER_BACKUP", Some("user"), Some(&user.id.to_string()), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()), ); let date_str = Utc::now().format("%Y-%m-%d").to_string(); let filename = format!("user-{}-bzod-backup.tar.gz", date_str); ( StatusCode::OK, [ ("Content-Type", "application/gzip"), ( "Content-Disposition", &format!("attachment; filename=\"{}\"", filename), ), ], buffer, ) .into_response() } Err(e) => { Redirect::to(&format!("/user/settings?error=Backup failed: {}", e)).into_response() } } } pub async fn user_restore_backup_post( State(state): State, jar: CookieJar, headers: HeaderMap, connect_info: Option>, mut multipart: axum::extract::Multipart, ) -> Response { let (user, session_id) = match require_user_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let ip = get_client_ip(&headers, connect_info); let mut file_bytes = Vec::new(); let mut confirm_text = String::new(); let mut csrf_token = String::new(); while let Ok(Some(field)) = multipart.next_field().await { let name = field.name().unwrap_or("").to_string(); if name == "backup_file" { if let Ok(bytes) = field.bytes().await { file_bytes = bytes.to_vec(); } } else if name == "confirm_text" { if let Ok(text) = field.text().await { confirm_text = text.trim().to_string(); } } else if name == "csrf_token" { if let Ok(token) = field.text().await { csrf_token = token.trim().to_string(); } } } if !verify_csrf(&session_id, &csrf_token) { return Redirect::to("/user/settings?error=Invalid CSRF token").into_response(); } if confirm_text != "RESTORE" { return Redirect::to("/user/settings?error=Confirmation text must be exactly 'RESTORE'") .into_response(); } if file_bytes.is_empty() { return Redirect::to("/user/settings?error=No backup file uploaded").into_response(); } let temp_file_path = std::env::temp_dir().join(format!("bzod_user_restore_{}.tar.gz", uuid::Uuid::new_v4())); if let Err(e) = std::fs::write(&temp_file_path, &file_bytes) { return Redirect::to(&format!( "/user/settings?error=Failed to write temp file: {}", e )) .into_response(); } 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())); if let Err(e) = std::fs::create_dir_all(&temp_unpack_dir) { let _ = std::fs::remove_file(&temp_file_path); return Redirect::to(&format!( "/user/settings?error=Failed to create temp dir: {}", 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); 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); match restore_res { Ok(_) => { // Success! Copy unpacked files from temp_unpack_dir to user_dir if let Err(e) = std::fs::create_dir_all(&user_dir) { let _ = std::fs::remove_dir_all(&temp_unpack_dir); return Redirect::to(&format!( "/user/settings?error=Failed to create user directory: {}", e )) .into_response(); } for file_name in &["content.db", "analytics.db", "profile.db"] { let src = temp_unpack_dir.join(file_name); if src.exists() { let dst = user_dir.join(file_name); if let Err(e) = std::fs::copy(&src, &dst) { let _ = std::fs::remove_dir_all(&temp_unpack_dir); return Redirect::to(&format!( "/user/settings?error=Failed to copy database: {}", e )) .into_response(); } } } let _ = std::fs::remove_dir_all(&temp_unpack_dir); // Reconcile quotas let users_conn = state.users_db.lock().unwrap(); if let Ok(content_conn) = rusqlite::Connection::open(user_dir.join("content.db")) { let _ = crate::db::users::reconcile_user_quotas(&users_conn, user.id, &content_conn); } let mut pool = state.user_dbs.lock().unwrap(); 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, &user.username, "USER_RESTORE", Some("user"), Some(&user.id.to_string()), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()), ); Redirect::to("/user/settings?success=User database restored successfully") .into_response() } Err(e) => { let _ = std::fs::remove_dir_all(&temp_unpack_dir); Redirect::to(&format!("/user/settings?error=Restore failed: {}", e)).into_response() } } } // GET /admin/settings #[derive(Deserialize)] pub struct SettingsQuery { pub success: Option, pub error: Option, } pub async fn settings_get( State(state): State, jar: CookieJar, Query(query): Query, ) -> Response { let (user, session_id) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let api_keys = { let conn = state.admin_db.lock().unwrap(); list_api_keys(&conn, &user.id).unwrap_or_default() }; let data_retention = { let conn = state.admin_db.lock().unwrap(); get_config(&conn, "retention_days") .unwrap_or(None) .unwrap_or_else(|| { state .config .data_retention_days .map(|d| d.to_string()) .unwrap_or_else(|| "unlimited".to_string()) }) }; let csrf_token = generate_csrf_token(&session_id); let template = crate::templates::SettingsTemplate { admin_username: user.username, api_keys, data_retention, csrf_token, success: query.success, error: query.error, }; template.into_response() } #[derive(Deserialize)] pub struct ChangePasswordForm { pub current_password: String, pub new_password: String, pub csrf_token: String, } // POST /admin/settings/password pub async fn change_password_post( State(state): State, jar: CookieJar, headers: HeaderMap, connect_info: Option>, Form(form): Form, ) -> Response { 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/settings?error=Invalid CSRF token").into_response(); } let ip = get_client_ip(&headers, connect_info); let conn = state.admin_db.lock().unwrap(); if !verify_password(&form.current_password, &user.password_hash) { let _ = write_audit_log( &conn, &state, &user.username, "PASSWORD_CHANGE_FAIL", Some("user"), Some(&user.id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()), ); return Redirect::to("/admin/settings?error=Incorrect current password").into_response(); } let new_hash = match hash_password(&form.new_password) { Ok(h) => h, Err(_) => return Redirect::to("/admin/settings?error=Hashing error").into_response(), }; let res = conn.execute( "UPDATE users SET password_hash = ?1 WHERE id = ?2;", params![new_hash, user.id], ); match res { Ok(_) => { let _ = write_audit_log( &conn, &state, &user.username, "PASSWORD_CHANGE_SUCCESS", Some("user"), Some(&user.id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()), ); Redirect::to("/admin/settings?success=Password updated successfully").into_response() } Err(e) => Redirect::to(&format!( "/admin/settings?error=Failed to update password: {}", e )) .into_response(), } } #[derive(Deserialize)] pub struct RetentionForm { pub retention: String, pub csrf_token: String, } // POST /admin/settings/retention pub async fn change_retention_post( State(state): State, jar: CookieJar, headers: HeaderMap, connect_info: Option>, Form(form): Form, ) -> Response { 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/settings?error=Invalid CSRF token").into_response(); } let ip = get_client_ip(&headers, connect_info); let conn = state.admin_db.lock().unwrap(); match set_config(&conn, "retention_days", &form.retention) { Ok(_) => { let _ = write_audit_log( &conn, &state, &user.username, "RETENTION_POLICY_CHANGED", Some("config"), Some("retention_days"), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()), ); Redirect::to("/admin/settings?success=Retention policy saved").into_response() } Err(e) => { Redirect::to(&format!("/admin/settings?error=Database error: {}", e)).into_response() } } } // POST /admin/settings/compact pub async fn compact_db_post( State(state): State, jar: CookieJar, headers: HeaderMap, connect_info: Option>, ) -> Response { let (user, _session_id) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let ip = get_client_ip(&headers, connect_info); match state.db_compact() { Ok(_) => { let conn = state.admin_db.lock().unwrap(); let _ = write_audit_log( &conn, &state, &user.username, "DATABASE_COMPACTION", Some("system"), Some("all_dbs"), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()), ); Redirect::to("/admin/settings?success=Database files compacted successfully") .into_response() } Err(e) => { Redirect::to(&format!("/admin/settings?error=Failed to compact: {}", e)).into_response() } } } // GET /admin/settings/backup pub async fn download_backup( State(state): State, jar: CookieJar, headers: HeaderMap, connect_info: Option>, ) -> Response { let (user, _) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let ip = get_client_ip(&headers, connect_info); // Create tar.gz in memory let mut buffer = Vec::new(); let res = { let enc = GzEncoder::new(&mut buffer, Compression::default()); let mut tar = Builder::new(enc); 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; let mut manifest_files = Vec::new(); for (name, path) in files { if path.exists() { 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); } } match add_err { Some(e) => Err(e), None => match tar.into_inner().and_then(|encoder| encoder.finish()) { Ok(_) => Ok(()), Err(e) => Err(e), }, } }; match res { Ok(_) => { { let conn = state.admin_db.lock().unwrap(); let _ = write_audit_log( &conn, &state, &user.username, "DATABASE_BACKUP", Some("system"), Some("tarball"), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()), ); } let date_str = Utc::now().format("%Y-%m-%d").to_string(); let filename = format!("{}-bzod-backup.tar.gz", date_str); ( StatusCode::OK, [ ("Content-Type", "application/gzip"), ( "Content-Disposition", &format!("attachment; filename=\"{}\"", filename), ), ], buffer, ) .into_response() } Err(e) => { Redirect::to(&format!("/admin/settings?error=Backup failed: {}", e)).into_response() } } } #[derive(Deserialize)] pub struct BulkQrExportForm { pub format: String, pub csrf_token: String, } // POST /admin/settings/bulk-qr pub async fn bulk_qr_export_post( State(state): State, jar: CookieJar, headers: HeaderMap, connect_info: Option>, Form(form): Form, ) -> Response { 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/settings?error=Invalid CSRF token").into_response(); } let ip = get_client_ip(&headers, connect_info); let urls = { 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() { return Redirect::to("/admin/settings?error=No shortened URLs found to export") .into_response(); } let proto = if state.config.cookie_secure { "https" } else { "http" }; let host_header = headers .get("host") .and_then(|h| h.to_str().ok()) .unwrap_or("localhost:8654"); let base_url = state .config .base_url .clone() .unwrap_or_else(|| format!("{}://{}", proto, host_header)); match crate::services::bulk::export_qr_zip(&urls, &form.format, &base_url) { Ok(zip_data) => { // Write Audit Log let _ = write_audit_log( &state.admin_db.lock().unwrap(), &state, &user.username, "BULK_QR_EXPORT", Some("bulk"), Some("qr"), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()), ); Response::builder() .header("content-type", "application/zip") .header( "content-disposition", "attachment; filename=\"qr_codes.zip\"", ) .body(axum::body::Body::from(zip_data)) .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()) } Err(e) => { Redirect::to(&format!("/admin/settings?error=Export failed: {}", e)).into_response() } } } // GET /admin/status pub async fn status_get(State(state): State, jar: CookieJar) -> Response { let (user, _) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let app_status = "Healthy"; let db_status = { let conn_ok = { let conn = state.admin_db.lock().unwrap(); get_user_count(&conn).is_ok() }; if conn_ok { format!( "Operational\n\nDatabase Files:\n{}", get_db_file_info(&state.config.data_dir) ) } else { "Degraded (Database connections failed)".to_string() } }; let queue_size = 0; let memory_usage = get_memory_usage(); let uptime_duration = state.start_time.elapsed(); let uptime = crate::utils::format_duration(uptime_duration); let urls = vec![]; let template = crate::templates::StatusTemplate { admin_username: user.username, app_status, db_status, queue_size, memory_usage, uptime, version: crate::build_info::APP_VERSION, git_commit: crate::build_info::GIT_COMMIT, urls, }; template.into_response() } // GET /user/status pub async fn user_status_get(State(state): State, jar: CookieJar) -> Response { let (user, _) = match require_user_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let user_dbs = match state.get_user_dbs(user.id) { Ok(dbs) => dbs, Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), }; let app_status = "Healthy"; let db_status = { let conn_ok = { let conn = user_dbs.content.lock().unwrap(); get_url_counts(&conn).is_ok() }; if conn_ok { "Operational".to_string() } else { "Degraded (Database connection failed)".to_string() } }; let queue_size = 0; let memory_usage = get_memory_usage(); let uptime_duration = state.start_time.elapsed(); let uptime = crate::utils::format_duration(uptime_duration); let urls = { let conn = user_dbs.content.lock().unwrap(); crate::db::content::list_urls(&conn, 50, 0, None).unwrap_or_default() }; let template = crate::templates::UserStatusTemplate { admin_username: user.username, app_status, db_status, queue_size, memory_usage, uptime, version: crate::build_info::APP_VERSION, git_commit: crate::build_info::GIT_COMMIT, urls, }; template.into_response() } // POST /admin/settings/restore pub async fn restore_backup_post( State(state): State, jar: CookieJar, headers: HeaderMap, connect_info: Option>, mut multipart: axum::extract::Multipart, ) -> Response { let (user, session_id) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let ip = get_client_ip(&headers, connect_info); let mut file_bytes = Vec::new(); let mut confirm_text = String::new(); let mut csrf_token = String::new(); while let Ok(Some(field)) = multipart.next_field().await { let name = field.name().unwrap_or("").to_string(); if name == "backup_file" { if let Ok(bytes) = field.bytes().await { file_bytes = bytes.to_vec(); } } else if name == "confirm_text" { if let Ok(text) = field.text().await { confirm_text = text.trim().to_string(); } } else if name == "csrf_token" { if let Ok(token) = field.text().await { csrf_token = token.trim().to_string(); } } } if !verify_csrf(&session_id, &csrf_token) { return Redirect::to("/admin/settings?error=Invalid CSRF token").into_response(); } if confirm_text != "RESTORE" { return Redirect::to("/admin/settings?error=Confirmation text must be exactly 'RESTORE'") .into_response(); } if file_bytes.is_empty() { return Redirect::to("/admin/settings?error=No backup file uploaded").into_response(); } // Save uploaded archive to a temporary file let temp_file_path = std::env::temp_dir().join(format!("bzod_restore_{}.tar.gz", uuid::Uuid::new_v4())); if let Err(e) = std::fs::write(&temp_file_path, &file_bytes) { return Redirect::to(&format!( "/admin/settings?error=Failed to write temp file: {}", e )) .into_response(); } // Log RESTORE_INITIATED audit event before restore { let conn = state.admin_db.lock().unwrap(); let _ = write_audit_log( &conn, &state, &user.username, "RESTORE_INITIATED", Some("system"), Some("tarball"), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()), ); } // Call the perform_restore engine inside closed connection blocks let restore_res = { // Temporarily suspend access to active SQLite connections let mut admin_conn = state.admin_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 = 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. 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); // 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_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(&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(&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; *system_conn = sys; *users_conn = usr; *urls_conn = urls; *pages_conn = pages; *reserved_conn = resv; } _ => { return Redirect::to("/admin/settings?error=Failed to reopen restored databases") .into_response(); } } res }; let _ = std::fs::remove_file(&temp_file_path); match restore_res { Ok(_) => { // Write database restore success log to newly restored admin db { let conn = state.admin_db.lock().unwrap(); let _ = write_audit_log( &conn, &state, &user.username, "DATABASE_RESTORE", Some("system"), Some("tarball"), Some(&ip), 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) => { Redirect::to(&format!("/admin/settings?error=Restore failed: {}", e)).into_response() } } }