Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4947489af | ||
|
|
2761863c14 | ||
|
|
a32c0fd7ca |
No files matched your search
Generated
+1
-1
@@ -345,7 +345,7 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bzod"
|
name = "bzod"
|
||||||
version = "0.5.1"
|
version = "0.5.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"askama",
|
"askama",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "bzod"
|
name = "bzod"
|
||||||
description = "Self-hosted multi-user URL management, landing page and QR analytics platform"
|
description = "Self-hosted multi-user URL management, landing page and QR analytics platform"
|
||||||
version = "0.5.1"
|
version = "0.5.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
repository = "https://github.com/thakares/nx9-url-shortener"
|
repository = "https://github.com/thakares/nx9-url-shortener"
|
||||||
|
|||||||
@@ -54,6 +54,23 @@ within a single deployable application.
|
|||||||
The goal is operational simplicity without sacrificing reliability, security, or ownership.
|
The goal is operational simplicity without sacrificing reliability, security, or ownership.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
## Runtime Efficiency (v0.5.1)
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
|---------------------|------------|
|
||||||
|
| Binary Size | 11 MB |
|
||||||
|
| RSS Memory | 11.8 MB |
|
||||||
|
| Peak RSS | 11.8 MB |
|
||||||
|
| CPU Idle | 0.02% |
|
||||||
|
| Swap Usage | 0 KB |
|
||||||
|
| PIDs | 7 |
|
||||||
|
|
||||||
|
**On a typical 32 GB server:**
|
||||||
|
- Memory usage: ~0.04%
|
||||||
|
- No swapping
|
||||||
|
- Plenty of headroom
|
||||||
|
|
||||||
|
BZOD runs closer to a lightweight infrastructure service than a typical web application.
|
||||||
|
|
||||||
# Design Philosophy
|
# Design Philosophy
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,23 @@ While v0.5.0 introduced the multi-user architecture, v0.5.1 strengthens the foun
|
|||||||
---
|
---
|
||||||
|
|
||||||
# Highlights
|
# Highlights
|
||||||
|
## Runtime Efficiency (v0.5.1)
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
|---------------------|------------|
|
||||||
|
| Binary Size | 11 MB |
|
||||||
|
| RSS Memory | 11.8 MB |
|
||||||
|
| Peak RSS | 11.8 MB |
|
||||||
|
| CPU Idle | 0.02% |
|
||||||
|
| Swap Usage | 0 KB |
|
||||||
|
| PIDs | 7 |
|
||||||
|
|
||||||
|
**On a typical 32 GB server:**
|
||||||
|
- Memory usage: ~0.04%
|
||||||
|
- No swapping
|
||||||
|
- Plenty of headroom
|
||||||
|
|
||||||
|
BZOD runs closer to a lightweight infrastructure service than a typical web application.
|
||||||
|
|
||||||
## Global Slug Registry
|
## Global Slug Registry
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,42 @@ use crate::state::AppState;
|
|||||||
use axum::{
|
use axum::{
|
||||||
extract::{FromRef, FromRequestParts},
|
extract::{FromRef, FromRequestParts},
|
||||||
http::{request::Parts, StatusCode},
|
http::{request::Parts, StatusCode},
|
||||||
|
Json,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Extractor: Authenticate API requests using Bearer token
|
// Extractor: Authenticate API requests using Bearer token
|
||||||
pub struct ApiUser(pub ApiActor);
|
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]
|
#[axum::async_trait]
|
||||||
impl<S> FromRequestParts<S> for ApiUser
|
impl<S> FromRequestParts<S> for ApiUser
|
||||||
where
|
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(())
|
||||||
|
}
|
||||||
+54
-15
@@ -26,12 +26,14 @@ pub async fn run(
|
|||||||
|
|
||||||
// Define target databases in the new layout
|
// Define target databases in the new layout
|
||||||
let admin_dir = config.data_dir.join("admin");
|
let admin_dir = config.data_dir.join("admin");
|
||||||
|
let legacy_user_dir = config.data_dir.join("users").join("1");
|
||||||
|
|
||||||
let dbs = vec![
|
let dbs = vec![
|
||||||
("admin", admin_dir.join("admin.db")),
|
("admin", admin_dir.join("admin.db")),
|
||||||
("system", admin_dir.join("system.db")),
|
("system", admin_dir.join("system.db")),
|
||||||
("users", admin_dir.join("users.db")),
|
("users", admin_dir.join("users.db")),
|
||||||
("legacy content", config.data_dir.join("content.db")),
|
("legacy content", legacy_user_dir.join("content.db")),
|
||||||
("legacy analytics", config.data_dir.join("analytics.db")),
|
("legacy analytics", legacy_user_dir.join("analytics.db")),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (db_name, db_path) in dbs {
|
for (db_name, db_path) in dbs {
|
||||||
@@ -99,32 +101,69 @@ pub async fn run(
|
|||||||
Connection::open(&users_db_path),
|
Connection::open(&users_db_path),
|
||||||
) {
|
) {
|
||||||
(Ok(sys_conn), Ok(usr_conn)) => {
|
(Ok(sys_conn), Ok(usr_conn)) => {
|
||||||
match crate::db::users::verify_global_slug_registry_integrity(
|
match crate::services::registry_validator::RegistryValidator::scan(
|
||||||
&sys_conn,
|
&sys_conn,
|
||||||
&usr_conn,
|
&usr_conn,
|
||||||
&config.data_dir,
|
&config.data_dir,
|
||||||
|
None,
|
||||||
) {
|
) {
|
||||||
Ok((errors, warnings)) => {
|
Ok(issues) => {
|
||||||
if errors.is_empty() && warnings.is_empty() {
|
if issues.is_empty() {
|
||||||
println!(" Status: HEALTHY (no issues found)");
|
println!(" Status: HEALTHY (no issues found)");
|
||||||
} else {
|
} else {
|
||||||
if !errors.is_empty() {
|
println!(" Status: ISSUES DETECTED");
|
||||||
println!(" Errors (Action Required):");
|
|
||||||
for err in &errors {
|
|
||||||
println!(" - {}", err);
|
|
||||||
}
|
|
||||||
all_healthy = false;
|
all_healthy = false;
|
||||||
|
|
||||||
|
for issue in &issues {
|
||||||
|
println!();
|
||||||
|
println!("ERROR");
|
||||||
|
println!();
|
||||||
|
println!("Slug:");
|
||||||
|
println!(" {}", issue.slug);
|
||||||
|
println!();
|
||||||
|
println!("Type:");
|
||||||
|
println!(
|
||||||
|
" {}",
|
||||||
|
if issue.target_type == "url" {
|
||||||
|
"URL"
|
||||||
|
} else if issue.target_type == "page" {
|
||||||
|
"Landing Page"
|
||||||
|
} else {
|
||||||
|
&issue.target_type
|
||||||
}
|
}
|
||||||
if !warnings.is_empty() {
|
);
|
||||||
println!(" Warnings (Attention Needed):");
|
println!();
|
||||||
for warn in &warnings {
|
println!("Owner:");
|
||||||
println!(" - {}", warn);
|
println!(" User ID {}", issue.owner_user_id);
|
||||||
|
println!();
|
||||||
|
println!("Database:");
|
||||||
|
println!(" {}", issue.database_path.display());
|
||||||
|
println!();
|
||||||
|
println!("Target UUID:");
|
||||||
|
println!(" {}", issue.target_id);
|
||||||
|
println!();
|
||||||
|
println!("Issue:");
|
||||||
|
println!(" {:?}", issue.issue_type);
|
||||||
|
println!();
|
||||||
|
println!("Description:");
|
||||||
|
println!(" {}", issue.description);
|
||||||
|
println!();
|
||||||
|
println!("Suggested Repair:");
|
||||||
|
println!();
|
||||||
|
if issue.slug != "*" {
|
||||||
|
println!(
|
||||||
|
" bzod repair registry --slug {} --dry-run",
|
||||||
|
issue.slug
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!(" bzod repair registry --dry-run");
|
||||||
}
|
}
|
||||||
|
println!("--------------------");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
println!(" Status: ERROR running integrity check: {}", e);
|
println!(" Status: ERROR running registry scan: {}", e);
|
||||||
all_healthy = false;
|
all_healthy = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-9
@@ -1,25 +1,26 @@
|
|||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
|
|
||||||
|
pub mod admin_migrate;
|
||||||
pub mod backup;
|
pub mod backup;
|
||||||
|
pub mod backup_user;
|
||||||
pub mod create_admin;
|
pub mod create_admin;
|
||||||
|
pub mod create_user;
|
||||||
|
pub mod delete_user;
|
||||||
|
pub mod disable_user;
|
||||||
pub mod doctor;
|
pub mod doctor;
|
||||||
|
pub mod enable_user;
|
||||||
pub mod expand;
|
pub mod expand;
|
||||||
|
pub mod list_users;
|
||||||
pub mod migrate;
|
pub mod migrate;
|
||||||
|
pub mod repair;
|
||||||
|
pub mod reset_password;
|
||||||
pub mod restore;
|
pub mod restore;
|
||||||
|
pub mod restore_user;
|
||||||
pub mod serve;
|
pub mod serve;
|
||||||
pub mod shorten;
|
pub mod shorten;
|
||||||
pub mod stats;
|
pub mod stats;
|
||||||
pub mod validate;
|
pub mod validate;
|
||||||
|
|
||||||
pub mod backup_user;
|
|
||||||
pub mod create_user;
|
|
||||||
pub mod delete_user;
|
|
||||||
pub mod disable_user;
|
|
||||||
pub mod enable_user;
|
|
||||||
pub mod list_users;
|
|
||||||
pub mod reset_password;
|
|
||||||
pub mod restore_user;
|
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "bzod")]
|
#[command(name = "bzod")]
|
||||||
#[command(about = "BZOD - Personal Redirector & Landing Page Platform")]
|
#[command(about = "BZOD - Personal Redirector & Landing Page Platform")]
|
||||||
@@ -165,4 +166,37 @@ pub enum Commands {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
data_dir: Option<String>,
|
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,
|
||||||
|
},
|
||||||
|
/// Repair registry and database inconsistencies
|
||||||
|
Repair {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: RepairCommands,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(clap::Subcommand)]
|
||||||
|
pub enum RepairCommands {
|
||||||
|
/// Repair Global Slug Registry inconsistencies
|
||||||
|
Registry {
|
||||||
|
#[arg(long)]
|
||||||
|
dry_run: bool,
|
||||||
|
#[arg(long)]
|
||||||
|
force: bool,
|
||||||
|
#[arg(long)]
|
||||||
|
slug: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
data_dir: Option<String>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
use crate::cli::RepairCommands;
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::services::registry_validator::{RegistryIssueType, RegistryValidator};
|
||||||
|
use rusqlite::Connection;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
pub async fn run(
|
||||||
|
command: RepairCommands,
|
||||||
|
mut config: Config,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
match command {
|
||||||
|
RepairCommands::Registry {
|
||||||
|
dry_run,
|
||||||
|
force,
|
||||||
|
slug,
|
||||||
|
data_dir,
|
||||||
|
} => {
|
||||||
|
if let Some(d) = data_dir {
|
||||||
|
config.data_dir = PathBuf::from(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !dry_run && !force {
|
||||||
|
println!("Error: You must specify either --dry-run or --force");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if dry_run && force {
|
||||||
|
println!("Error: Cannot specify both --dry-run and --force");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let start_time = std::time::Instant::now();
|
||||||
|
let admin_dir = config.data_dir.join("admin");
|
||||||
|
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() {
|
||||||
|
println!("Error: system.db or users.db not found.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut sys_conn = Connection::open(&system_db_path)?;
|
||||||
|
let usr_conn = Connection::open(&users_db_path)?;
|
||||||
|
|
||||||
|
let slug_filter = slug.as_deref();
|
||||||
|
|
||||||
|
if dry_run {
|
||||||
|
println!("BZOD Registry Repair\n");
|
||||||
|
println!("Scanning Global Slug Registry...");
|
||||||
|
|
||||||
|
let issues =
|
||||||
|
RegistryValidator::scan(&sys_conn, &usr_conn, &config.data_dir, slug_filter)?;
|
||||||
|
let orphaned = issues
|
||||||
|
.into_iter()
|
||||||
|
.filter(|i| {
|
||||||
|
matches!(
|
||||||
|
i.issue_type,
|
||||||
|
RegistryIssueType::MissingTarget
|
||||||
|
| RegistryIssueType::MissingDatabase
|
||||||
|
| RegistryIssueType::MissingOwner
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let orphaned_pages = orphaned.iter().filter(|i| i.target_type == "page").count();
|
||||||
|
let orphaned_urls = orphaned.iter().filter(|i| i.target_type == "url").count();
|
||||||
|
|
||||||
|
println!("\nDetected:");
|
||||||
|
println!("\nPages:\n {} orphaned", orphaned_pages);
|
||||||
|
println!("\nURLs:\n {} orphaned", orphaned_urls);
|
||||||
|
|
||||||
|
if !orphaned.is_empty() {
|
||||||
|
println!("\nThe following entries would be removed:");
|
||||||
|
for issue in &orphaned {
|
||||||
|
println!("\n{}\n {}", issue.target_type.to_uppercase(), issue.slug);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\nNo changes have been made.");
|
||||||
|
println!(
|
||||||
|
"\nRun again with:\n\n bzod repair registry --force{}",
|
||||||
|
if let Some(s) = slug_filter {
|
||||||
|
format!(" --slug {}", s)
|
||||||
|
} else {
|
||||||
|
"".to_string()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"Registry Repair Started. Scanned. Orphaned Pages: {}, Orphaned URLs: {}. Duration: {:?}",
|
||||||
|
orphaned_pages, orphaned_urls, start_time.elapsed()
|
||||||
|
);
|
||||||
|
} else if force {
|
||||||
|
let tx = sys_conn.transaction()?;
|
||||||
|
|
||||||
|
let issues =
|
||||||
|
RegistryValidator::scan(&tx, &usr_conn, &config.data_dir, slug_filter)?;
|
||||||
|
let orphaned = issues
|
||||||
|
.into_iter()
|
||||||
|
.filter(|i| {
|
||||||
|
matches!(
|
||||||
|
i.issue_type,
|
||||||
|
RegistryIssueType::MissingTarget
|
||||||
|
| RegistryIssueType::MissingDatabase
|
||||||
|
| RegistryIssueType::MissingOwner
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let orphaned_pages = orphaned.iter().filter(|i| i.target_type == "page").count();
|
||||||
|
let orphaned_urls = orphaned.iter().filter(|i| i.target_type == "url").count();
|
||||||
|
|
||||||
|
if orphaned.is_empty() {
|
||||||
|
println!("No repairs required.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(s) = slug_filter {
|
||||||
|
println!("Checking slug:\n\n{}\n", s);
|
||||||
|
if let Some(issue) = orphaned.first() {
|
||||||
|
println!("Owner:\n\n{}\n", issue.owner_user_id);
|
||||||
|
println!("Status:\n\nOrphaned\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut removed_count = 0;
|
||||||
|
for issue in &orphaned {
|
||||||
|
let rows = tx.execute(
|
||||||
|
"DELETE FROM global_slugs WHERE slug = ?1",
|
||||||
|
rusqlite::params![issue.slug],
|
||||||
|
)?;
|
||||||
|
removed_count += rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit()?;
|
||||||
|
|
||||||
|
if slug_filter.is_some() {
|
||||||
|
println!("Removed:\n\nSUCCESS");
|
||||||
|
} else {
|
||||||
|
println!("Repair Complete\n");
|
||||||
|
println!("Removed:\n");
|
||||||
|
println!("Pages:\n {}\n", orphaned_pages);
|
||||||
|
println!("URLs:\n {}\n", orphaned_urls);
|
||||||
|
|
||||||
|
let remaining: i64 =
|
||||||
|
sys_conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM global_slugs;", [], |r| r.get(0))?;
|
||||||
|
println!("Remaining Registry Entries:\n {}\n", remaining);
|
||||||
|
|
||||||
|
let post_issues =
|
||||||
|
RegistryValidator::scan(&sys_conn, &usr_conn, &config.data_dir, None)?;
|
||||||
|
let post_orphaned = post_issues
|
||||||
|
.iter()
|
||||||
|
.filter(|i| {
|
||||||
|
matches!(
|
||||||
|
i.issue_type,
|
||||||
|
RegistryIssueType::MissingTarget
|
||||||
|
| RegistryIssueType::MissingDatabase
|
||||||
|
| RegistryIssueType::MissingOwner
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"Integrity:\n {}",
|
||||||
|
if post_orphaned == 0 { "PASS" } else { "FAIL" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"Registry Repair Started. Scanned. Orphaned Pages: {}, Orphaned URLs: {}. Removed: {}. Duration: {:?}",
|
||||||
|
orphaned_pages, orphaned_urls, removed_count, start_time.elapsed()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
+9
-4
@@ -60,15 +60,20 @@ pub fn perform_restore(
|
|||||||
if system_db_path.exists() && users_db_path.exists() {
|
if system_db_path.exists() && users_db_path.exists() {
|
||||||
let system_conn = rusqlite::Connection::open(&system_db_path)?;
|
let system_conn = rusqlite::Connection::open(&system_db_path)?;
|
||||||
let users_conn = rusqlite::Connection::open(&users_db_path)?;
|
let users_conn = rusqlite::Connection::open(&users_db_path)?;
|
||||||
match crate::db::users::verify_global_slug_registry_integrity(
|
match crate::services::registry_validator::RegistryValidator::scan(
|
||||||
&system_conn,
|
&system_conn,
|
||||||
&users_conn,
|
&users_conn,
|
||||||
&temp_dir,
|
&temp_dir,
|
||||||
|
None,
|
||||||
) {
|
) {
|
||||||
Ok((errors, _warnings)) => {
|
Ok(issues) => {
|
||||||
if !errors.is_empty() {
|
if !issues.is_empty() {
|
||||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||||
return Err(format!("Registry integrity errors in backup: {:?}", errors).into());
|
return Err(format!(
|
||||||
|
"Registry integrity errors in backup: {} issues detected",
|
||||||
|
issues.len()
|
||||||
|
)
|
||||||
|
.into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
+18
-6
@@ -14,14 +14,26 @@ pub async fn run(
|
|||||||
println!("=== BZOD Database Stats ===");
|
println!("=== BZOD Database Stats ===");
|
||||||
println!("Storage Directory: {:?}", config.data_dir);
|
println!("Storage Directory: {:?}", config.data_dir);
|
||||||
|
|
||||||
let files = vec!["admin.db", "content.db", "analytics.db", "system.db"];
|
let files = vec![
|
||||||
for f in files {
|
("admin.db", config.data_dir.join("admin/admin.db")),
|
||||||
let p = config.data_dir.join(f);
|
("system.db", config.data_dir.join("admin/system.db")),
|
||||||
if p.exists() {
|
("users.db", config.data_dir.join("admin/users.db")),
|
||||||
let sz = std::fs::metadata(&p)?.len();
|
(
|
||||||
|
"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!(
|
println!(
|
||||||
" File: {} - Size: {} bytes ({:.2} MB)",
|
" File: {} - Size: {} bytes ({:.2} MB)",
|
||||||
f,
|
name,
|
||||||
sz,
|
sz,
|
||||||
sz as f64 / 1_048_576.0
|
sz as f64 / 1_048_576.0
|
||||||
);
|
);
|
||||||
|
|||||||
+12
-14
@@ -50,7 +50,7 @@ impl Db {
|
|||||||
|
|
||||||
// 1. If legacy admin.db exists at root, move admin/system DBs to config.data_dir/admin/
|
// 1. If legacy admin.db exists at root, move admin/system DBs to config.data_dir/admin/
|
||||||
if legacy_admin_db.exists() {
|
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![
|
let files = vec![
|
||||||
"admin.db",
|
"admin.db",
|
||||||
"admin.db-wal",
|
"admin.db-wal",
|
||||||
@@ -219,7 +219,7 @@ impl Db {
|
|||||||
fs::create_dir_all(&legacy_user_dir)?;
|
fs::create_dir_all(&legacy_user_dir)?;
|
||||||
|
|
||||||
if legacy_content_db.exists() || legacy_analytics_db.exists() {
|
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"];
|
let content_files = vec!["content.db", "content.db-wal", "content.db-shm"];
|
||||||
for f in content_files {
|
for f in content_files {
|
||||||
let src = config.data_dir.join(f);
|
let src = config.data_dir.join(f);
|
||||||
@@ -356,17 +356,19 @@ impl Db {
|
|||||||
{
|
{
|
||||||
let system_conn = db.system.lock().unwrap();
|
let system_conn = db.system.lock().unwrap();
|
||||||
let users_conn = db.users.lock().unwrap();
|
let users_conn = db.users.lock().unwrap();
|
||||||
match crate::db::users::verify_global_slug_registry_integrity(
|
match crate::services::registry_validator::RegistryValidator::scan(
|
||||||
&system_conn,
|
&system_conn,
|
||||||
&users_conn,
|
&users_conn,
|
||||||
&config.data_dir,
|
&config.data_dir,
|
||||||
|
None,
|
||||||
) {
|
) {
|
||||||
Ok((errors, warnings)) => {
|
Ok(issues) => {
|
||||||
for err in errors {
|
for issue in issues {
|
||||||
tracing::error!("Global registry integrity error: {}", err);
|
tracing::error!(
|
||||||
}
|
"Global registry integrity issue: {:?} for slug {}",
|
||||||
for warn in warnings {
|
issue.issue_type,
|
||||||
tracing::warn!("Global registry integrity warning: {}", warn);
|
issue.slug
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -461,11 +463,7 @@ impl Db {
|
|||||||
|
|
||||||
for user_id in user_ids {
|
for user_id in user_ids {
|
||||||
let user_dir = config.data_dir.join("users").join(user_id.to_string());
|
let user_dir = config.data_dir.join("users").join(user_id.to_string());
|
||||||
let content_path = if user_id == 1 {
|
let content_path = user_dir.join("content.db");
|
||||||
config.data_dir.join("content.db") // legacy admin content db path
|
|
||||||
} else {
|
|
||||||
user_dir.join("content.db")
|
|
||||||
};
|
|
||||||
|
|
||||||
if content_path.exists() {
|
if content_path.exists() {
|
||||||
let content_conn = Connection::open(&content_path)?;
|
let content_conn = Connection::open(&content_path)?;
|
||||||
|
|||||||
-143
@@ -722,149 +722,6 @@ pub fn cleanup_stale_reservations(
|
|||||||
Ok(cleaned_count)
|
Ok(cleaned_count)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn verify_global_slug_registry_integrity(
|
|
||||||
system_conn: &Connection,
|
|
||||||
users_conn: &Connection,
|
|
||||||
data_dir: &std::path::Path,
|
|
||||||
) -> Result<(Vec<String>, Vec<String>), Box<dyn std::error::Error>> {
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
let mut errors = Vec::new();
|
|
||||||
let mut warnings = Vec::new();
|
|
||||||
|
|
||||||
// 1. Check duplicate slugs
|
|
||||||
let total_count: i64 =
|
|
||||||
system_conn.query_row("SELECT COUNT(*) FROM global_slugs;", [], |r| r.get(0))?;
|
|
||||||
let distinct_count: i64 =
|
|
||||||
system_conn.query_row("SELECT COUNT(DISTINCT slug) FROM global_slugs;", [], |r| {
|
|
||||||
r.get(0)
|
|
||||||
})?;
|
|
||||||
if total_count != distinct_count {
|
|
||||||
errors.push(format!(
|
|
||||||
"Duplicate slugs found in global_slugs table (total rows: {}, distinct slugs: {})",
|
|
||||||
total_count, distinct_count
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Scan all global slugs
|
|
||||||
let mut stmt = system_conn.prepare(
|
|
||||||
"SELECT slug, owner_user_id, target_type, target_id, created_at, status FROM global_slugs;",
|
|
||||||
)?;
|
|
||||||
let mut rows = stmt.query([])?;
|
|
||||||
|
|
||||||
while let Some(row) = rows.next()? {
|
|
||||||
let slug: String = row.get(0)?;
|
|
||||||
let owner_user_id: i64 = row.get(1)?;
|
|
||||||
let target_type: String = row.get(2)?;
|
|
||||||
let target_id: String = row.get(3)?;
|
|
||||||
let created_at_str: String = row.get(4)?;
|
|
||||||
let status: String = row.get(5)?;
|
|
||||||
|
|
||||||
// Target type check
|
|
||||||
if target_type != "url" && target_type != "page" {
|
|
||||||
errors.push(format!(
|
|
||||||
"Slug '{}' has invalid target_type '{}'",
|
|
||||||
slug, target_type
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Status check
|
|
||||||
if status != "active" && status != "disabled" && status != "reserving" {
|
|
||||||
errors.push(format!("Slug '{}' has invalid status '{}'", slug, status));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check owner
|
|
||||||
let owner_exists: bool = users_conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
|
|
||||||
[owner_user_id],
|
|
||||||
|r| r.get(0),
|
|
||||||
)
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
if !owner_exists {
|
|
||||||
errors.push(format!(
|
|
||||||
"Slug '{}' references missing owner user ID {}",
|
|
||||||
slug, owner_user_id
|
|
||||||
));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stale warning check
|
|
||||||
if status == "reserving" {
|
|
||||||
if let Ok(created_at) = DateTime::parse_from_rfc3339(&created_at_str) {
|
|
||||||
let age = Utc::now().signed_duration_since(created_at.with_timezone(&Utc));
|
|
||||||
if age > chrono::Duration::minutes(15) {
|
|
||||||
warnings.push(format!(
|
|
||||||
"Reserving slug '{}' has been stale for over 15 minutes",
|
|
||||||
slug
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check target record exists for active / disabled (and reserving with target_id)
|
|
||||||
if status == "active"
|
|
||||||
|| status == "disabled"
|
|
||||||
|| (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")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
data_dir
|
|
||||||
.join("users")
|
|
||||||
.join(owner_user_id.to_string())
|
|
||||||
.join("content.db")
|
|
||||||
};
|
|
||||||
|
|
||||||
if !content_db_path.exists() {
|
|
||||||
errors.push(format!(
|
|
||||||
"Slug '{}' owner content database does not exist at {:?}",
|
|
||||||
slug, content_db_path
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
match Connection::open(&content_db_path) {
|
|
||||||
Ok(conn) => {
|
|
||||||
let exists = if target_type == "url" {
|
|
||||||
conn.query_row(
|
|
||||||
"SELECT EXISTS(SELECT 1 FROM urls WHERE id = ?1);",
|
|
||||||
[&target_id],
|
|
||||||
|r| r.get(0),
|
|
||||||
)
|
|
||||||
.unwrap_or(false)
|
|
||||||
} else if target_type == "page" {
|
|
||||||
conn.query_row(
|
|
||||||
"SELECT EXISTS(SELECT 1 FROM landing_pages WHERE id = ?1);",
|
|
||||||
[&target_id],
|
|
||||||
|r| r.get(0),
|
|
||||||
)
|
|
||||||
.unwrap_or(false)
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
};
|
|
||||||
|
|
||||||
if !exists {
|
|
||||||
errors.push(format!("Slug '{}' (type: '{}', id: '{}') references missing target record in owner's content database", slug, target_type, target_id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
errors.push(format!(
|
|
||||||
"Slug '{}' owner content database could not be opened: {}",
|
|
||||||
slug, e
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((errors, warnings))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn register_restored_user_slugs(
|
pub fn register_restored_user_slugs(
|
||||||
system_conn: &Connection,
|
system_conn: &Connection,
|
||||||
target_user_id: i64,
|
target_user_id: i64,
|
||||||
|
|||||||
+12
@@ -94,6 +94,18 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
Commands::RestoreUser { file, data_dir } => {
|
Commands::RestoreUser { file, data_dir } => {
|
||||||
bzod::cli::restore_user::run(file, data_dir, config).await?;
|
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?;
|
||||||
|
}
|
||||||
|
Commands::Repair { command } => {
|
||||||
|
bzod::cli::repair::run(command, config).await?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ pub mod audit;
|
|||||||
pub mod bulk;
|
pub mod bulk;
|
||||||
pub mod landing_pages;
|
pub mod landing_pages;
|
||||||
pub mod qr;
|
pub mod qr;
|
||||||
|
pub mod registry_validator;
|
||||||
pub mod shortener;
|
pub mod shortener;
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
use rusqlite::Connection;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum RegistryIssueType {
|
||||||
|
DuplicateSlug,
|
||||||
|
InvalidTargetType,
|
||||||
|
InvalidStatus,
|
||||||
|
MissingOwner,
|
||||||
|
MissingDatabase,
|
||||||
|
MissingTarget,
|
||||||
|
StaleReservation,
|
||||||
|
TenantAdminHasIsolatedContent,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RegistryIssue {
|
||||||
|
pub slug: String,
|
||||||
|
pub target_type: String,
|
||||||
|
pub owner_user_id: i64,
|
||||||
|
pub database_path: PathBuf,
|
||||||
|
pub target_id: String,
|
||||||
|
pub issue_type: RegistryIssueType,
|
||||||
|
pub description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RegistryValidator;
|
||||||
|
|
||||||
|
impl RegistryValidator {
|
||||||
|
/// Scans the global_slugs registry and returns a list of detected issues.
|
||||||
|
pub fn scan(
|
||||||
|
system_conn: &Connection,
|
||||||
|
users_conn: &Connection,
|
||||||
|
data_dir: &Path,
|
||||||
|
slug_filter: Option<&str>,
|
||||||
|
) -> Result<Vec<RegistryIssue>, Box<dyn std::error::Error>> {
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
let mut issues = Vec::new();
|
||||||
|
|
||||||
|
// 1. Check duplicate slugs (only if not filtering by single slug)
|
||||||
|
if slug_filter.is_none() {
|
||||||
|
let total_count: i64 =
|
||||||
|
system_conn.query_row("SELECT COUNT(*) FROM global_slugs;", [], |r| r.get(0))?;
|
||||||
|
let distinct_count: i64 = system_conn.query_row(
|
||||||
|
"SELECT COUNT(DISTINCT slug) FROM global_slugs;",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)?;
|
||||||
|
if total_count != distinct_count {
|
||||||
|
issues.push(RegistryIssue {
|
||||||
|
slug: "*".to_string(),
|
||||||
|
target_type: "system".to_string(),
|
||||||
|
owner_user_id: 0,
|
||||||
|
database_path: data_dir.join("admin/system.db"),
|
||||||
|
target_id: "".to_string(),
|
||||||
|
issue_type: RegistryIssueType::DuplicateSlug,
|
||||||
|
description: format!(
|
||||||
|
"Duplicate slugs found in global_slugs table (total rows: {}, distinct slugs: {})",
|
||||||
|
total_count, distinct_count
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Scan global slugs
|
||||||
|
let (query, params_string) = if let Some(slug) = slug_filter {
|
||||||
|
(
|
||||||
|
"SELECT slug, owner_user_id, target_type, target_id, created_at, status FROM global_slugs WHERE slug = ?1;",
|
||||||
|
vec![slug.to_string()],
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
"SELECT slug, owner_user_id, target_type, target_id, created_at, status FROM global_slugs;",
|
||||||
|
vec![],
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut stmt = system_conn.prepare(query)?;
|
||||||
|
let mut rows = stmt.query(rusqlite::params_from_iter(params_string))?;
|
||||||
|
|
||||||
|
while let Some(row) = rows.next()? {
|
||||||
|
let slug: String = row.get(0)?;
|
||||||
|
let owner_user_id: i64 = row.get(1)?;
|
||||||
|
let target_type: String = row.get(2)?;
|
||||||
|
let target_id: String = row.get(3)?;
|
||||||
|
let created_at_str: String = row.get(4)?;
|
||||||
|
let status: String = row.get(5)?;
|
||||||
|
|
||||||
|
let content_db_path = if owner_user_id == 1 {
|
||||||
|
data_dir.join("users").join("1").join("content.db")
|
||||||
|
} else {
|
||||||
|
data_dir
|
||||||
|
.join("users")
|
||||||
|
.join(owner_user_id.to_string())
|
||||||
|
.join("content.db")
|
||||||
|
};
|
||||||
|
|
||||||
|
// Target type check
|
||||||
|
if target_type != "url" && target_type != "page" {
|
||||||
|
issues.push(RegistryIssue {
|
||||||
|
slug: slug.clone(),
|
||||||
|
target_type: target_type.clone(),
|
||||||
|
owner_user_id,
|
||||||
|
database_path: content_db_path.clone(),
|
||||||
|
target_id: target_id.clone(),
|
||||||
|
issue_type: RegistryIssueType::InvalidTargetType,
|
||||||
|
description: format!(
|
||||||
|
"Slug '{}' has invalid target_type '{}'",
|
||||||
|
slug, target_type
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status check
|
||||||
|
if status != "active" && status != "disabled" && status != "reserving" {
|
||||||
|
issues.push(RegistryIssue {
|
||||||
|
slug: slug.clone(),
|
||||||
|
target_type: target_type.clone(),
|
||||||
|
owner_user_id,
|
||||||
|
database_path: content_db_path.clone(),
|
||||||
|
target_id: target_id.clone(),
|
||||||
|
issue_type: RegistryIssueType::InvalidStatus,
|
||||||
|
description: format!("Slug '{}' has invalid status '{}'", slug, status),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check owner
|
||||||
|
let owner_exists: bool = users_conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
|
||||||
|
[owner_user_id],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if !owner_exists {
|
||||||
|
issues.push(RegistryIssue {
|
||||||
|
slug: slug.clone(),
|
||||||
|
target_type: target_type.clone(),
|
||||||
|
owner_user_id,
|
||||||
|
database_path: content_db_path.clone(),
|
||||||
|
target_id: target_id.clone(),
|
||||||
|
issue_type: RegistryIssueType::MissingOwner,
|
||||||
|
description: format!(
|
||||||
|
"Slug '{}' references missing owner user ID {}",
|
||||||
|
slug, owner_user_id
|
||||||
|
),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stale warning check
|
||||||
|
if status == "reserving" {
|
||||||
|
if let Ok(created_at) = DateTime::parse_from_rfc3339(&created_at_str) {
|
||||||
|
let age = Utc::now().signed_duration_since(created_at.with_timezone(&Utc));
|
||||||
|
if age > chrono::Duration::try_minutes(15).unwrap_or_default() {
|
||||||
|
issues.push(RegistryIssue {
|
||||||
|
slug: slug.clone(),
|
||||||
|
target_type: target_type.clone(),
|
||||||
|
owner_user_id,
|
||||||
|
database_path: content_db_path.clone(),
|
||||||
|
target_id: target_id.clone(),
|
||||||
|
issue_type: RegistryIssueType::StaleReservation,
|
||||||
|
description: format!(
|
||||||
|
"Reserving slug '{}' has been stale for over 15 minutes",
|
||||||
|
slug
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check target record exists for active / disabled (and reserving with target_id)
|
||||||
|
if status == "active"
|
||||||
|
|| status == "disabled"
|
||||||
|
|| (status == "reserving" && !target_id.is_empty())
|
||||||
|
{
|
||||||
|
if !content_db_path.exists() {
|
||||||
|
issues.push(RegistryIssue {
|
||||||
|
slug: slug.clone(),
|
||||||
|
target_type: target_type.clone(),
|
||||||
|
owner_user_id,
|
||||||
|
database_path: content_db_path.clone(),
|
||||||
|
target_id: target_id.clone(),
|
||||||
|
issue_type: RegistryIssueType::MissingDatabase,
|
||||||
|
description: format!(
|
||||||
|
"Slug '{}' owner content database does not exist at {:?}",
|
||||||
|
slug, content_db_path
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
match Connection::open(&content_db_path) {
|
||||||
|
Ok(conn) => {
|
||||||
|
let exists = if target_type == "url" {
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM urls WHERE id = ?1);",
|
||||||
|
[&target_id],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap_or(false)
|
||||||
|
} else if target_type == "page" {
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM landing_pages WHERE id = ?1);",
|
||||||
|
[&target_id],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap_or(false)
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
issues.push(RegistryIssue {
|
||||||
|
slug: slug.clone(),
|
||||||
|
target_type: target_type.clone(),
|
||||||
|
owner_user_id,
|
||||||
|
database_path: content_db_path.clone(),
|
||||||
|
target_id: target_id.clone(),
|
||||||
|
issue_type: RegistryIssueType::MissingTarget,
|
||||||
|
description: format!("Slug '{}' (type: '{}', id: '{}') references missing target record in owner's content database", slug, target_type, target_id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
issues.push(RegistryIssue {
|
||||||
|
slug: slug.clone(),
|
||||||
|
target_type: target_type.clone(),
|
||||||
|
owner_user_id,
|
||||||
|
database_path: content_db_path.clone(),
|
||||||
|
target_id: target_id.clone(),
|
||||||
|
issue_type: RegistryIssueType::MissingDatabase,
|
||||||
|
description: format!(
|
||||||
|
"Slug '{}' owner content database could not be opened: {}",
|
||||||
|
slug, e
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Admin Content Reverse Consistency Check (Legacy DB)
|
||||||
|
// Check if tenant databases contain content for admin users incorrectly (isolated admin content)
|
||||||
|
if slug_filter.is_none() {
|
||||||
|
let mut stmt = users_conn.prepare(
|
||||||
|
"SELECT id, username FROM users WHERE account_type = 'admin' AND id != 1;",
|
||||||
|
)?;
|
||||||
|
let mut admin_rows = stmt.query([])?;
|
||||||
|
while let Some(row) = admin_rows.next()? {
|
||||||
|
let id: i64 = row.get(0)?;
|
||||||
|
let username: String = row.get(1)?;
|
||||||
|
let tenant_db_path = data_dir
|
||||||
|
.join("users")
|
||||||
|
.join(id.to_string())
|
||||||
|
.join("content.db");
|
||||||
|
|
||||||
|
if tenant_db_path.exists() {
|
||||||
|
if let Ok(tenant_conn) = Connection::open(&tenant_db_path) {
|
||||||
|
let url_count: i64 = tenant_conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM urls;", [], |r| r.get(0))
|
||||||
|
.unwrap_or(0);
|
||||||
|
let page_count: i64 = tenant_conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM landing_pages;", [], |r| r.get(0))
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
if url_count > 0 || page_count > 0 {
|
||||||
|
issues.push(RegistryIssue {
|
||||||
|
slug: "*".to_string(),
|
||||||
|
target_type: "system".to_string(),
|
||||||
|
owner_user_id: id,
|
||||||
|
database_path: tenant_db_path.clone(),
|
||||||
|
target_id: "".to_string(),
|
||||||
|
issue_type: RegistryIssueType::TenantAdminHasIsolatedContent,
|
||||||
|
description: 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(issues)
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-4
@@ -19,10 +19,11 @@ pub fn get_memory_usage() -> String {
|
|||||||
pub fn get_db_file_info(data_dir: &Path) -> String {
|
pub fn get_db_file_info(data_dir: &Path) -> String {
|
||||||
let mut stats = String::new();
|
let mut stats = String::new();
|
||||||
let files = vec![
|
let files = vec![
|
||||||
("admin.db", "Admin DB"),
|
("admin/admin.db", "Admin DB"),
|
||||||
("content.db", "Content DB"),
|
("admin/system.db", "System DB"),
|
||||||
("analytics.db", "Analytics DB"),
|
("admin/users.db", "Users DB"),
|
||||||
("system.db", "System DB"),
|
("users/1/content.db", "Legacy Content DB"),
|
||||||
|
("users/1/analytics.db", "Legacy Analytics DB"),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (f, name) in files {
|
for (f, name) in files {
|
||||||
|
|||||||
+137
-37
@@ -1872,14 +1872,10 @@ pub async fn urls_create(
|
|||||||
return Redirect::to("/admin/urls?error=Short code/slug already exists")
|
return Redirect::to("/admin/urls?error=Short code/slug already exists")
|
||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
if let Err(e) = crate::db::users::register_global_slug(
|
// Always use owner_user_id = 1 for admin content so it resolves via state.content_db
|
||||||
&system_conn,
|
if let Err(e) =
|
||||||
&code,
|
crate::db::users::register_global_slug(&system_conn, &code, 1, "url", "", "reserving")
|
||||||
admin_user_id,
|
{
|
||||||
"url",
|
|
||||||
"",
|
|
||||||
"reserving",
|
|
||||||
) {
|
|
||||||
return Redirect::to(&format!("/admin/urls?error=Failed to reserve slug: {}", e))
|
return Redirect::to(&format!("/admin/urls?error=Failed to reserve slug: {}", e))
|
||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
@@ -1931,7 +1927,7 @@ pub async fn urls_create(
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let system_conn = state.system_db.lock().unwrap();
|
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()
|
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) {
|
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();
|
return Redirect::to("/admin/pages?error=Short code already exists").into_response();
|
||||||
}
|
}
|
||||||
if let Err(e) = crate::db::users::register_global_slug(
|
// Always use owner_user_id = 1 for admin content so it resolves via state.content_db
|
||||||
&system_conn,
|
if let Err(e) =
|
||||||
&code,
|
crate::db::users::register_global_slug(&system_conn, &code, 1, "page", "", "reserving")
|
||||||
admin_user_id,
|
{
|
||||||
"page",
|
|
||||||
"",
|
|
||||||
"reserving",
|
|
||||||
) {
|
|
||||||
return Redirect::to(&format!("/admin/pages?error=Failed to reserve slug: {}", e))
|
return Redirect::to(&format!("/admin/pages?error=Failed to reserve slug: {}", e))
|
||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
@@ -2508,7 +2500,7 @@ pub async fn pages_create(
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let system_conn = state.system_db.lock().unwrap();
|
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()
|
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 enc = GzEncoder::new(&mut buffer, Compression::default());
|
||||||
let mut tar = Builder::new(enc);
|
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;
|
let mut add_err = None;
|
||||||
for f in files {
|
let mut manifest_files = Vec::new();
|
||||||
let path = state.config.data_dir.join(f);
|
|
||||||
|
for (name, path) in files {
|
||||||
if path.exists() {
|
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);
|
add_err = Some(e);
|
||||||
break;
|
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
|
// 2. Perform restore unpacking/validation
|
||||||
let res = crate::cli::restore::perform_restore(&temp_file_path, &state.config.data_dir);
|
let res = crate::cli::restore::perform_restore(&temp_file_path, &state.config.data_dir);
|
||||||
|
|
||||||
// 3. Reinitialize database connections
|
// 3. Post-Restore Path Normalization (Move flat files to multi-tenant structure)
|
||||||
let new_admin = rusqlite::Connection::open(state.config.data_dir.join("admin.db"));
|
let admin_dir = state.config.data_dir.join("admin");
|
||||||
let new_content = rusqlite::Connection::open(state.config.data_dir.join("content.db"));
|
let users_1_dir = state.config.data_dir.join("users").join("1");
|
||||||
let new_analytics = rusqlite::Connection::open(state.config.data_dir.join("analytics.db"));
|
let _ = std::fs::create_dir_all(&admin_dir);
|
||||||
let new_system = rusqlite::Connection::open(state.config.data_dir.join("system.db"));
|
let _ = std::fs::create_dir_all(&users_1_dir);
|
||||||
|
|
||||||
match (new_admin, new_content, new_analytics, new_system) {
|
let admin_files = vec![
|
||||||
(Ok(adm), Ok(cnt), Ok(any), Ok(sys)) => {
|
"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(&adm, "admin");
|
||||||
let _ = crate::db::sqlite::enable_wal(&cnt, "content");
|
let _ = crate::db::sqlite::enable_wal(&cnt, "content");
|
||||||
let _ = crate::db::sqlite::enable_wal(&any, "analytics");
|
let _ = crate::db::sqlite::enable_wal(&any, "analytics");
|
||||||
let _ = crate::db::sqlite::enable_wal(&sys, "system");
|
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(&adm, "admin");
|
||||||
let _ = crate::db::sqlite::enable_foreign_keys(&cnt, "content");
|
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(&any, "analytics");
|
||||||
let _ = crate::db::sqlite::enable_foreign_keys(&sys, "system");
|
let _ = crate::db::sqlite::enable_foreign_keys(&sys, "system");
|
||||||
|
let _ = crate::db::sqlite::enable_foreign_keys(&usr, "users");
|
||||||
|
|
||||||
*admin_conn = adm;
|
*admin_conn = adm;
|
||||||
*content_conn = cnt;
|
*content_conn = cnt;
|
||||||
*analytics_conn = any;
|
*analytics_conn = any;
|
||||||
*system_conn = sys;
|
*system_conn = sys;
|
||||||
|
*state.db.users.lock().unwrap() = usr;
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
return Redirect::to("/admin/settings?error=Failed to reopen restored databases")
|
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()),
|
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()
|
Redirect::to("/admin/login").into_response()
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -5578,17 +5658,37 @@ pub async fn health_get(
|
|||||||
let (registry_errors, registry_warnings) = {
|
let (registry_errors, registry_warnings) = {
|
||||||
let system_conn = state.system_db.lock().unwrap();
|
let system_conn = state.system_db.lock().unwrap();
|
||||||
let users_conn = state.users_db.lock().unwrap();
|
let users_conn = state.users_db.lock().unwrap();
|
||||||
crate::db::users::verify_global_slug_registry_integrity(
|
match crate::services::registry_validator::RegistryValidator::scan(
|
||||||
&system_conn,
|
&system_conn,
|
||||||
&users_conn,
|
&users_conn,
|
||||||
&state.config.data_dir,
|
&state.config.data_dir,
|
||||||
)
|
None,
|
||||||
.unwrap_or_else(|e| {
|
) {
|
||||||
(
|
Ok(issues) => {
|
||||||
vec![format!("Failed to run integrity check: {}", e)],
|
let mut errors = Vec::new();
|
||||||
vec![],
|
let mut warnings = Vec::new();
|
||||||
)
|
for issue in issues {
|
||||||
})
|
use crate::services::registry_validator::RegistryIssueType;
|
||||||
|
match issue.issue_type {
|
||||||
|
RegistryIssueType::StaleReservation
|
||||||
|
| RegistryIssueType::TenantAdminHasIsolatedContent => {
|
||||||
|
warnings.push(format!(
|
||||||
|
"Warning for slug {}: {}",
|
||||||
|
issue.slug, issue.description
|
||||||
|
));
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
errors.push(format!(
|
||||||
|
"Error for slug {}: {}",
|
||||||
|
issue.slug, issue.description
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(errors, warnings)
|
||||||
|
}
|
||||||
|
Err(e) => (vec![format!("Failed to run registry scan: {}", e)], vec![]),
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let template = crate::templates::HealthTemplate {
|
let template = crate::templates::HealthTemplate {
|
||||||
|
|||||||
+10
-2
@@ -793,7 +793,11 @@ pub struct OverallStatsResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/v1/stats
|
// 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 (total_urls, active_links, dead_links) = {
|
||||||
let conn = state.content_db.lock().unwrap();
|
let conn = state.content_db.lock().unwrap();
|
||||||
get_url_counts(&conn).unwrap_or((0, 0, 0))
|
get_url_counts(&conn).unwrap_or((0, 0, 0))
|
||||||
@@ -982,9 +986,13 @@ pub struct AuditQuery {
|
|||||||
// GET /api/v1/audit
|
// GET /api/v1/audit
|
||||||
pub async fn api_list_audit(
|
pub async fn api_list_audit(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
_user: ApiUser,
|
user: ApiUser,
|
||||||
Query(query): Query<AuditQuery>,
|
Query(query): Query<AuditQuery>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
|
if let Err(err) = user.require_admin() {
|
||||||
|
return err.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
let limit = query.limit.unwrap_or(50);
|
let limit = query.limit.unwrap_or(50);
|
||||||
let offset = query.offset.unwrap_or(0);
|
let offset = query.offset.unwrap_or(0);
|
||||||
|
|
||||||
|
|||||||
+2
-7
@@ -63,15 +63,10 @@ pub async fn resolve_page(
|
|||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Get content database connection - admin (user_id=1) uses legacy content_db,
|
// 2. Get content database connection via tenant DB resolution
|
||||||
// tenant users use per-user content databases
|
let content_conn = match state.get_user_dbs(owner_user_id) {
|
||||||
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,
|
Ok(dbs) => dbs.content,
|
||||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let page_opt = {
|
let page_opt = {
|
||||||
|
|||||||
+2
-7
@@ -74,15 +74,10 @@ pub async fn resolve_redirect(
|
|||||||
return Redirect::permanent(&format!("/p/{}", code)).into_response();
|
return Redirect::permanent(&format!("/p/{}", code)).into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Get content database connection - admin (user_id=1) uses legacy content_db,
|
// 2. Get content database connection via tenant DB resolution
|
||||||
// tenant users use per-user content databases
|
let content_conn = match state.get_user_dbs(owner_user_id) {
|
||||||
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,
|
Ok(dbs) => dbs.content,
|
||||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let url_opt = {
|
let url_opt = {
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
use bzod::cli::RepairCommands;
|
||||||
|
use bzod::config::Config;
|
||||||
|
use bzod::db::Db;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
fn create_temp_config(temp_dir: PathBuf) -> Config {
|
||||||
|
let mut config = Config::load();
|
||||||
|
config.data_dir = temp_dir.clone();
|
||||||
|
config.backup_dir = temp_dir.clone();
|
||||||
|
config.base_url = Some("http://bzo.in".to_string());
|
||||||
|
config
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_registry_repair_dry_run() {
|
||||||
|
let temp_dir =
|
||||||
|
std::env::temp_dir().join(format!("bzod_test_repair_dry_{}", uuid::Uuid::new_v4()));
|
||||||
|
fs::create_dir_all(&temp_dir).unwrap();
|
||||||
|
let config = create_temp_config(temp_dir.clone());
|
||||||
|
let db = Db::init(&config).expect("Failed to init Db");
|
||||||
|
|
||||||
|
{
|
||||||
|
let system_conn = db.system.lock().unwrap();
|
||||||
|
// Insert orphaned slug (owner exists, but no target db/record)
|
||||||
|
bzod::db::users::register_global_slug(
|
||||||
|
&system_conn,
|
||||||
|
"orphan1",
|
||||||
|
1,
|
||||||
|
"url",
|
||||||
|
"target1",
|
||||||
|
"active",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let command = RepairCommands::Registry {
|
||||||
|
dry_run: true,
|
||||||
|
force: false,
|
||||||
|
slug: None,
|
||||||
|
data_dir: Some(temp_dir.to_string_lossy().to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
bzod::cli::repair::run(command, config.clone())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Verify it was NOT deleted
|
||||||
|
{
|
||||||
|
let system_conn = db.system.lock().unwrap();
|
||||||
|
let exists: bool = system_conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = 'orphan1')",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(exists, "Slug should not be deleted in dry run");
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_registry_repair_force() {
|
||||||
|
let temp_dir =
|
||||||
|
std::env::temp_dir().join(format!("bzod_test_repair_force_{}", uuid::Uuid::new_v4()));
|
||||||
|
fs::create_dir_all(&temp_dir).unwrap();
|
||||||
|
let config = create_temp_config(temp_dir.clone());
|
||||||
|
let db = Db::init(&config).expect("Failed to init Db");
|
||||||
|
|
||||||
|
{
|
||||||
|
let system_conn = db.system.lock().unwrap();
|
||||||
|
// Insert orphaned slug
|
||||||
|
bzod::db::users::register_global_slug(
|
||||||
|
&system_conn,
|
||||||
|
"orphan2",
|
||||||
|
1,
|
||||||
|
"url",
|
||||||
|
"target2",
|
||||||
|
"active",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let command = RepairCommands::Registry {
|
||||||
|
dry_run: false,
|
||||||
|
force: true,
|
||||||
|
slug: None,
|
||||||
|
data_dir: Some(temp_dir.to_string_lossy().to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
bzod::cli::repair::run(command, config.clone())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Verify it WAS deleted
|
||||||
|
{
|
||||||
|
let system_conn = db.system.lock().unwrap();
|
||||||
|
let exists: bool = system_conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = 'orphan2')",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(!exists, "Slug should be deleted in force mode");
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_registry_repair_single_slug() {
|
||||||
|
let temp_dir =
|
||||||
|
std::env::temp_dir().join(format!("bzod_test_repair_single_{}", uuid::Uuid::new_v4()));
|
||||||
|
fs::create_dir_all(&temp_dir).unwrap();
|
||||||
|
let config = create_temp_config(temp_dir.clone());
|
||||||
|
let db = Db::init(&config).expect("Failed to init Db");
|
||||||
|
|
||||||
|
{
|
||||||
|
let system_conn = db.system.lock().unwrap();
|
||||||
|
// Insert two orphaned slugs
|
||||||
|
bzod::db::users::register_global_slug(
|
||||||
|
&system_conn,
|
||||||
|
"orphan3",
|
||||||
|
1,
|
||||||
|
"url",
|
||||||
|
"target3",
|
||||||
|
"active",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
bzod::db::users::register_global_slug(
|
||||||
|
&system_conn,
|
||||||
|
"orphan4",
|
||||||
|
1,
|
||||||
|
"url",
|
||||||
|
"target4",
|
||||||
|
"active",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let command = RepairCommands::Registry {
|
||||||
|
dry_run: false,
|
||||||
|
force: true,
|
||||||
|
slug: Some("orphan3".to_string()),
|
||||||
|
data_dir: Some(temp_dir.to_string_lossy().to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
bzod::cli::repair::run(command, config.clone())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Verify targeted slug was deleted
|
||||||
|
{
|
||||||
|
let system_conn = db.system.lock().unwrap();
|
||||||
|
let exists3: bool = system_conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = 'orphan3')",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(!exists3, "Targeted slug should be deleted");
|
||||||
|
|
||||||
|
let exists4: bool = system_conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = 'orphan4')",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(exists4, "Non-targeted slug should NOT be deleted");
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_registry_repair_transaction_safety() {
|
||||||
|
let temp_dir =
|
||||||
|
std::env::temp_dir().join(format!("bzod_test_repair_tx_{}", uuid::Uuid::new_v4()));
|
||||||
|
fs::create_dir_all(&temp_dir).unwrap();
|
||||||
|
let config = create_temp_config(temp_dir.clone());
|
||||||
|
let _db = Db::init(&config).expect("Failed to init Db");
|
||||||
|
|
||||||
|
// Simulate transaction rollback safety indirectly by validating dual flags return immediately
|
||||||
|
let command = RepairCommands::Registry {
|
||||||
|
dry_run: true,
|
||||||
|
force: true,
|
||||||
|
slug: None,
|
||||||
|
data_dir: Some(temp_dir.to_string_lossy().to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = bzod::cli::repair::run(command, config.clone()).await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user