Release v0.5.1: namespace integrity, dashboard parity and QR hardening
This commit is contained in:
1 parent
0295b4bd7c
commit
115f6e9a23
45 files changed
+5851
-1765
No files matched your search
+67
-3
@@ -22,11 +22,23 @@ pub async fn run(
|
||||
println!("Data directory: {:?}", config.data_dir);
|
||||
println!();
|
||||
|
||||
let databases = ["admin", "content", "analytics", "system"];
|
||||
let mut all_healthy = true;
|
||||
|
||||
for db_name in &databases {
|
||||
let db_path = config.data_dir.join(format!("{}.db", db_name));
|
||||
// Define target databases in the new layout
|
||||
let admin_dir = config.data_dir.join("admin");
|
||||
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")),
|
||||
];
|
||||
|
||||
for (db_name, db_path) in dbs {
|
||||
// Skip legacy databases if they don't exist
|
||||
if db_name.starts_with("legacy") && !db_path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !db_path.exists() {
|
||||
println!("Database: {}", db_name);
|
||||
@@ -75,6 +87,58 @@ pub async fn run(
|
||||
println!();
|
||||
}
|
||||
|
||||
// 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");
|
||||
|
||||
if system_db_path.exists() && users_db_path.exists() {
|
||||
match (
|
||||
Connection::open(&system_db_path),
|
||||
Connection::open(&users_db_path),
|
||||
) {
|
||||
(Ok(sys_conn), Ok(usr_conn)) => {
|
||||
match crate::db::users::verify_global_slug_registry_integrity(
|
||||
&sys_conn,
|
||||
&usr_conn,
|
||||
&config.data_dir,
|
||||
) {
|
||||
Ok((errors, warnings)) => {
|
||||
if errors.is_empty() && warnings.is_empty() {
|
||||
println!(" Status: HEALTHY (no issues found)");
|
||||
} else {
|
||||
if !errors.is_empty() {
|
||||
println!(" Errors (Action Required):");
|
||||
for err in &errors {
|
||||
println!(" - {}", err);
|
||||
}
|
||||
all_healthy = false;
|
||||
}
|
||||
if !warnings.is_empty() {
|
||||
println!(" Warnings (Attention Needed):");
|
||||
for warn in &warnings {
|
||||
println!(" - {}", warn);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!(" Status: ERROR running integrity check: {}", e);
|
||||
all_healthy = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
println!(" Status: ERROR opening system.db or users.db for integrity check");
|
||||
all_healthy = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!(" Status: SKIPPED (system.db/users.db not found)");
|
||||
}
|
||||
println!();
|
||||
|
||||
println!("--------------------");
|
||||
if all_healthy {
|
||||
println!("Overall status: HEALTHY");
|
||||
|
||||
+83
-22
@@ -15,34 +15,95 @@ pub fn perform_restore(
|
||||
let tar_gz = GzDecoder::new(f);
|
||||
let mut archive = Archive::new(tar_gz);
|
||||
|
||||
// 2. Validate that the archive contains the expected BZOD database files
|
||||
let mut has_admin = false;
|
||||
let mut has_content = false;
|
||||
let mut has_analytics = false;
|
||||
let mut has_system = false;
|
||||
// 2. Unpack to temporary directory first
|
||||
let temp_dir =
|
||||
std::env::temp_dir().join(format!("bzod_system_restore_{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&temp_dir)?;
|
||||
|
||||
for entry_res in archive.entries()? {
|
||||
let entry = entry_res?;
|
||||
let path = entry.path()?;
|
||||
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
match file_name {
|
||||
"admin.db" => has_admin = true,
|
||||
"content.db" => has_content = true,
|
||||
"analytics.db" => has_analytics = true,
|
||||
"system.db" => has_system = true,
|
||||
_ => {}
|
||||
if let Err(e) = archive.unpack(&temp_dir) {
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
return Err(e.into());
|
||||
}
|
||||
|
||||
// 3. Run validation on temp_dir
|
||||
let mut temp_config = Config::load();
|
||||
temp_config.data_dir = temp_dir.clone();
|
||||
|
||||
// Namespace audit
|
||||
match crate::db::users::audit_slug_namespace(&temp_config) {
|
||||
Ok(report) => {
|
||||
if !report.duplicates.is_empty() {
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
return Err(
|
||||
format!("Slug conflicts detected in backup: {:?}", report.duplicates).into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
return Err(format!("Failed to audit slug namespace in backup: {}", e).into());
|
||||
}
|
||||
}
|
||||
|
||||
if !has_admin || !has_content || !has_analytics || !has_system {
|
||||
return Err("Archive is missing one or more required database files (admin.db, content.db, analytics.db, system.db)".into());
|
||||
// Registry integrity check
|
||||
let system_db_path = if temp_dir.join("admin/system.db").exists() {
|
||||
temp_dir.join("admin/system.db")
|
||||
} else {
|
||||
temp_dir.join("system.db")
|
||||
};
|
||||
let users_db_path = if temp_dir.join("admin/users.db").exists() {
|
||||
temp_dir.join("admin/users.db")
|
||||
} else {
|
||||
temp_dir.join("users.db")
|
||||
};
|
||||
|
||||
if system_db_path.exists() && users_db_path.exists() {
|
||||
let system_conn = rusqlite::Connection::open(&system_db_path)?;
|
||||
let users_conn = rusqlite::Connection::open(&users_db_path)?;
|
||||
match crate::db::users::verify_global_slug_registry_integrity(
|
||||
&system_conn,
|
||||
&users_conn,
|
||||
&temp_dir,
|
||||
) {
|
||||
Ok((errors, _warnings)) => {
|
||||
if !errors.is_empty() {
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
return Err(format!("Registry integrity errors in backup: {:?}", errors).into());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
return Err(format!("Failed to verify registry integrity in backup: {}", e).into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Unpack archive to data_dir
|
||||
let f2 = File::open(file_path)?;
|
||||
let tar_gz2 = GzDecoder::new(f2);
|
||||
let mut archive2 = Archive::new(tar_gz2);
|
||||
archive2.unpack(data_dir)?;
|
||||
// 4. If validation succeeds, copy temp_dir contents to data_dir
|
||||
if data_dir.exists() {
|
||||
let _ = std::fs::remove_dir_all(data_dir);
|
||||
}
|
||||
std::fs::create_dir_all(data_dir)?;
|
||||
|
||||
fn copy_dir_all(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(dst)?;
|
||||
for entry in std::fs::read_dir(src)? {
|
||||
let entry = entry?;
|
||||
let ty = entry.file_type()?;
|
||||
if ty.is_dir() {
|
||||
copy_dir_all(&entry.path(), &dst.join(entry.file_name()))?;
|
||||
} else {
|
||||
std::fs::copy(entry.path(), dst.join(entry.file_name()))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
if let Err(e) = copy_dir_all(&temp_dir, data_dir) {
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
return Err(format!("Failed to copy restored files: {}", e).into());
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
+7
-94
@@ -1,7 +1,5 @@
|
||||
use crate::config::Config;
|
||||
use crate::db::Db;
|
||||
use chrono::Utc;
|
||||
use rusqlite::OptionalExtension;
|
||||
use std::fs::File;
|
||||
use std::path::PathBuf;
|
||||
use tar::Archive;
|
||||
@@ -158,103 +156,18 @@ pub async fn run(
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Register slugs in global_slugs
|
||||
let restored_content_conn = rusqlite::Connection::open(dest_dir.join("content.db"))?;
|
||||
// 4. Register slugs in global_slugs using the shared helper
|
||||
{
|
||||
let mut system_conn = db.system.lock().unwrap();
|
||||
let tx = system_conn.transaction()?;
|
||||
|
||||
// Delete any existing global slugs owned by this user
|
||||
tx.execute(
|
||||
"DELETE FROM global_slugs WHERE owner_user_id = ?1;",
|
||||
[target_user_id],
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
crate::db::users::register_restored_user_slugs(
|
||||
&system_conn,
|
||||
target_user_id,
|
||||
&dest_dir.join("content.db"),
|
||||
)?;
|
||||
|
||||
// Register URLs
|
||||
{
|
||||
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 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 now = Utc::now().to_rfc3339();
|
||||
|
||||
let existing_owner: Option<i64> = tx
|
||||
.query_row(
|
||||
"SELECT owner_user_id FROM global_slugs WHERE slug = ?1;",
|
||||
[&slug],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
|
||||
if let Some(owner) = existing_owner {
|
||||
if owner != target_user_id {
|
||||
error!(
|
||||
"Conflict: Slug '{}' is already owned by user ID {}. Skipping.",
|
||||
slug, owner
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
tx.execute(
|
||||
"INSERT OR REPLACE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||
VALUES (?1, ?2, 'url', ?3, ?4, ?5, ?6);",
|
||||
rusqlite::params![slug, target_user_id, target_id, created_at, now, status],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Register Landing Pages
|
||||
{
|
||||
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 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 existing_owner: Option<i64> = tx
|
||||
.query_row(
|
||||
"SELECT owner_user_id FROM global_slugs WHERE slug = ?1;",
|
||||
[&slug],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
|
||||
if let Some(owner) = existing_owner {
|
||||
if owner != target_user_id {
|
||||
error!(
|
||||
"Conflict: Slug '{}' is already owned by user ID {}. Skipping.",
|
||||
slug, owner
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
tx.execute(
|
||||
"INSERT OR REPLACE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||
VALUES (?1, ?2, 'page', ?3, ?4, ?5, ?6);",
|
||||
rusqlite::params![slug, target_user_id, target_id, created_at, now, status],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
// 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,
|
||||
|
||||
+29
-7
@@ -33,7 +33,16 @@ pub async fn run(
|
||||
None => crate::utils::random::generate_token(3),
|
||||
};
|
||||
|
||||
// 3. Persist URL
|
||||
// 3. Register slug in system.db with status 'reserving' and check availability
|
||||
{
|
||||
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());
|
||||
}
|
||||
crate::db::users::register_global_slug(&system_conn, &code, 1, "url", "", "reserving")?;
|
||||
}
|
||||
|
||||
// 4. Persist URL
|
||||
let conn = db.content.lock().unwrap();
|
||||
let res = crate::db::content::create_url_extended(
|
||||
&conn,
|
||||
@@ -48,7 +57,21 @@ pub async fn run(
|
||||
);
|
||||
|
||||
match res {
|
||||
Ok(_) => {
|
||||
Ok(url) => {
|
||||
// Activate slug in system.db
|
||||
{
|
||||
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],
|
||||
)?;
|
||||
}
|
||||
// Increment quota for user ID 1
|
||||
{
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
crate::db::users::increment_quota_counter(&users_conn, 1, "urls")?;
|
||||
}
|
||||
|
||||
let proto = if config.cookie_secure {
|
||||
"https"
|
||||
} else {
|
||||
@@ -63,11 +86,10 @@ pub async fn run(
|
||||
println!("{}/{}", base_url, code);
|
||||
Ok(())
|
||||
}
|
||||
Err(rusqlite::Error::SqliteFailure(err, _))
|
||||
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
|
||||
{
|
||||
Err("Short code/slug already exists".into())
|
||||
Err(e) => {
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let _ = crate::db::users::release_global_slug(&system_conn, &code, 1);
|
||||
Err(e.into())
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user