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
This commit is contained in:
1 parent
a32c0fd7ca
commit
2761863c14
14 files changed
+433
-71
No files matched your search
+1
-1
@@ -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"
|
||||
|
||||
@@ -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<crate::web::api::ApiError>)> {
|
||||
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<crate::web::api::ApiError>)> {
|
||||
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<S> FromRequestParts<S> for ApiUser
|
||||
where
|
||||
|
||||
@@ -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<String>,
|
||||
dry_run: bool,
|
||||
force: bool,
|
||||
mut config: Config,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
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(())
|
||||
}
|
||||
+4
-2
@@ -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 {
|
||||
|
||||
@@ -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<String>,
|
||||
},
|
||||
/// FUTURE: Migrate legacy admin content to a specific admin tenant database
|
||||
AdminMigrate {
|
||||
/// Target Admin ID
|
||||
target_admin_id: i64,
|
||||
#[arg(long)]
|
||||
data_dir: Option<String>,
|
||||
/// Preview what would be moved without making changes
|
||||
#[arg(long)]
|
||||
dry_run: bool,
|
||||
/// Force the migration to execute
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
},
|
||||
}
|
||||
+18
-6
@@ -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
|
||||
);
|
||||
|
||||
+3
-7
@@ -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)?;
|
||||
|
||||
+73
-6
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -94,6 +94,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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(())
|
||||
|
||||
+5
-4
@@ -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 {
|
||||
|
||||
+109
-29
@@ -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) => {
|
||||
|
||||
+10
-2
@@ -793,7 +793,11 @@ pub struct OverallStatsResponse {
|
||||
}
|
||||
|
||||
// GET /api/v1/stats
|
||||
pub async fn api_overall_stats(State(state): State<AppState>, _user: ApiUser) -> Response {
|
||||
pub async fn api_overall_stats(State(state): State<AppState>, 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<AppState>,
|
||||
_user: ApiUser,
|
||||
user: ApiUser,
|
||||
Query(query): Query<AuditQuery>,
|
||||
) -> 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);
|
||||
|
||||
|
||||
+2
-7
@@ -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) {
|
||||
// 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 = {
|
||||
|
||||
+2
-7
@@ -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) {
|
||||
// 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 = {
|
||||
|
||||
Reference in new issue
Block a user