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
+437
-75
No files matched your search
@@ -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
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user