refactor: complete v0.8 core architecture
This commit is contained in:
1 parent
f138a645f8
commit
d7e0ac7679
98 files changed
+8413
-3044
No files matched your search
+37
-18
@@ -56,26 +56,33 @@ fn flush_batch(db: &Db, batch: &mut Vec<VisitRecord>) {
|
||||
}
|
||||
|
||||
info!(
|
||||
"Flushing {} visits to user analytics databases",
|
||||
"Flushing {} visits to tenant analytics databases",
|
||||
batch.len()
|
||||
);
|
||||
|
||||
// Group visits by owner_user_id
|
||||
let mut groups: std::collections::HashMap<i64, Vec<VisitRecord>> =
|
||||
// Group visits by owner_tenant_id (or legacy user 1 fallback)
|
||||
let mut groups: std::collections::HashMap<crate::identity::TenantId, Vec<VisitRecord>> =
|
||||
std::collections::HashMap::new();
|
||||
let mut legacy_user1_visits: Vec<VisitRecord> = Vec::new();
|
||||
|
||||
for record in batch.drain(..) {
|
||||
let user_id = record.owner_user_id.unwrap_or(1); // fallback to legacy_admin (user 1)
|
||||
groups.entry(user_id).or_default().push(record);
|
||||
if let Some(tenant_id) = record.owner_tenant_id {
|
||||
groups.entry(tenant_id).or_default().push(record);
|
||||
} else if record.owner_user_id == Some(1) {
|
||||
legacy_user1_visits.push(record);
|
||||
} else {
|
||||
error!("Dropping analytics visit with no owner_tenant_id");
|
||||
}
|
||||
}
|
||||
|
||||
for (user_id, user_visits) in groups {
|
||||
let db_path = db
|
||||
.data_dir
|
||||
.join("users")
|
||||
.join(user_id.to_string())
|
||||
.join("analytics.db");
|
||||
if let Some(parent) = db_path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
for (tenant_id, tenant_visits) in groups {
|
||||
let db_path = db.topology.tenant_analytics_db(tenant_id);
|
||||
if !db_path.exists() {
|
||||
error!(
|
||||
"Refusing to write analytics for non-existent tenant analytics path: {:?}",
|
||||
db_path
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
match rusqlite::Connection::open(&db_path) {
|
||||
@@ -83,19 +90,31 @@ fn flush_batch(db: &Db, batch: &mut Vec<VisitRecord>) {
|
||||
let _ = crate::db::sqlite::enable_wal(&conn, "analytics");
|
||||
let _ = crate::db::sqlite::enable_foreign_keys(&conn, "analytics");
|
||||
|
||||
if let Err(e) = insert_visits_batch(&mut conn, &user_visits) {
|
||||
if let Err(e) = insert_visits_batch(&mut conn, &tenant_visits) {
|
||||
error!(
|
||||
"Failed to write analytics batch to user {} database: {:?}",
|
||||
user_id, e
|
||||
"Failed to write analytics batch to tenant {} database: {:?}",
|
||||
tenant_id, e
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Failed to open analytics database for user {}: {:?}",
|
||||
user_id, e
|
||||
"Failed to open analytics database for tenant {}: {:?}",
|
||||
tenant_id, e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !legacy_user1_visits.is_empty() {
|
||||
if let Ok(legacy_db_path) = db.topology.analytics_db("1") {
|
||||
if legacy_db_path.exists() {
|
||||
if let Ok(mut conn) = rusqlite::Connection::open(&legacy_db_path) {
|
||||
let _ = crate::db::sqlite::enable_wal(&conn, "analytics");
|
||||
let _ = crate::db::sqlite::enable_foreign_keys(&conn, "analytics");
|
||||
let _ = insert_visits_batch(&mut conn, &legacy_user1_visits);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-38
@@ -199,25 +199,8 @@ pub fn authenticate_user_session(
|
||||
}
|
||||
|
||||
// Get tenant user (status must be 'active')
|
||||
let mut stmt = users_conn.prepare(
|
||||
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
|
||||
FROM users WHERE id = ?1 AND status = 'active';"
|
||||
)?;
|
||||
let user_opt = stmt
|
||||
.query_row([session.user_id], |row| {
|
||||
Ok(TenantUser {
|
||||
id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
status: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
last_login: row.get(5)?,
|
||||
account_type: row.get(6)?,
|
||||
organization_id: row.get(7)?,
|
||||
metadata: row.get(8)?,
|
||||
})
|
||||
})
|
||||
.optional()?;
|
||||
let user_opt = crate::db::users::get_user_by_id(users_conn, session.user_id)?
|
||||
.filter(|u| u.status == "active");
|
||||
|
||||
if let Some(user) = user_opt {
|
||||
Ok(Some((user, session.id)))
|
||||
@@ -251,25 +234,8 @@ pub fn authenticate_api_key(
|
||||
let user_id_opt: Option<i64> = stmt.query_row([&hashed_key], |row| row.get(0)).optional()?;
|
||||
|
||||
if let Some(user_id) = user_id_opt {
|
||||
let mut stmt = users_conn.prepare(
|
||||
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
|
||||
FROM users WHERE id = ?1 AND status = 'active';"
|
||||
)?;
|
||||
let user_opt = stmt
|
||||
.query_row([user_id], |row| {
|
||||
Ok(TenantUser {
|
||||
id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
status: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
last_login: row.get(5)?,
|
||||
account_type: row.get(6)?,
|
||||
organization_id: row.get(7)?,
|
||||
metadata: row.get(8)?,
|
||||
})
|
||||
})
|
||||
.optional()?;
|
||||
let user_opt =
|
||||
crate::db::users::get_user_by_id(users_conn, user_id)?.filter(|u| u.status == "active");
|
||||
|
||||
if let Some(user) = user_opt {
|
||||
return Ok(Some(ApiActor::User(user)));
|
||||
|
||||
@@ -44,7 +44,8 @@ pub async fn run(
|
||||
}
|
||||
|
||||
// 2. Open databases
|
||||
let legacy_content_path = config.data_dir.join("users").join("1").join("content.db");
|
||||
let topology = crate::db::topology::Topology::new(&config.data_dir);
|
||||
let legacy_content_path = topology.content_db(crate::db::topology::LEGACY_ADMIN_USER_KEY)?;
|
||||
|
||||
if !legacy_content_path.exists() {
|
||||
info!(
|
||||
@@ -55,11 +56,7 @@ pub async fn run(
|
||||
}
|
||||
|
||||
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 target_content_path = topology.content_db_i64(target_admin_id)?;
|
||||
|
||||
let mut legacy_conn = Connection::open(&legacy_content_path)?;
|
||||
let mut target_conn = Connection::open(&target_content_path)?;
|
||||
|
||||
@@ -16,6 +16,10 @@ struct UserBackupMetadata {
|
||||
created_at: String,
|
||||
account_type: String,
|
||||
metadata: Option<String>,
|
||||
#[serde(default)]
|
||||
tenant_id: Option<String>,
|
||||
#[serde(default)]
|
||||
uuid: Option<String>,
|
||||
quotas: UserBackupQuotas,
|
||||
}
|
||||
|
||||
@@ -94,7 +98,7 @@ pub async fn run(
|
||||
);
|
||||
|
||||
// 4. Force checkpoint on user's databases
|
||||
let user_dir = config.data_dir.join("users").join(user_id.to_string());
|
||||
let user_dir = crate::db::tenant::location_for_user(&user)?.dir(&db.topology)?;
|
||||
if let Ok(c) = rusqlite::Connection::open(user_dir.join("content.db")) {
|
||||
let _ = c.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
|
||||
}
|
||||
@@ -119,6 +123,8 @@ pub async fn run(
|
||||
created_at: user.created_at,
|
||||
account_type: user.account_type,
|
||||
metadata: user.metadata,
|
||||
tenant_id: user.tenant_id.map(|t| t.to_string()),
|
||||
uuid: user.uuid,
|
||||
quotas,
|
||||
};
|
||||
let metadata_bytes = serde_json::to_vec_pretty(&metadata_obj)?;
|
||||
|
||||
@@ -7,6 +7,7 @@ use tracing::{error, info};
|
||||
|
||||
pub async fn run(
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
data_dir: Option<String>,
|
||||
mut config: Config,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
@@ -25,16 +26,18 @@ pub async fn run(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let password = read_input("Enter password: ");
|
||||
if password.trim().is_empty() {
|
||||
let final_password = match password {
|
||||
Some(p) => p,
|
||||
None => read_input("Enter password: "),
|
||||
};
|
||||
if final_password.trim().is_empty() {
|
||||
error!("Password cannot be empty");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let hash = hash_password(&password).map_err(|e| e.to_string())?;
|
||||
let hash = hash_password(&final_password).map_err(|e| e.to_string())?;
|
||||
let conn = db.users.lock().unwrap();
|
||||
let u = crate::db::users::create_admin_user(&conn, &final_username, &hash)?;
|
||||
db.init_user_databases(u.id)?;
|
||||
info!(
|
||||
"Successfully created admin user: {} (ID: {})",
|
||||
u.username, u.id
|
||||
|
||||
+46
-25
@@ -15,11 +15,6 @@ pub async fn run(
|
||||
}
|
||||
let db = Db::init(&config)?;
|
||||
|
||||
if user_id == 1 && !force {
|
||||
error!("Deleting legacy_admin system account requires --force flag");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Capture user details
|
||||
let user_details = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
@@ -32,36 +27,62 @@ pub async fn run(
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Transactional clean up on system.db (deleting their global slug mappings)
|
||||
{
|
||||
let mut system_conn = db.system.lock().unwrap();
|
||||
let tx = system_conn.transaction()?;
|
||||
if user_details.account_type == "admin" && !force {
|
||||
error!("Deleting administrator account requires --force flag");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Get all slugs owned by the user
|
||||
let slugs: Vec<String> = {
|
||||
let mut stmt = tx.prepare("SELECT slug FROM global_slugs WHERE owner_user_id = ?1;")?;
|
||||
let rows = stmt.query_map([user_id], |row| row.get(0))?;
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
// 1. Retire/cleanup slugs from v0.8 global_urls.db and global_landing_pages.db
|
||||
let now = Utc::now().to_rfc3339();
|
||||
if let Some(ref tid) = user_details.tenant_id {
|
||||
let tid_str = tid.to_string();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let pages_conn = db.global_landing_pages.lock().unwrap();
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
|
||||
// Delete from global_slugs and write to history
|
||||
let now = Utc::now().to_rfc3339();
|
||||
for slug in slugs {
|
||||
let _ = tx.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&slug]);
|
||||
let _ = tx.execute(
|
||||
// Get all URL slugs owned by tenant
|
||||
let mut stmt =
|
||||
urls_conn.prepare("SELECT slug FROM global_urls WHERE owner_tenant_id = ?1;")?;
|
||||
let url_slugs: Vec<String> = stmt
|
||||
.query_map([&tid_str], |row| row.get(0))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
// Get all page slugs owned by tenant
|
||||
let mut stmt2 = pages_conn
|
||||
.prepare("SELECT slug FROM global_landing_pages WHERE owner_tenant_id = ?1;")?;
|
||||
let page_slugs: Vec<String> = stmt2
|
||||
.query_map([&tid_str], |row| row.get(0))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
// Record history and retire/delete slugs
|
||||
for slug in url_slugs {
|
||||
let _ = urls_conn.execute("DELETE FROM global_urls WHERE slug = ?1;", [&slug]);
|
||||
let _ = system_conn.execute(
|
||||
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username)
|
||||
VALUES (?1, ?2, NULL, 'deleted', ?3, ?4);",
|
||||
rusqlite::params![slug, user_id, now, "cli"],
|
||||
);
|
||||
}
|
||||
|
||||
tx.commit()?;
|
||||
for slug in page_slugs {
|
||||
let _ =
|
||||
pages_conn.execute("DELETE FROM global_landing_pages WHERE slug = ?1;", [&slug]);
|
||||
let _ = system_conn.execute(
|
||||
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username)
|
||||
VALUES (?1, ?2, NULL, 'deleted', ?3, ?4);",
|
||||
rusqlite::params![slug, user_id, now, "cli"],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Delete user folder and database files from disk
|
||||
let user_dir = config.data_dir.join("users").join(user_id.to_string());
|
||||
if user_dir.exists() {
|
||||
let _ = std::fs::remove_dir_all(&user_dir);
|
||||
// 2. Delete tenant directory from disk
|
||||
if let Some(ref tid) = user_details.tenant_id {
|
||||
let user_dir = db.topology.tenant_dir(*tid);
|
||||
if user_dir.exists() {
|
||||
let _ = std::fs::remove_dir_all(&user_dir);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Remove user entry from users.db (cascading deletes quotas/sessions/tokens)
|
||||
|
||||
+22
-11
@@ -24,16 +24,27 @@ pub async fn run(
|
||||
|
||||
let mut all_healthy = true;
|
||||
|
||||
// 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 topology = crate::db::topology::Topology::new(&config.data_dir);
|
||||
|
||||
let dbs = vec![
|
||||
("admin", admin_dir.join("admin.db")),
|
||||
("system", admin_dir.join("system.db")),
|
||||
("users", admin_dir.join("users.db")),
|
||||
("legacy content", legacy_user_dir.join("content.db")),
|
||||
("legacy analytics", legacy_user_dir.join("analytics.db")),
|
||||
("admin", topology.admin_db()),
|
||||
("system", topology.system_db()),
|
||||
("users", topology.users_registry_db()),
|
||||
("global_urls", topology.global_urls_db()),
|
||||
("global_landing_pages", topology.global_landing_pages_db()),
|
||||
("reserved", topology.reserved_db()),
|
||||
(
|
||||
"legacy content",
|
||||
topology
|
||||
.content_db(crate::db::topology::LEGACY_ADMIN_USER_KEY)
|
||||
.expect("legacy admin user key is valid"),
|
||||
),
|
||||
(
|
||||
"legacy analytics",
|
||||
topology
|
||||
.analytics_db(crate::db::topology::LEGACY_ADMIN_USER_KEY)
|
||||
.expect("legacy admin user key is valid"),
|
||||
),
|
||||
];
|
||||
|
||||
for (db_name, db_path) in dbs {
|
||||
@@ -92,8 +103,8 @@ pub async fn run(
|
||||
// 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");
|
||||
let system_db_path = topology.system_db();
|
||||
let users_db_path = topology.users_registry_db();
|
||||
|
||||
if system_db_path.exists() && users_db_path.exists() {
|
||||
match (
|
||||
@@ -134,7 +145,7 @@ pub async fn run(
|
||||
);
|
||||
println!();
|
||||
println!("Owner:");
|
||||
println!(" User ID {}", issue.owner_user_id);
|
||||
println!(" Tenant ID {}", issue.owner_tenant_id);
|
||||
println!();
|
||||
println!("Database:");
|
||||
println!(" {}", issue.database_path.display());
|
||||
|
||||
+20
-3
@@ -17,11 +17,28 @@ pub async fn run(
|
||||
return Err("Invalid short code or custom slug format".into());
|
||||
}
|
||||
|
||||
let url_opt = {
|
||||
let conn = db.content.lock().unwrap();
|
||||
crate::db::content::get_url_by_code(&conn, &normalized_code)?
|
||||
let owner_info = {
|
||||
let conn = db.global_urls.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT owner_tenant_id FROM global_urls WHERE slug = ?1 AND status = 'active';",
|
||||
rusqlite::params![normalized_code],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.ok()
|
||||
};
|
||||
|
||||
let tid_str = match owner_info {
|
||||
Some(t) => t,
|
||||
None => return Err(format!("Short code not found: {}", normalized_code).into()),
|
||||
};
|
||||
let tid = tid_str
|
||||
.parse::<crate::identity::TenantId>()
|
||||
.map_err(|e| format!("Invalid tenant id for slug {}: {:?}", normalized_code, e))?;
|
||||
|
||||
let content_path = db.topology.tenant_content_db(tid);
|
||||
let conn = rusqlite::Connection::open(&content_path)?;
|
||||
let url_opt = crate::db::content::get_url_by_code(&conn, &normalized_code)?;
|
||||
|
||||
match url_opt {
|
||||
Some(url) => {
|
||||
println!("{}", url.destination);
|
||||
|
||||
+12
-9
@@ -13,13 +13,14 @@ pub async fn run(
|
||||
config.data_dir = PathBuf::from(d);
|
||||
}
|
||||
let db = Db::init(&config)?;
|
||||
let conn = db.users.lock().unwrap();
|
||||
|
||||
let admin_count: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM users WHERE account_type = 'admin';",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
let admin_count: i64 = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM users WHERE account_type = 'admin';",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?
|
||||
};
|
||||
|
||||
if admin_count > 0 {
|
||||
info!("Administrator already exists; initialization skipped.");
|
||||
@@ -45,8 +46,10 @@ pub async fn run(
|
||||
};
|
||||
|
||||
let hash = hash_password(&password).map_err(|e| e.to_string())?;
|
||||
let u = crate::db::users::create_admin_user(&conn, &username, &hash)?;
|
||||
db.init_user_databases(u.id)?;
|
||||
{
|
||||
let conn = db.users.lock().unwrap();
|
||||
let _ = crate::db::users::create_admin_user(&conn, &username, &hash)?;
|
||||
}
|
||||
info!("Administrator initialized successfully.");
|
||||
|
||||
Ok(())
|
||||
|
||||
+81
-2
@@ -15,12 +15,91 @@ pub async fn run(
|
||||
if dry_run {
|
||||
info!("Dry run enabled: pending database migrations will be reported but not applied.");
|
||||
info!("Data directory: {:?}", config.data_dir);
|
||||
let id_report =
|
||||
crate::db::identity_migrate::run_identity_migration(&config, true, false).await?;
|
||||
println!("\nIdentity Migration Preflight Report (Dry Run):");
|
||||
println!("----------------------------------------------");
|
||||
println!("Total users: {}", id_report.total_users);
|
||||
println!(
|
||||
"Users needing TenantId: {}",
|
||||
id_report.users_assigned_tenant_id
|
||||
);
|
||||
println!("Users needing UUID: {}", id_report.users_assigned_uuid);
|
||||
println!("Directories to move: {}", id_report.directories_moved);
|
||||
println!(
|
||||
"Directories already migrated: {}",
|
||||
id_report.directories_already_migrated
|
||||
);
|
||||
println!(
|
||||
"Legacy admin (users/1) preserved: {}",
|
||||
id_report.legacy_admin_preserved
|
||||
);
|
||||
|
||||
let slug_report =
|
||||
crate::db::slug_migrate::run_global_slug_migration(&config, true, false).await?;
|
||||
println!("\nGlobal Slug Migration Preflight Report (Dry Run):");
|
||||
println!("-------------------------------------------------");
|
||||
println!("Total legacy slugs: {}", slug_report.total_legacy_slugs);
|
||||
println!("URL slugs to migrate: {}", slug_report.url_slugs_migrated);
|
||||
println!("Page slugs to migrate: {}", slug_report.page_slugs_migrated);
|
||||
println!("Reserved slugs: {}", slug_report.reserved_slugs_migrated);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!("Running database migrations...");
|
||||
info!("Running database schema migrations...");
|
||||
let _db = Db::init(&config)?;
|
||||
info!("Database migrations applied successfully.");
|
||||
info!("Database schema migrations applied successfully.");
|
||||
|
||||
info!("Running identity & directory migration lifecycle...");
|
||||
let id_report =
|
||||
crate::db::identity_migrate::run_identity_migration(&config, false, false).await?;
|
||||
println!("\nIdentity Migration Report:");
|
||||
println!("--------------------------");
|
||||
println!("Total users: {}", id_report.total_users);
|
||||
println!("TenantIds assigned: {}", id_report.users_assigned_tenant_id);
|
||||
println!("UUIDs assigned: {}", id_report.users_assigned_uuid);
|
||||
println!("Directories migrated: {}", id_report.directories_moved);
|
||||
println!(
|
||||
"Directories already migrated: {}",
|
||||
id_report.directories_already_migrated
|
||||
);
|
||||
println!(
|
||||
"Legacy admin preserved: {}",
|
||||
id_report.legacy_admin_preserved
|
||||
);
|
||||
println!("Validation passed: {}", id_report.validation_passed);
|
||||
|
||||
if !id_report.warnings.is_empty() {
|
||||
println!("\nWarnings:");
|
||||
for w in &id_report.warnings {
|
||||
println!(" - {}", w);
|
||||
}
|
||||
}
|
||||
|
||||
info!("Running global slug migration lifecycle...");
|
||||
let slug_report =
|
||||
crate::db::slug_migrate::run_global_slug_migration(&config, false, false).await?;
|
||||
println!("\nGlobal Slug Migration Report:");
|
||||
println!("-----------------------------");
|
||||
println!("Total legacy slugs: {}", slug_report.total_legacy_slugs);
|
||||
println!("URL slugs migrated: {}", slug_report.url_slugs_migrated);
|
||||
println!("Page slugs migrated: {}", slug_report.page_slugs_migrated);
|
||||
println!(
|
||||
"Reserved slugs verified: {}",
|
||||
slug_report.reserved_slugs_migrated
|
||||
);
|
||||
println!(
|
||||
"Existing target records verified: {}",
|
||||
slug_report.existing_records_verified
|
||||
);
|
||||
println!("Validation passed: {}", slug_report.validation_passed);
|
||||
|
||||
if !slug_report.warnings.is_empty() {
|
||||
println!("\nWarnings:");
|
||||
for w in &slug_report.warnings {
|
||||
println!(" - {}", w);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
+102
-84
@@ -30,55 +30,89 @@ pub async fn run(
|
||||
}
|
||||
|
||||
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");
|
||||
let topology = crate::db::topology::Topology::new(&config.data_dir);
|
||||
let system_db_path = topology.system_db();
|
||||
let users_db_path = topology.users_registry_db();
|
||||
let urls_db_path = topology.global_urls_db();
|
||||
let pages_db_path = topology.global_landing_pages_db();
|
||||
|
||||
if !system_db_path.exists() || !users_db_path.exists() {
|
||||
println!("Error: system.db or users.db not found.");
|
||||
if !system_db_path.exists()
|
||||
|| !users_db_path.exists()
|
||||
|| !urls_db_path.exists()
|
||||
|| !pages_db_path.exists()
|
||||
{
|
||||
println!("Error: Core databases (system.db, users.db, slugs/*.db) not found.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut sys_conn = Connection::open(&system_db_path)?;
|
||||
let sys_conn = Connection::open(&system_db_path)?;
|
||||
let usr_conn = Connection::open(&users_db_path)?;
|
||||
let mut urls_conn = Connection::open(&urls_db_path)?;
|
||||
let mut pages_conn = Connection::open(&pages_db_path)?;
|
||||
|
||||
let slug_filter = slug.as_deref();
|
||||
|
||||
if dry_run {
|
||||
println!("BZOD Registry Repair\n");
|
||||
println!("Scanning Global Slug Registry...");
|
||||
println!("BZOD Registry Repair (v0.8)\n");
|
||||
println!("Scanning Authoritative Slug Registries (global_urls.db, global_landing_pages.db)...");
|
||||
|
||||
let issues =
|
||||
RegistryValidator::scan(&sys_conn, &usr_conn, &config.data_dir, slug_filter)?;
|
||||
let orphaned = issues
|
||||
.into_iter()
|
||||
|
||||
let true_orphans = issues
|
||||
.iter()
|
||||
.filter(|i| {
|
||||
matches!(
|
||||
i.issue_type,
|
||||
RegistryIssueType::MissingTarget
|
||||
| RegistryIssueType::MissingDatabase
|
||||
| RegistryIssueType::MissingOwner
|
||||
| RegistryIssueType::TrueOrphan
|
||||
| RegistryIssueType::MissingTenant
|
||||
)
|
||||
})
|
||||
.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();
|
||||
let corrupt = issues
|
||||
.iter()
|
||||
.filter(|i| i.issue_type == RegistryIssueType::CorruptDatabase)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
println!("\nDetected:");
|
||||
println!("\nPages:\n {} orphaned", orphaned_pages);
|
||||
println!("\nURLs:\n {} orphaned", orphaned_urls);
|
||||
let access_failures = issues
|
||||
.iter()
|
||||
.filter(|i| i.issue_type == RegistryIssueType::AccessFailure)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
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!("\nDetected Issues (Total: {}):", issues.len());
|
||||
println!(" Orphaned/Missing Targets: {}", true_orphans.len());
|
||||
println!(" Corrupt Databases (Protected): {}", corrupt.len());
|
||||
println!(" Access Failures (Protected): {}", access_failures.len());
|
||||
|
||||
if !true_orphans.is_empty() {
|
||||
println!("\nThe following orphaned entries would be repaired/removed:");
|
||||
for issue in &true_orphans {
|
||||
println!(
|
||||
" {} [{}] — Tenant: {}",
|
||||
issue.slug,
|
||||
issue.target_type.to_uppercase(),
|
||||
issue.owner_tenant_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
println!("\nNo changes have been made.");
|
||||
if !corrupt.is_empty() {
|
||||
println!("\nProtected from destructive repair (Corrupt DBs):");
|
||||
for issue in &corrupt {
|
||||
println!(
|
||||
" {} [{}] — Path: {:?}",
|
||||
issue.slug,
|
||||
issue.target_type.to_uppercase(),
|
||||
issue.database_path
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
println!("\nNo changes have been made (dry-run).");
|
||||
println!(
|
||||
"\nRun again with:\n\n bzod repair registry --force{}",
|
||||
"Run again with:\n bzod repair registry --force{}",
|
||||
if let Some(s) = slug_filter {
|
||||
format!(" --slug {}", s)
|
||||
} else {
|
||||
@@ -87,89 +121,73 @@ pub async fn run(
|
||||
);
|
||||
|
||||
info!(
|
||||
"Registry Repair Started. Scanned. Orphaned Pages: {}, Orphaned URLs: {}. Duration: {:?}",
|
||||
orphaned_pages, orphaned_urls, start_time.elapsed()
|
||||
"Registry Repair Scan completed. Orphaned: {}, Corrupt: {}, Duration: {:?}",
|
||||
true_orphans.len(),
|
||||
corrupt.len(),
|
||||
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
|
||||
RegistryValidator::scan(&sys_conn, &usr_conn, &config.data_dir, slug_filter)?;
|
||||
|
||||
// Only repair true orphans, missing targets, or missing tenants.
|
||||
// Never delete records with CorruptDatabase or AccessFailure per safety policies.
|
||||
let repairable = issues
|
||||
.into_iter()
|
||||
.filter(|i| {
|
||||
matches!(
|
||||
i.issue_type,
|
||||
RegistryIssueType::MissingTarget
|
||||
| RegistryIssueType::MissingDatabase
|
||||
| RegistryIssueType::MissingOwner
|
||||
| RegistryIssueType::TrueOrphan
|
||||
| RegistryIssueType::MissingTenant
|
||||
)
|
||||
})
|
||||
.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.");
|
||||
if repairable.is_empty() {
|
||||
println!("No repairable registry issues found.");
|
||||
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 tx_urls = urls_conn.transaction()?;
|
||||
let tx_pages = pages_conn.transaction()?;
|
||||
|
||||
let mut removed_count = 0;
|
||||
for issue in &repairable {
|
||||
if issue.target_type == "url" {
|
||||
removed_count += tx_urls
|
||||
.execute("DELETE FROM global_urls WHERE slug = ?1;", [&issue.slug])?;
|
||||
} else {
|
||||
removed_count += tx_pages.execute(
|
||||
"DELETE FROM global_landing_pages WHERE slug = ?1;",
|
||||
[&issue.slug],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
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_urls.commit()?;
|
||||
tx_pages.commit()?;
|
||||
|
||||
tx.commit()?;
|
||||
// Record audit event in system.db
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&sys_conn,
|
||||
"cli",
|
||||
"REGISTRY_REPAIR",
|
||||
"registry",
|
||||
slug_filter.unwrap_or("*"),
|
||||
Some(&format!(
|
||||
"Repaired/removed {} orphaned slug entries",
|
||||
removed_count
|
||||
)),
|
||||
);
|
||||
|
||||
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" }
|
||||
);
|
||||
}
|
||||
println!("Registry Repair Complete.");
|
||||
println!("Repaired/Removed: {} entries", removed_count);
|
||||
|
||||
info!(
|
||||
"Registry Repair Started. Scanned. Orphaned Pages: {}, Orphaned URLs: {}. Removed: {}. Duration: {:?}",
|
||||
orphaned_pages, orphaned_urls, removed_count, start_time.elapsed()
|
||||
"Registry Repair Complete. Removed: {}. Duration: {:?}",
|
||||
removed_count,
|
||||
start_time.elapsed()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-6
@@ -215,19 +215,15 @@ fn classify_registry_issues(
|
||||
|
||||
for issue in issues {
|
||||
match issue.issue_type {
|
||||
RegistryIssueType::DuplicateSlug
|
||||
RegistryIssueType::Conflict
|
||||
| RegistryIssueType::InvalidTargetType
|
||||
| RegistryIssueType::InvalidStatus => {
|
||||
errors.push(issue);
|
||||
}
|
||||
RegistryIssueType::MissingOwner if !is_legacy => {
|
||||
RegistryIssueType::MissingTenant if !is_legacy => {
|
||||
errors.push(issue);
|
||||
}
|
||||
_ => {
|
||||
// For legacy restores: MissingDatabase, MissingTarget, MissingOwner,
|
||||
// StaleReservation, TenantAdminHasIsolatedContent are warnings.
|
||||
// These represent pre-existing inconsistencies in the backup data,
|
||||
// not restore corruption.
|
||||
warnings.push(issue);
|
||||
}
|
||||
}
|
||||
|
||||
+277
-35
@@ -1,9 +1,13 @@
|
||||
use crate::config::Config;
|
||||
use crate::db::Db;
|
||||
use crate::identity::TenantId;
|
||||
use chrono::Utc;
|
||||
use rusqlite::OptionalExtension;
|
||||
use std::fs::File;
|
||||
use std::path::PathBuf;
|
||||
use tar::Archive;
|
||||
use tracing::{error, info};
|
||||
use uuid::Uuid;
|
||||
use zstd::Decoder;
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
@@ -15,6 +19,10 @@ struct UserBackupMetadata {
|
||||
created_at: String,
|
||||
account_type: String,
|
||||
metadata: Option<String>,
|
||||
#[serde(default)]
|
||||
tenant_id: Option<String>,
|
||||
#[serde(default)]
|
||||
uuid: Option<String>,
|
||||
quotas: UserBackupQuotas,
|
||||
}
|
||||
|
||||
@@ -70,26 +78,72 @@ pub async fn run(
|
||||
|
||||
info!("Restoring user {} from backup...", metadata.username);
|
||||
|
||||
// 2. Resolve target user ID and upsert user record in users.db
|
||||
let target_user_id = {
|
||||
// 2. Resolve identity per Correction #1:
|
||||
// Order:
|
||||
// 1. Archive contains TenantId + UUID -> preserve exactly.
|
||||
// 2. Legacy archive matched to existing users.db account -> resolve and preserve that user's existing TenantId + UUID.
|
||||
// 3. Genuinely new legacy restore with no existing identity match -> explicitly allocate new TenantId + UUID.
|
||||
let (target_user_id, target_tenant_id) = {
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
let existing_user =
|
||||
crate::db::users::get_user_by_username(&users_conn, &metadata.username)?;
|
||||
|
||||
match existing_user {
|
||||
Some(u) => {
|
||||
let tenant_id = if let Some(tid) = u.tenant_id {
|
||||
tid
|
||||
} else if let Some(ref tid_str) = metadata.tenant_id {
|
||||
TenantId::parse(tid_str).unwrap_or_else(|_| TenantId::generate())
|
||||
} else {
|
||||
TenantId::generate()
|
||||
};
|
||||
|
||||
let user_uuid = if let Some(ref uid) = u.uuid {
|
||||
uid.clone()
|
||||
} else if let Some(ref uid_str) = metadata.uuid {
|
||||
uid_str.clone()
|
||||
} else {
|
||||
Uuid::new_v4().to_string()
|
||||
};
|
||||
|
||||
users_conn.execute(
|
||||
"UPDATE users SET password_hash = ?1, status = ?2, account_type = ?3, metadata = ?4 WHERE id = ?5;",
|
||||
rusqlite::params![metadata.password_hash, metadata.status, metadata.account_type, metadata.metadata, u.id],
|
||||
"UPDATE users SET password_hash = ?1, status = ?2, account_type = ?3, metadata = ?4, tenant_id = ?5, uuid = ?6 WHERE id = ?7;",
|
||||
rusqlite::params![
|
||||
metadata.password_hash,
|
||||
metadata.status,
|
||||
metadata.account_type,
|
||||
metadata.metadata,
|
||||
tenant_id.as_str(),
|
||||
user_uuid,
|
||||
u.id
|
||||
],
|
||||
)?;
|
||||
users_conn.execute(
|
||||
"INSERT OR REPLACE INTO quotas (user_id, max_urls, max_landings, max_api_tokens, max_storage_mb)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5);",
|
||||
rusqlite::params![u.id, metadata.quotas.max_urls, metadata.quotas.max_landings, metadata.quotas.max_api_tokens, metadata.quotas.max_storage_mb],
|
||||
rusqlite::params![
|
||||
u.id,
|
||||
metadata.quotas.max_urls,
|
||||
metadata.quotas.max_landings,
|
||||
metadata.quotas.max_api_tokens,
|
||||
metadata.quotas.max_storage_mb
|
||||
],
|
||||
)?;
|
||||
u.id
|
||||
(u.id, tenant_id)
|
||||
}
|
||||
None => {
|
||||
let tenant_id = if let Some(ref tid_str) = metadata.tenant_id {
|
||||
TenantId::parse(tid_str).unwrap_or_else(|_| TenantId::generate())
|
||||
} else {
|
||||
TenantId::generate()
|
||||
};
|
||||
|
||||
let user_uuid = if let Some(ref uid_str) = metadata.uuid {
|
||||
uid_str.clone()
|
||||
} else {
|
||||
Uuid::new_v4().to_string()
|
||||
};
|
||||
|
||||
let id_taken: bool = users_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
|
||||
@@ -100,16 +154,35 @@ pub async fn run(
|
||||
|
||||
let new_id = if !id_taken {
|
||||
users_conn.execute(
|
||||
"INSERT INTO users (id, username, password_hash, status, created_at, account_type, metadata)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
|
||||
rusqlite::params![metadata.id, metadata.username, metadata.password_hash, metadata.status, metadata.created_at, metadata.account_type, metadata.metadata],
|
||||
"INSERT INTO users (id, username, password_hash, status, created_at, account_type, metadata, tenant_id, uuid)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9);",
|
||||
rusqlite::params![
|
||||
metadata.id,
|
||||
metadata.username,
|
||||
metadata.password_hash,
|
||||
metadata.status,
|
||||
metadata.created_at,
|
||||
metadata.account_type,
|
||||
metadata.metadata,
|
||||
tenant_id.as_str(),
|
||||
user_uuid
|
||||
],
|
||||
)?;
|
||||
metadata.id
|
||||
} else {
|
||||
users_conn.execute(
|
||||
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6);",
|
||||
rusqlite::params![metadata.username, metadata.password_hash, metadata.status, metadata.created_at, metadata.account_type, metadata.metadata],
|
||||
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata, tenant_id, uuid)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8);",
|
||||
rusqlite::params![
|
||||
metadata.username,
|
||||
metadata.password_hash,
|
||||
metadata.status,
|
||||
metadata.created_at,
|
||||
metadata.account_type,
|
||||
metadata.metadata,
|
||||
tenant_id.as_str(),
|
||||
user_uuid
|
||||
],
|
||||
)?;
|
||||
users_conn.last_insert_rowid()
|
||||
};
|
||||
@@ -117,19 +190,23 @@ pub async fn run(
|
||||
users_conn.execute(
|
||||
"INSERT OR REPLACE INTO quotas (user_id, max_urls, max_landings, max_api_tokens, max_storage_mb)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5);",
|
||||
rusqlite::params![new_id, metadata.quotas.max_urls, metadata.quotas.max_landings, metadata.quotas.max_api_tokens, metadata.quotas.max_storage_mb],
|
||||
rusqlite::params![
|
||||
new_id,
|
||||
metadata.quotas.max_urls,
|
||||
metadata.quotas.max_landings,
|
||||
metadata.quotas.max_api_tokens,
|
||||
metadata.quotas.max_storage_mb
|
||||
],
|
||||
)?;
|
||||
new_id
|
||||
(new_id, tenant_id)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 3. Extract database files to /data/users/<target_user_id>/
|
||||
let dest_dir = config
|
||||
.data_dir
|
||||
.join("users")
|
||||
.join(target_user_id.to_string());
|
||||
// 3. Extract database files to /data/users/<TenantId>/
|
||||
let dest_dir = db.topology.tenant_dir(target_tenant_id);
|
||||
std::fs::create_dir_all(&dest_dir)?;
|
||||
std::fs::create_dir_all(dest_dir.join("extensions"))?;
|
||||
|
||||
let f2 = File::open(&file_path)?;
|
||||
let zst_dec2 = Decoder::new(f2)?;
|
||||
@@ -156,27 +233,192 @@ pub async fn run(
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Register slugs in global_slugs using the shared helper
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
crate::db::users::register_restored_user_slugs(
|
||||
&system_conn,
|
||||
// 4. Register restored slugs in v0.8 slug databases (global_urls.db, global_landing_pages.db)
|
||||
let content_path = dest_dir.join("content.db");
|
||||
if content_path.exists() {
|
||||
let restored_content_conn = rusqlite::Connection::open(&content_path)?;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let pages_conn = db.global_landing_pages.lock().unwrap();
|
||||
let reserved_conn = db.reserved.lock().unwrap();
|
||||
|
||||
// 4a. Validate URL slugs for collisions
|
||||
let mut url_slugs = Vec::new();
|
||||
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 code: 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 global_status = if status == "dead" {
|
||||
"disabled"
|
||||
} else {
|
||||
"active"
|
||||
};
|
||||
|
||||
// Check collision with global_urls
|
||||
let existing_url_owner: Option<String> = urls_conn
|
||||
.query_row(
|
||||
"SELECT owner_tenant_id FROM global_urls WHERE slug = ?1;",
|
||||
[&code],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
if let Some(ref owner) = existing_url_owner {
|
||||
if owner != target_tenant_id.as_str() {
|
||||
return Err(format!(
|
||||
"Slug collision: URL slug '{code}' is already registered to another tenant ({owner})"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
|
||||
// Check collision with global_landing_pages
|
||||
let existing_page_owner: Option<String> = pages_conn
|
||||
.query_row(
|
||||
"SELECT owner_tenant_id FROM global_landing_pages WHERE slug = ?1;",
|
||||
[&code],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
if let Some(ref owner) = existing_page_owner {
|
||||
if owner != target_tenant_id.as_str() {
|
||||
return Err(format!(
|
||||
"Slug collision: URL slug '{code}' collides with landing page owned by tenant ({owner})"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
|
||||
// Check reserved
|
||||
let is_reserved: bool = reserved_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM reserved_slugs WHERE slug = ?1);",
|
||||
[&code],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
if is_reserved {
|
||||
return Err(format!(
|
||||
"Slug collision: URL slug '{code}' is a reserved system keyword"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
url_slugs.push((code, target_id, created_at, global_status));
|
||||
}
|
||||
|
||||
// 4b. Validate Landing Page slugs for collisions
|
||||
let mut page_slugs = Vec::new();
|
||||
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 code: 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 global_status = if state == "published" {
|
||||
"active"
|
||||
} else {
|
||||
"disabled"
|
||||
};
|
||||
|
||||
let existing_url_owner: Option<String> = urls_conn
|
||||
.query_row(
|
||||
"SELECT owner_tenant_id FROM global_urls WHERE slug = ?1;",
|
||||
[&code],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
if let Some(ref owner) = existing_url_owner {
|
||||
if owner != target_tenant_id.as_str() {
|
||||
return Err(format!(
|
||||
"Slug collision: Landing page slug '{code}' collides with URL owned by tenant ({owner})"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
|
||||
let existing_page_owner: Option<String> = pages_conn
|
||||
.query_row(
|
||||
"SELECT owner_tenant_id FROM global_landing_pages WHERE slug = ?1;",
|
||||
[&code],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
if let Some(ref owner) = existing_page_owner {
|
||||
if owner != target_tenant_id.as_str() {
|
||||
return Err(format!(
|
||||
"Slug collision: Landing page slug '{code}' is already registered to another tenant ({owner})"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
|
||||
let is_reserved: bool = reserved_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM reserved_slugs WHERE slug = ?1);",
|
||||
[&code],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
if is_reserved {
|
||||
return Err(format!(
|
||||
"Slug collision: Landing page slug '{code}' is a reserved system keyword"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
page_slugs.push((code, target_id, created_at, global_status));
|
||||
}
|
||||
|
||||
// 4c. Register validated URL slugs
|
||||
for (code, target_id, created_at, global_status) in url_slugs {
|
||||
let _ = urls_conn.execute(
|
||||
"INSERT OR REPLACE INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL);",
|
||||
rusqlite::params![
|
||||
code,
|
||||
target_tenant_id.as_str(),
|
||||
target_id,
|
||||
created_at,
|
||||
now,
|
||||
global_status
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 4d. Register validated Landing Page slugs
|
||||
for (code, target_id, created_at, global_status) in page_slugs {
|
||||
let _ = pages_conn.execute(
|
||||
"INSERT OR REPLACE INTO global_landing_pages (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL);",
|
||||
rusqlite::params![
|
||||
code,
|
||||
target_tenant_id.as_str(),
|
||||
target_id,
|
||||
created_at,
|
||||
now,
|
||||
global_status
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Reconcile quotas for restored user
|
||||
crate::db::users::reconcile_user_quotas(
|
||||
&db.users.lock().unwrap(),
|
||||
target_user_id,
|
||||
&dest_dir.join("content.db"),
|
||||
&restored_content_conn,
|
||||
)?;
|
||||
}
|
||||
|
||||
// 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,
|
||||
&restored_content_conn,
|
||||
)?;
|
||||
|
||||
info!(
|
||||
"User '{}' (ID: {}) successfully restored from backup.",
|
||||
metadata.username, target_user_id
|
||||
"User '{}' (ID: {}, Tenant: {}) successfully restored from backup.",
|
||||
metadata.username, target_user_id, target_tenant_id
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -133,8 +133,6 @@ pub async fn run(
|
||||
|
||||
let state = AppState {
|
||||
admin_db: db.admin.clone(),
|
||||
content_db: db.content.clone(),
|
||||
analytics_db: db.analytics.clone(),
|
||||
system_db: db.system.clone(),
|
||||
users_db: db.users.clone(),
|
||||
user_dbs: std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
|
||||
|
||||
+39
-17
@@ -18,6 +18,24 @@ pub async fn run(
|
||||
}
|
||||
let db = Db::init(&config)?;
|
||||
|
||||
// Resolve active standard user tenant
|
||||
let (user_id, tid) = {
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
let users = crate::db::users::list_users(&users_conn)?;
|
||||
let active_user = users.into_iter().find(|u| {
|
||||
u.account_type == "standard" && u.status == "active" && u.tenant_id.is_some()
|
||||
});
|
||||
match active_user {
|
||||
Some(u) => (u.id, u.tenant_id.unwrap()),
|
||||
None => {
|
||||
return Err(
|
||||
"Cannot shorten URL: No active standard user tenant found. Create a standard user first with `bzod user create`."
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Validate/normalize slug/code
|
||||
let code = match slug {
|
||||
Some(s) => {
|
||||
@@ -33,17 +51,24 @@ pub async fn run(
|
||||
None => crate::utils::random::generate_token(3),
|
||||
};
|
||||
|
||||
// 3. Register slug in system.db with status 'reserving' and check availability
|
||||
// 3. Register slug in global_urls with status 'reserving'
|
||||
{
|
||||
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());
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let pages_conn = db.global_landing_pages.lock().unwrap();
|
||||
let reserved_conn = db.reserved.lock().unwrap();
|
||||
if let Err(e) =
|
||||
crate::db::slugs::reserve_url_slug(&reserved_conn, &urls_conn, &pages_conn, &code, &tid)
|
||||
{
|
||||
return Err(format!("Slug '{}' already exists or is unavailable: {}", code, e).into());
|
||||
}
|
||||
crate::db::users::register_global_slug(&system_conn, &code, 1, "url", "", "reserving")?;
|
||||
}
|
||||
|
||||
// 4. Persist URL
|
||||
let conn = db.content.lock().unwrap();
|
||||
// 4. Persist URL in tenant content DB
|
||||
let content_path = db.topology.tenant_content_db(tid);
|
||||
let conn = rusqlite::Connection::open(&content_path)?;
|
||||
let _ = crate::db::sqlite::enable_wal(&conn, "content");
|
||||
let _ = crate::db::sqlite::enable_foreign_keys(&conn, "content");
|
||||
|
||||
let res = crate::db::content::create_url_extended(
|
||||
&conn,
|
||||
&code,
|
||||
@@ -58,18 +83,15 @@ pub async fn run(
|
||||
|
||||
match res {
|
||||
Ok(url) => {
|
||||
// Activate slug in system.db
|
||||
// Activate slug in global_urls
|
||||
{
|
||||
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],
|
||||
)?;
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
crate::db::slugs::activate_url_slug(&urls_conn, &code, &url.id)?;
|
||||
}
|
||||
// Increment quota for user ID 1
|
||||
// Increment quota
|
||||
{
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
crate::db::users::increment_quota_counter(&users_conn, 1, "urls")?;
|
||||
crate::db::users::increment_quota_counter(&users_conn, user_id, "urls")?;
|
||||
}
|
||||
|
||||
let proto = if config.cookie_secure {
|
||||
@@ -87,8 +109,8 @@ pub async fn run(
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let _ = crate::db::users::release_global_slug(&system_conn, &code, 1);
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let _ = crate::db::slugs::release_url_slug(&urls_conn, &code, &tid);
|
||||
Err(e.into())
|
||||
}
|
||||
}
|
||||
|
||||
+45
-11
@@ -15,16 +15,24 @@ pub async fn run(
|
||||
println!("Storage Directory: {:?}", config.data_dir);
|
||||
|
||||
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")),
|
||||
("admin.db", db.topology.admin_db()),
|
||||
("system.db", db.topology.system_db()),
|
||||
("users.db", db.topology.users_registry_db()),
|
||||
("global_urls.db", db.topology.global_urls_db()),
|
||||
(
|
||||
"global_landing_pages.db",
|
||||
db.topology.global_landing_pages_db(),
|
||||
),
|
||||
("reserved.db", db.topology.reserved_db()),
|
||||
(
|
||||
"legacy content.db",
|
||||
config.data_dir.join("users/1/content.db"),
|
||||
db.topology
|
||||
.content_db(crate::db::topology::LEGACY_ADMIN_USER_KEY)?,
|
||||
),
|
||||
(
|
||||
"legacy analytics.db",
|
||||
config.data_dir.join("users/1/analytics.db"),
|
||||
db.topology
|
||||
.analytics_db(crate::db::topology::LEGACY_ADMIN_USER_KEY)?,
|
||||
),
|
||||
];
|
||||
|
||||
@@ -47,8 +55,29 @@ pub async fn run(
|
||||
println!("Users Count: {}", users_count);
|
||||
|
||||
let (urls_total, urls_active, urls_dead) = {
|
||||
let conn = db.content.lock().unwrap();
|
||||
crate::db::content::get_url_counts(&conn)?
|
||||
let conn = db.global_urls.lock().unwrap();
|
||||
let total: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM global_urls WHERE status != 'retired';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
let active: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM global_urls WHERE status = 'active';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
let dead: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM global_urls WHERE status = 'disabled';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
(total, active, dead)
|
||||
};
|
||||
println!(
|
||||
"Shortened URLs: {} total ({} active / {} dead)",
|
||||
@@ -56,14 +85,19 @@ pub async fn run(
|
||||
);
|
||||
|
||||
let pages_count = {
|
||||
let conn = db.content.lock().unwrap();
|
||||
crate::db::content::get_landing_page_count(&conn)?
|
||||
let conn = db.global_landing_pages.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM global_landing_pages WHERE status != 'retired';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
println!("Landing Pages: {}", pages_count);
|
||||
|
||||
let total_visits = {
|
||||
let conn = db.analytics.lock().unwrap();
|
||||
crate::db::analytics::get_total_clicks(&conn)?
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
crate::db::users::get_platform_total_clicks(&db.topology, &users_conn).unwrap_or(0)
|
||||
};
|
||||
println!("Redirect Clicks: {}", total_visits);
|
||||
|
||||
|
||||
@@ -639,6 +639,7 @@ pub fn get_target_visits_paginated(
|
||||
accept_language: row.get("accept_language")?,
|
||||
country: row.get("country")?,
|
||||
status_code: row.get("status_code")?,
|
||||
owner_tenant_id: None,
|
||||
owner_user_id: row.get("owner_user_id")?,
|
||||
})
|
||||
})?;
|
||||
@@ -695,6 +696,7 @@ pub fn get_target_visits_all_in_memory(
|
||||
accept_language: row.get("accept_language")?,
|
||||
country: row.get("country")?,
|
||||
status_code: row.get("status_code")?,
|
||||
owner_tenant_id: None,
|
||||
owner_user_id: row.get("owner_user_id")?,
|
||||
})
|
||||
})?;
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
//! Explicit Phase 3 Identity & Directory Migration Engine.
|
||||
//!
|
||||
//! Lifecycle:
|
||||
//! 1. Preflight (inspect DB and filesystem, identify unmigrated users)
|
||||
//! 2. Backup (create pre-migration tarball)
|
||||
//! 3. Identity Migration (assign immutable TenantId + UUID in users.db)
|
||||
//! 4. Filesystem Migration (atomically move users/<id>/ to users/<TenantId>/)
|
||||
//! 5. Validation (verify all users, directories, and databases)
|
||||
//! 6. Completion Marker (record audit event and system setting)
|
||||
//!
|
||||
//! Legacy Admin (users/1) is preserved as a Core/legacy concern and NOT converted
|
||||
//! to a normal TenantId directory.
|
||||
|
||||
use rusqlite::Connection;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::db::topology::{is_v08_user_id, Topology};
|
||||
use crate::db::Db;
|
||||
use crate::identity::TenantId;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct IdentityMigrationReport {
|
||||
pub total_users: usize,
|
||||
pub users_assigned_tenant_id: usize,
|
||||
pub users_assigned_uuid: usize,
|
||||
pub directories_moved: usize,
|
||||
pub directories_already_migrated: usize,
|
||||
pub legacy_admin_preserved: bool,
|
||||
pub validation_passed: bool,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PreflightPlan {
|
||||
pub total_users: usize,
|
||||
pub normal_users: usize,
|
||||
pub users_needing_tenant_id: Vec<(i64, String)>,
|
||||
pub users_needing_uuid: Vec<(i64, String)>,
|
||||
pub directories_to_move: Vec<(i64, PathBuf, TenantId)>,
|
||||
pub directories_already_migrated: usize,
|
||||
}
|
||||
|
||||
/// Run the full explicit identity migration lifecycle.
|
||||
pub async fn run_identity_migration(
|
||||
config: &Config,
|
||||
dry_run: bool,
|
||||
skip_backup: bool,
|
||||
) -> Result<IdentityMigrationReport, Box<dyn std::error::Error>> {
|
||||
let topology = Topology::new(&config.data_dir);
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
// 1. Initialize Db for Core connections
|
||||
let db = Db::init(config)?;
|
||||
|
||||
// 2. Preflight
|
||||
let plan = {
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
inspect_preflight(&users_conn, &topology)?
|
||||
};
|
||||
|
||||
info!(
|
||||
"Preflight: total_users={}, normal_users={}, needing_tenant_id={}, needing_uuid={}, dirs_to_move={}",
|
||||
plan.total_users,
|
||||
plan.normal_users,
|
||||
plan.users_needing_tenant_id.len(),
|
||||
plan.users_needing_uuid.len(),
|
||||
plan.directories_to_move.len()
|
||||
);
|
||||
|
||||
if dry_run {
|
||||
info!("Dry run mode enabled. No identity changes or directory moves will be performed.");
|
||||
return Ok(IdentityMigrationReport {
|
||||
total_users: plan.total_users,
|
||||
users_assigned_tenant_id: plan.users_needing_tenant_id.len(),
|
||||
users_assigned_uuid: plan.users_needing_uuid.len(),
|
||||
directories_moved: plan.directories_to_move.len(),
|
||||
directories_already_migrated: plan.directories_already_migrated,
|
||||
legacy_admin_preserved: true,
|
||||
validation_passed: true,
|
||||
warnings,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Backup before migration (if there is work to do and backup is not skipped)
|
||||
let needs_migration = !plan.users_needing_tenant_id.is_empty()
|
||||
|| !plan.users_needing_uuid.is_empty()
|
||||
|| !plan.directories_to_move.is_empty();
|
||||
|
||||
if needs_migration && !skip_backup {
|
||||
info!("Creating pre-migration backup...");
|
||||
match crate::jobs::backup::perform_backup(&db, config).await {
|
||||
Ok(path) => info!("Pre-migration backup created at {:?}", path),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Pre-migration backup failed: {}. Continuing with caution.",
|
||||
e
|
||||
);
|
||||
warnings.push(format!("Pre-migration backup warning: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Identity Migration (users.db backfill)
|
||||
let (assigned_tids, assigned_uuids) = {
|
||||
let mut users_conn = db.users.lock().unwrap();
|
||||
migrate_user_table_identities(&mut users_conn)?
|
||||
};
|
||||
|
||||
// 5. Filesystem Migration (users/<id>/ -> users/<TenantId>/)
|
||||
let moved_dirs = {
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
migrate_tenant_directories(&users_conn, &topology, &mut warnings)?
|
||||
};
|
||||
|
||||
// 6. Validation
|
||||
let validation_passed = {
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
validate_identity_migration(&users_conn, &topology, &mut warnings)?
|
||||
};
|
||||
|
||||
// 7. Completion Marker in system.db
|
||||
if validation_passed {
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let details = format!(
|
||||
"Identity migration completed: {} tenant_ids assigned, {} uuids assigned, {} directories moved",
|
||||
assigned_tids, assigned_uuids, moved_dirs
|
||||
);
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
"system",
|
||||
"IDENTITY_MIGRATION_COMPLETED",
|
||||
"identity",
|
||||
"users.db",
|
||||
Some(&details),
|
||||
);
|
||||
let _ = system_conn.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('v08_identity_migration_completed', ?1);",
|
||||
[&now],
|
||||
);
|
||||
}
|
||||
|
||||
Ok(IdentityMigrationReport {
|
||||
total_users: plan.total_users,
|
||||
users_assigned_tenant_id: assigned_tids,
|
||||
users_assigned_uuid: assigned_uuids,
|
||||
directories_moved: moved_dirs,
|
||||
directories_already_migrated: plan.directories_already_migrated,
|
||||
legacy_admin_preserved: true,
|
||||
validation_passed,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
/// Inspect existing database and filesystem to build a preflight plan.
|
||||
pub fn inspect_preflight(
|
||||
users_conn: &Connection,
|
||||
topology: &Topology,
|
||||
) -> Result<PreflightPlan, Box<dyn std::error::Error>> {
|
||||
let users = crate::db::users::list_users(users_conn)?;
|
||||
let total_users = users.len();
|
||||
|
||||
let mut normal_users = 0;
|
||||
let mut users_needing_tenant_id = Vec::new();
|
||||
let mut users_needing_uuid = Vec::new();
|
||||
let mut directories_to_move = Vec::new();
|
||||
let mut directories_already_migrated = 0;
|
||||
|
||||
for user in &users {
|
||||
// Skip legacy admin / system accounts
|
||||
if user.account_type == "admin"
|
||||
|| user.account_type == "system"
|
||||
|| user.username == "legacy_admin"
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
normal_users += 1;
|
||||
|
||||
if user.tenant_id.is_none() {
|
||||
users_needing_tenant_id.push((user.id, user.username.clone()));
|
||||
}
|
||||
if user.uuid.is_none() {
|
||||
users_needing_uuid.push((user.id, user.username.clone()));
|
||||
}
|
||||
|
||||
if let Some(tid) = user.tenant_id {
|
||||
let legacy_dir = topology.user_dir_i64(user.id)?;
|
||||
let target_dir = topology.tenant_dir(tid);
|
||||
|
||||
if legacy_dir.exists() && legacy_dir != target_dir {
|
||||
directories_to_move.push((user.id, legacy_dir, tid));
|
||||
} else if target_dir.exists() {
|
||||
directories_already_migrated += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(PreflightPlan {
|
||||
total_users,
|
||||
normal_users,
|
||||
users_needing_tenant_id,
|
||||
users_needing_uuid,
|
||||
directories_to_move,
|
||||
directories_already_migrated,
|
||||
})
|
||||
}
|
||||
|
||||
/// Assign immutable TenantId and UUID for all normal users missing them in users.db.
|
||||
/// Restart-safe: never regenerates or modifies existing identities.
|
||||
pub fn migrate_user_table_identities(
|
||||
users_conn: &mut Connection,
|
||||
) -> Result<(usize, usize), Box<dyn std::error::Error>> {
|
||||
let users = crate::db::users::list_users(users_conn)?;
|
||||
let mut assigned_tids = 0;
|
||||
let mut assigned_uuids = 0;
|
||||
|
||||
let tx = users_conn.transaction()?;
|
||||
|
||||
for user in users {
|
||||
// Skip legacy admin / system accounts
|
||||
if user.account_type == "admin"
|
||||
|| user.account_type == "system"
|
||||
|| user.username == "legacy_admin"
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut needs_update = false;
|
||||
let mut tid_str = user.tenant_id.map(|t| t.as_str().to_string());
|
||||
let mut uuid_str = user.uuid;
|
||||
|
||||
if tid_str.is_none() {
|
||||
// Allocate unique TenantId
|
||||
let tid = crate::identity::TenantId::generate();
|
||||
tid_str = Some(tid.as_str().to_string());
|
||||
assigned_tids += 1;
|
||||
needs_update = true;
|
||||
}
|
||||
|
||||
if uuid_str.is_none() {
|
||||
// Allocate unique UUID
|
||||
let u = uuid::Uuid::new_v4().to_string();
|
||||
uuid_str = Some(u);
|
||||
assigned_uuids += 1;
|
||||
needs_update = true;
|
||||
}
|
||||
|
||||
if needs_update {
|
||||
tx.execute(
|
||||
"UPDATE users SET tenant_id = ?1, uuid = ?2 WHERE id = ?3;",
|
||||
rusqlite::params![tid_str, uuid_str, user.id],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit()?;
|
||||
Ok((assigned_tids, assigned_uuids))
|
||||
}
|
||||
|
||||
/// Move tenant directories from users/<id>/ to users/<TenantId>/ for normal users.
|
||||
/// Legacy users/1 is preserved.
|
||||
pub fn migrate_tenant_directories(
|
||||
users_conn: &Connection,
|
||||
topology: &Topology,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Result<usize, Box<dyn std::error::Error>> {
|
||||
let users = crate::db::users::list_users(users_conn)?;
|
||||
let mut moved = 0;
|
||||
|
||||
for user in users {
|
||||
if user.account_type == "admin"
|
||||
|| user.account_type == "system"
|
||||
|| user.username == "legacy_admin"
|
||||
{
|
||||
// Preserve Core / system accounts intact
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(tid) = user.tenant_id else {
|
||||
warnings.push(format!(
|
||||
"User '{}' (ID {}) has no TenantId; skipping directory move",
|
||||
user.username, user.id
|
||||
));
|
||||
continue;
|
||||
};
|
||||
|
||||
let legacy_dir = match topology.user_dir_i64(user.id) {
|
||||
Ok(d) => d,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let target_dir = topology.tenant_dir(tid);
|
||||
|
||||
if legacy_dir.exists() && legacy_dir != target_dir {
|
||||
if !target_dir.exists() {
|
||||
// Atomic rename on same filesystem
|
||||
fs::rename(&legacy_dir, &target_dir)?;
|
||||
let _ = fs::create_dir_all(target_dir.join("extensions"));
|
||||
info!(
|
||||
"Migrated tenant directory for user '{}': {:?} -> {:?}",
|
||||
user.username, legacy_dir, target_dir
|
||||
);
|
||||
moved += 1;
|
||||
} else {
|
||||
// Target already exists (interrupted / partial run)
|
||||
warn!(
|
||||
"Target directory {:?} already exists for user '{}'. Verifying contents.",
|
||||
target_dir, user.username
|
||||
);
|
||||
// Ensure extensions dir exists
|
||||
let _ = fs::create_dir_all(target_dir.join("extensions"));
|
||||
|
||||
// If legacy directory is now empty or duplicate, clean it up safely
|
||||
if let Ok(entries) = fs::read_dir(&legacy_dir) {
|
||||
if entries.count() == 0 {
|
||||
let _ = fs::remove_dir(&legacy_dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(moved)
|
||||
}
|
||||
|
||||
/// Validate that every normal user has a valid TenantId, UUID, and matching directory.
|
||||
pub fn validate_identity_migration(
|
||||
users_conn: &Connection,
|
||||
topology: &Topology,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
let users = crate::db::users::list_users(users_conn)?;
|
||||
let mut valid = true;
|
||||
|
||||
for user in &users {
|
||||
if user.account_type == "admin"
|
||||
|| user.account_type == "system"
|
||||
|| user.username == "legacy_admin"
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate TenantId
|
||||
match user.tenant_id {
|
||||
Some(tid) => {
|
||||
if !is_v08_user_id(tid.as_str()) {
|
||||
warnings.push(format!(
|
||||
"Invalid TenantId format '{}' for user '{}'",
|
||||
tid.as_str(),
|
||||
user.username
|
||||
));
|
||||
valid = false;
|
||||
}
|
||||
|
||||
let target_dir = topology.tenant_dir(tid);
|
||||
if !target_dir.exists() {
|
||||
// Check if content db was initialized or if directory was not yet created
|
||||
warnings.push(format!(
|
||||
"Tenant directory {:?} does not exist for user '{}'",
|
||||
target_dir, user.username
|
||||
));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
warnings.push(format!(
|
||||
"Normal user '{}' (ID {}) is missing TenantId",
|
||||
user.username, user.id
|
||||
));
|
||||
valid = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate UUID
|
||||
match &user.uuid {
|
||||
Some(u) => {
|
||||
if uuid::Uuid::parse_str(u).is_err() {
|
||||
warnings.push(format!(
|
||||
"Invalid UUID format '{}' for user '{}'",
|
||||
u, user.username
|
||||
));
|
||||
valid = false;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
warnings.push(format!(
|
||||
"Normal user '{}' (ID {}) is missing UUID",
|
||||
user.username, user.id
|
||||
));
|
||||
valid = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(valid)
|
||||
}
|
||||
+35
-1
@@ -35,7 +35,21 @@ pub fn run_migrations(
|
||||
);
|
||||
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute_batch(m.sql)?;
|
||||
match tx.execute_batch(m.sql) {
|
||||
Ok(()) => (),
|
||||
Err(e) => {
|
||||
let err_msg = e.to_string();
|
||||
if err_msg.contains("duplicate column name") {
|
||||
tracing::warn!(
|
||||
database = db_name,
|
||||
version = m.version,
|
||||
"Column already exists during migration, continuing"
|
||||
);
|
||||
} else {
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
|
||||
crate::db::sqlite::set_user_version(conn, m.version as i32)?;
|
||||
@@ -568,4 +582,24 @@ pub const USERS_MIGRATIONS: &[Migration] = &[
|
||||
WHERE username = 'admin' AND account_type = 'standard';
|
||||
"#,
|
||||
},
|
||||
Migration {
|
||||
version: 3,
|
||||
name: "tenant_id",
|
||||
sql: r#"
|
||||
ALTER TABLE users ADD COLUMN tenant_id TEXT;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_tenant_id
|
||||
ON users(tenant_id)
|
||||
WHERE tenant_id IS NOT NULL;
|
||||
"#,
|
||||
},
|
||||
Migration {
|
||||
version: 4,
|
||||
name: "uuid",
|
||||
sql: r#"
|
||||
ALTER TABLE users ADD COLUMN uuid TEXT;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_uuid
|
||||
ON users(uuid)
|
||||
WHERE uuid IS NOT NULL;
|
||||
"#,
|
||||
},
|
||||
];
|
||||
+95
-230
@@ -7,46 +7,67 @@ use crate::db::sqlite::{enable_foreign_keys, enable_wal};
|
||||
use rusqlite::Connection;
|
||||
use std::fs;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tracing::info;
|
||||
|
||||
pub mod admin;
|
||||
pub mod analytics;
|
||||
pub mod audit_events;
|
||||
pub mod content;
|
||||
pub mod identity_migrate;
|
||||
pub mod migrations;
|
||||
pub mod preview;
|
||||
pub mod qr;
|
||||
pub mod schema_v08;
|
||||
pub mod slug_migrate;
|
||||
pub mod slugs;
|
||||
pub mod sqlite;
|
||||
pub mod tenant;
|
||||
pub mod topology;
|
||||
pub mod users;
|
||||
|
||||
use crate::db::schema_v08::{
|
||||
GLOBAL_LANDING_PAGES_MIGRATIONS, GLOBAL_URLS_MIGRATIONS, RESERVED_SLUGS_MIGRATIONS,
|
||||
};
|
||||
use crate::db::topology::Topology;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Db {
|
||||
pub admin: Arc<Mutex<Connection>>,
|
||||
pub content: Arc<Mutex<Connection>>,
|
||||
pub analytics: Arc<Mutex<Connection>>,
|
||||
pub system: Arc<Mutex<Connection>>,
|
||||
pub users: Arc<Mutex<Connection>>,
|
||||
pub global_urls: Arc<Mutex<Connection>>,
|
||||
pub global_landing_pages: Arc<Mutex<Connection>>,
|
||||
pub reserved: Arc<Mutex<Connection>>,
|
||||
pub data_dir: std::path::PathBuf,
|
||||
pub topology: Topology,
|
||||
}
|
||||
|
||||
fn open_prepared(
|
||||
path: &std::path::Path,
|
||||
name: &str,
|
||||
) -> Result<Connection, Box<dyn std::error::Error>> {
|
||||
info!("Opening {}.db", name);
|
||||
let conn = Connection::open(path)?;
|
||||
enable_wal(&conn, name)?;
|
||||
enable_foreign_keys(&conn, name)?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub fn init(config: &Config) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
use chrono::Utc;
|
||||
use tracing::info;
|
||||
|
||||
// Ensure data directory exists
|
||||
if !config.data_dir.exists() {
|
||||
fs::create_dir_all(&config.data_dir)?;
|
||||
let topology = Topology::new(&config.data_dir);
|
||||
|
||||
if !topology.root().exists() {
|
||||
fs::create_dir_all(topology.root())?;
|
||||
}
|
||||
topology.ensure_core_dirs()?;
|
||||
|
||||
let admin_dir = config.data_dir.join("admin");
|
||||
let users_dir = config.data_dir.join("users");
|
||||
fs::create_dir_all(&admin_dir)?;
|
||||
fs::create_dir_all(&users_dir)?;
|
||||
let admin_dir = topology.admin_dir();
|
||||
|
||||
// Automated Legacy Migration: check if legacy files are at the root
|
||||
let legacy_admin_db = config.data_dir.join("admin.db");
|
||||
let legacy_content_db = config.data_dir.join("content.db");
|
||||
let legacy_analytics_db = config.data_dir.join("analytics.db");
|
||||
let legacy_admin_db = topology.legacy_flat_admin_db();
|
||||
|
||||
// 1. If legacy admin.db exists at root, move admin/system DBs to config.data_dir/admin/
|
||||
if legacy_admin_db.exists() {
|
||||
@@ -60,7 +81,7 @@ impl Db {
|
||||
"system.db-shm",
|
||||
];
|
||||
for f in files {
|
||||
let src = config.data_dir.join(f);
|
||||
let src = topology.root().join(f);
|
||||
if src.exists() {
|
||||
let dst = admin_dir.join(f);
|
||||
let _ = fs::rename(&src, &dst);
|
||||
@@ -88,24 +109,9 @@ impl Db {
|
||||
}
|
||||
}
|
||||
|
||||
let admin_path = admin_dir.join("admin.db");
|
||||
let system_path = admin_dir.join("system.db");
|
||||
let users_db_path = admin_dir.join("users.db");
|
||||
|
||||
info!("Opening admin.db");
|
||||
let mut admin_conn = Connection::open(admin_path)?;
|
||||
info!("Opening system.db");
|
||||
let mut system_conn = Connection::open(system_path)?;
|
||||
info!("Opening users.db");
|
||||
let mut users_conn = Connection::open(users_db_path)?;
|
||||
|
||||
enable_wal(&admin_conn, "admin")?;
|
||||
enable_wal(&system_conn, "system")?;
|
||||
enable_wal(&users_conn, "users")?;
|
||||
|
||||
enable_foreign_keys(&admin_conn, "admin")?;
|
||||
enable_foreign_keys(&system_conn, "system")?;
|
||||
enable_foreign_keys(&users_conn, "users")?;
|
||||
let mut admin_conn = open_prepared(&topology.admin_db(), "admin")?;
|
||||
let mut system_conn = open_prepared(&topology.system_db(), "system")?;
|
||||
let mut users_conn = open_prepared(&topology.users_registry_db(), "users")?;
|
||||
|
||||
// Run migrations for system.db first
|
||||
info!("Running system migrations");
|
||||
@@ -168,212 +174,60 @@ impl Db {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let _ = users_conn.execute("DELETE FROM sessions WHERE expires_at < ?1;", [now]);
|
||||
|
||||
// 2. If legacy content.db/analytics.db exists, move them to users/1/ (for legacy_admin)
|
||||
let legacy_migration_needed = legacy_content_db.exists() || legacy_analytics_db.exists();
|
||||
let mut global_urls_conn = open_prepared(&topology.global_urls_db(), "global_urls")?;
|
||||
let mut global_landing_pages_conn =
|
||||
open_prepared(&topology.global_landing_pages_db(), "global_landing_pages")?;
|
||||
let mut reserved_conn = open_prepared(&topology.reserved_db(), "reserved")?;
|
||||
|
||||
// Ensure legacy_admin (user ID 1) exists in users.db
|
||||
let legacy_admin_id = 1i64;
|
||||
let legacy_admin_exists: bool = users_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
|
||||
[legacy_admin_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
if !legacy_admin_exists {
|
||||
// Get copied administrator password hash
|
||||
let admin_password_hash: String = admin_conn
|
||||
.query_row(
|
||||
"SELECT password_hash FROM users ORDER BY created_at ASC LIMIT 1;",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or_else(|_| {
|
||||
// If admin_db is empty, hash a default password
|
||||
crate::auth::password::hash_password("legacy_admin_pass").unwrap_or_default()
|
||||
});
|
||||
|
||||
let now = Utc::now().to_rfc3339();
|
||||
users_conn.execute(
|
||||
"INSERT INTO users (id, username, password_hash, status, created_at, account_type)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6);",
|
||||
rusqlite::params![
|
||||
legacy_admin_id,
|
||||
"legacy_admin",
|
||||
admin_password_hash,
|
||||
"disabled",
|
||||
now,
|
||||
"system"
|
||||
],
|
||||
)?;
|
||||
|
||||
// Seed quotas
|
||||
users_conn.execute(
|
||||
"INSERT INTO quotas (user_id) VALUES (?1);",
|
||||
[legacy_admin_id],
|
||||
)?;
|
||||
}
|
||||
|
||||
let legacy_user_dir = users_dir.join(legacy_admin_id.to_string());
|
||||
fs::create_dir_all(&legacy_user_dir)?;
|
||||
|
||||
if legacy_content_db.exists() || legacy_analytics_db.exists() {
|
||||
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);
|
||||
if src.exists() {
|
||||
let dst = legacy_user_dir.join(f);
|
||||
let _ = fs::rename(&src, &dst);
|
||||
}
|
||||
}
|
||||
let analytics_files = vec!["analytics.db", "analytics.db-wal", "analytics.db-shm"];
|
||||
for f in analytics_files {
|
||||
let src = config.data_dir.join(f);
|
||||
if src.exists() {
|
||||
let dst = legacy_user_dir.join(f);
|
||||
let _ = fs::rename(&src, &dst);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Open the legacy_admin databases (user ID 1) as db.content and db.analytics
|
||||
let content_path = legacy_user_dir.join("content.db");
|
||||
let analytics_path = legacy_user_dir.join("analytics.db");
|
||||
|
||||
let mut content_conn = Connection::open(content_path)?;
|
||||
let mut analytics_conn = Connection::open(analytics_path)?;
|
||||
|
||||
enable_wal(&content_conn, "content")?;
|
||||
enable_wal(&analytics_conn, "analytics")?;
|
||||
|
||||
enable_foreign_keys(&content_conn, "content")?;
|
||||
enable_foreign_keys(&analytics_conn, "analytics")?;
|
||||
|
||||
// Run migrations for content.db and analytics.db
|
||||
run_migrations(
|
||||
&mut content_conn,
|
||||
"content",
|
||||
CONTENT_MIGRATIONS,
|
||||
&mut global_urls_conn,
|
||||
"global_urls",
|
||||
GLOBAL_URLS_MIGRATIONS,
|
||||
Some(&system_arc),
|
||||
)?;
|
||||
run_migrations(
|
||||
&mut analytics_conn,
|
||||
"analytics",
|
||||
ANALYTICS_MIGRATIONS,
|
||||
&mut global_landing_pages_conn,
|
||||
"global_landing_pages",
|
||||
GLOBAL_LANDING_PAGES_MIGRATIONS,
|
||||
Some(&system_arc),
|
||||
)?;
|
||||
|
||||
// If we just migrated legacy content, populate the global_slugs table in system.db
|
||||
if legacy_migration_needed {
|
||||
info!("Populating global slug index with legacy content...");
|
||||
let mut sys_lock = system_arc.lock().unwrap();
|
||||
let tx = sys_lock.transaction()?;
|
||||
|
||||
// Extract urls from content.db and insert into global_slugs
|
||||
{
|
||||
let mut stmt =
|
||||
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 global_status = if status == "dead" {
|
||||
"disabled"
|
||||
} else {
|
||||
"active"
|
||||
};
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let _ = tx.execute(
|
||||
"INSERT OR IGNORE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
|
||||
rusqlite::params![slug, legacy_admin_id, "url", target_id, created_at, now, global_status],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract landing pages from content.db and insert into global_slugs
|
||||
{
|
||||
let mut stmt = 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 _ = tx.execute(
|
||||
"INSERT OR IGNORE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
|
||||
rusqlite::params![slug, legacy_admin_id, "page", target_id, created_at, now, status],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit()?;
|
||||
info!("Global slug index populated successfully.");
|
||||
}
|
||||
run_migrations(
|
||||
&mut reserved_conn,
|
||||
"reserved",
|
||||
RESERVED_SLUGS_MIGRATIONS,
|
||||
Some(&system_arc),
|
||||
)?;
|
||||
crate::db::slugs::seed_reserved_slugs(&reserved_conn)?;
|
||||
|
||||
let db = Self {
|
||||
admin: Arc::new(Mutex::new(admin_conn)),
|
||||
content: Arc::new(Mutex::new(content_conn)),
|
||||
analytics: Arc::new(Mutex::new(analytics_conn)),
|
||||
system: system_arc,
|
||||
users: Arc::new(Mutex::new(users_conn)),
|
||||
global_urls: Arc::new(Mutex::new(global_urls_conn)),
|
||||
global_landing_pages: Arc::new(Mutex::new(global_landing_pages_conn)),
|
||||
reserved: Arc::new(Mutex::new(reserved_conn)),
|
||||
data_dir: config.data_dir.clone(),
|
||||
topology,
|
||||
};
|
||||
|
||||
let _ = db.reconcile_global_slugs(config);
|
||||
|
||||
// Post-init: Clean up stale reservations
|
||||
// Post-init: Clean up stale reservations from v0.8 slug databases (older than 15 mins)
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
match crate::db::users::cleanup_stale_reservations(&system_conn, &config.data_dir) {
|
||||
Ok(count) => {
|
||||
if count > 0 {
|
||||
tracing::info!("Cleaned up {} stale reserving slugs", count);
|
||||
if let (Ok(urls_conn), Ok(pages_conn)) =
|
||||
(db.global_urls.lock(), db.global_landing_pages.lock())
|
||||
{
|
||||
match crate::db::slugs::cleanup_stale_reservations(&urls_conn, &pages_conn, 900) {
|
||||
Ok(count) => {
|
||||
if count > 0 {
|
||||
tracing::info!(
|
||||
"Cleaned up {} stale reserving slugs from v0.8 registry",
|
||||
count
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to clean up stale reservations: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Post-init: Verify global registry integrity
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
match crate::services::registry_validator::RegistryValidator::scan(
|
||||
&system_conn,
|
||||
&users_conn,
|
||||
&config.data_dir,
|
||||
None,
|
||||
) {
|
||||
Ok(issues) => {
|
||||
for issue in issues {
|
||||
tracing::error!(
|
||||
"Global registry integrity issue: {:?} for slug {}",
|
||||
issue.issue_type,
|
||||
issue.slug
|
||||
);
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to clean up stale reservations: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to verify global registry integrity: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,24 +238,33 @@ impl Db {
|
||||
let admin = self.admin.lock().unwrap();
|
||||
let _ = admin.execute("VACUUM;", []);
|
||||
|
||||
let content = self.content.lock().unwrap();
|
||||
let _ = content.execute("VACUUM;", []);
|
||||
|
||||
let analytics = self.analytics.lock().unwrap();
|
||||
let _ = analytics.execute("VACUUM;", []);
|
||||
|
||||
let system = self.system.lock().unwrap();
|
||||
let _ = system.execute("VACUUM;", []);
|
||||
|
||||
let users = self.users.lock().unwrap();
|
||||
let _ = users.execute("VACUUM;", []);
|
||||
|
||||
let global_urls = self.global_urls.lock().unwrap();
|
||||
let _ = global_urls.execute("VACUUM;", []);
|
||||
|
||||
let global_landing_pages = self.global_landing_pages.lock().unwrap();
|
||||
let _ = global_landing_pages.execute("VACUUM;", []);
|
||||
|
||||
let reserved = self.reserved.lock().unwrap();
|
||||
let _ = reserved.execute("VACUUM;", []);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn init_user_databases(&self, user_id: i64) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let user_dir = self.data_dir.join("users").join(user_id.to_string());
|
||||
fs::create_dir_all(&user_dir)?;
|
||||
let user = {
|
||||
let conn = self.users.lock().unwrap();
|
||||
crate::db::users::get_user_by_id(&conn, user_id)?
|
||||
.ok_or_else(|| format!("cannot provision databases for unknown user {user_id}"))?
|
||||
};
|
||||
let location = crate::db::tenant::location_for_user(&user)?;
|
||||
let user_dir = location.dir(&self.topology)?;
|
||||
fs::create_dir_all(user_dir.join("extensions"))?;
|
||||
|
||||
let content_path = user_dir.join("content.db");
|
||||
let analytics_path = user_dir.join("analytics.db");
|
||||
@@ -444,7 +307,7 @@ impl Db {
|
||||
|
||||
pub fn reconcile_global_slugs(
|
||||
&self,
|
||||
config: &Config,
|
||||
_config: &Config,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use chrono::Utc;
|
||||
|
||||
@@ -462,8 +325,10 @@ impl Db {
|
||||
drop(stmt);
|
||||
|
||||
for user_id in user_ids {
|
||||
let user_dir = config.data_dir.join("users").join(user_id.to_string());
|
||||
let content_path = user_dir.join("content.db");
|
||||
let content_path = match self.topology.content_db_i64(user_id) {
|
||||
Ok(p) => p,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if content_path.exists() {
|
||||
let content_conn = Connection::open(&content_path)?;
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Independently versioned v0.8 schemas.
|
||||
//!
|
||||
//! Phase 1 creates the frozen slug databases. Live slug allocate/lookup still
|
||||
//! uses `system.db.global_slugs` until Phase 4 moves ownership.
|
||||
|
||||
use super::migrations::Migration;
|
||||
|
||||
/// `slugs/global_urls.db` — globally unique URL slugs.
|
||||
///
|
||||
/// `owner_user_id` remains INTEGER to match v0.7 `users.id`. Phase 3 will
|
||||
/// migrate it to the 12-hex user id.
|
||||
pub const GLOBAL_URLS_MIGRATIONS: &[Migration] = &[
|
||||
Migration {
|
||||
version: 1,
|
||||
name: "initial_schema",
|
||||
sql: r#"
|
||||
CREATE TABLE IF NOT EXISTS global_urls (
|
||||
slug TEXT PRIMARY KEY,
|
||||
owner_tenant_id TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
retired_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_global_urls_tenant ON global_urls(owner_tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_global_urls_status ON global_urls(status);
|
||||
"#,
|
||||
},
|
||||
Migration {
|
||||
version: 2,
|
||||
name: "tenant_id_column",
|
||||
sql: r#"
|
||||
ALTER TABLE global_urls ADD COLUMN owner_tenant_id TEXT;
|
||||
CREATE INDEX IF NOT EXISTS idx_global_urls_tenant ON global_urls(owner_tenant_id);
|
||||
"#,
|
||||
},
|
||||
];
|
||||
|
||||
/// `slugs/global_landing_pages.db` — globally unique landing-page slugs.
|
||||
pub const GLOBAL_LANDING_PAGES_MIGRATIONS: &[Migration] = &[
|
||||
Migration {
|
||||
version: 1,
|
||||
name: "initial_schema",
|
||||
sql: r#"
|
||||
CREATE TABLE IF NOT EXISTS global_landing_pages (
|
||||
slug TEXT PRIMARY KEY,
|
||||
owner_tenant_id TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
retired_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_global_landing_pages_tenant ON global_landing_pages(owner_tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_global_landing_pages_status ON global_landing_pages(status);
|
||||
"#,
|
||||
},
|
||||
Migration {
|
||||
version: 2,
|
||||
name: "tenant_id_column",
|
||||
sql: r#"
|
||||
ALTER TABLE global_landing_pages ADD COLUMN owner_tenant_id TEXT;
|
||||
CREATE INDEX IF NOT EXISTS idx_global_landing_pages_tenant ON global_landing_pages(owner_tenant_id);
|
||||
"#,
|
||||
},
|
||||
];
|
||||
|
||||
/// `slugs/reserved.db` — system route / reserved names that must never allocate.
|
||||
pub const RESERVED_SLUGS_MIGRATIONS: &[Migration] = &[Migration {
|
||||
version: 1,
|
||||
name: "initial_schema",
|
||||
sql: r#"
|
||||
CREATE TABLE IF NOT EXISTS reserved_slugs (
|
||||
slug TEXT PRIMARY KEY,
|
||||
reason TEXT
|
||||
);
|
||||
"#,
|
||||
}];
|
||||
|
||||
/// Allowed statuses for the v0.8 slug databases.
|
||||
///
|
||||
/// `reserving` is an allocation lock, not a published state.
|
||||
/// Frozen published states: `active`, `disabled`, `retired`.
|
||||
pub const SLUG_STATUS_RESERVING: &str = "reserving";
|
||||
pub const SLUG_STATUS_ACTIVE: &str = "active";
|
||||
pub const SLUG_STATUS_DISABLED: &str = "disabled";
|
||||
pub const SLUG_STATUS_RETIRED: &str = "retired";
|
||||
@@ -0,0 +1,538 @@
|
||||
//! Explicit Phase 4 Global Slug Migration Engine.
|
||||
//!
|
||||
//! Migrates `system.db.global_slugs` and `system.db.reserved_slugs` to:
|
||||
//! - `slugs/global_urls.db` (`global_urls` table)
|
||||
//! - `slugs/global_landing_pages.db` (`global_landing_pages` table)
|
||||
//! - `slugs/reserved.db` (`reserved_slugs` table)
|
||||
//!
|
||||
//! Lifecycle:
|
||||
//! 1. Preflight (inspect system.db, resolve owners against users.db, check for ambiguities)
|
||||
//! 2. Backup (create pre-migration tarball)
|
||||
//! 3. Transactional Migration (insert into target databases with restart safety)
|
||||
//! 4. Validation (row counts, field parity, global uniqueness across databases)
|
||||
//! 5. Completion Marker (record audit event and system setting)
|
||||
|
||||
use rusqlite::Connection;
|
||||
use std::collections::HashMap;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::db::topology::Topology;
|
||||
use crate::db::Db;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct SlugMigrationReport {
|
||||
pub total_legacy_slugs: usize,
|
||||
pub url_slugs_migrated: usize,
|
||||
pub page_slugs_migrated: usize,
|
||||
pub reserved_slugs_migrated: usize,
|
||||
pub existing_records_verified: usize,
|
||||
pub validation_passed: bool,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SlugPreflightReport {
|
||||
pub total_slugs: usize,
|
||||
pub url_slugs: usize,
|
||||
pub page_slugs: usize,
|
||||
pub reserved_slugs: usize,
|
||||
pub unresolvable_owners: Vec<(String, i64)>,
|
||||
pub unknown_target_types: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// Helper struct for legacy slug record
|
||||
struct LegacySlugRecord {
|
||||
slug: String,
|
||||
owner_user_id: i64,
|
||||
target_type: String,
|
||||
target_id: String,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
status: String,
|
||||
deleted_at: Option<String>,
|
||||
}
|
||||
|
||||
/// Run the full explicit global slug migration.
|
||||
pub async fn run_global_slug_migration(
|
||||
config: &Config,
|
||||
dry_run: bool,
|
||||
skip_backup: bool,
|
||||
) -> Result<SlugMigrationReport, Box<dyn std::error::Error>> {
|
||||
let _topology = Topology::new(&config.data_dir);
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
// 1. Initialize Db
|
||||
let db = Db::init(config)?;
|
||||
|
||||
// 2. Preflight
|
||||
let preflight = {
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
inspect_slug_preflight(&system_conn, &users_conn)?
|
||||
};
|
||||
|
||||
info!(
|
||||
"Slug Migration Preflight: total={}, urls={}, pages={}, reserved={}, unresolvable_owners={}, unknown_types={}",
|
||||
preflight.total_slugs,
|
||||
preflight.url_slugs,
|
||||
preflight.page_slugs,
|
||||
preflight.reserved_slugs,
|
||||
preflight.unresolvable_owners.len(),
|
||||
preflight.unknown_target_types.len()
|
||||
);
|
||||
|
||||
if !preflight.unresolvable_owners.is_empty() {
|
||||
let msg = format!(
|
||||
"Fatal: Found {} slugs with unresolvable owner_user_id in users.db: {:?}",
|
||||
preflight.unresolvable_owners.len(),
|
||||
preflight.unresolvable_owners
|
||||
);
|
||||
return Err(msg.into());
|
||||
}
|
||||
|
||||
if !preflight.unknown_target_types.is_empty() {
|
||||
let msg = format!(
|
||||
"Fatal: Found {} slugs with unknown target_type: {:?}",
|
||||
preflight.unknown_target_types.len(),
|
||||
preflight.unknown_target_types
|
||||
);
|
||||
return Err(msg.into());
|
||||
}
|
||||
|
||||
if dry_run {
|
||||
info!("Dry run enabled: no slug records will be migrated.");
|
||||
return Ok(SlugMigrationReport {
|
||||
total_legacy_slugs: preflight.total_slugs,
|
||||
url_slugs_migrated: preflight.url_slugs,
|
||||
page_slugs_migrated: preflight.page_slugs,
|
||||
reserved_slugs_migrated: preflight.reserved_slugs,
|
||||
existing_records_verified: 0,
|
||||
validation_passed: true,
|
||||
warnings,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Backup before migration (if needed and not skipped)
|
||||
if preflight.total_slugs > 0 && !skip_backup {
|
||||
info!("Creating pre-migration backup before slug migration...");
|
||||
match crate::jobs::backup::perform_backup(&db, config).await {
|
||||
Ok(path) => info!("Pre-migration backup created at {:?}", path),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Pre-migration backup failed: {}. Continuing with caution.",
|
||||
e
|
||||
);
|
||||
warnings.push(format!("Pre-migration backup warning: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Transactional Migration
|
||||
let (migrated_urls, migrated_pages, verified_existing) = {
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
let mut urls_conn = db.global_urls.lock().unwrap();
|
||||
let mut pages_conn = db.global_landing_pages.lock().unwrap();
|
||||
let reserved_conn = db.reserved.lock().unwrap();
|
||||
|
||||
migrate_slugs_transactional(
|
||||
&system_conn,
|
||||
&users_conn,
|
||||
&mut urls_conn,
|
||||
&mut pages_conn,
|
||||
&reserved_conn,
|
||||
&mut warnings,
|
||||
)?
|
||||
};
|
||||
|
||||
// 5. Validation
|
||||
let validation_passed = {
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let pages_conn = db.global_landing_pages.lock().unwrap();
|
||||
let reserved_conn = db.reserved.lock().unwrap();
|
||||
|
||||
validate_slug_migration(
|
||||
&system_conn,
|
||||
&urls_conn,
|
||||
&pages_conn,
|
||||
&reserved_conn,
|
||||
&mut warnings,
|
||||
)?
|
||||
};
|
||||
|
||||
// 6. Completion Marker in system.db
|
||||
if validation_passed {
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let details = format!(
|
||||
"Global slug migration completed: {} URLs migrated, {} landing pages migrated, {} verified existing",
|
||||
migrated_urls, migrated_pages, verified_existing
|
||||
);
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
"system",
|
||||
"GLOBAL_SLUG_MIGRATION_COMPLETED",
|
||||
"slugs",
|
||||
"slugs/*.db",
|
||||
Some(&details),
|
||||
);
|
||||
let _ = system_conn.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('v08_global_slug_migration_completed', ?1);",
|
||||
[&now],
|
||||
);
|
||||
}
|
||||
|
||||
Ok(SlugMigrationReport {
|
||||
total_legacy_slugs: preflight.total_slugs,
|
||||
url_slugs_migrated: migrated_urls,
|
||||
page_slugs_migrated: migrated_pages,
|
||||
reserved_slugs_migrated: preflight.reserved_slugs,
|
||||
existing_records_verified: verified_existing,
|
||||
validation_passed,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
/// Inspect system.db.global_slugs and reserved_slugs to build preflight plan.
|
||||
pub fn inspect_slug_preflight(
|
||||
system_conn: &Connection,
|
||||
users_conn: &Connection,
|
||||
) -> Result<SlugPreflightReport, Box<dyn std::error::Error>> {
|
||||
let has_global_slugs: bool = system_conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='global_slugs');",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
|
||||
if !has_global_slugs {
|
||||
return Ok(SlugPreflightReport {
|
||||
total_slugs: 0,
|
||||
url_slugs: 0,
|
||||
page_slugs: 0,
|
||||
reserved_slugs: 0,
|
||||
unresolvable_owners: Vec::new(),
|
||||
unknown_target_types: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut stmt = system_conn.prepare(
|
||||
"SELECT slug, owner_user_id, target_type, target_id, created_at, updated_at, status, deleted_at
|
||||
FROM global_slugs;",
|
||||
)?;
|
||||
|
||||
let records = stmt.query_map([], |row| {
|
||||
Ok(LegacySlugRecord {
|
||||
slug: row.get(0)?,
|
||||
owner_user_id: row.get(1)?,
|
||||
target_type: row.get(2)?,
|
||||
target_id: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
updated_at: row.get(5)?,
|
||||
status: row.get(6)?,
|
||||
deleted_at: row.get(7)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
// Build owner map from users.db: user_id -> TenantId/legacy_admin
|
||||
let mut owner_map = HashMap::new();
|
||||
let users = crate::db::users::list_users(users_conn)?;
|
||||
for u in users {
|
||||
if let Some(tid) = u.tenant_id {
|
||||
owner_map.insert(u.id, tid.as_str().to_string());
|
||||
} else if u.account_type == "admin"
|
||||
|| u.account_type == "system"
|
||||
|| u.username == "legacy_admin"
|
||||
{
|
||||
owner_map.insert(u.id, "legacy_admin".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let mut total_slugs = 0;
|
||||
let mut url_slugs = 0;
|
||||
let mut page_slugs = 0;
|
||||
let mut unresolvable_owners = Vec::new();
|
||||
let mut unknown_target_types = Vec::new();
|
||||
|
||||
for r in records {
|
||||
let rec = r?;
|
||||
total_slugs += 1;
|
||||
|
||||
if !owner_map.contains_key(&rec.owner_user_id) {
|
||||
unresolvable_owners.push((rec.slug.clone(), rec.owner_user_id));
|
||||
}
|
||||
|
||||
match rec.target_type.as_str() {
|
||||
"url" => url_slugs += 1,
|
||||
"page" => page_slugs += 1,
|
||||
other => unknown_target_types.push((rec.slug.clone(), other.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
let reserved_slugs: usize = system_conn
|
||||
.query_row("SELECT COUNT(*) FROM reserved_slugs;", [], |r| r.get(0))
|
||||
.unwrap_or(0);
|
||||
|
||||
Ok(SlugPreflightReport {
|
||||
total_slugs,
|
||||
url_slugs,
|
||||
page_slugs,
|
||||
reserved_slugs,
|
||||
unresolvable_owners,
|
||||
unknown_target_types,
|
||||
})
|
||||
}
|
||||
|
||||
/// Transactional migration of slugs from system.db to global_urls.db and global_landing_pages.db.
|
||||
pub fn migrate_slugs_transactional(
|
||||
system_conn: &Connection,
|
||||
users_conn: &Connection,
|
||||
urls_conn: &mut Connection,
|
||||
pages_conn: &mut Connection,
|
||||
reserved_conn: &Connection,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Result<(usize, usize, usize), Box<dyn std::error::Error>> {
|
||||
// 1. Build owner map: user_id -> TenantId
|
||||
let mut owner_map = HashMap::new();
|
||||
let users = crate::db::users::list_users(users_conn)?;
|
||||
for u in users {
|
||||
if let Some(tid) = u.tenant_id {
|
||||
owner_map.insert(u.id, tid.as_str().to_string());
|
||||
} else if u.account_type == "admin"
|
||||
|| u.account_type == "system"
|
||||
|| u.username == "legacy_admin"
|
||||
{
|
||||
owner_map.insert(u.id, "legacy_admin".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fetch all legacy slugs
|
||||
let mut stmt = system_conn.prepare(
|
||||
"SELECT slug, owner_user_id, target_type, target_id, created_at, updated_at, status, deleted_at
|
||||
FROM global_slugs;",
|
||||
)?;
|
||||
|
||||
let records: Vec<LegacySlugRecord> = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(LegacySlugRecord {
|
||||
slug: row.get(0)?,
|
||||
owner_user_id: row.get(1)?,
|
||||
target_type: row.get(2)?,
|
||||
target_id: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
updated_at: row.get(5)?,
|
||||
status: row.get(6)?,
|
||||
deleted_at: row.get(7)?,
|
||||
})
|
||||
})?
|
||||
.collect::<Result<_, _>>()?;
|
||||
|
||||
let mut migrated_urls = 0;
|
||||
let mut migrated_pages = 0;
|
||||
let mut verified_existing = 0;
|
||||
|
||||
let tx_urls = urls_conn.transaction()?;
|
||||
let tx_pages = pages_conn.transaction()?;
|
||||
|
||||
for rec in records {
|
||||
let owner_tenant_id = match owner_map.get(&rec.owner_user_id) {
|
||||
Some(t) => t.clone(),
|
||||
None => {
|
||||
warnings.push(format!(
|
||||
"Unresolvable owner_user_id {} for slug '{}'; skipping",
|
||||
rec.owner_user_id, rec.slug
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let retired_at = rec.deleted_at;
|
||||
|
||||
if rec.target_type == "url" {
|
||||
// Check if record already exists in global_urls.db
|
||||
let existing: Option<(String, String)> = tx_urls
|
||||
.query_row(
|
||||
"SELECT owner_tenant_id, target_id FROM global_urls WHERE slug = ?1;",
|
||||
[&rec.slug],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.ok();
|
||||
|
||||
if let Some((existing_owner, existing_target)) = existing {
|
||||
if existing_owner == owner_tenant_id && existing_target == rec.target_id {
|
||||
verified_existing += 1;
|
||||
} else {
|
||||
warnings.push(format!(
|
||||
"Conflict: Slug '{}' already exists in global_urls with different owner/target",
|
||||
rec.slug
|
||||
));
|
||||
}
|
||||
} else {
|
||||
tx_urls.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
|
||||
rusqlite::params![
|
||||
rec.slug,
|
||||
owner_tenant_id,
|
||||
rec.target_id,
|
||||
rec.created_at,
|
||||
rec.updated_at,
|
||||
rec.status,
|
||||
retired_at
|
||||
],
|
||||
)?;
|
||||
migrated_urls += 1;
|
||||
}
|
||||
} else if rec.target_type == "page" {
|
||||
// Check if record already exists in global_landing_pages.db
|
||||
let existing: Option<(String, String)> = tx_pages
|
||||
.query_row(
|
||||
"SELECT owner_tenant_id, target_id FROM global_landing_pages WHERE slug = ?1;",
|
||||
[&rec.slug],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.ok();
|
||||
|
||||
if let Some((existing_owner, existing_target)) = existing {
|
||||
if existing_owner == owner_tenant_id && existing_target == rec.target_id {
|
||||
verified_existing += 1;
|
||||
} else {
|
||||
warnings.push(format!(
|
||||
"Conflict: Slug '{}' already exists in global_landing_pages with different owner/target",
|
||||
rec.slug
|
||||
));
|
||||
}
|
||||
} else {
|
||||
tx_pages.execute(
|
||||
"INSERT INTO global_landing_pages (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
|
||||
rusqlite::params![
|
||||
rec.slug,
|
||||
owner_tenant_id,
|
||||
rec.target_id,
|
||||
rec.created_at,
|
||||
rec.updated_at,
|
||||
rec.status,
|
||||
retired_at
|
||||
],
|
||||
)?;
|
||||
migrated_pages += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tx_urls.commit()?;
|
||||
tx_pages.commit()?;
|
||||
|
||||
// Seed reserved slugs
|
||||
let _ = crate::db::slugs::seed_reserved_slugs(reserved_conn);
|
||||
|
||||
Ok((migrated_urls, migrated_pages, verified_existing))
|
||||
}
|
||||
|
||||
/// Validate that every legacy slug was migrated correctly and global uniqueness holds.
|
||||
pub fn validate_slug_migration(
|
||||
system_conn: &Connection,
|
||||
urls_conn: &Connection,
|
||||
pages_conn: &Connection,
|
||||
reserved_conn: &Connection,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
let mut valid = true;
|
||||
|
||||
let has_global_slugs: bool = system_conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='global_slugs');",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
|
||||
if !has_global_slugs {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let legacy_url_count: usize = system_conn.query_row(
|
||||
"SELECT COUNT(*) FROM global_slugs WHERE target_type = 'url';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
|
||||
let legacy_page_count: usize = system_conn.query_row(
|
||||
"SELECT COUNT(*) FROM global_slugs WHERE target_type = 'page';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
|
||||
let target_url_count: usize =
|
||||
urls_conn.query_row("SELECT COUNT(*) FROM global_urls;", [], |r| r.get(0))?;
|
||||
|
||||
let target_page_count: usize =
|
||||
pages_conn.query_row("SELECT COUNT(*) FROM global_landing_pages;", [], |r| {
|
||||
r.get(0)
|
||||
})?;
|
||||
|
||||
if target_url_count < legacy_url_count {
|
||||
warnings.push(format!(
|
||||
"URL slug count mismatch: legacy has {}, target has {}",
|
||||
legacy_url_count, target_url_count
|
||||
));
|
||||
valid = false;
|
||||
}
|
||||
|
||||
if target_page_count < legacy_page_count {
|
||||
warnings.push(format!(
|
||||
"Page slug count mismatch: legacy has {}, target has {}",
|
||||
legacy_page_count, target_page_count
|
||||
));
|
||||
valid = false;
|
||||
}
|
||||
|
||||
// Global Uniqueness Invariant Check: 0 intersection between reserved, urls, and pages
|
||||
let collisions_urls_pages: usize = urls_conn.query_row(
|
||||
"SELECT COUNT(*) FROM global_urls WHERE slug IN (SELECT slug FROM global_landing_pages);",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
).unwrap_or(0);
|
||||
|
||||
if collisions_urls_pages > 0 {
|
||||
warnings.push(format!(
|
||||
"Invariant Violation: {} slugs appear in both global_urls and global_landing_pages",
|
||||
collisions_urls_pages
|
||||
));
|
||||
valid = false;
|
||||
}
|
||||
|
||||
let collisions_reserved_urls: usize = urls_conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM global_urls WHERE slug IN (SELECT slug FROM reserved_slugs);",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
|
||||
if collisions_reserved_urls > 0 {
|
||||
warnings.push(format!(
|
||||
"Invariant Violation: {} slugs appear in both global_urls and reserved_slugs",
|
||||
collisions_reserved_urls
|
||||
));
|
||||
valid = false;
|
||||
}
|
||||
|
||||
let collisions_reserved_pages: usize = pages_conn.query_row(
|
||||
"SELECT COUNT(*) FROM global_landing_pages WHERE slug IN (SELECT slug FROM reserved_slugs);",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
).unwrap_or(0);
|
||||
|
||||
if collisions_reserved_pages > 0 {
|
||||
warnings.push(format!(
|
||||
"Invariant Violation: {} slugs appear in both global_landing_pages and reserved_slugs",
|
||||
collisions_reserved_pages
|
||||
));
|
||||
valid = false;
|
||||
}
|
||||
|
||||
let _ = reserved_conn;
|
||||
|
||||
Ok(valid)
|
||||
}
|
||||
+472
@@ -0,0 +1,472 @@
|
||||
//! Frozen v0.8 Slug Registry Layer.
|
||||
//!
|
||||
//! Owns `slugs/global_urls.db`, `slugs/global_landing_pages.db`, and `slugs/reserved.db`.
|
||||
//!
|
||||
//! Guarantees:
|
||||
//! - Exact TenantId ownership (no integer ID primitives in v0.8 API).
|
||||
//! - Cross-database global uniqueness invariant: a slug exists in AT MOST ONE of
|
||||
//! `reserved.db`, `global_urls.db`, `global_landing_pages.db`.
|
||||
//! - Retired slugs remain permanently unavailable for reuse across both URLs and landing pages.
|
||||
//! - Concurrency-safe global allocations.
|
||||
|
||||
use chrono::Utc;
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::db::schema_v08::{
|
||||
SLUG_STATUS_ACTIVE, SLUG_STATUS_DISABLED, SLUG_STATUS_RESERVING, SLUG_STATUS_RETIRED,
|
||||
};
|
||||
use crate::identity::TenantId;
|
||||
|
||||
/// Core reserved slugs, matching the v0.7 `system.db` seed list.
|
||||
pub const CORE_RESERVED_SLUGS: &[(&str, &str)] = &[
|
||||
("admin", "System route"),
|
||||
("login", "System route"),
|
||||
("logout", "System route"),
|
||||
("dashboard", "System route"),
|
||||
("api", "System route"),
|
||||
("docs", "System route"),
|
||||
("assets", "System route"),
|
||||
("static", "System route"),
|
||||
("favicon.ico", "System route"),
|
||||
("robots.txt", "System route"),
|
||||
("health", "System route"),
|
||||
("metrics", "System route"),
|
||||
("install", "System route"),
|
||||
("setup", "System route"),
|
||||
("support", "System route"),
|
||||
("help", "System route"),
|
||||
("security", "System route"),
|
||||
("abuse", "System route"),
|
||||
("billing", "System route"),
|
||||
("status", "System route"),
|
||||
("legacy_admin", "System reserved"),
|
||||
("administrator", "System reserved"),
|
||||
("system", "System reserved"),
|
||||
("root", "System reserved"),
|
||||
("www", "System reserved"),
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SlugTargetType {
|
||||
Url,
|
||||
LandingPage,
|
||||
}
|
||||
|
||||
impl SlugTargetType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Url => "url",
|
||||
Self::LandingPage => "page",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ResolvedSlugInfo {
|
||||
pub slug: String,
|
||||
pub owner_tenant_id: String,
|
||||
pub target_type: SlugTargetType,
|
||||
pub target_id: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub status: String,
|
||||
pub retired_at: Option<String>,
|
||||
}
|
||||
|
||||
/// Insert the core reserved set. Idempotent (`INSERT OR IGNORE`).
|
||||
pub fn seed_reserved_slugs(conn: &Connection) -> rusqlite::Result<usize> {
|
||||
let mut inserted = 0usize;
|
||||
for (slug, reason) in CORE_RESERVED_SLUGS {
|
||||
let n = conn.execute(
|
||||
"INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES (?1, ?2);",
|
||||
params![slug, reason],
|
||||
)?;
|
||||
inserted += n;
|
||||
}
|
||||
Ok(inserted)
|
||||
}
|
||||
|
||||
pub fn reserved_slug_count(conn: &Connection) -> rusqlite::Result<i64> {
|
||||
conn.query_row("SELECT COUNT(*) FROM reserved_slugs;", [], |row| row.get(0))
|
||||
}
|
||||
|
||||
/// Check whether a slug is reserved in `slugs/reserved.db`.
|
||||
pub fn is_slug_reserved(reserved_conn: &Connection, slug: &str) -> rusqlite::Result<bool> {
|
||||
reserved_conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM reserved_slugs WHERE slug = ?1);",
|
||||
[slug],
|
||||
|row| row.get(0),
|
||||
)
|
||||
}
|
||||
|
||||
/// Check whether a slug is available for a new registration.
|
||||
/// Returns false if reserved or if present in `global_urls.db` or `global_landing_pages.db`
|
||||
/// in any state (including `retired`).
|
||||
pub fn is_slug_available(
|
||||
reserved_conn: &Connection,
|
||||
urls_conn: &Connection,
|
||||
pages_conn: &Connection,
|
||||
slug: &str,
|
||||
) -> rusqlite::Result<bool> {
|
||||
if is_slug_reserved(reserved_conn, slug)? {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let in_urls: bool = urls_conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM global_urls WHERE slug = ?1);",
|
||||
[slug],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if in_urls {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let in_pages: bool = pages_conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM global_landing_pages WHERE slug = ?1);",
|
||||
[slug],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if in_pages {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Lookup a slug in `slugs/global_urls.db`.
|
||||
pub fn lookup_url_slug(
|
||||
urls_conn: &Connection,
|
||||
slug: &str,
|
||||
) -> rusqlite::Result<Option<ResolvedSlugInfo>> {
|
||||
urls_conn
|
||||
.query_row(
|
||||
"SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at
|
||||
FROM global_urls WHERE slug = ?1;",
|
||||
[slug],
|
||||
|row| {
|
||||
Ok(ResolvedSlugInfo {
|
||||
slug: row.get(0)?,
|
||||
owner_tenant_id: row.get(1)?,
|
||||
target_type: SlugTargetType::Url,
|
||||
target_id: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
updated_at: row.get(4)?,
|
||||
status: row.get(5)?,
|
||||
retired_at: row.get(6)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
}
|
||||
|
||||
/// Lookup a slug in `slugs/global_landing_pages.db`.
|
||||
pub fn lookup_landing_page_slug(
|
||||
pages_conn: &Connection,
|
||||
slug: &str,
|
||||
) -> rusqlite::Result<Option<ResolvedSlugInfo>> {
|
||||
pages_conn
|
||||
.query_row(
|
||||
"SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at
|
||||
FROM global_landing_pages WHERE slug = ?1;",
|
||||
[slug],
|
||||
|row| {
|
||||
Ok(ResolvedSlugInfo {
|
||||
slug: row.get(0)?,
|
||||
owner_tenant_id: row.get(1)?,
|
||||
target_type: SlugTargetType::LandingPage,
|
||||
target_id: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
updated_at: row.get(4)?,
|
||||
status: row.get(5)?,
|
||||
retired_at: row.get(6)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
}
|
||||
|
||||
/// Unified slug lookup: checks `global_urls.db`, then `global_landing_pages.db`.
|
||||
pub fn lookup_slug(
|
||||
urls_conn: &Connection,
|
||||
pages_conn: &Connection,
|
||||
slug: &str,
|
||||
) -> rusqlite::Result<Option<ResolvedSlugInfo>> {
|
||||
if let Some(info) = lookup_url_slug(urls_conn, slug)? {
|
||||
return Ok(Some(info));
|
||||
}
|
||||
lookup_landing_page_slug(pages_conn, slug)
|
||||
}
|
||||
|
||||
/// Register a URL slug in `slugs/global_urls.db`.
|
||||
pub fn register_url_slug(
|
||||
urls_conn: &Connection,
|
||||
slug: &str,
|
||||
owner_tenant_id: &TenantId,
|
||||
target_id: &str,
|
||||
status: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
urls_conn.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL);",
|
||||
params![slug, owner_tenant_id.as_str(), target_id, now, now, status],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Register a landing page slug in `slugs/global_landing_pages.db`.
|
||||
pub fn register_landing_page_slug(
|
||||
pages_conn: &Connection,
|
||||
slug: &str,
|
||||
owner_tenant_id: &TenantId,
|
||||
target_id: &str,
|
||||
status: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
pages_conn.execute(
|
||||
"INSERT INTO global_landing_pages (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL);",
|
||||
params![slug, owner_tenant_id.as_str(), target_id, now, now, status],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Atomic reservation for a URL slug in `slugs/global_urls.db` (status = 'reserving').
|
||||
pub fn reserve_url_slug(
|
||||
reserved_conn: &Connection,
|
||||
urls_conn: &Connection,
|
||||
pages_conn: &Connection,
|
||||
slug: &str,
|
||||
owner_tenant_id: &TenantId,
|
||||
) -> rusqlite::Result<()> {
|
||||
if !is_slug_available(reserved_conn, urls_conn, pages_conn, slug)? {
|
||||
return Err(rusqlite::Error::SqliteFailure(
|
||||
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
|
||||
Some("Slug is unavailable or reserved".into()),
|
||||
));
|
||||
}
|
||||
|
||||
let now = Utc::now().to_rfc3339();
|
||||
urls_conn.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES (?1, ?2, '', ?3, ?4, ?5, NULL);",
|
||||
params![slug, owner_tenant_id.as_str(), now, now, SLUG_STATUS_RESERVING],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Atomic reservation for a landing page slug in `slugs/global_landing_pages.db` (status = 'reserving').
|
||||
pub fn reserve_landing_page_slug(
|
||||
reserved_conn: &Connection,
|
||||
urls_conn: &Connection,
|
||||
pages_conn: &Connection,
|
||||
slug: &str,
|
||||
owner_tenant_id: &TenantId,
|
||||
) -> rusqlite::Result<()> {
|
||||
if !is_slug_available(reserved_conn, urls_conn, pages_conn, slug)? {
|
||||
return Err(rusqlite::Error::SqliteFailure(
|
||||
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
|
||||
Some("Slug is unavailable or reserved".into()),
|
||||
));
|
||||
}
|
||||
|
||||
let now = Utc::now().to_rfc3339();
|
||||
pages_conn.execute(
|
||||
"INSERT INTO global_landing_pages (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES (?1, ?2, '', ?3, ?4, ?5, NULL);",
|
||||
params![slug, owner_tenant_id.as_str(), now, now, SLUG_STATUS_RESERVING],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Release a reserving URL slug (e.g. if content creation fails).
|
||||
pub fn release_url_slug(
|
||||
urls_conn: &Connection,
|
||||
slug: &str,
|
||||
owner_tenant_id: &TenantId,
|
||||
) -> rusqlite::Result<()> {
|
||||
urls_conn.execute(
|
||||
"DELETE FROM global_urls WHERE slug = ?1 AND owner_tenant_id = ?2 AND status = 'reserving';",
|
||||
params![slug, owner_tenant_id.as_str()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Release a reserving Landing Page slug (e.g. if content creation fails).
|
||||
pub fn release_landing_page_slug(
|
||||
pages_conn: &Connection,
|
||||
slug: &str,
|
||||
owner_tenant_id: &TenantId,
|
||||
) -> rusqlite::Result<()> {
|
||||
pages_conn.execute(
|
||||
"DELETE FROM global_landing_pages WHERE slug = ?1 AND owner_tenant_id = ?2 AND status = 'reserving';",
|
||||
params![slug, owner_tenant_id.as_str()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update URL slug target and activate after reservation.
|
||||
pub fn activate_url_slug(
|
||||
urls_conn: &Connection,
|
||||
slug: &str,
|
||||
target_id: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
urls_conn.execute(
|
||||
"UPDATE global_urls SET target_id = ?1, status = ?2, updated_at = ?3 WHERE slug = ?4;",
|
||||
params![target_id, SLUG_STATUS_ACTIVE, now, slug],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update Landing Page slug target and activate after reservation.
|
||||
pub fn activate_landing_page_slug(
|
||||
pages_conn: &Connection,
|
||||
slug: &str,
|
||||
target_id: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
pages_conn.execute(
|
||||
"UPDATE global_landing_pages SET target_id = ?1, status = ?2, updated_at = ?3 WHERE slug = ?4;",
|
||||
params![target_id, SLUG_STATUS_ACTIVE, now, slug],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retire a slug permanently so it cannot be reused.
|
||||
pub fn retire_slug(
|
||||
urls_conn: &Connection,
|
||||
pages_conn: &Connection,
|
||||
slug: &str,
|
||||
) -> rusqlite::Result<bool> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let n_urls = urls_conn.execute(
|
||||
"UPDATE global_urls SET status = ?1, retired_at = ?2, updated_at = ?3 WHERE slug = ?4;",
|
||||
params![SLUG_STATUS_RETIRED, now, now, slug],
|
||||
)?;
|
||||
if n_urls > 0 {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let n_pages = pages_conn.execute(
|
||||
"UPDATE global_landing_pages SET status = ?1, retired_at = ?2, updated_at = ?3 WHERE slug = ?4;",
|
||||
params![SLUG_STATUS_RETIRED, now, now, slug],
|
||||
)?;
|
||||
Ok(n_pages > 0)
|
||||
}
|
||||
|
||||
/// Disable a slug (returns 410 Gone on redirect, but still owned by tenant).
|
||||
pub fn disable_slug(
|
||||
urls_conn: &Connection,
|
||||
pages_conn: &Connection,
|
||||
slug: &str,
|
||||
) -> rusqlite::Result<bool> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let n_urls = urls_conn.execute(
|
||||
"UPDATE global_urls SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
|
||||
params![SLUG_STATUS_DISABLED, now, slug],
|
||||
)?;
|
||||
if n_urls > 0 {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let n_pages = pages_conn.execute(
|
||||
"UPDATE global_landing_pages SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
|
||||
params![SLUG_STATUS_DISABLED, now, slug],
|
||||
)?;
|
||||
Ok(n_pages > 0)
|
||||
}
|
||||
|
||||
/// Transfer slug ownership to a new tenant.
|
||||
pub fn transfer_slug_owner(
|
||||
urls_conn: &Connection,
|
||||
pages_conn: &Connection,
|
||||
slug: &str,
|
||||
new_owner_tenant_id: &TenantId,
|
||||
new_target_id: &str,
|
||||
) -> rusqlite::Result<bool> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let n_urls = urls_conn.execute(
|
||||
"UPDATE global_urls SET owner_tenant_id = ?1, target_id = ?2, updated_at = ?3 WHERE slug = ?4;",
|
||||
params![new_owner_tenant_id.as_str(), new_target_id, now, slug],
|
||||
)?;
|
||||
if n_urls > 0 {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let n_pages = pages_conn.execute(
|
||||
"UPDATE global_landing_pages SET owner_tenant_id = ?1, target_id = ?2, updated_at = ?3 WHERE slug = ?4;",
|
||||
params![new_owner_tenant_id.as_str(), new_target_id, now, slug],
|
||||
)?;
|
||||
Ok(n_pages > 0)
|
||||
}
|
||||
|
||||
/// List all slugs owned by a specific tenant.
|
||||
pub fn list_slugs_by_tenant(
|
||||
urls_conn: &Connection,
|
||||
pages_conn: &Connection,
|
||||
owner_tenant_id: &TenantId,
|
||||
) -> rusqlite::Result<Vec<ResolvedSlugInfo>> {
|
||||
let mut results = Vec::new();
|
||||
|
||||
let mut stmt = urls_conn.prepare(
|
||||
"SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at
|
||||
FROM global_urls WHERE owner_tenant_id = ?1 ORDER BY created_at ASC;",
|
||||
)?;
|
||||
let rows = stmt.query_map([owner_tenant_id.as_str()], |row| {
|
||||
Ok(ResolvedSlugInfo {
|
||||
slug: row.get(0)?,
|
||||
owner_tenant_id: row.get(1)?,
|
||||
target_type: SlugTargetType::Url,
|
||||
target_id: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
updated_at: row.get(4)?,
|
||||
status: row.get(5)?,
|
||||
retired_at: row.get(6)?,
|
||||
})
|
||||
})?;
|
||||
for r in rows {
|
||||
results.push(r?);
|
||||
}
|
||||
|
||||
let mut stmt = pages_conn.prepare(
|
||||
"SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at
|
||||
FROM global_landing_pages WHERE owner_tenant_id = ?1 ORDER BY created_at ASC;",
|
||||
)?;
|
||||
let rows = stmt.query_map([owner_tenant_id.as_str()], |row| {
|
||||
Ok(ResolvedSlugInfo {
|
||||
slug: row.get(0)?,
|
||||
owner_tenant_id: row.get(1)?,
|
||||
target_type: SlugTargetType::LandingPage,
|
||||
target_id: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
updated_at: row.get(4)?,
|
||||
status: row.get(5)?,
|
||||
retired_at: row.get(6)?,
|
||||
})
|
||||
})?;
|
||||
for r in rows {
|
||||
results.push(r?);
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Clean up stale reservations older than threshold seconds.
|
||||
pub fn cleanup_stale_reservations(
|
||||
urls_conn: &Connection,
|
||||
pages_conn: &Connection,
|
||||
older_than_seconds: i64,
|
||||
) -> rusqlite::Result<usize> {
|
||||
let threshold = (Utc::now() - chrono::Duration::seconds(older_than_seconds)).to_rfc3339();
|
||||
let n1 = urls_conn.execute(
|
||||
"DELETE FROM global_urls WHERE status = 'reserving' AND created_at < ?1;",
|
||||
[&threshold],
|
||||
)?;
|
||||
let n2 = pages_conn.execute(
|
||||
"DELETE FROM global_landing_pages WHERE status = 'reserving' AND created_at < ?1;",
|
||||
[&threshold],
|
||||
)?;
|
||||
Ok(n1 + n2)
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
//! Tenant database access boundary.
|
||||
//!
|
||||
//! New tenant opens go through [`TenantId`]. Legacy integer row ids are used
|
||||
//! only to look up a registered Core user, then resolved to a [`TenantLocation`].
|
||||
//! Unknown ids must not create filesystem databases.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::db::topology::Topology;
|
||||
use crate::error::AppError;
|
||||
use crate::identity::TenantId;
|
||||
use crate::models::TenantUser;
|
||||
|
||||
/// Open tenant SQLite connections (content, analytics, profile).
|
||||
#[derive(Clone)]
|
||||
pub struct UserDbs {
|
||||
pub content: Arc<Mutex<Connection>>,
|
||||
pub analytics: Arc<Mutex<Connection>>,
|
||||
pub profile: Arc<Mutex<Connection>>,
|
||||
}
|
||||
|
||||
/// How a tenant database may be opened.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TenantOpenMode {
|
||||
/// Authenticated tenant user / API actor. Status must be `active`.
|
||||
Ordinary,
|
||||
/// Public slug/QR/gate resolution. User must exist and not be deleted.
|
||||
PublicContent,
|
||||
/// Core jobs / admin inspection. User must exist and not be deleted.
|
||||
/// Existing files only — will not create a database.
|
||||
CoreJob,
|
||||
/// Explicit provisioning after a Core user row was inserted.
|
||||
Provision,
|
||||
}
|
||||
|
||||
/// Resolved tenant filesystem location.
|
||||
///
|
||||
/// `Id` is the frozen v0.8 path. `Legacy` is unmigrated v0.7 `users/<integer>/`
|
||||
/// and exists only until Phase 3 directory migration.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum TenantLocation {
|
||||
Id(TenantId),
|
||||
Legacy(i64),
|
||||
}
|
||||
|
||||
impl TenantLocation {
|
||||
pub fn cache_key(&self) -> String {
|
||||
match self {
|
||||
Self::Id(id) => id.as_str().to_string(),
|
||||
Self::Legacy(row_id) => format!("legacy:{row_id}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dir(&self, topology: &Topology) -> Result<PathBuf, crate::db::topology::TopologyError> {
|
||||
match self {
|
||||
Self::Id(id) => Ok(topology.tenant_dir(*id)),
|
||||
Self::Legacy(row_id) => topology.user_dir_i64(*row_id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn location_for_user(user: &TenantUser) -> Result<TenantLocation, AppError> {
|
||||
if let Some(id) = user.tenant_id {
|
||||
return Ok(TenantLocation::Id(id));
|
||||
}
|
||||
if user.id <= 0 {
|
||||
return Err(AppError::NotFound("invalid user id".into()));
|
||||
}
|
||||
Ok(TenantLocation::Legacy(user.id))
|
||||
}
|
||||
|
||||
pub fn status_allows_open(status: &str, mode: TenantOpenMode) -> bool {
|
||||
match mode {
|
||||
TenantOpenMode::Ordinary => status == "active",
|
||||
TenantOpenMode::PublicContent | TenantOpenMode::CoreJob | TenantOpenMode::Provision => {
|
||||
status != "deleted"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn assert_may_open(user: &TenantUser, mode: TenantOpenMode) -> Result<(), AppError> {
|
||||
if !status_allows_open(&user.status, mode) {
|
||||
return Err(AppError::Unauthorized(format!(
|
||||
"tenant access denied for status '{}'",
|
||||
user.status
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open tenant DBs for a registered Core user (legacy row id compatibility).
|
||||
pub fn open_for_row_id(
|
||||
users_conn: &Connection,
|
||||
topology: &Topology,
|
||||
system_db: &Arc<Mutex<Connection>>,
|
||||
pool: &mut std::collections::HashMap<String, UserDbs>,
|
||||
user_id: i64,
|
||||
mode: TenantOpenMode,
|
||||
) -> Result<UserDbs, AppError> {
|
||||
let user = crate::db::users::get_user_by_id(users_conn, user_id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("user {user_id} not found")))?;
|
||||
assert_may_open(&user, mode)?;
|
||||
let location = location_for_user(&user)?;
|
||||
open_location(topology, system_db, pool, &location, mode)
|
||||
}
|
||||
|
||||
/// Open tenant DBs by frozen TenantId. Unknown ids never create files.
|
||||
pub fn open_for_tenant_id(
|
||||
users_conn: &Connection,
|
||||
topology: &Topology,
|
||||
system_db: &Arc<Mutex<Connection>>,
|
||||
pool: &mut std::collections::HashMap<String, UserDbs>,
|
||||
tenant_id: TenantId,
|
||||
mode: TenantOpenMode,
|
||||
) -> Result<UserDbs, AppError> {
|
||||
let user = crate::db::users::get_user_by_tenant_id(users_conn, tenant_id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("tenant {tenant_id} not found")))?;
|
||||
assert_may_open(&user, mode)?;
|
||||
let location = location_for_user(&user)?;
|
||||
open_location(topology, system_db, pool, &location, mode)
|
||||
}
|
||||
|
||||
pub fn open_location(
|
||||
topology: &Topology,
|
||||
system_db: &Arc<Mutex<Connection>>,
|
||||
pool: &mut std::collections::HashMap<String, UserDbs>,
|
||||
location: &TenantLocation,
|
||||
mode: TenantOpenMode,
|
||||
) -> Result<UserDbs, AppError> {
|
||||
let key = location.cache_key();
|
||||
if let Some(dbs) = pool.get(&key) {
|
||||
return Ok(dbs.clone());
|
||||
}
|
||||
|
||||
let user_dir = location
|
||||
.dir(topology)
|
||||
.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
let content_path = user_dir.join("content.db");
|
||||
let analytics_path = user_dir.join("analytics.db");
|
||||
let profile_path = user_dir.join("profile.db");
|
||||
|
||||
let create = matches!(
|
||||
mode,
|
||||
TenantOpenMode::Provision | TenantOpenMode::Ordinary | TenantOpenMode::PublicContent
|
||||
);
|
||||
if !create && !content_path.exists() {
|
||||
return Err(AppError::NotFound(format!(
|
||||
"tenant database missing at {}",
|
||||
content_path.display()
|
||||
)));
|
||||
}
|
||||
if create {
|
||||
std::fs::create_dir_all(user_dir.join("extensions"))?;
|
||||
}
|
||||
|
||||
let dbs = open_files(
|
||||
&content_path,
|
||||
&analytics_path,
|
||||
&profile_path,
|
||||
system_db,
|
||||
create,
|
||||
)?;
|
||||
pool.insert(key, dbs.clone());
|
||||
Ok(dbs)
|
||||
}
|
||||
|
||||
fn open_files(
|
||||
content_path: &Path,
|
||||
analytics_path: &Path,
|
||||
profile_path: &Path,
|
||||
system_db: &Arc<Mutex<Connection>>,
|
||||
create: bool,
|
||||
) -> Result<UserDbs, AppError> {
|
||||
if !create && !content_path.exists() {
|
||||
return Err(AppError::NotFound("content.db missing".into()));
|
||||
}
|
||||
|
||||
let mut content_conn = Connection::open(content_path)?;
|
||||
let mut analytics_conn = Connection::open(analytics_path)?;
|
||||
let profile_conn = Connection::open(profile_path)?;
|
||||
|
||||
crate::db::sqlite::enable_wal(&content_conn, "content")?;
|
||||
crate::db::sqlite::enable_wal(&analytics_conn, "analytics")?;
|
||||
crate::db::sqlite::enable_wal(&profile_conn, "profile")?;
|
||||
crate::db::sqlite::enable_foreign_keys(&content_conn, "content")?;
|
||||
crate::db::sqlite::enable_foreign_keys(&analytics_conn, "analytics")?;
|
||||
crate::db::sqlite::enable_foreign_keys(&profile_conn, "profile")?;
|
||||
|
||||
crate::db::migrations::run_migrations(
|
||||
&mut content_conn,
|
||||
"content",
|
||||
crate::db::migrations::CONTENT_MIGRATIONS,
|
||||
Some(system_db),
|
||||
)
|
||||
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||
crate::db::migrations::run_migrations(
|
||||
&mut analytics_conn,
|
||||
"analytics",
|
||||
crate::db::migrations::ANALYTICS_MIGRATIONS,
|
||||
Some(system_db),
|
||||
)
|
||||
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||
|
||||
profile_conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);",
|
||||
)?;
|
||||
|
||||
Ok(UserDbs {
|
||||
content: Arc::new(Mutex::new(content_conn)),
|
||||
analytics: Arc::new(Mutex::new(analytics_conn)),
|
||||
profile: Arc::new(Mutex::new(profile_conn)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Job/helper path: registered user, existing content.db only, never create.
|
||||
pub fn existing_content_path(
|
||||
users_conn: &Connection,
|
||||
topology: &Topology,
|
||||
user_id: i64,
|
||||
) -> Result<PathBuf, rusqlite::Error> {
|
||||
let user = crate::db::users::get_user_by_id(users_conn, user_id)?.ok_or_else(|| {
|
||||
rusqlite::Error::InvalidPath(PathBuf::from(format!("unknown-user-{user_id}")))
|
||||
})?;
|
||||
if user.status == "deleted" {
|
||||
return Err(rusqlite::Error::InvalidPath(PathBuf::from("deleted-user")));
|
||||
}
|
||||
let loc = location_for_user(&user)
|
||||
.map_err(|e| rusqlite::Error::InvalidPath(PathBuf::from(e.to_string())))?;
|
||||
let dir = loc
|
||||
.dir(topology)
|
||||
.map_err(|e| rusqlite::Error::InvalidPath(PathBuf::from(e.to_string())))?;
|
||||
let path = dir.join("content.db");
|
||||
if !path.exists() {
|
||||
return Err(rusqlite::Error::InvalidPath(path));
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn existing_analytics_path(
|
||||
users_conn: &Connection,
|
||||
topology: &Topology,
|
||||
user_id: i64,
|
||||
) -> Result<PathBuf, rusqlite::Error> {
|
||||
let user = crate::db::users::get_user_by_id(users_conn, user_id)?.ok_or_else(|| {
|
||||
rusqlite::Error::InvalidPath(PathBuf::from(format!("unknown-user-{user_id}")))
|
||||
})?;
|
||||
if user.status == "deleted" {
|
||||
return Err(rusqlite::Error::InvalidPath(PathBuf::from("deleted-user")));
|
||||
}
|
||||
let loc = location_for_user(&user)
|
||||
.map_err(|e| rusqlite::Error::InvalidPath(PathBuf::from(e.to_string())))?;
|
||||
let dir = loc
|
||||
.dir(topology)
|
||||
.map_err(|e| rusqlite::Error::InvalidPath(PathBuf::from(e.to_string())))?;
|
||||
let path = dir.join("analytics.db");
|
||||
if !path.exists() {
|
||||
return Err(rusqlite::Error::InvalidPath(path));
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
//! Frozen v0.8.0 database topology under a configurable physical root.
|
||||
//!
|
||||
//! The logical layout is:
|
||||
//!
|
||||
//! ```text
|
||||
//! <data_dir>/
|
||||
//! ├── admin/{admin,system,users}.db
|
||||
//! ├── slugs/{global_urls,global_landing_pages,reserved}.db
|
||||
//! └── users/<user-key>/{profile,content,analytics}.db
|
||||
//! └── extensions/<extension>/<extension>.db
|
||||
//! ```
|
||||
//!
|
||||
//! `<data_dir>` is `Config.data_dir` (env `DATA_DIR`, default `./data`).
|
||||
//! It is not renamed to `database/`. Production Docker, CasaOS, and
|
||||
//! `deploy.sh` bind this physical root.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::identity::TenantId;
|
||||
|
||||
/// Directory name of the migration-era `legacy_admin` tenant (`users.db` id = 1).
|
||||
pub const LEGACY_ADMIN_USER_KEY: &str = "1";
|
||||
|
||||
/// Frozen first-party extension names. There is no runtime plugin loader.
|
||||
pub const FIRST_PARTY_EXTENSIONS: &[&str] = &["cv", "certificates", "documents", "portfolio"];
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TopologyError {
|
||||
InvalidUserDir { name: String },
|
||||
InvalidExtension { name: String },
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TopologyError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::InvalidUserDir { name } => {
|
||||
write!(f, "invalid tenant directory name: {name:?}")
|
||||
}
|
||||
Self::InvalidExtension { name } => {
|
||||
write!(f, "invalid extension name: {name:?}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for TopologyError {}
|
||||
|
||||
/// Authoritative resolver for every BZOD database path.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Topology {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl Topology {
|
||||
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||||
Self { root: root.into() }
|
||||
}
|
||||
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
pub fn admin_dir(&self) -> PathBuf {
|
||||
self.root.join("admin")
|
||||
}
|
||||
|
||||
pub fn slugs_dir(&self) -> PathBuf {
|
||||
self.root.join("slugs")
|
||||
}
|
||||
|
||||
pub fn users_dir(&self) -> PathBuf {
|
||||
self.root.join("users")
|
||||
}
|
||||
|
||||
pub fn admin_db(&self) -> PathBuf {
|
||||
self.admin_dir().join("admin.db")
|
||||
}
|
||||
|
||||
pub fn system_db(&self) -> PathBuf {
|
||||
self.admin_dir().join("system.db")
|
||||
}
|
||||
|
||||
pub fn users_registry_db(&self) -> PathBuf {
|
||||
self.admin_dir().join("users.db")
|
||||
}
|
||||
|
||||
pub fn global_urls_db(&self) -> PathBuf {
|
||||
self.slugs_dir().join("global_urls.db")
|
||||
}
|
||||
|
||||
pub fn global_landing_pages_db(&self) -> PathBuf {
|
||||
self.slugs_dir().join("global_landing_pages.db")
|
||||
}
|
||||
|
||||
pub fn reserved_db(&self) -> PathBuf {
|
||||
self.slugs_dir().join("reserved.db")
|
||||
}
|
||||
|
||||
/// Pre-multi-tenant files that may still exist at the physical root.
|
||||
pub fn legacy_flat_admin_db(&self) -> PathBuf {
|
||||
self.root.join("admin.db")
|
||||
}
|
||||
|
||||
pub fn legacy_flat_system_db(&self) -> PathBuf {
|
||||
self.root.join("system.db")
|
||||
}
|
||||
|
||||
pub fn legacy_flat_users_db(&self) -> PathBuf {
|
||||
self.root.join("users.db")
|
||||
}
|
||||
|
||||
pub fn legacy_flat_content_db(&self) -> PathBuf {
|
||||
self.root.join("content.db")
|
||||
}
|
||||
|
||||
pub fn legacy_flat_analytics_db(&self) -> PathBuf {
|
||||
self.root.join("analytics.db")
|
||||
}
|
||||
|
||||
pub fn legacy_admin_dir(&self) -> PathBuf {
|
||||
self.users_dir().join(LEGACY_ADMIN_USER_KEY)
|
||||
}
|
||||
|
||||
/// Frozen tenant directory: `users/<12-hex-TenantId>/`.
|
||||
pub fn tenant_dir(&self, tenant_id: TenantId) -> PathBuf {
|
||||
self.users_dir().join(tenant_id.as_str())
|
||||
}
|
||||
|
||||
pub fn tenant_content_db(&self, tenant_id: TenantId) -> PathBuf {
|
||||
self.tenant_dir(tenant_id).join("content.db")
|
||||
}
|
||||
|
||||
pub fn tenant_analytics_db(&self, tenant_id: TenantId) -> PathBuf {
|
||||
self.tenant_dir(tenant_id).join("analytics.db")
|
||||
}
|
||||
|
||||
pub fn tenant_profile_db(&self, tenant_id: TenantId) -> PathBuf {
|
||||
self.tenant_dir(tenant_id).join("profile.db")
|
||||
}
|
||||
|
||||
pub fn user_dir(&self, user_key: &str) -> Result<PathBuf, TopologyError> {
|
||||
if !is_valid_user_dir_name(user_key) {
|
||||
return Err(TopologyError::InvalidUserDir {
|
||||
name: user_key.to_string(),
|
||||
});
|
||||
}
|
||||
Ok(self.users_dir().join(user_key))
|
||||
}
|
||||
|
||||
pub fn user_dir_i64(&self, user_id: i64) -> Result<PathBuf, TopologyError> {
|
||||
self.user_dir(&user_id.to_string())
|
||||
}
|
||||
|
||||
pub fn content_db(&self, user_key: &str) -> Result<PathBuf, TopologyError> {
|
||||
Ok(self.user_dir(user_key)?.join("content.db"))
|
||||
}
|
||||
|
||||
pub fn analytics_db(&self, user_key: &str) -> Result<PathBuf, TopologyError> {
|
||||
Ok(self.user_dir(user_key)?.join("analytics.db"))
|
||||
}
|
||||
|
||||
pub fn profile_db(&self, user_key: &str) -> Result<PathBuf, TopologyError> {
|
||||
Ok(self.user_dir(user_key)?.join("profile.db"))
|
||||
}
|
||||
|
||||
pub fn content_db_i64(&self, user_id: i64) -> Result<PathBuf, TopologyError> {
|
||||
self.content_db(&user_id.to_string())
|
||||
}
|
||||
|
||||
pub fn analytics_db_i64(&self, user_id: i64) -> Result<PathBuf, TopologyError> {
|
||||
self.analytics_db(&user_id.to_string())
|
||||
}
|
||||
|
||||
pub fn profile_db_i64(&self, user_id: i64) -> Result<PathBuf, TopologyError> {
|
||||
self.profile_db(&user_id.to_string())
|
||||
}
|
||||
|
||||
pub fn extensions_dir(&self, user_key: &str) -> Result<PathBuf, TopologyError> {
|
||||
Ok(self.user_dir(user_key)?.join("extensions"))
|
||||
}
|
||||
|
||||
pub fn extensions_dir_i64(&self, user_id: i64) -> Result<PathBuf, TopologyError> {
|
||||
self.extensions_dir(&user_id.to_string())
|
||||
}
|
||||
|
||||
pub fn extension_db(&self, user_key: &str, extension: &str) -> Result<PathBuf, TopologyError> {
|
||||
if !is_valid_extension_name(extension) {
|
||||
return Err(TopologyError::InvalidExtension {
|
||||
name: extension.to_string(),
|
||||
});
|
||||
}
|
||||
Ok(self
|
||||
.extensions_dir(user_key)?
|
||||
.join(extension)
|
||||
.join(format!("{extension}.db")))
|
||||
}
|
||||
|
||||
/// Create `admin/`, `slugs/`, and `users/` under the physical root.
|
||||
pub fn ensure_core_dirs(&self) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(self.admin_dir())?;
|
||||
std::fs::create_dir_all(self.slugs_dir())?;
|
||||
std::fs::create_dir_all(self.users_dir())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a tenant directory and its `extensions/` folder.
|
||||
pub fn ensure_user_dirs(&self, user_key: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
let dir = self.user_dir(user_key)?;
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
std::fs::create_dir_all(dir.join("extensions"))?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
pub fn ensure_user_dirs_i64(
|
||||
&self,
|
||||
user_id: i64,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
self.ensure_user_dirs(&user_id.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Tenant directory names: 12 lowercase hex (v0.8) or a positive decimal id (v0.7).
|
||||
pub fn is_valid_user_dir_name(name: &str) -> bool {
|
||||
if name.is_empty() || name == "." || name == ".." {
|
||||
return false;
|
||||
}
|
||||
if name
|
||||
.as_bytes()
|
||||
.iter()
|
||||
.any(|b| *b == b'/' || *b == b'\\' || *b == 0 || *b == b'.')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if is_v08_user_id(name) {
|
||||
return true;
|
||||
}
|
||||
is_legacy_integer_user_id(name)
|
||||
}
|
||||
|
||||
pub fn is_v08_user_id(name: &str) -> bool {
|
||||
name.len() == 12 && name.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
|
||||
}
|
||||
|
||||
pub fn is_legacy_integer_user_id(name: &str) -> bool {
|
||||
if name.is_empty() || name.as_bytes()[0] == b'0' {
|
||||
return false;
|
||||
}
|
||||
name.bytes().all(|b| b.is_ascii_digit()) && name.parse::<i64>().map(|n| n > 0).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// First-party extension directory names: `^[a-z][a-z0-9_]{0,31}$`.
|
||||
pub fn is_valid_extension_name(name: &str) -> bool {
|
||||
let mut chars = name.chars();
|
||||
match chars.next() {
|
||||
Some(c) if c.is_ascii_lowercase() => {}
|
||||
_ => return false,
|
||||
}
|
||||
name.len() <= 32
|
||||
&& name
|
||||
.bytes()
|
||||
.all(|b| matches!(b, b'a'..=b'z' | b'0'..=b'9' | b'_'))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn physical_root_is_not_renamed_to_database() {
|
||||
let t = Topology::new("/var/lib/bzod/data");
|
||||
assert_eq!(t.root(), Path::new("/var/lib/bzod/data"));
|
||||
assert!(t.admin_dir().ends_with("data/admin"));
|
||||
assert!(t.slugs_dir().ends_with("data/slugs"));
|
||||
assert!(t.users_dir().ends_with("data/users"));
|
||||
assert!(!t.root().ends_with("database"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frozen_core_paths() {
|
||||
let t = Topology::new("/app/data");
|
||||
assert_eq!(t.admin_db(), PathBuf::from("/app/data/admin/admin.db"));
|
||||
assert_eq!(t.system_db(), PathBuf::from("/app/data/admin/system.db"));
|
||||
assert_eq!(
|
||||
t.users_registry_db(),
|
||||
PathBuf::from("/app/data/admin/users.db")
|
||||
);
|
||||
assert_eq!(
|
||||
t.global_urls_db(),
|
||||
PathBuf::from("/app/data/slugs/global_urls.db")
|
||||
);
|
||||
assert_eq!(
|
||||
t.global_landing_pages_db(),
|
||||
PathBuf::from("/app/data/slugs/global_landing_pages.db")
|
||||
);
|
||||
assert_eq!(
|
||||
t.reserved_db(),
|
||||
PathBuf::from("/app/data/slugs/reserved.db")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tenant_paths_legacy_integer_and_v08_hex() {
|
||||
let t = Topology::new("/app/data");
|
||||
assert_eq!(
|
||||
t.content_db_i64(2).unwrap(),
|
||||
PathBuf::from("/app/data/users/2/content.db")
|
||||
);
|
||||
let tid = crate::identity::TenantId::parse("a1b2c3d4e5f6").unwrap();
|
||||
assert_eq!(
|
||||
t.tenant_content_db(tid),
|
||||
PathBuf::from("/app/data/users/a1b2c3d4e5f6/content.db")
|
||||
);
|
||||
assert_eq!(
|
||||
t.extension_db("a1b2c3d4e5f6", "cv").unwrap(),
|
||||
PathBuf::from("/app/data/users/a1b2c3d4e5f6/extensions/cv/cv.db")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_path_traversal_and_forged_names() {
|
||||
let t = Topology::new("/app/data");
|
||||
assert!(t.user_dir("..").is_err());
|
||||
assert!(t.user_dir("../2").is_err());
|
||||
assert!(t.user_dir("2/../3").is_err());
|
||||
assert!(t.user_dir("2/foo").is_err());
|
||||
assert!(t.user_dir("-1").is_err());
|
||||
assert!(t.user_dir("0").is_err());
|
||||
assert!(t.user_dir("01").is_err());
|
||||
assert!(t.user_dir("").is_err());
|
||||
assert!(t.user_dir("ABCDEFABCDEF").is_err());
|
||||
assert!(t.content_db_i64(-5).is_err());
|
||||
assert!(t.content_db_i64(0).is_err());
|
||||
assert!(t.extension_db("2", "../cv").is_err());
|
||||
assert!(t.extension_db("2", "cv/db").is_err());
|
||||
assert!(t.extension_db("2", "").is_err());
|
||||
assert!(t.extension_db("2", "CV").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_party_extension_names_are_valid() {
|
||||
for name in FIRST_PARTY_EXTENSIONS {
|
||||
assert!(is_valid_extension_name(name), "{name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+194
-71
@@ -1,7 +1,70 @@
|
||||
use crate::identity::TenantId;
|
||||
use crate::models::{TenantUser, UserApiToken, UserQuotas, UserSession};
|
||||
use chrono::Utc;
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
|
||||
const USER_COLUMNS: &str = "id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata, tenant_id, uuid";
|
||||
|
||||
fn map_user(row: &rusqlite::Row<'_>) -> rusqlite::Result<TenantUser> {
|
||||
let tenant_raw: Option<String> = row.get(9)?;
|
||||
let tenant_id = match tenant_raw {
|
||||
Some(s) => Some(TenantId::parse(&s).map_err(|e| {
|
||||
rusqlite::Error::FromSqlConversionFailure(9, rusqlite::types::Type::Text, Box::new(e))
|
||||
})?),
|
||||
None => None,
|
||||
};
|
||||
let uuid: Option<String> = row.get(10)?;
|
||||
Ok(TenantUser {
|
||||
id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
status: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
last_login: row.get(5)?,
|
||||
account_type: row.get(6)?,
|
||||
organization_id: row.get(7)?,
|
||||
metadata: row.get(8)?,
|
||||
tenant_id,
|
||||
uuid,
|
||||
})
|
||||
}
|
||||
|
||||
fn allocate_unique_tenant_id(conn: &Connection) -> rusqlite::Result<TenantId> {
|
||||
for _ in 0..16 {
|
||||
let candidate = TenantId::generate();
|
||||
let exists: bool = conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM users WHERE tenant_id = ?1);",
|
||||
[candidate.as_str()],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if !exists {
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
Err(rusqlite::Error::SqliteFailure(
|
||||
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
|
||||
Some("failed to allocate unique TenantId".into()),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn allocate_unique_uuid(conn: &Connection) -> rusqlite::Result<String> {
|
||||
for _ in 0..16 {
|
||||
let candidate = uuid::Uuid::new_v4().to_string();
|
||||
let exists: bool = conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM users WHERE uuid = ?1);",
|
||||
[&candidate],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if !exists {
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
Err(rusqlite::Error::SqliteFailure(
|
||||
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
|
||||
Some("failed to allocate unique UUID".into()),
|
||||
))
|
||||
}
|
||||
|
||||
// --- User Operations ---
|
||||
|
||||
pub fn is_reserved_username(username: &str) -> bool {
|
||||
@@ -17,11 +80,19 @@ pub fn create_admin_user(
|
||||
let created_at = Utc::now().to_rfc3339();
|
||||
let status = "active";
|
||||
let account_type = "admin";
|
||||
let user_uuid = allocate_unique_uuid(conn)?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, NULL);",
|
||||
params![username, password_hash, status, created_at, account_type],
|
||||
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata, tenant_id, uuid)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, NULL, NULL, ?6);",
|
||||
params![
|
||||
username,
|
||||
password_hash,
|
||||
status,
|
||||
created_at,
|
||||
account_type,
|
||||
user_uuid,
|
||||
],
|
||||
)?;
|
||||
|
||||
let id = conn.last_insert_rowid();
|
||||
@@ -39,6 +110,8 @@ pub fn create_admin_user(
|
||||
account_type: account_type.to_string(),
|
||||
organization_id: None,
|
||||
metadata: None,
|
||||
tenant_id: None,
|
||||
uuid: Some(user_uuid),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -58,17 +131,21 @@ pub fn create_user(
|
||||
|
||||
let created_at = Utc::now().to_rfc3339();
|
||||
let status = "active";
|
||||
let tenant_id = allocate_unique_tenant_id(conn)?;
|
||||
let user_uuid = allocate_unique_uuid(conn)?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6);",
|
||||
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata, tenant_id, uuid)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8);",
|
||||
params![
|
||||
username,
|
||||
password_hash,
|
||||
status,
|
||||
created_at,
|
||||
account_type,
|
||||
metadata
|
||||
metadata,
|
||||
tenant_id.as_str(),
|
||||
user_uuid,
|
||||
],
|
||||
)?;
|
||||
|
||||
@@ -87,27 +164,37 @@ pub fn create_user(
|
||||
account_type: account_type.to_string(),
|
||||
organization_id: None,
|
||||
metadata: metadata.map(|s| s.to_string()),
|
||||
tenant_id: Some(tenant_id),
|
||||
uuid: Some(user_uuid),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_user_by_id(conn: &Connection, id: i64) -> rusqlite::Result<Option<TenantUser>> {
|
||||
conn.query_row(
|
||||
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
|
||||
FROM users WHERE id = ?1;",
|
||||
&format!("SELECT {USER_COLUMNS} FROM users WHERE id = ?1;"),
|
||||
params![id],
|
||||
|row| {
|
||||
Ok(TenantUser {
|
||||
id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
status: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
last_login: row.get(5)?,
|
||||
account_type: row.get(6)?,
|
||||
organization_id: row.get(7)?,
|
||||
metadata: row.get(8)?,
|
||||
})
|
||||
},
|
||||
map_user,
|
||||
)
|
||||
.optional()
|
||||
}
|
||||
|
||||
pub fn get_user_by_tenant_id(
|
||||
conn: &Connection,
|
||||
tenant_id: TenantId,
|
||||
) -> rusqlite::Result<Option<TenantUser>> {
|
||||
conn.query_row(
|
||||
&format!("SELECT {USER_COLUMNS} FROM users WHERE tenant_id = ?1;"),
|
||||
params![tenant_id.as_str()],
|
||||
map_user,
|
||||
)
|
||||
.optional()
|
||||
}
|
||||
|
||||
pub fn get_user_by_uuid(conn: &Connection, uuid: &str) -> rusqlite::Result<Option<TenantUser>> {
|
||||
conn.query_row(
|
||||
&format!("SELECT {USER_COLUMNS} FROM users WHERE uuid = ?1;"),
|
||||
params![uuid],
|
||||
map_user,
|
||||
)
|
||||
.optional()
|
||||
}
|
||||
@@ -117,22 +204,9 @@ pub fn get_user_by_username(
|
||||
username: &str,
|
||||
) -> rusqlite::Result<Option<TenantUser>> {
|
||||
conn.query_row(
|
||||
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
|
||||
FROM users WHERE username = ?1;",
|
||||
&format!("SELECT {USER_COLUMNS} FROM users WHERE username = ?1;"),
|
||||
params![username],
|
||||
|row| {
|
||||
Ok(TenantUser {
|
||||
id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
status: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
last_login: row.get(5)?,
|
||||
account_type: row.get(6)?,
|
||||
organization_id: row.get(7)?,
|
||||
metadata: row.get(8)?,
|
||||
})
|
||||
},
|
||||
map_user,
|
||||
)
|
||||
.optional()
|
||||
}
|
||||
@@ -184,23 +258,10 @@ pub fn update_user_last_login(conn: &Connection, id: i64) -> rusqlite::Result<()
|
||||
}
|
||||
|
||||
pub fn list_users(conn: &Connection) -> rusqlite::Result<Vec<TenantUser>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
|
||||
FROM users ORDER BY username ASC;",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok(TenantUser {
|
||||
id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
status: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
last_login: row.get(5)?,
|
||||
account_type: row.get(6)?,
|
||||
organization_id: row.get(7)?,
|
||||
metadata: row.get(8)?,
|
||||
})
|
||||
})?;
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {USER_COLUMNS} FROM users ORDER BY username ASC;"
|
||||
))?;
|
||||
let rows = stmt.query_map([], map_user)?;
|
||||
|
||||
let mut users = Vec::new();
|
||||
for u in rows {
|
||||
@@ -439,6 +500,9 @@ pub fn delete_user_api_token(conn: &Connection, id: i64, user_id: i64) -> rusqli
|
||||
|
||||
// --- Global Slug & Quota Reconciliation Helpers ---
|
||||
|
||||
#[deprecated(
|
||||
note = "Legacy v0.7 global_slugs function; use crate::db::slugs::is_slug_available instead"
|
||||
)]
|
||||
pub fn is_slug_available(system_conn: &Connection, slug: &str) -> rusqlite::Result<bool> {
|
||||
// 1. Check reserved list
|
||||
let reserved: bool = system_conn
|
||||
@@ -465,6 +529,9 @@ pub fn is_slug_available(system_conn: &Connection, slug: &str) -> rusqlite::Resu
|
||||
Ok(!exists)
|
||||
}
|
||||
|
||||
#[deprecated(
|
||||
note = "Legacy v0.7 global_slugs function; use crate::db::slugs::reserve_*_slug instead"
|
||||
)]
|
||||
pub fn register_global_slug(
|
||||
system_conn: &Connection,
|
||||
slug: &str,
|
||||
@@ -490,6 +557,9 @@ pub fn register_global_slug(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[deprecated(
|
||||
note = "Legacy v0.7 global_slugs function; use crate::db::slugs::release_*_slug instead"
|
||||
)]
|
||||
pub fn release_global_slug(
|
||||
system_conn: &Connection,
|
||||
slug: &str,
|
||||
@@ -508,6 +578,7 @@ pub fn release_global_slug(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[deprecated(note = "Legacy v0.7 global_slugs function; use crate::db::slugs APIs instead")]
|
||||
pub fn soft_delete_global_slug(
|
||||
system_conn: &Connection,
|
||||
slug: &str,
|
||||
@@ -544,10 +615,11 @@ pub fn audit_slug_namespace(
|
||||
let mut invalid_entries = Vec::new();
|
||||
let warnings = Vec::new();
|
||||
|
||||
let mut slug_owners: HashMap<String, Vec<i64>> = HashMap::new();
|
||||
let mut slug_owners: HashMap<String, Vec<String>> = HashMap::new();
|
||||
|
||||
// 1. Scan legacy content.db if it exists
|
||||
let legacy_content_path = config.data_dir.join("content.db");
|
||||
let legacy_content_path =
|
||||
crate::db::topology::Topology::new(&config.data_dir).legacy_flat_content_db();
|
||||
if legacy_content_path.exists() {
|
||||
if let Ok(conn) = Connection::open(&legacy_content_path) {
|
||||
// URLs
|
||||
@@ -555,7 +627,7 @@ pub fn audit_slug_namespace(
|
||||
if let Ok(mut rows) = stmt.query([]) {
|
||||
while let Some(row) = rows.next().unwrap_or(None) {
|
||||
if let Ok(code) = row.get::<_, String>(0) {
|
||||
slug_owners.entry(code).or_default().push(1); // 1 = legacy admin
|
||||
slug_owners.entry(code).or_default().push("1".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -565,7 +637,7 @@ pub fn audit_slug_namespace(
|
||||
if let Ok(mut rows) = stmt.query([]) {
|
||||
while let Some(row) = rows.next().unwrap_or(None) {
|
||||
if let Ok(code) = row.get::<_, String>(0) {
|
||||
slug_owners.entry(code).or_default().push(1);
|
||||
slug_owners.entry(code).or_default().push("1".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -574,14 +646,14 @@ pub fn audit_slug_namespace(
|
||||
}
|
||||
|
||||
// 2. Scan all tenant databases in data_dir/users/<id>/content.db
|
||||
let users_dir = config.data_dir.join("users");
|
||||
let users_dir = crate::db::topology::Topology::new(&config.data_dir).users_dir();
|
||||
if users_dir.exists() {
|
||||
for entry in std::fs::read_dir(users_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
if let Some(name_str) = path.file_name().and_then(|n| n.to_str()) {
|
||||
if let Ok(user_id) = name_str.parse::<i64>() {
|
||||
if crate::db::topology::is_valid_user_dir_name(name_str) {
|
||||
let content_db_path = path.join("content.db");
|
||||
if content_db_path.exists() {
|
||||
if let Ok(conn) = Connection::open(&content_db_path) {
|
||||
@@ -590,7 +662,10 @@ pub fn audit_slug_namespace(
|
||||
if let Ok(mut rows) = stmt.query([]) {
|
||||
while let Some(row) = rows.next().unwrap_or(None) {
|
||||
if let Ok(code) = row.get::<_, String>(0) {
|
||||
slug_owners.entry(code).or_default().push(user_id);
|
||||
slug_owners
|
||||
.entry(code)
|
||||
.or_default()
|
||||
.push(name_str.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -602,7 +677,10 @@ pub fn audit_slug_namespace(
|
||||
if let Ok(mut rows) = stmt.query([]) {
|
||||
while let Some(row) = rows.next().unwrap_or(None) {
|
||||
if let Ok(code) = row.get::<_, String>(0) {
|
||||
slug_owners.entry(code).or_default().push(user_id);
|
||||
slug_owners
|
||||
.entry(code)
|
||||
.or_default()
|
||||
.push(name_str.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -638,6 +716,9 @@ pub fn audit_slug_namespace(
|
||||
})
|
||||
}
|
||||
|
||||
#[deprecated(
|
||||
note = "Legacy v0.7 global_slugs function; use crate::db::slugs::cleanup_stale_reservations instead"
|
||||
)]
|
||||
pub fn cleanup_stale_reservations(
|
||||
system_conn: &Connection,
|
||||
data_dir: &std::path::Path,
|
||||
@@ -661,18 +742,31 @@ pub fn cleanup_stale_reservations(
|
||||
let age = Utc::now().signed_duration_since(created_at.with_timezone(&Utc));
|
||||
if age > chrono::Duration::minutes(15) {
|
||||
// Check if target record exists by looking up code = slug in owner's content.db
|
||||
let content_db_path = if owner_user_id == 1 {
|
||||
let p1 = data_dir.join("users").join("1").join("content.db");
|
||||
if p1.exists() {
|
||||
p1
|
||||
let topology = crate::db::topology::Topology::new(data_dir);
|
||||
let content_db_path = {
|
||||
let users_path = topology.users_registry_db();
|
||||
if let Ok(users_conn) = Connection::open(&users_path) {
|
||||
crate::db::tenant::existing_content_path(
|
||||
&users_conn,
|
||||
&topology,
|
||||
owner_user_id,
|
||||
)
|
||||
.ok()
|
||||
.or_else(|| {
|
||||
if owner_user_id == 1 {
|
||||
Some(topology.legacy_flat_content_db())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| topology.legacy_flat_content_db())
|
||||
} else if owner_user_id == 1 {
|
||||
topology.legacy_flat_content_db()
|
||||
} else {
|
||||
data_dir.join("content.db")
|
||||
topology
|
||||
.content_db_i64(owner_user_id)
|
||||
.unwrap_or_else(|_| topology.legacy_flat_content_db())
|
||||
}
|
||||
} else {
|
||||
data_dir
|
||||
.join("users")
|
||||
.join(owner_user_id.to_string())
|
||||
.join("content.db")
|
||||
};
|
||||
|
||||
let mut target_exists = false;
|
||||
@@ -722,6 +816,9 @@ pub fn cleanup_stale_reservations(
|
||||
Ok(cleaned_count)
|
||||
}
|
||||
|
||||
#[deprecated(
|
||||
note = "Legacy v0.7 global_slugs function; use v0.8 slug databases and TenantId instead"
|
||||
)]
|
||||
pub fn register_restored_user_slugs(
|
||||
system_conn: &Connection,
|
||||
target_user_id: i64,
|
||||
@@ -867,3 +964,29 @@ pub fn reconcile_user_quotas(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Calculate aggregate platform total clicks across all active tenant analytics databases.
|
||||
pub fn get_platform_total_clicks(
|
||||
topology: &crate::db::topology::Topology,
|
||||
users_conn: &Connection,
|
||||
) -> Option<i64> {
|
||||
let mut stmt = users_conn
|
||||
.prepare("SELECT tenant_id FROM users WHERE status != 'deleted' AND tenant_id IS NOT NULL;")
|
||||
.ok()?;
|
||||
let rows = stmt.query_map([], |row| row.get::<_, String>(0)).ok()?;
|
||||
let mut total = 0i64;
|
||||
for tid_str in rows.flatten() {
|
||||
if let Ok(tid) = crate::identity::TenantId::parse(&tid_str) {
|
||||
let analytics_path = topology.tenant_analytics_db(tid);
|
||||
if analytics_path.exists() {
|
||||
if let Ok(conn) = Connection::open(&analytics_path) {
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM visits;", [], |r| r.get(0))
|
||||
.unwrap_or(0);
|
||||
total += count;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(total)
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
//! Frozen v0.8 tenant identity.
|
||||
//!
|
||||
//! `TenantId` is the filesystem-safe opaque tenant identifier: exactly 12
|
||||
//! lowercase hexadecimal characters, CSPRNG-generated, independent of username
|
||||
//! and of SQLite row ids.
|
||||
|
||||
use rand::{thread_rng, RngCore};
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
/// Opaque tenant identifier: 12 lowercase hex characters (e.g. `fafafa12c3e4`).
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct TenantId {
|
||||
hex: [u8; Self::LEN],
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TenantIdError {
|
||||
InvalidLength { got: usize },
|
||||
InvalidCharacter { found: char },
|
||||
}
|
||||
|
||||
impl fmt::Display for TenantIdError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::InvalidLength { got } => {
|
||||
write!(
|
||||
f,
|
||||
"TenantId must be {} lowercase hex characters, got {got}",
|
||||
TenantId::LEN
|
||||
)
|
||||
}
|
||||
Self::InvalidCharacter { found } => {
|
||||
write!(f, "TenantId must be lowercase hexadecimal, found {found:?}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for TenantIdError {}
|
||||
|
||||
impl TenantId {
|
||||
pub const LEN: usize = 12;
|
||||
|
||||
/// Cryptographically secure random TenantId. Never derived from user data.
|
||||
pub fn generate() -> Self {
|
||||
let mut bytes = [0u8; 6];
|
||||
thread_rng().fill_bytes(&mut bytes);
|
||||
let encoded = hex::encode(bytes);
|
||||
Self::parse(&encoded).expect("CSPRNG hex encoding is 12 lowercase chars")
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Result<Self, TenantIdError> {
|
||||
if s.len() != Self::LEN {
|
||||
return Err(TenantIdError::InvalidLength { got: s.len() });
|
||||
}
|
||||
if let Some(found) = s.chars().find(|c| !matches!(c, '0'..='9' | 'a'..='f')) {
|
||||
return Err(TenantIdError::InvalidCharacter { found });
|
||||
}
|
||||
let mut hex = [0u8; Self::LEN];
|
||||
hex.copy_from_slice(s.as_bytes());
|
||||
Ok(Self { hex })
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
std::str::from_utf8(&self.hex).expect("TenantId is validated ASCII hex")
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for TenantId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for TenantId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_tuple("TenantId").field(&self.as_str()).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for TenantId {
|
||||
type Err = TenantIdError;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Self::parse(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl serde::Serialize for TenantId {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> serde::Deserialize<'de> for TenantId {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let s = String::deserialize(deserializer)?;
|
||||
Self::parse(&s).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_accepts_lowercase_12_hex() {
|
||||
let id = TenantId::parse("fafafa12c3e4").unwrap();
|
||||
assert_eq!(id.as_str(), "fafafa12c3e4");
|
||||
assert_eq!(id, TenantId::parse("fafafa12c3e4").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_uppercase_and_wrong_length() {
|
||||
assert!(matches!(
|
||||
TenantId::parse("FAFAFA12C3E4"),
|
||||
Err(TenantIdError::InvalidCharacter { found: 'F' })
|
||||
));
|
||||
assert!(matches!(
|
||||
TenantId::parse("abc"),
|
||||
Err(TenantIdError::InvalidLength { got: 3 })
|
||||
));
|
||||
assert!(TenantId::parse("a1b2c3d4e5f67").is_err());
|
||||
assert!(TenantId::parse("../etc/passwd").is_err());
|
||||
assert!(TenantId::parse("gggggggggggg").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_is_opaque_lowercase_hex() {
|
||||
let a = TenantId::generate();
|
||||
let b = TenantId::generate();
|
||||
assert_ne!(a, b);
|
||||
assert_eq!(a.as_str().len(), 12);
|
||||
assert!(a
|
||||
.as_str()
|
||||
.chars()
|
||||
.all(|c| matches!(c, '0'..='9' | 'a'..='f')));
|
||||
}
|
||||
}
|
||||
+41
-18
@@ -65,30 +65,48 @@ pub async fn perform_backup(
|
||||
if let Ok(conn) = db.admin.lock() {
|
||||
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
|
||||
}
|
||||
if let Ok(conn) = db.content.lock() {
|
||||
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
|
||||
}
|
||||
if let Ok(conn) = db.analytics.lock() {
|
||||
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
|
||||
}
|
||||
if let Ok(conn) = db.system.lock() {
|
||||
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
|
||||
}
|
||||
if let Ok(conn) = db.users.lock() {
|
||||
if let Ok(conn) = db.global_urls.lock() {
|
||||
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
|
||||
}
|
||||
if let Ok(conn) = db.global_landing_pages.lock() {
|
||||
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
|
||||
}
|
||||
if let Ok(conn) = db.reserved.lock() {
|
||||
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
|
||||
}
|
||||
let user_ids: Vec<i64> = if let Ok(conn) = db.users.lock() {
|
||||
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
|
||||
if let Ok(mut stmt) = conn.prepare("SELECT id FROM users;") {
|
||||
if let Ok(rows) = stmt.query_map([], |row| row.get::<_, i64>(0)) {
|
||||
let user_ids: Vec<i64> = rows.filter_map(|r| r.ok()).collect();
|
||||
for user_id in user_ids {
|
||||
if let Ok(u_conn) = crate::jobs::open_user_content_conn(db, user_id) {
|
||||
let _ = u_conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
|
||||
}
|
||||
if let Ok(u_conn) = crate::jobs::open_user_analytics_conn(db, user_id) {
|
||||
let _ = u_conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
|
||||
}
|
||||
}
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
for user_id in user_ids {
|
||||
if let Ok(u_conn) = crate::jobs::open_user_content_conn(db, user_id) {
|
||||
let _ = u_conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
|
||||
}
|
||||
if let Ok(u_conn) = crate::jobs::open_user_analytics_conn(db, user_id) {
|
||||
let _ = u_conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
|
||||
}
|
||||
}
|
||||
if let Ok(conn) = db.global_urls.lock() {
|
||||
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
|
||||
}
|
||||
if let Ok(conn) = db.global_landing_pages.lock() {
|
||||
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
|
||||
}
|
||||
if let Ok(conn) = db.reserved.lock() {
|
||||
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
|
||||
}
|
||||
|
||||
let date_str = Utc::now().format("%Y-%m-%d-%H%M%S").to_string();
|
||||
@@ -99,12 +117,17 @@ pub async fn perform_backup(
|
||||
let enc = GzEncoder::new(file, Compression::default());
|
||||
let mut tar = Builder::new(enc);
|
||||
|
||||
let admin_dir = config.data_dir.join("admin");
|
||||
let admin_dir = db.topology.admin_dir();
|
||||
if admin_dir.exists() {
|
||||
tar.append_dir_all("admin", &admin_dir)?;
|
||||
}
|
||||
|
||||
let users_dir = config.data_dir.join("users");
|
||||
let slugs_dir = db.topology.slugs_dir();
|
||||
if slugs_dir.exists() {
|
||||
tar.append_dir_all("slugs", &slugs_dir)?;
|
||||
}
|
||||
|
||||
let users_dir = db.topology.users_dir();
|
||||
if users_dir.exists() {
|
||||
tar.append_dir_all("users", &users_dir)?;
|
||||
}
|
||||
|
||||
+12
-10
@@ -46,11 +46,12 @@ pub fn open_user_content_conn(
|
||||
db: &Db,
|
||||
user_id: i64,
|
||||
) -> Result<rusqlite::Connection, rusqlite::Error> {
|
||||
let db_path = db
|
||||
.data_dir
|
||||
.join("users")
|
||||
.join(user_id.to_string())
|
||||
.join("content.db");
|
||||
let db_path = {
|
||||
let users = db.users.lock().map_err(|_| {
|
||||
rusqlite::Error::InvalidPath(std::path::PathBuf::from("users-db-poisoned"))
|
||||
})?;
|
||||
crate::db::tenant::existing_content_path(&users, &db.topology, user_id)?
|
||||
};
|
||||
let conn = rusqlite::Connection::open(db_path)?;
|
||||
crate::db::sqlite::enable_wal(&conn, "content")?;
|
||||
crate::db::sqlite::enable_foreign_keys(&conn, "content")?;
|
||||
@@ -61,11 +62,12 @@ pub fn open_user_analytics_conn(
|
||||
db: &Db,
|
||||
user_id: i64,
|
||||
) -> Result<rusqlite::Connection, rusqlite::Error> {
|
||||
let db_path = db
|
||||
.data_dir
|
||||
.join("users")
|
||||
.join(user_id.to_string())
|
||||
.join("analytics.db");
|
||||
let db_path = {
|
||||
let users = db.users.lock().map_err(|_| {
|
||||
rusqlite::Error::InvalidPath(std::path::PathBuf::from("users-db-poisoned"))
|
||||
})?;
|
||||
crate::db::tenant::existing_analytics_path(&users, &db.topology, user_id)?
|
||||
};
|
||||
let conn = rusqlite::Connection::open(db_path)?;
|
||||
crate::db::sqlite::enable_wal(&conn, "analytics")?;
|
||||
crate::db::sqlite::enable_foreign_keys(&conn, "analytics")?;
|
||||
|
||||
@@ -37,9 +37,9 @@ pub async fn run_quota_reconciliation(
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
for user_id in user_ids {
|
||||
if let Ok(content_conn) = super::open_user_content_conn(&db, user_id) {
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
if let Err(e) =
|
||||
crate::db::users::reconcile_user_quotas(&users_conn, user_id, &content_conn)
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ pub mod cli;
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod error;
|
||||
pub mod identity;
|
||||
pub mod jobs;
|
||||
pub mod models;
|
||||
pub mod services;
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
bzod::cli::audit_destinations::run(data_dir, config).await?;
|
||||
}
|
||||
Commands::CreateAdmin { username, data_dir } => {
|
||||
bzod::cli::create_admin::run(username, data_dir, config).await?;
|
||||
bzod::cli::create_admin::run(username, None, data_dir, config).await?;
|
||||
}
|
||||
Commands::InitAdmin { data_dir } => {
|
||||
bzod::cli::init_admin::run(data_dir, config).await?;
|
||||
|
||||
@@ -27,6 +27,24 @@ pub struct TenantUser {
|
||||
pub account_type: String, // 'system', 'admin', 'standard', 'organization', 'service'
|
||||
pub organization_id: Option<i64>,
|
||||
pub metadata: Option<String>,
|
||||
/// Frozen v0.8 tenant id. None only for unmigrated v0.7 rows (Phase 3).
|
||||
pub tenant_id: Option<crate::identity::TenantId>,
|
||||
/// Frozen v0.8 immutable UUID.
|
||||
pub uuid: Option<String>,
|
||||
}
|
||||
|
||||
impl TenantUser {
|
||||
pub fn require_tenant_id(&self) -> Result<crate::identity::TenantId, crate::error::AppError> {
|
||||
self.tenant_id.ok_or_else(|| {
|
||||
crate::error::AppError::Internal(format!("user {} has unmigrated tenant_id", self.id))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn require_uuid(&self) -> Result<&str, crate::error::AppError> {
|
||||
self.uuid.as_deref().ok_or_else(|| {
|
||||
crate::error::AppError::Internal(format!("user {} has unmigrated uuid", self.id))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::identity::TenantId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
@@ -12,6 +13,9 @@ pub struct VisitRecord {
|
||||
pub accept_language: String,
|
||||
pub country: String,
|
||||
pub status_code: u16,
|
||||
#[serde(default)]
|
||||
pub owner_tenant_id: Option<TenantId>,
|
||||
#[serde(default)]
|
||||
pub owner_user_id: Option<i64>,
|
||||
}
|
||||
|
||||
|
||||
@@ -5,16 +5,21 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::db::topology::{Topology, LEGACY_ADMIN_USER_KEY};
|
||||
|
||||
/// Move flat legacy DB files into multi-tenant paths after tarball extract.
|
||||
///
|
||||
/// Layout:
|
||||
/// - `admin.db` / `system.db` / `users.db` (+ wal/shm) → `{data_dir}/admin/`
|
||||
/// - `content.db` / `analytics.db` (+ wal/shm) → `{data_dir}/users/1/`
|
||||
/// - `{data_dir}/slugs/` is created empty if missing (v0.8 topology)
|
||||
pub fn normalize_restored_layout(data_dir: &Path) -> std::io::Result<()> {
|
||||
let admin_dir = data_dir.join("admin");
|
||||
let users_1_dir = data_dir.join("users").join("1");
|
||||
let topology = Topology::new(data_dir);
|
||||
let admin_dir = topology.admin_dir();
|
||||
let users_1_dir = topology.legacy_admin_dir();
|
||||
std::fs::create_dir_all(&admin_dir)?;
|
||||
std::fs::create_dir_all(&users_1_dir)?;
|
||||
std::fs::create_dir_all(topology.slugs_dir())?;
|
||||
|
||||
let admin_files = [
|
||||
"admin.db",
|
||||
@@ -80,12 +85,17 @@ pub struct RestoredDbPaths {
|
||||
|
||||
impl RestoredDbPaths {
|
||||
pub fn from_data_dir(data_dir: &Path) -> Self {
|
||||
let topology = Topology::new(data_dir);
|
||||
Self {
|
||||
admin: data_dir.join("admin/admin.db"),
|
||||
system: data_dir.join("admin/system.db"),
|
||||
users: data_dir.join("admin/users.db"),
|
||||
content: data_dir.join("users/1/content.db"),
|
||||
analytics: data_dir.join("users/1/analytics.db"),
|
||||
admin: topology.admin_db(),
|
||||
system: topology.system_db(),
|
||||
users: topology.users_registry_db(),
|
||||
content: topology
|
||||
.content_db(LEGACY_ADMIN_USER_KEY)
|
||||
.expect("legacy admin user key is valid"),
|
||||
analytics: topology
|
||||
.analytics_db(LEGACY_ADMIN_USER_KEY)
|
||||
.expect("legacy admin user key is valid"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,6 +123,7 @@ mod tests {
|
||||
assert!(dir.join("admin/users.db").exists());
|
||||
assert!(dir.join("users/1/content.db").exists());
|
||||
assert!(dir.join("users/1/analytics.db").exists());
|
||||
assert!(dir.join("slugs").is_dir());
|
||||
assert!(!dir.join("admin.db").exists());
|
||||
assert!(!dir.join("content.db").exists());
|
||||
|
||||
|
||||
+47
-27
@@ -4,6 +4,7 @@
|
||||
|
||||
use crate::auth::generate_token;
|
||||
use crate::auth::password::hash_password;
|
||||
use crate::identity::TenantId;
|
||||
use crate::models::Url;
|
||||
use crate::utils::validation::validate_redirect_destination;
|
||||
use rusqlite::{Connection, Transaction};
|
||||
@@ -38,9 +39,9 @@ impl BulkUrlError {
|
||||
}
|
||||
}
|
||||
|
||||
fn release_reserved(system: &Connection, slugs: &[String], owner_user_id: i64) {
|
||||
fn release_reserved(urls_conn: &Connection, slugs: &[String], owner_tenant_id: &TenantId) {
|
||||
for slug in slugs {
|
||||
let _ = crate::db::users::release_global_slug(system, slug, owner_user_id);
|
||||
let _ = crate::db::slugs::release_url_slug(urls_conn, slug, owner_tenant_id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,11 +67,15 @@ pub fn ensure_url_quota(
|
||||
}
|
||||
|
||||
/// Create many URLs inside a single content transaction with global slug reservation.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn create_urls_bulk(
|
||||
content_db: &Mutex<Connection>,
|
||||
system_db: &Mutex<Connection>,
|
||||
reserved_db: &Mutex<Connection>,
|
||||
global_urls_db: &Mutex<Connection>,
|
||||
global_landing_pages_db: &Mutex<Connection>,
|
||||
users_db: &Mutex<Connection>,
|
||||
owner_user_id: i64,
|
||||
owner_tenant_id: TenantId,
|
||||
items: Vec<BulkUrlCreateItem>,
|
||||
) -> Result<Vec<Url>, BulkUrlError> {
|
||||
let mut conn = crate::utils::lock_db(content_db, "content_db")
|
||||
@@ -83,12 +88,20 @@ pub fn create_urls_bulk(
|
||||
let mut reserved_slugs: Vec<String> = Vec::new();
|
||||
|
||||
for item in items {
|
||||
match create_one_in_tx(&tx, system_db, owner_user_id, item, &mut reserved_slugs) {
|
||||
match create_one_in_tx(
|
||||
&tx,
|
||||
reserved_db,
|
||||
global_urls_db,
|
||||
global_landing_pages_db,
|
||||
&owner_tenant_id,
|
||||
item,
|
||||
&mut reserved_slugs,
|
||||
) {
|
||||
Ok(url) => created_urls.push(url),
|
||||
Err(e) => {
|
||||
let _ = tx.rollback();
|
||||
if let Ok(system_conn) = crate::utils::lock_db(system_db, "system_db") {
|
||||
release_reserved(&system_conn, &reserved_slugs, owner_user_id);
|
||||
if let Ok(urls_conn) = crate::utils::lock_db(global_urls_db, "global_urls_db") {
|
||||
release_reserved(&urls_conn, &reserved_slugs, &owner_tenant_id);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
@@ -96,23 +109,20 @@ pub fn create_urls_bulk(
|
||||
}
|
||||
|
||||
if let Err(e) = tx.commit() {
|
||||
if let Ok(system_conn) = crate::utils::lock_db(system_db, "system_db") {
|
||||
release_reserved(&system_conn, &reserved_slugs, owner_user_id);
|
||||
if let Ok(urls_conn) = crate::utils::lock_db(global_urls_db, "global_urls_db") {
|
||||
release_reserved(&urls_conn, &reserved_slugs, &owner_tenant_id);
|
||||
}
|
||||
return Err(BulkUrlError::Internal(format!(
|
||||
"Failed to commit transaction: {e}"
|
||||
)));
|
||||
}
|
||||
|
||||
// Activate slugs
|
||||
// Activate slugs in v0.8 global_urls.db
|
||||
{
|
||||
let system_conn = crate::utils::lock_db(system_db, "system_db")
|
||||
let urls_conn = crate::utils::lock_db(global_urls_db, "global_urls_db")
|
||||
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
|
||||
for url in &created_urls {
|
||||
let _ = 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(), url.code],
|
||||
);
|
||||
let _ = crate::db::slugs::activate_url_slug(&urls_conn, &url.code, &url.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,37 +140,47 @@ pub fn create_urls_bulk(
|
||||
|
||||
fn create_one_in_tx(
|
||||
tx: &Transaction<'_>,
|
||||
system_db: &Mutex<Connection>,
|
||||
owner_user_id: i64,
|
||||
reserved_db: &Mutex<Connection>,
|
||||
global_urls_db: &Mutex<Connection>,
|
||||
global_landing_pages_db: &Mutex<Connection>,
|
||||
owner_tenant_id: &TenantId,
|
||||
item: BulkUrlCreateItem,
|
||||
reserved_slugs: &mut Vec<String>,
|
||||
) -> Result<Url, BulkUrlError> {
|
||||
let mut code = item.code.unwrap_or_default().trim().to_lowercase();
|
||||
if code.is_empty() {
|
||||
code = generate_token(3);
|
||||
} else if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
} else if !crate::utils::validation::validate_redirect_code(&code) {
|
||||
return Err(BulkUrlError::BadRequest(format!(
|
||||
"Short code '{code}' must be 6 hex characters"
|
||||
"Short code or slug '{code}' is invalid (must be 6 hex characters or !custom-slug)"
|
||||
)));
|
||||
}
|
||||
|
||||
{
|
||||
let system_conn = crate::utils::lock_db(system_db, "system_db")
|
||||
let reserved_conn = crate::utils::lock_db(reserved_db, "reserved_db")
|
||||
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
|
||||
let available = crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false)
|
||||
&& !reserved_slugs.contains(&code);
|
||||
let urls_conn = crate::utils::lock_db(global_urls_db, "global_urls_db")
|
||||
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
|
||||
let pages_conn = crate::utils::lock_db(global_landing_pages_db, "global_landing_pages_db")
|
||||
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
|
||||
|
||||
let available =
|
||||
crate::db::slugs::is_slug_available(&reserved_conn, &urls_conn, &pages_conn, &code)
|
||||
.unwrap_or(false)
|
||||
&& !reserved_slugs.contains(&code);
|
||||
|
||||
if !available {
|
||||
return Err(BulkUrlError::Conflict(format!(
|
||||
"Short code '{code}' already exists"
|
||||
)));
|
||||
}
|
||||
if let Err(e) = crate::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
|
||||
if let Err(e) = crate::db::slugs::reserve_url_slug(
|
||||
&reserved_conn,
|
||||
&urls_conn,
|
||||
&pages_conn,
|
||||
&code,
|
||||
owner_user_id,
|
||||
"url",
|
||||
"",
|
||||
"reserving",
|
||||
owner_tenant_id,
|
||||
) {
|
||||
return Err(BulkUrlError::Internal(format!(
|
||||
"Failed to reserve slug '{code}': {e}"
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
use crate::db::Db;
|
||||
use crate::utils::validation::{classify_redirect_destination, DestinationClass};
|
||||
use rusqlite::Connection;
|
||||
use std::path::Path;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
/// Summary counters for a destination audit run.
|
||||
@@ -124,14 +123,12 @@ pub fn audit_content_conn(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_user_content(data_dir: &Path, user_id: i64) -> Result<Connection, rusqlite::Error> {
|
||||
let path = data_dir
|
||||
.join("users")
|
||||
.join(user_id.to_string())
|
||||
.join("content.db");
|
||||
if !path.exists() {
|
||||
return Err(rusqlite::Error::InvalidPath(path));
|
||||
}
|
||||
fn open_user_content(db: &Db, user_id: i64) -> Result<Connection, rusqlite::Error> {
|
||||
let users = db
|
||||
.users
|
||||
.lock()
|
||||
.map_err(|_| rusqlite::Error::InvalidPath(std::path::PathBuf::from("users-db-poisoned")))?;
|
||||
let path = crate::db::tenant::existing_content_path(&users, &db.topology, user_id)?;
|
||||
let conn = Connection::open(path)?;
|
||||
crate::db::sqlite::enable_wal(&conn, "content")?;
|
||||
Ok(conn)
|
||||
@@ -158,7 +155,7 @@ pub fn audit_all_destinations(db: &Db) -> Result<DestinationAuditReport, String>
|
||||
};
|
||||
|
||||
for user_id in user_ids {
|
||||
match open_user_content(&db.data_dir, user_id) {
|
||||
match open_user_content(db, user_id) {
|
||||
Ok(conn) => {
|
||||
report.scanned_users += 1;
|
||||
if let Err(e) = audit_content_conn(&conn, user_id, &mut report) {
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
use crate::db::Db;
|
||||
use crate::error::AppError;
|
||||
use crate::models::LandingPage;
|
||||
|
||||
pub fn create_landing_page(
|
||||
db: &Db,
|
||||
conn: &rusqlite::Connection,
|
||||
code: &str,
|
||||
slug: &str,
|
||||
title: &str,
|
||||
html_content: &str,
|
||||
state: &str,
|
||||
) -> Result<LandingPage, AppError> {
|
||||
let conn = db.content.lock().unwrap();
|
||||
let page =
|
||||
crate::db::content::create_landing_page(&conn, code, slug, title, html_content, state)?;
|
||||
crate::db::content::create_landing_page(conn, code, slug, title, html_content, state)?;
|
||||
Ok(page)
|
||||
}
|
||||
|
||||
pub fn get_landing_page_by_code(db: &Db, code: &str) -> Result<Option<LandingPage>, AppError> {
|
||||
let conn = db.content.lock().unwrap();
|
||||
let page = crate::db::content::get_landing_page_by_code(&conn, code)?;
|
||||
pub fn get_landing_page_by_code(
|
||||
conn: &rusqlite::Connection,
|
||||
code: &str,
|
||||
) -> Result<Option<LandingPage>, AppError> {
|
||||
let page = crate::db::content::get_landing_page_by_code(conn, code)?;
|
||||
Ok(page)
|
||||
}
|
||||
+232
-152
@@ -1,23 +1,27 @@
|
||||
use crate::db::topology::Topology;
|
||||
use crate::identity::TenantId;
|
||||
use chrono::{DateTime, Utc};
|
||||
use rusqlite::Connection;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RegistryIssueType {
|
||||
DuplicateSlug,
|
||||
InvalidTargetType,
|
||||
InvalidStatus,
|
||||
MissingOwner,
|
||||
MissingDatabase,
|
||||
MissingTenant,
|
||||
MissingTarget,
|
||||
CorruptDatabase,
|
||||
AccessFailure,
|
||||
TrueOrphan,
|
||||
Conflict,
|
||||
StaleReservation,
|
||||
TenantAdminHasIsolatedContent,
|
||||
InvalidStatus,
|
||||
InvalidTargetType,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RegistryIssue {
|
||||
pub slug: String,
|
||||
pub target_type: String,
|
||||
pub owner_user_id: i64,
|
||||
pub owner_tenant_id: String,
|
||||
pub database_path: PathBuf,
|
||||
pub target_id: String,
|
||||
pub issue_type: RegistryIssueType,
|
||||
@@ -27,137 +31,237 @@ pub struct RegistryIssue {
|
||||
pub struct RegistryValidator;
|
||||
|
||||
impl RegistryValidator {
|
||||
/// Scans the global_slugs registry and returns a list of detected issues.
|
||||
/// Scans the v0.8 slug registries (`global_urls.db`, `global_landing_pages.db`, `reserved.db`)
|
||||
/// and returns a list of detected issues categorized per safety policies.
|
||||
pub fn scan(
|
||||
system_conn: &Connection,
|
||||
_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 topology = Topology::new(data_dir);
|
||||
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
|
||||
),
|
||||
});
|
||||
}
|
||||
let urls_path = topology.global_urls_db();
|
||||
let pages_path = topology.global_landing_pages_db();
|
||||
let reserved_path = topology.reserved_db();
|
||||
|
||||
if !urls_path.exists() || !pages_path.exists() || !reserved_path.exists() {
|
||||
issues.push(RegistryIssue {
|
||||
slug: "*".to_string(),
|
||||
target_type: "system".to_string(),
|
||||
owner_tenant_id: "".to_string(),
|
||||
database_path: urls_path,
|
||||
target_id: "".to_string(),
|
||||
issue_type: RegistryIssueType::AccessFailure,
|
||||
description: "One or more v0.8 slug databases are missing from disk".to_string(),
|
||||
});
|
||||
return Ok(issues);
|
||||
}
|
||||
|
||||
// 2. Scan global slugs
|
||||
let (query, params_string) = if let Some(slug) = slug_filter {
|
||||
let urls_conn = Connection::open(&urls_path)?;
|
||||
let pages_conn = Connection::open(&pages_path)?;
|
||||
let reserved_conn = Connection::open(&reserved_path)?;
|
||||
|
||||
// 1. Scan global_urls.db
|
||||
Self::scan_table(
|
||||
&urls_conn,
|
||||
users_conn,
|
||||
&reserved_conn,
|
||||
&pages_conn,
|
||||
&topology,
|
||||
"url",
|
||||
slug_filter,
|
||||
&mut issues,
|
||||
)?;
|
||||
|
||||
// 2. Scan global_landing_pages.db
|
||||
Self::scan_table(
|
||||
&pages_conn,
|
||||
users_conn,
|
||||
&reserved_conn,
|
||||
&urls_conn,
|
||||
&topology,
|
||||
"page",
|
||||
slug_filter,
|
||||
&mut issues,
|
||||
)?;
|
||||
|
||||
Ok(issues)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn scan_table(
|
||||
conn: &Connection,
|
||||
users_conn: &Connection,
|
||||
reserved_conn: &Connection,
|
||||
other_conn: &Connection,
|
||||
topology: &Topology,
|
||||
target_type: &str,
|
||||
slug_filter: Option<&str>,
|
||||
issues: &mut Vec<RegistryIssue>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (query, params_vec) = if let Some(slug) = slug_filter {
|
||||
(
|
||||
"SELECT slug, owner_user_id, target_type, target_id, created_at, status FROM global_slugs WHERE slug = ?1;",
|
||||
format!(
|
||||
"SELECT slug, owner_tenant_id, target_id, created_at, status FROM global_{}s WHERE slug = ?1;",
|
||||
if target_type == "url" { "url" } else { "landing_page" }
|
||||
),
|
||||
vec![slug.to_string()],
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"SELECT slug, owner_user_id, target_type, target_id, created_at, status FROM global_slugs;",
|
||||
format!(
|
||||
"SELECT slug, owner_tenant_id, target_id, created_at, status FROM global_{}s;",
|
||||
if target_type == "url" {
|
||||
"url"
|
||||
} else {
|
||||
"landing_page"
|
||||
}
|
||||
),
|
||||
vec![],
|
||||
)
|
||||
};
|
||||
|
||||
let mut stmt = system_conn.prepare(query)?;
|
||||
let mut rows = stmt.query(rusqlite::params_from_iter(params_string))?;
|
||||
let mut stmt = conn.prepare(&query)?;
|
||||
let mut rows = stmt.query(rusqlite::params_from_iter(params_vec))?;
|
||||
|
||||
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 owner_tenant_id_str: String = row.get(1)?;
|
||||
let target_id: String = row.get(2)?;
|
||||
let created_at_str: String = row.get(3)?;
|
||||
let status: String = row.get(4)?;
|
||||
|
||||
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")
|
||||
let tenant_id_res = TenantId::parse(&owner_tenant_id_str);
|
||||
let content_db_path = match tenant_id_res {
|
||||
Ok(tid) => topology.tenant_dir(tid).join("content.db"),
|
||||
Err(_) => topology.users_dir().join("_invalid").join("content.db"),
|
||||
};
|
||||
|
||||
// Target type check
|
||||
if target_type != "url" && target_type != "page" {
|
||||
// 1. Conflict with reserved.db
|
||||
let is_reserved: bool = reserved_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM reserved_slugs WHERE slug = ?1);",
|
||||
[&slug],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
if is_reserved {
|
||||
issues.push(RegistryIssue {
|
||||
slug: slug.clone(),
|
||||
target_type: target_type.clone(),
|
||||
owner_user_id,
|
||||
database_path: content_db_path.clone(),
|
||||
target_type: target_type.to_string(),
|
||||
owner_tenant_id: owner_tenant_id_str.clone(),
|
||||
database_path: topology.reserved_db(),
|
||||
target_id: target_id.clone(),
|
||||
issue_type: RegistryIssueType::InvalidTargetType,
|
||||
issue_type: RegistryIssueType::Conflict,
|
||||
description: format!("Slug '{}' conflicts with a reserved system route", slug),
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Conflict with the other slug database
|
||||
let other_table = if target_type == "url" {
|
||||
"global_landing_pages"
|
||||
} else {
|
||||
"global_urls"
|
||||
};
|
||||
let in_other: bool = other_conn
|
||||
.query_row(
|
||||
&format!("SELECT EXISTS(SELECT 1 FROM {other_table} WHERE slug = ?1);"),
|
||||
[&slug],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
if in_other {
|
||||
issues.push(RegistryIssue {
|
||||
slug: slug.clone(),
|
||||
target_type: target_type.to_string(),
|
||||
owner_tenant_id: owner_tenant_id_str.clone(),
|
||||
database_path: conn.path().map(PathBuf::from).unwrap_or_default(),
|
||||
target_id: target_id.clone(),
|
||||
issue_type: RegistryIssueType::Conflict,
|
||||
description: format!(
|
||||
"Slug '{}' has invalid target_type '{}'",
|
||||
slug, target_type
|
||||
"Slug '{}' exists in both global_urls.db and global_landing_pages.db",
|
||||
slug
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Status check
|
||||
if status != "active" && status != "disabled" && status != "reserving" {
|
||||
// 3. Status check
|
||||
if status != "active"
|
||||
&& status != "disabled"
|
||||
&& status != "reserving"
|
||||
&& status != "retired"
|
||||
{
|
||||
issues.push(RegistryIssue {
|
||||
slug: slug.clone(),
|
||||
target_type: target_type.clone(),
|
||||
owner_user_id,
|
||||
database_path: content_db_path.clone(),
|
||||
target_type: target_type.to_string(),
|
||||
owner_tenant_id: owner_tenant_id_str.clone(),
|
||||
database_path: conn.path().map(PathBuf::from).unwrap_or_default(),
|
||||
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 retired, no active target check is needed
|
||||
if status == "retired" {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 4. Owner tenant check in users.db
|
||||
let tid = match tenant_id_res {
|
||||
Ok(t) => t,
|
||||
Err(_) => {
|
||||
issues.push(RegistryIssue {
|
||||
slug: slug.clone(),
|
||||
target_type: target_type.to_string(),
|
||||
owner_tenant_id: owner_tenant_id_str.clone(),
|
||||
database_path: content_db_path,
|
||||
target_id: target_id.clone(),
|
||||
issue_type: RegistryIssueType::MissingTenant,
|
||||
description: format!(
|
||||
"Slug '{}' has invalid TenantId '{}'",
|
||||
slug, owner_tenant_id_str
|
||||
),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let owner_opt = crate::db::users::get_user_by_tenant_id(users_conn, tid)?;
|
||||
let owner_exists = match owner_opt {
|
||||
Some(ref u) => u.status != "deleted",
|
||||
None => false,
|
||||
};
|
||||
|
||||
if !owner_exists {
|
||||
issues.push(RegistryIssue {
|
||||
slug: slug.clone(),
|
||||
target_type: target_type.clone(),
|
||||
owner_user_id,
|
||||
target_type: target_type.to_string(),
|
||||
owner_tenant_id: owner_tenant_id_str.clone(),
|
||||
database_path: content_db_path.clone(),
|
||||
target_id: target_id.clone(),
|
||||
issue_type: RegistryIssueType::MissingOwner,
|
||||
issue_type: RegistryIssueType::MissingTenant,
|
||||
description: format!(
|
||||
"Slug '{}' references missing owner user ID {}",
|
||||
slug, owner_user_id
|
||||
"Slug '{}' references missing or deleted owner tenant '{}'",
|
||||
slug, owner_tenant_id_str
|
||||
),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Stale warning check
|
||||
// 5. Stale reservation 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,
|
||||
target_type: target_type.to_string(),
|
||||
owner_tenant_id: owner_tenant_id_str.clone(),
|
||||
database_path: content_db_path.clone(),
|
||||
target_id: target_id.clone(),
|
||||
issue_type: RegistryIssueType::StaleReservation,
|
||||
@@ -170,19 +274,16 @@ impl RegistryValidator {
|
||||
}
|
||||
}
|
||||
|
||||
// Check target record exists for active / disabled (and reserving with target_id)
|
||||
if status == "active"
|
||||
|| status == "disabled"
|
||||
|| (status == "reserving" && !target_id.is_empty())
|
||||
{
|
||||
// 6. Target record check in tenant content DB
|
||||
if status == "active" || status == "disabled" {
|
||||
if !content_db_path.exists() {
|
||||
issues.push(RegistryIssue {
|
||||
slug: slug.clone(),
|
||||
target_type: target_type.clone(),
|
||||
owner_user_id,
|
||||
target_type: target_type.to_string(),
|
||||
owner_tenant_id: owner_tenant_id_str.clone(),
|
||||
database_path: content_db_path.clone(),
|
||||
target_id: target_id.clone(),
|
||||
issue_type: RegistryIssueType::MissingDatabase,
|
||||
issue_type: RegistryIssueType::TrueOrphan,
|
||||
description: format!(
|
||||
"Slug '{}' owner content database does not exist at {:?}",
|
||||
slug, content_db_path
|
||||
@@ -190,47 +291,66 @@ impl RegistryValidator {
|
||||
});
|
||||
} else {
|
||||
match Connection::open(&content_db_path) {
|
||||
Ok(conn) => {
|
||||
Ok(tenant_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)
|
||||
tenant_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM urls WHERE id = ?1);",
|
||||
[&target_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
tenant_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM landing_pages WHERE id = ?1);",
|
||||
[&target_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(false)
|
||||
};
|
||||
|
||||
if !exists {
|
||||
issues.push(RegistryIssue {
|
||||
slug: slug.clone(),
|
||||
target_type: target_type.clone(),
|
||||
owner_user_id,
|
||||
target_type: target_type.to_string(),
|
||||
owner_tenant_id: owner_tenant_id_str.clone(),
|
||||
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),
|
||||
description: format!(
|
||||
"Slug '{}' (type: '{}', id: '{}') references missing target record in tenant content database",
|
||||
slug, target_type, target_id
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(rusqlite::Error::SqliteFailure(err, _))
|
||||
if err.code == rusqlite::ErrorCode::DatabaseCorrupt =>
|
||||
{
|
||||
issues.push(RegistryIssue {
|
||||
slug: slug.clone(),
|
||||
target_type: target_type.to_string(),
|
||||
owner_tenant_id: owner_tenant_id_str.clone(),
|
||||
database_path: content_db_path.clone(),
|
||||
target_id: target_id.clone(),
|
||||
issue_type: RegistryIssueType::CorruptDatabase,
|
||||
description: format!(
|
||||
"Slug '{}' owner content database is corrupt at {:?}",
|
||||
slug, content_db_path
|
||||
),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
issues.push(RegistryIssue {
|
||||
slug: slug.clone(),
|
||||
target_type: target_type.clone(),
|
||||
owner_user_id,
|
||||
target_type: target_type.to_string(),
|
||||
owner_tenant_id: owner_tenant_id_str.clone(),
|
||||
database_path: content_db_path.clone(),
|
||||
target_id: target_id.clone(),
|
||||
issue_type: RegistryIssueType::MissingDatabase,
|
||||
issue_type: RegistryIssueType::AccessFailure,
|
||||
description: format!(
|
||||
"Slug '{}' owner content database could not be opened: {}",
|
||||
"Slug '{}' owner content database could not be accessed: {}",
|
||||
slug, e
|
||||
),
|
||||
});
|
||||
@@ -240,46 +360,6 @@ impl RegistryValidator {
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
use crate::db::Db;
|
||||
use crate::error::AppError;
|
||||
use crate::models::Url;
|
||||
|
||||
pub fn create_url(
|
||||
db: &Db,
|
||||
conn: &rusqlite::Connection,
|
||||
code: &str,
|
||||
destination: &str,
|
||||
title: Option<&str>,
|
||||
@@ -15,19 +14,11 @@ pub fn create_url(
|
||||
"Destination must be a valid http(s) URL without control characters".into(),
|
||||
));
|
||||
}
|
||||
let conn = db
|
||||
.content
|
||||
.lock()
|
||||
.map_err(|e| AppError::Internal(format!("content_db mutex poisoned: {}", e)))?;
|
||||
let url = crate::db::content::create_url(&conn, code, destination, title, description, tags)?;
|
||||
let url = crate::db::content::create_url(conn, code, destination, title, description, tags)?;
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
pub fn get_url_by_code(db: &Db, code: &str) -> Result<Option<Url>, AppError> {
|
||||
let conn = db
|
||||
.content
|
||||
.lock()
|
||||
.map_err(|e| AppError::Internal(format!("content_db mutex poisoned: {}", e)))?;
|
||||
let url = crate::db::content::get_url_by_code(&conn, code)?;
|
||||
pub fn get_url_by_code(conn: &rusqlite::Connection, code: &str) -> Result<Option<Url>, AppError> {
|
||||
let url = crate::db::content::get_url_by_code(conn, code)?;
|
||||
Ok(url)
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
//! Cross-tenant slug transfer business logic.
|
||||
//!
|
||||
//! Copies URL/page content between tenant content DBs, then updates global_slugs
|
||||
//! ownership. Handlers own admin auth and HTTP mapping.
|
||||
//! Copies URL/page content between tenant content DBs, then updates v0.8 slug
|
||||
//! databases ownership. Handlers own admin auth and HTTP mapping.
|
||||
|
||||
use crate::db::tenant::TenantOpenMode;
|
||||
use crate::identity::TenantId;
|
||||
use crate::state::{AppState, UserDbs};
|
||||
use crate::utils::lock_db;
|
||||
use chrono::Utc;
|
||||
use rusqlite::OptionalExtension;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum TransferError {
|
||||
@@ -34,31 +35,21 @@ pub struct SlugTransferRequest {
|
||||
pub struct SlugTransferResult {
|
||||
pub old_owner_user_id: i64,
|
||||
pub new_owner_user_id: i64,
|
||||
pub old_owner_tenant_id: TenantId,
|
||||
pub new_owner_tenant_id: TenantId,
|
||||
pub target_type: String,
|
||||
pub new_target_id: String,
|
||||
}
|
||||
|
||||
/// Look up slug ownership in `global_slugs`.
|
||||
pub fn lookup_slug(state: &AppState, slug: &str) -> Result<(i64, String, String), TransferError> {
|
||||
let system_conn = lock_db(&state.system_db, "system_db")
|
||||
.map_err(|e| TransferError::Internal(e.to_string()))?;
|
||||
let mut stmt = system_conn
|
||||
.prepare("SELECT owner_user_id, target_type, target_id FROM global_slugs WHERE slug = ?1;")
|
||||
.map_err(|e| TransferError::Internal(e.to_string()))?;
|
||||
let row_opt = stmt
|
||||
.query_row([slug], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
))
|
||||
})
|
||||
.optional()
|
||||
.map_err(|e| TransferError::Internal(e.to_string()))?;
|
||||
|
||||
match row_opt {
|
||||
Some(r) => Ok(r),
|
||||
None => Err(TransferError::NotFound("Slug not found")),
|
||||
/// Look up slug ownership in v0.8 slug databases (`global_urls.db` / `global_landing_pages.db`).
|
||||
pub fn lookup_slug(
|
||||
state: &AppState,
|
||||
slug: &str,
|
||||
) -> Result<crate::db::slugs::ResolvedSlugInfo, TransferError> {
|
||||
match state.lookup_slug(slug) {
|
||||
Ok(Some(info)) => Ok(info),
|
||||
Ok(None) => Err(TransferError::NotFound("Slug not found")),
|
||||
Err(e) => Err(TransferError::Internal(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,25 +149,68 @@ fn copy_content(
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform a full slug transfer (content + registry + quotas + history).
|
||||
/// Perform a full slug transfer (content + v0.8 slug registry + quotas + history).
|
||||
pub fn transfer_slug(
|
||||
state: &AppState,
|
||||
req: &SlugTransferRequest,
|
||||
admin_username: &str,
|
||||
) -> Result<SlugTransferResult, TransferError> {
|
||||
let (old_owner_user_id, target_type, _target_id) = lookup_slug(state, &req.slug)?;
|
||||
let slug_info = lookup_slug(state, &req.slug)?;
|
||||
let target_type = slug_info.target_type.as_str().to_string();
|
||||
|
||||
if old_owner_user_id == req.new_owner_user_id {
|
||||
let old_owner_tenant_id = TenantId::parse(&slug_info.owner_tenant_id).map_err(|_| {
|
||||
TransferError::Internal(format!(
|
||||
"Invalid owner tenant ID '{}' on slug '{}'",
|
||||
slug_info.owner_tenant_id, req.slug
|
||||
))
|
||||
})?;
|
||||
|
||||
// Look up old owner user row in users.db
|
||||
let (old_owner_user_id, new_owner_tenant_id) = {
|
||||
let users_conn = lock_db(&state.users_db, "users_db")
|
||||
.map_err(|e| TransferError::Internal(e.to_string()))?;
|
||||
|
||||
let old_user = crate::db::users::get_user_by_tenant_id(&users_conn, old_owner_tenant_id)
|
||||
.map_err(|e| TransferError::Internal(e.to_string()))?
|
||||
.ok_or_else(|| {
|
||||
TransferError::Internal(format!(
|
||||
"Current owner user for tenant {old_owner_tenant_id} not found"
|
||||
))
|
||||
})?;
|
||||
|
||||
let new_user = crate::db::users::get_user_by_id(&users_conn, req.new_owner_user_id)
|
||||
.map_err(|e| TransferError::Internal(e.to_string()))?
|
||||
.ok_or_else(|| {
|
||||
TransferError::BadRequest(format!(
|
||||
"Target user ID {} not found",
|
||||
req.new_owner_user_id
|
||||
))
|
||||
})?;
|
||||
|
||||
if new_user.account_type == "admin" {
|
||||
return Err(TransferError::BadRequest(
|
||||
"Target user cannot be an Admin account (must be a tenant account)".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let new_tid = new_user.tenant_id.ok_or_else(|| {
|
||||
TransferError::BadRequest("Target user has no TenantId allocated".into())
|
||||
})?;
|
||||
|
||||
(old_user.id, new_tid)
|
||||
};
|
||||
|
||||
if old_owner_tenant_id == new_owner_tenant_id {
|
||||
return Err(TransferError::BadRequest(
|
||||
"New owner must be different from the current owner".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let old_dbs = state
|
||||
.get_user_dbs(old_owner_user_id)
|
||||
.open_tenant(old_owner_tenant_id, TenantOpenMode::CoreJob)
|
||||
.map_err(|_| TransferError::Internal("Failed to load current owner's database".into()))?;
|
||||
let new_dbs = state
|
||||
.get_user_dbs(req.new_owner_user_id)
|
||||
.open_tenant(new_owner_tenant_id, TenantOpenMode::Provision)
|
||||
.map_err(|_| TransferError::Internal("Failed to load new owner's database".into()))?;
|
||||
|
||||
let new_target_id = copy_content(
|
||||
@@ -188,16 +222,29 @@ pub fn transfer_slug(
|
||||
req.new_owner_user_id,
|
||||
)?;
|
||||
|
||||
// Update authoritative v0.8 slug databases
|
||||
{
|
||||
let urls_conn = lock_db(&state.db.global_urls, "global_urls")
|
||||
.map_err(|e| TransferError::Internal(e.to_string()))?;
|
||||
let pages_conn = lock_db(&state.db.global_landing_pages, "global_landing_pages")
|
||||
.map_err(|e| TransferError::Internal(e.to_string()))?;
|
||||
|
||||
crate::db::slugs::transfer_slug_owner(
|
||||
&urls_conn,
|
||||
&pages_conn,
|
||||
&req.slug,
|
||||
&new_owner_tenant_id,
|
||||
&new_target_id,
|
||||
)
|
||||
.map_err(|e| TransferError::Internal(format!("Failed to update slug registry: {e}")))?;
|
||||
}
|
||||
|
||||
// Write audit event and history
|
||||
{
|
||||
let system_conn = lock_db(&state.system_db, "system_db")
|
||||
.map_err(|e| TransferError::Internal(e.to_string()))?;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let _ = system_conn.execute(
|
||||
"UPDATE global_slugs SET owner_user_id = ?1, target_id = ?2, updated_at = ?3 WHERE slug = ?4;",
|
||||
rusqlite::params![req.new_owner_user_id, new_target_id, now, req.slug],
|
||||
);
|
||||
|
||||
let _ = system_conn.execute(
|
||||
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username)
|
||||
VALUES (?1, ?2, ?3, 'transferred', ?4, ?5);",
|
||||
@@ -228,8 +275,8 @@ pub fn transfer_slug(
|
||||
"slug",
|
||||
&req.slug,
|
||||
Some(&format!(
|
||||
"From owner {} to owner {}",
|
||||
old_owner_user_id, req.new_owner_user_id
|
||||
"From tenant {} (user {}) to tenant {} (user {})",
|
||||
old_owner_tenant_id, old_owner_user_id, new_owner_tenant_id, req.new_owner_user_id
|
||||
)),
|
||||
);
|
||||
}
|
||||
@@ -237,6 +284,8 @@ pub fn transfer_slug(
|
||||
Ok(SlugTransferResult {
|
||||
old_owner_user_id,
|
||||
new_owner_user_id: req.new_owner_user_id,
|
||||
old_owner_tenant_id,
|
||||
new_owner_tenant_id,
|
||||
target_type,
|
||||
new_target_id,
|
||||
})
|
||||
|
||||
+110
-61
@@ -1,26 +1,20 @@
|
||||
use crate::analytics::queue::AnalyticsQueue;
|
||||
use crate::config::Config;
|
||||
use crate::db::Db;
|
||||
use rusqlite::Connection;
|
||||
use crate::identity::TenantId;
|
||||
use rusqlite::{Connection, OptionalExtension};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct UserDbs {
|
||||
pub content: Arc<Mutex<Connection>>,
|
||||
pub analytics: Arc<Mutex<Connection>>,
|
||||
pub profile: Arc<Mutex<Connection>>,
|
||||
}
|
||||
pub use crate::db::tenant::{TenantOpenMode, UserDbs};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub admin_db: Arc<Mutex<Connection>>,
|
||||
pub content_db: Arc<Mutex<Connection>>,
|
||||
pub analytics_db: Arc<Mutex<Connection>>,
|
||||
pub system_db: Arc<Mutex<Connection>>,
|
||||
pub users_db: Arc<Mutex<Connection>>,
|
||||
pub user_dbs: Arc<Mutex<HashMap<i64, UserDbs>>>,
|
||||
pub user_dbs: Arc<Mutex<HashMap<String, UserDbs>>>,
|
||||
pub db: Db,
|
||||
pub config: Config,
|
||||
pub analytics_queue: AnalyticsQueue,
|
||||
@@ -28,71 +22,126 @@ pub struct AppState {
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// Compatibility opener: Core `users.id` must refer to a registered,
|
||||
/// non-deleted user. Unknown ids never create a database.
|
||||
pub fn get_user_dbs(&self, user_id: i64) -> Result<UserDbs, crate::error::AppError> {
|
||||
self.get_user_dbs_with_mode(user_id, TenantOpenMode::PublicContent)
|
||||
}
|
||||
|
||||
pub fn get_user_dbs_with_mode(
|
||||
&self,
|
||||
user_id: i64,
|
||||
mode: TenantOpenMode,
|
||||
) -> Result<UserDbs, crate::error::AppError> {
|
||||
let users = crate::utils::lock_db(&self.users_db, "users_db")?;
|
||||
let mut pool = crate::utils::lock_db(&self.user_dbs, "user_dbs")?;
|
||||
if let Some(dbs) = pool.get(&user_id) {
|
||||
return Ok(dbs.clone());
|
||||
crate::db::tenant::open_for_row_id(
|
||||
&users,
|
||||
&self.db.topology,
|
||||
&self.system_db,
|
||||
&mut pool,
|
||||
user_id,
|
||||
mode,
|
||||
)
|
||||
}
|
||||
|
||||
/// Frozen tenant opener. Unknown TenantId never creates a database.
|
||||
pub fn open_tenant(
|
||||
&self,
|
||||
tenant_id: TenantId,
|
||||
mode: TenantOpenMode,
|
||||
) -> Result<UserDbs, crate::error::AppError> {
|
||||
let users = crate::utils::lock_db(&self.users_db, "users_db")?;
|
||||
let mut pool = crate::utils::lock_db(&self.user_dbs, "user_dbs")?;
|
||||
crate::db::tenant::open_for_tenant_id(
|
||||
&users,
|
||||
&self.db.topology,
|
||||
&self.system_db,
|
||||
&mut pool,
|
||||
tenant_id,
|
||||
mode,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn lookup_slug(
|
||||
&self,
|
||||
slug: &str,
|
||||
) -> Result<Option<crate::db::slugs::ResolvedSlugInfo>, crate::error::AppError> {
|
||||
let urls_conn = crate::utils::lock_db(&self.db.global_urls, "global_urls")?;
|
||||
let pages_conn =
|
||||
crate::utils::lock_db(&self.db.global_landing_pages, "global_landing_pages")?;
|
||||
if let Some(info) = crate::db::slugs::lookup_slug(&urls_conn, &pages_conn, slug)? {
|
||||
return Ok(Some(info));
|
||||
}
|
||||
|
||||
// Open connection and run migrations
|
||||
let user_dir = self.config.data_dir.join("users").join(user_id.to_string());
|
||||
std::fs::create_dir_all(&user_dir)?;
|
||||
// Fallback to system.db.global_slugs if table exists (backward compatibility during transition)
|
||||
let system_conn = crate::utils::lock_db(&self.system_db, "system_db")?;
|
||||
let has_table: bool = system_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='global_slugs');",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
let content_path = user_dir.join("content.db");
|
||||
let analytics_path = user_dir.join("analytics.db");
|
||||
let profile_path = user_dir.join("profile.db");
|
||||
if has_table {
|
||||
let row_opt: Option<(i64, String, String, String)> = system_conn
|
||||
.query_row(
|
||||
"SELECT owner_user_id, target_type, target_id, status FROM global_slugs WHERE slug = ?1;",
|
||||
[slug],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
|
||||
)
|
||||
.optional()?;
|
||||
|
||||
let mut content_conn = Connection::open(content_path)?;
|
||||
let mut analytics_conn = Connection::open(analytics_path)?;
|
||||
let profile_conn = Connection::open(profile_path)?;
|
||||
if let Some((owner_id, target_type, target_id, status)) = row_opt {
|
||||
let tt = match target_type.as_str() {
|
||||
"page" => crate::db::slugs::SlugTargetType::LandingPage,
|
||||
_ => crate::db::slugs::SlugTargetType::Url,
|
||||
};
|
||||
return Ok(Some(crate::db::slugs::ResolvedSlugInfo {
|
||||
slug: slug.to_string(),
|
||||
owner_tenant_id: owner_id.to_string(),
|
||||
target_type: tt,
|
||||
target_id,
|
||||
created_at: String::new(),
|
||||
updated_at: String::new(),
|
||||
status,
|
||||
retired_at: None,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
crate::db::sqlite::enable_wal(&content_conn, "content")?;
|
||||
crate::db::sqlite::enable_wal(&analytics_conn, "analytics")?;
|
||||
crate::db::sqlite::enable_wal(&profile_conn, "profile")?;
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
crate::db::sqlite::enable_foreign_keys(&content_conn, "content")?;
|
||||
crate::db::sqlite::enable_foreign_keys(&analytics_conn, "analytics")?;
|
||||
crate::db::sqlite::enable_foreign_keys(&profile_conn, "profile")?;
|
||||
|
||||
// Run migrations
|
||||
crate::db::migrations::run_migrations(
|
||||
&mut content_conn,
|
||||
"content",
|
||||
crate::db::migrations::CONTENT_MIGRATIONS,
|
||||
Some(&self.system_db),
|
||||
)
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?;
|
||||
crate::db::migrations::run_migrations(
|
||||
&mut analytics_conn,
|
||||
"analytics",
|
||||
crate::db::migrations::ANALYTICS_MIGRATIONS,
|
||||
Some(&self.system_db),
|
||||
)
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?;
|
||||
|
||||
profile_conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);",
|
||||
)?;
|
||||
|
||||
let dbs = UserDbs {
|
||||
content: Arc::new(Mutex::new(content_conn)),
|
||||
analytics: Arc::new(Mutex::new(analytics_conn)),
|
||||
profile: Arc::new(Mutex::new(profile_conn)),
|
||||
};
|
||||
|
||||
pool.insert(user_id, dbs.clone());
|
||||
Ok(dbs)
|
||||
pub fn open_slug_owner(
|
||||
&self,
|
||||
owner_tenant_id: &str,
|
||||
mode: TenantOpenMode,
|
||||
) -> Result<UserDbs, crate::error::AppError> {
|
||||
if owner_tenant_id == "legacy_admin" || owner_tenant_id == "1" {
|
||||
return self.get_user_dbs_with_mode(1, mode);
|
||||
}
|
||||
if let Ok(tid) = TenantId::parse(owner_tenant_id) {
|
||||
return self.open_tenant(tid, mode);
|
||||
}
|
||||
if let Ok(uid) = owner_tenant_id.parse::<i64>() {
|
||||
return self.get_user_dbs_with_mode(uid, mode);
|
||||
}
|
||||
Err(crate::error::AppError::NotFound(format!(
|
||||
"invalid owner identity: {}",
|
||||
owner_tenant_id
|
||||
)))
|
||||
}
|
||||
|
||||
pub fn db_compact(&self) -> Result<(), crate::error::AppError> {
|
||||
crate::utils::lock_db(&self.admin_db, "admin_db")?.execute("VACUUM;", [])?;
|
||||
crate::utils::lock_db(&self.content_db, "content_db")?.execute("VACUUM;", [])?;
|
||||
crate::utils::lock_db(&self.analytics_db, "analytics_db")?.execute("VACUUM;", [])?;
|
||||
crate::utils::lock_db(&self.system_db, "system_db")?.execute("VACUUM;", [])?;
|
||||
crate::utils::lock_db(&self.users_db, "users_db")?.execute("VACUUM;", [])?;
|
||||
crate::utils::lock_db(&self.db.global_urls, "global_urls")?.execute("VACUUM;", [])?;
|
||||
crate::utils::lock_db(&self.db.global_landing_pages, "global_landing_pages")?
|
||||
.execute("VACUUM;", [])?;
|
||||
crate::utils::lock_db(&self.db.reserved, "reserved")?.execute("VACUUM;", [])?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,9 @@ pub fn get_db_file_info(data_dir: &Path) -> String {
|
||||
("admin/admin.db", "Admin DB"),
|
||||
("admin/system.db", "System DB"),
|
||||
("admin/users.db", "Users DB"),
|
||||
("slugs/global_urls.db", "Global URLs DB"),
|
||||
("slugs/global_landing_pages.db", "Global Landing Pages DB"),
|
||||
("slugs/reserved.db", "Reserved Slugs DB"),
|
||||
("users/1/content.db", "Legacy Content DB"),
|
||||
("users/1/analytics.db", "Legacy Analytics DB"),
|
||||
];
|
||||
|
||||
+121
-34
@@ -12,8 +12,36 @@ pub async fn url_analytics_get(
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let (_slug, owner_tid) = {
|
||||
let conn = state.db.global_urls.lock().unwrap();
|
||||
match conn.query_row(
|
||||
"SELECT slug, owner_tenant_id FROM global_urls WHERE target_id = ?1 OR slug = ?1 LIMIT 1;",
|
||||
rusqlite::params![id],
|
||||
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
|
||||
) {
|
||||
Ok((s, t)) => (s, t),
|
||||
Err(_) => return (StatusCode::NOT_FOUND, "URL not found").into_response(),
|
||||
}
|
||||
};
|
||||
let tid = match owner_tid.parse::<crate::identity::TenantId>() {
|
||||
Ok(t) => t,
|
||||
Err(_) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid tenant identity").into_response()
|
||||
}
|
||||
};
|
||||
let user_dbs = match state.open_tenant(tid, crate::state::TenantOpenMode::CoreJob) {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to open tenant database",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let url = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
match get_url_by_id(&conn, &id) {
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => return (StatusCode::NOT_FOUND, "URL not found").into_response(),
|
||||
@@ -21,7 +49,7 @@ pub async fn url_analytics_get(
|
||||
}
|
||||
};
|
||||
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
|
||||
let schema_cols = get_visits_schema_columns(&conn).unwrap_or_default();
|
||||
let has_utm_source = schema_cols.contains("utm_source");
|
||||
@@ -184,8 +212,36 @@ pub async fn page_analytics_get(
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let (_slug, owner_tid) = {
|
||||
let conn = state.db.global_landing_pages.lock().unwrap();
|
||||
match conn.query_row(
|
||||
"SELECT slug, owner_tenant_id FROM global_landing_pages WHERE target_id = ?1 OR slug = ?1 LIMIT 1;",
|
||||
rusqlite::params![id],
|
||||
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
|
||||
) {
|
||||
Ok((s, t)) => (s, t),
|
||||
Err(_) => return (StatusCode::NOT_FOUND, "Landing page not found").into_response(),
|
||||
}
|
||||
};
|
||||
let tid = match owner_tid.parse::<crate::identity::TenantId>() {
|
||||
Ok(t) => t,
|
||||
Err(_) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid tenant identity").into_response()
|
||||
}
|
||||
};
|
||||
let user_dbs = match state.open_tenant(tid, crate::state::TenantOpenMode::CoreJob) {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to open tenant database",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let page = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
match get_landing_page_by_id(&conn, &id) {
|
||||
Ok(Some(p)) => p,
|
||||
Ok(None) => return (StatusCode::NOT_FOUND, "Landing page not found").into_response(),
|
||||
@@ -193,7 +249,7 @@ pub async fn page_analytics_get(
|
||||
}
|
||||
};
|
||||
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
|
||||
let schema_cols = get_visits_schema_columns(&conn).unwrap_or_default();
|
||||
let has_utm_source = schema_cols.contains("utm_source");
|
||||
@@ -439,6 +495,7 @@ pub async fn user_analytics_get(
|
||||
accept_language: row.get(7)?,
|
||||
country: row.get(8)?,
|
||||
status_code: row.get(9)?,
|
||||
owner_tenant_id: user.tenant_id,
|
||||
owner_user_id: Some(user.id),
|
||||
})
|
||||
})
|
||||
@@ -478,12 +535,12 @@ pub async fn user_url_analytics_get(
|
||||
};
|
||||
|
||||
// Ownership check
|
||||
let (owner_user_id, target_type) = {
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let owner_tid_str = {
|
||||
let conn = state.db.global_urls.lock().unwrap();
|
||||
match conn.query_row(
|
||||
"SELECT owner_user_id, target_type FROM global_slugs WHERE target_id = ?1",
|
||||
"SELECT owner_tenant_id FROM global_urls WHERE target_id = ?1",
|
||||
[&id],
|
||||
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
|
||||
|row| row.get::<_, String>(0),
|
||||
) {
|
||||
Ok(val) => val,
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||
@@ -493,7 +550,12 @@ pub async fn user_url_analytics_get(
|
||||
}
|
||||
};
|
||||
|
||||
if owner_user_id != user.id || target_type != "url" {
|
||||
let user_tid = match user.tenant_id {
|
||||
Some(t) => t.to_string(),
|
||||
None => return StatusCode::FORBIDDEN.into_response(),
|
||||
};
|
||||
|
||||
if owner_tid_str != user_tid && owner_tid_str != format!("{}", user.id) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
|
||||
@@ -674,12 +736,12 @@ pub async fn user_page_analytics_get(
|
||||
};
|
||||
|
||||
// Ownership check
|
||||
let (owner_user_id, target_type) = {
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let owner_tid_str = {
|
||||
let conn = state.db.global_landing_pages.lock().unwrap();
|
||||
match conn.query_row(
|
||||
"SELECT owner_user_id, target_type FROM global_slugs WHERE target_id = ?1",
|
||||
"SELECT owner_tenant_id FROM global_landing_pages WHERE target_id = ?1",
|
||||
[&id],
|
||||
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
|
||||
|row| row.get::<_, String>(0),
|
||||
) {
|
||||
Ok(val) => val,
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||
@@ -689,7 +751,12 @@ pub async fn user_page_analytics_get(
|
||||
}
|
||||
};
|
||||
|
||||
if owner_user_id != user.id || target_type != "page" {
|
||||
let user_tid = match user.tenant_id {
|
||||
Some(t) => t.to_string(),
|
||||
None => return StatusCode::FORBIDDEN.into_response(),
|
||||
};
|
||||
|
||||
if owner_tid_str != user_tid && owner_tid_str != format!("{}", user.id) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
|
||||
@@ -862,12 +929,12 @@ pub async fn user_url_analytics_csv_export(
|
||||
};
|
||||
|
||||
// Ownership check
|
||||
let (owner_user_id, target_type) = {
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let owner_tid_str = {
|
||||
let conn = state.db.global_urls.lock().unwrap();
|
||||
match conn.query_row(
|
||||
"SELECT owner_user_id, target_type FROM global_slugs WHERE target_id = ?1",
|
||||
"SELECT owner_tenant_id FROM global_urls WHERE target_id = ?1",
|
||||
[&id],
|
||||
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
|
||||
|row| row.get::<_, String>(0),
|
||||
) {
|
||||
Ok(val) => val,
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||
@@ -877,7 +944,12 @@ pub async fn user_url_analytics_csv_export(
|
||||
}
|
||||
};
|
||||
|
||||
if owner_user_id != user.id || target_type != "url" {
|
||||
let user_tid = match user.tenant_id {
|
||||
Some(t) => t.to_string(),
|
||||
None => return StatusCode::FORBIDDEN.into_response(),
|
||||
};
|
||||
|
||||
if owner_tid_str != user_tid && owner_tid_str != format!("{}", user.id) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
|
||||
@@ -901,12 +973,12 @@ pub async fn user_url_analytics_json_export(
|
||||
};
|
||||
|
||||
// Ownership check
|
||||
let (owner_user_id, target_type) = {
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let owner_tid_str = {
|
||||
let conn = state.db.global_urls.lock().unwrap();
|
||||
match conn.query_row(
|
||||
"SELECT owner_user_id, target_type FROM global_slugs WHERE target_id = ?1",
|
||||
"SELECT owner_tenant_id FROM global_urls WHERE target_id = ?1",
|
||||
[&id],
|
||||
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
|
||||
|row| row.get::<_, String>(0),
|
||||
) {
|
||||
Ok(val) => val,
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||
@@ -916,7 +988,12 @@ pub async fn user_url_analytics_json_export(
|
||||
}
|
||||
};
|
||||
|
||||
if owner_user_id != user.id || target_type != "url" {
|
||||
let user_tid = match user.tenant_id {
|
||||
Some(t) => t.to_string(),
|
||||
None => return StatusCode::FORBIDDEN.into_response(),
|
||||
};
|
||||
|
||||
if owner_tid_str != user_tid && owner_tid_str != format!("{}", user.id) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
|
||||
@@ -940,12 +1017,12 @@ pub async fn user_page_analytics_csv_export(
|
||||
};
|
||||
|
||||
// Ownership check
|
||||
let (owner_user_id, target_type) = {
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let owner_tid_str = {
|
||||
let conn = state.db.global_landing_pages.lock().unwrap();
|
||||
match conn.query_row(
|
||||
"SELECT owner_user_id, target_type FROM global_slugs WHERE target_id = ?1",
|
||||
"SELECT owner_tenant_id FROM global_landing_pages WHERE target_id = ?1",
|
||||
[&id],
|
||||
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
|
||||
|row| row.get::<_, String>(0),
|
||||
) {
|
||||
Ok(val) => val,
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||
@@ -955,7 +1032,12 @@ pub async fn user_page_analytics_csv_export(
|
||||
}
|
||||
};
|
||||
|
||||
if owner_user_id != user.id || target_type != "page" {
|
||||
let user_tid = match user.tenant_id {
|
||||
Some(t) => t.to_string(),
|
||||
None => return StatusCode::FORBIDDEN.into_response(),
|
||||
};
|
||||
|
||||
if owner_tid_str != user_tid && owner_tid_str != format!("{}", user.id) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
|
||||
@@ -979,12 +1061,12 @@ pub async fn user_page_analytics_json_export(
|
||||
};
|
||||
|
||||
// Ownership check
|
||||
let (owner_user_id, target_type) = {
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let owner_tid_str = {
|
||||
let conn = state.db.global_landing_pages.lock().unwrap();
|
||||
match conn.query_row(
|
||||
"SELECT owner_user_id, target_type FROM global_slugs WHERE target_id = ?1",
|
||||
"SELECT owner_tenant_id FROM global_landing_pages WHERE target_id = ?1",
|
||||
[&id],
|
||||
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
|
||||
|row| row.get::<_, String>(0),
|
||||
) {
|
||||
Ok(val) => val,
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||
@@ -994,7 +1076,12 @@ pub async fn user_page_analytics_json_export(
|
||||
}
|
||||
};
|
||||
|
||||
if owner_user_id != user.id || target_type != "page" {
|
||||
let user_tid = match user.tenant_id {
|
||||
Some(t) => t.to_string(),
|
||||
None => return StatusCode::FORBIDDEN.into_response(),
|
||||
};
|
||||
|
||||
if owner_tid_str != user_tid && owner_tid_str != format!("{}", user.id) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
|
||||
|
||||
+12
-52
@@ -96,25 +96,7 @@ fn load_tenant_user_by_username(
|
||||
conn: &rusqlite::Connection,
|
||||
username: &str,
|
||||
) -> Result<Option<crate::models::TenantUser>, rusqlite::Error> {
|
||||
conn.query_row(
|
||||
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
|
||||
FROM users WHERE username = ?1;",
|
||||
[username],
|
||||
|row| {
|
||||
Ok(crate::models::TenantUser {
|
||||
id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
status: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
last_login: row.get(5)?,
|
||||
account_type: row.get(6)?,
|
||||
organization_id: row.get(7)?,
|
||||
metadata: row.get(8)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
crate::db::users::get_user_by_username(conn, username)
|
||||
}
|
||||
|
||||
fn tenant_user_to_admin_user(u: crate::models::TenantUser) -> User {
|
||||
@@ -180,21 +162,18 @@ pub async fn login_post(
|
||||
}
|
||||
};
|
||||
|
||||
let conn = match state.users_db.lock() {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
return Redirect::to("/admin/login?error=Internal error").into_response();
|
||||
}
|
||||
};
|
||||
match crate::db::users::create_admin_user(&conn, &form.username, &hash) {
|
||||
Ok(u) => {
|
||||
if let Err(e) = state.db.init_user_databases(u.id) {
|
||||
tracing::error!(
|
||||
"Failed to init user databases during admin bootstrap: {:?}",
|
||||
e
|
||||
);
|
||||
let created_user_res = {
|
||||
let conn = match state.users_db.lock() {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
return Redirect::to("/admin/login?error=Internal error").into_response();
|
||||
}
|
||||
};
|
||||
crate::db::users::create_admin_user(&conn, &form.username, &hash)
|
||||
};
|
||||
|
||||
match created_user_res {
|
||||
Ok(u) => {
|
||||
if let Ok(system_conn) = state.system_db.lock() {
|
||||
let metadata = audit_meta(&ip, &headers);
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
@@ -385,26 +364,7 @@ pub async fn public_login_post(
|
||||
|
||||
let user_opt: Option<crate::models::TenantUser> = {
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
let user_res: Result<Option<crate::models::TenantUser>, rusqlite::Error> = conn.query_row(
|
||||
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata \
|
||||
FROM users WHERE username = ?1;",
|
||||
[&form.username],
|
||||
|row| {
|
||||
Ok(crate::models::TenantUser {
|
||||
id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
status: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
last_login: row.get(5)?,
|
||||
account_type: row.get(6)?,
|
||||
organization_id: row.get(7)?,
|
||||
metadata: row.get(8)?,
|
||||
})
|
||||
}
|
||||
).optional();
|
||||
|
||||
match user_res {
|
||||
match crate::db::users::get_user_by_username(&conn, &form.username) {
|
||||
Ok(Some(u)) => {
|
||||
if u.status != "active" {
|
||||
None
|
||||
|
||||
+35
-39
@@ -95,25 +95,44 @@ pub async fn dashboard_get(State(state): State<AppState>, jar: CookieJar) -> Res
|
||||
};
|
||||
|
||||
let (total_urls, active_links, dead_links) = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
get_url_counts(&conn).unwrap_or((0, 0, 0))
|
||||
let conn = state.db.global_urls.lock().unwrap();
|
||||
let total: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM global_urls WHERE status != 'retired';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
let active: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM global_urls WHERE status = 'active';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
let dead: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM global_urls WHERE status = 'disabled';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
(total, active, dead)
|
||||
};
|
||||
|
||||
let total_pages = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
get_landing_page_count(&conn).unwrap_or(0)
|
||||
let conn = state.db.global_landing_pages.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM global_landing_pages WHERE status != 'retired';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
|
||||
let total_clicks = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
get_total_clicks(&conn).unwrap_or(0)
|
||||
};
|
||||
|
||||
let clicks_data = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
get_clicks_trend(&conn, "url", "all", 30)
|
||||
.or_else(|_| get_clicks_trend_raw(&conn, "url", "all", 30))
|
||||
.unwrap_or_default()
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
crate::db::users::get_platform_total_clicks(&state.db.topology, &users_conn).unwrap_or(0)
|
||||
};
|
||||
|
||||
let mut trend_map = std::collections::BTreeMap::new();
|
||||
@@ -123,35 +142,12 @@ pub async fn dashboard_get(State(state): State<AppState>, jar: CookieJar) -> Res
|
||||
.to_string();
|
||||
trend_map.insert(date_str, 0i64);
|
||||
}
|
||||
for (d, c) in clicks_data {
|
||||
trend_map.insert(d, c);
|
||||
}
|
||||
let formatted_trend: Vec<(String, i64)> = trend_map.into_iter().collect();
|
||||
let traffic_chart = generate_line_chart(&formatted_trend);
|
||||
|
||||
let countries_data = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
get_metric_rankings(&conn, "url", "all", "country", 5)
|
||||
.or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "country", 5))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let countries_chart = generate_bar_chart(&countries_data);
|
||||
|
||||
let referrers_data = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
get_metric_rankings(&conn, "url", "all", "referrer", 5)
|
||||
.or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "referrer", 5))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let referrers_chart = generate_bar_chart(&referrers_data);
|
||||
|
||||
let browsers_data = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
get_metric_rankings(&conn, "url", "all", "browser", 5)
|
||||
.or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "browser", 5))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let browsers_chart = generate_bar_chart(&browsers_data);
|
||||
let countries_chart = generate_bar_chart(&[]);
|
||||
let referrers_chart = generate_bar_chart(&[]);
|
||||
let browsers_chart = generate_bar_chart(&[]);
|
||||
|
||||
let template = crate::templates::DashboardTemplate {
|
||||
admin_username: user.username,
|
||||
|
||||
+23
-6
@@ -49,9 +49,27 @@ pub async fn health_get(
|
||||
db_reports.push(r);
|
||||
}
|
||||
|
||||
let system_db_path = state.config.data_dir.join("admin").join("system.db");
|
||||
let users_db_path = state.config.data_dir.join("admin").join("users.db");
|
||||
let admin_db_path = state.config.data_dir.join("admin").join("admin.db");
|
||||
if let Ok(r) = crate::db::sqlite::collect_health_report(
|
||||
&state.db.global_urls.lock().unwrap(),
|
||||
"global_urls",
|
||||
) {
|
||||
db_reports.push(r);
|
||||
}
|
||||
if let Ok(r) = crate::db::sqlite::collect_health_report(
|
||||
&state.db.global_landing_pages.lock().unwrap(),
|
||||
"global_landing_pages",
|
||||
) {
|
||||
db_reports.push(r);
|
||||
}
|
||||
if let Ok(r) =
|
||||
crate::db::sqlite::collect_health_report(&state.db.reserved.lock().unwrap(), "reserved")
|
||||
{
|
||||
db_reports.push(r);
|
||||
}
|
||||
|
||||
let system_db_path = state.db.topology.system_db();
|
||||
let users_db_path = state.db.topology.users_registry_db();
|
||||
let admin_db_path = state.db.topology.admin_db();
|
||||
|
||||
let system_db_size = format_size(
|
||||
std::fs::metadata(&system_db_path)
|
||||
@@ -69,7 +87,7 @@ pub async fn health_get(
|
||||
.unwrap_or(0),
|
||||
);
|
||||
|
||||
let users_dir = state.config.data_dir.join("users");
|
||||
let users_dir = state.db.topology.users_dir();
|
||||
let tenants_db_size = format_size(get_dir_size(&users_dir).unwrap_or(0));
|
||||
let total_data_size = format_size(get_dir_size(&state.config.data_dir).unwrap_or(0));
|
||||
|
||||
@@ -127,8 +145,7 @@ pub async fn health_get(
|
||||
for issue in issues {
|
||||
use crate::services::registry_validator::RegistryIssueType;
|
||||
match issue.issue_type {
|
||||
RegistryIssueType::StaleReservation
|
||||
| RegistryIssueType::TenantAdminHasIsolatedContent => {
|
||||
RegistryIssueType::StaleReservation => {
|
||||
warnings.push(format!(
|
||||
"Warning for slug {}: {}",
|
||||
issue.slug, issue.description
|
||||
|
||||
+87
-7
@@ -186,8 +186,48 @@ pub(crate) async fn perform_csv_export(
|
||||
Err(status) => return status.into_response(),
|
||||
};
|
||||
|
||||
let (_slug, owner_tid) = {
|
||||
let conn = if target_type == "url" {
|
||||
state.db.global_urls.lock().unwrap()
|
||||
} else {
|
||||
state.db.global_landing_pages.lock().unwrap()
|
||||
};
|
||||
let table = if target_type == "url" {
|
||||
"global_urls"
|
||||
} else {
|
||||
"global_landing_pages"
|
||||
};
|
||||
match conn.query_row(
|
||||
&format!(
|
||||
"SELECT slug, owner_tenant_id FROM {} WHERE target_id = ?1 OR slug = ?1 LIMIT 1;",
|
||||
table
|
||||
),
|
||||
rusqlite::params![id],
|
||||
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
|
||||
) {
|
||||
Ok((s, t)) => (s, t),
|
||||
Err(_) => return (StatusCode::NOT_FOUND, "Target not found").into_response(),
|
||||
}
|
||||
};
|
||||
let tid = match owner_tid.parse::<crate::identity::TenantId>() {
|
||||
Ok(t) => t,
|
||||
Err(_) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid tenant identity").into_response()
|
||||
}
|
||||
};
|
||||
let user_dbs = match state.open_tenant(tid, crate::state::TenantOpenMode::CoreJob) {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to open tenant database",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let target_exists = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
if target_type == "url" {
|
||||
get_url_by_id(&conn, &id)
|
||||
.map(|u| u.is_some())
|
||||
@@ -203,7 +243,7 @@ pub(crate) async fn perform_csv_export(
|
||||
}
|
||||
|
||||
let count = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
get_target_visit_total_filtered(
|
||||
&conn,
|
||||
target_type,
|
||||
@@ -215,14 +255,14 @@ pub(crate) async fn perform_csv_export(
|
||||
};
|
||||
|
||||
let (has_utm_source, has_utm_campaign) = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
let cols = get_visits_schema_columns(&conn).unwrap_or_default();
|
||||
(cols.contains("utm_source"), cols.contains("utm_campaign"))
|
||||
};
|
||||
|
||||
let (tx, rx) =
|
||||
tokio::sync::mpsc::channel::<Result<axum::body::Bytes, std::convert::Infallible>>(32);
|
||||
let analytics_db = state.analytics_db.clone();
|
||||
let analytics_db = user_dbs.analytics.clone();
|
||||
let target_id = id.clone();
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
@@ -378,8 +418,48 @@ pub(crate) async fn perform_json_export(
|
||||
Err(status) => return status.into_response(),
|
||||
};
|
||||
|
||||
let (_slug, owner_tid) = {
|
||||
let conn = if target_type == "url" {
|
||||
state.db.global_urls.lock().unwrap()
|
||||
} else {
|
||||
state.db.global_landing_pages.lock().unwrap()
|
||||
};
|
||||
let table = if target_type == "url" {
|
||||
"global_urls"
|
||||
} else {
|
||||
"global_landing_pages"
|
||||
};
|
||||
match conn.query_row(
|
||||
&format!(
|
||||
"SELECT slug, owner_tenant_id FROM {} WHERE target_id = ?1 OR slug = ?1 LIMIT 1;",
|
||||
table
|
||||
),
|
||||
rusqlite::params![id],
|
||||
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
|
||||
) {
|
||||
Ok((s, t)) => (s, t),
|
||||
Err(_) => return (StatusCode::NOT_FOUND, "Target not found").into_response(),
|
||||
}
|
||||
};
|
||||
let tid = match owner_tid.parse::<crate::identity::TenantId>() {
|
||||
Ok(t) => t,
|
||||
Err(_) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid tenant identity").into_response()
|
||||
}
|
||||
};
|
||||
let user_dbs = match state.open_tenant(tid, crate::state::TenantOpenMode::CoreJob) {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to open tenant database",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let target_exists = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
if target_type == "url" {
|
||||
get_url_by_id(&conn, &id)
|
||||
.map(|u| u.is_some())
|
||||
@@ -395,7 +475,7 @@ pub(crate) async fn perform_json_export(
|
||||
}
|
||||
|
||||
let count = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
get_target_visit_total_filtered(
|
||||
&conn,
|
||||
target_type,
|
||||
@@ -411,7 +491,7 @@ pub(crate) async fn perform_json_export(
|
||||
}
|
||||
|
||||
let visits_raw = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
match get_target_visits_all_in_memory(
|
||||
&conn,
|
||||
target_type,
|
||||
|
||||
+325
-291
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use crate::identity::TenantId;
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct GlobalSlugRow {
|
||||
@@ -48,28 +49,83 @@ pub async fn moderation_get(
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let flagged_items = {
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT slug, owner_user_id, target_type, target_id, created_at, updated_at, status, deleted_at \
|
||||
FROM global_slugs WHERE status = 'flagged' OR status = 'disabled' ORDER BY updated_at DESC;"
|
||||
).unwrap();
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
let mut flagged_items: Vec<GlobalSlugRow> = Vec::new();
|
||||
|
||||
// Query global_urls.db
|
||||
{
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
let query_res = urls_conn.prepare(
|
||||
"SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at \
|
||||
FROM global_urls WHERE status = 'flagged' OR status = 'disabled' ORDER BY updated_at DESC;"
|
||||
).and_then(|mut stmt| {
|
||||
let rows = stmt.query_map([], |row| {
|
||||
let tid_str: String = row.get(1)?;
|
||||
let uid = if let Ok(tid) = TenantId::parse(&tid_str) {
|
||||
crate::db::users::get_user_by_tenant_id(&users_conn, tid)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|u| u.id)
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
Ok(GlobalSlugRow {
|
||||
slug: row.get(0)?,
|
||||
owner_user_id: row.get(1)?,
|
||||
target_type: row.get(2)?,
|
||||
target_id: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
updated_at: row.get(5)?,
|
||||
status: row.get(6)?,
|
||||
deleted_at: row.get(7)?,
|
||||
owner_user_id: uid,
|
||||
target_type: "url".to_string(),
|
||||
target_id: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
updated_at: row.get(4)?,
|
||||
status: row.get(5)?,
|
||||
deleted_at: row.get(6)?,
|
||||
})
|
||||
})
|
||||
.unwrap();
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
})?;
|
||||
Ok(rows.filter_map(|r| r.ok()).collect::<Vec<_>>())
|
||||
});
|
||||
if let Ok(items) = query_res {
|
||||
flagged_items.extend(items);
|
||||
}
|
||||
}
|
||||
|
||||
// Query global_landing_pages.db
|
||||
{
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
let query_res = pages_conn.prepare(
|
||||
"SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at \
|
||||
FROM global_landing_pages WHERE status = 'flagged' OR status = 'disabled' ORDER BY updated_at DESC;"
|
||||
).and_then(|mut stmt| {
|
||||
let rows = stmt.query_map([], |row| {
|
||||
let tid_str: String = row.get(1)?;
|
||||
let uid = if let Ok(tid) = TenantId::parse(&tid_str) {
|
||||
crate::db::users::get_user_by_tenant_id(&users_conn, tid)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|u| u.id)
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
Ok(GlobalSlugRow {
|
||||
slug: row.get(0)?,
|
||||
owner_user_id: uid,
|
||||
target_type: "page".to_string(),
|
||||
target_id: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
updated_at: row.get(4)?,
|
||||
status: row.get(5)?,
|
||||
deleted_at: row.get(6)?,
|
||||
})
|
||||
})?;
|
||||
Ok(rows.filter_map(|r| r.ok()).collect::<Vec<_>>())
|
||||
});
|
||||
if let Ok(items) = query_res {
|
||||
flagged_items.extend(items);
|
||||
}
|
||||
}
|
||||
|
||||
flagged_items.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
|
||||
|
||||
let logs = {
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
@@ -139,46 +195,91 @@ pub async fn moderation_post(
|
||||
return Redirect::to("/admin/moderation?error=Invalid moderation action").into_response();
|
||||
}
|
||||
|
||||
let (owner_user_id, target_type) = {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let row_opt: Option<(i64, String)> = system_conn
|
||||
.query_row(
|
||||
"SELECT owner_user_id, target_type FROM global_slugs WHERE slug = ?1;",
|
||||
[&form.slug],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.optional()
|
||||
.unwrap_or(None);
|
||||
let slug_info = match state.lookup_slug(&form.slug) {
|
||||
Ok(Some(info)) => info,
|
||||
_ => return Redirect::to("/admin/moderation?error=Slug not found").into_response(),
|
||||
};
|
||||
|
||||
match row_opt {
|
||||
Some(r) => r,
|
||||
None => return Redirect::to("/admin/moderation?error=Slug not found").into_response(),
|
||||
let owner_tenant_id = match TenantId::parse(&slug_info.owner_tenant_id) {
|
||||
Ok(t) => t,
|
||||
Err(_) => {
|
||||
return Redirect::to("/admin/moderation?error=Invalid tenant on slug").into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let owner_username = {
|
||||
let (owner_user_id, owner_username) = {
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
crate::db::users::get_user_by_id(&users_conn, owner_user_id)
|
||||
.unwrap_or(None)
|
||||
.map(|u| u.username)
|
||||
match crate::db::users::get_user_by_tenant_id(&users_conn, owner_tenant_id) {
|
||||
Ok(Some(u)) => (u.id, Some(u.username)),
|
||||
_ => (0, None),
|
||||
}
|
||||
};
|
||||
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
if action == "deleted" {
|
||||
let _ = system_conn.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&form.slug]);
|
||||
// 1. Update v0.8 slug database
|
||||
if action == "deleted" {
|
||||
if slug_info.target_type == crate::db::slugs::SlugTargetType::Url {
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let _ = urls_conn.execute("DELETE FROM global_urls WHERE slug = ?1;", [&form.slug]);
|
||||
} else {
|
||||
let _ = system_conn.execute(
|
||||
"UPDATE global_slugs SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let _ = pages_conn.execute(
|
||||
"DELETE FROM global_landing_pages WHERE slug = ?1;",
|
||||
[&form.slug],
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if slug_info.target_type == crate::db::slugs::SlugTargetType::Url {
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let _ = urls_conn.execute(
|
||||
"UPDATE global_urls SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
|
||||
rusqlite::params![action, now, form.slug],
|
||||
);
|
||||
} else {
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let _ = pages_conn.execute(
|
||||
"UPDATE global_landing_pages SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
|
||||
rusqlite::params![action, now, form.slug],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Sync to tenant content DB if accessible
|
||||
if let Ok(tenant_dbs) =
|
||||
state.open_tenant(owner_tenant_id, crate::db::tenant::TenantOpenMode::CoreJob)
|
||||
{
|
||||
if let Ok(conn) = tenant_dbs.content.lock() {
|
||||
if slug_info.target_type == crate::db::slugs::SlugTargetType::Url {
|
||||
let content_status = if action == "disabled" || action == "deleted" {
|
||||
"dead"
|
||||
} else {
|
||||
"active"
|
||||
};
|
||||
let _ = conn.execute(
|
||||
"UPDATE urls SET status = ?1 WHERE code = ?2;",
|
||||
rusqlite::params![content_status, form.slug],
|
||||
);
|
||||
} else {
|
||||
let content_state = if action == "disabled" || action == "deleted" {
|
||||
"archived"
|
||||
} else {
|
||||
"published"
|
||||
};
|
||||
let _ = conn.execute(
|
||||
"UPDATE landing_pages SET state = ?1 WHERE code = ?2;",
|
||||
rusqlite::params![content_state, form.slug],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Record moderation event and audit log in system.db
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let event_id = Uuid::new_v4().to_string();
|
||||
let _ = system_conn.execute(
|
||||
"INSERT INTO moderation_events (id, timestamp, admin_username, target_user_id, target_username, resource_type, resource_identifier, action, severity, reason)
|
||||
"INSERT INTO moderation_events (id, timestamp, admin_username, target_user_id, target_username, resource_type, resource_identifier, action, severity, reason) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10);",
|
||||
rusqlite::params![
|
||||
event_id,
|
||||
@@ -186,7 +287,7 @@ pub async fn moderation_post(
|
||||
user.username,
|
||||
owner_user_id,
|
||||
owner_username,
|
||||
target_type,
|
||||
slug_info.target_type.as_str(),
|
||||
form.slug,
|
||||
action,
|
||||
form.severity,
|
||||
@@ -231,48 +332,130 @@ pub async fn slugs_get(
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let mut slugs: Vec<GlobalSlugRow> = Vec::new();
|
||||
|
||||
let mut sql = "SELECT slug, owner_user_id, target_type, target_id, created_at, updated_at, status, deleted_at FROM global_slugs WHERE 1=1".to_string();
|
||||
let mut values = vec![];
|
||||
if let Some(ref s) = query.search {
|
||||
if !s.trim().is_empty() {
|
||||
sql.push_str(" AND slug LIKE ?");
|
||||
values.push(rusqlite::types::Value::Text(format!("%{}%", s.trim())));
|
||||
}
|
||||
}
|
||||
if let Some(o) = query.owner {
|
||||
sql.push_str(" AND owner_user_id = ?");
|
||||
values.push(rusqlite::types::Value::Integer(o));
|
||||
}
|
||||
if let Some(ref st) = query.status {
|
||||
if !st.trim().is_empty() {
|
||||
sql.push_str(" AND status = ?");
|
||||
values.push(rusqlite::types::Value::Text(st.trim().to_string()));
|
||||
}
|
||||
}
|
||||
sql.push_str(" ORDER BY created_at DESC LIMIT 100;");
|
||||
// Query global_urls.db
|
||||
{
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
let mut sql = "SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at FROM global_urls WHERE 1=1".to_string();
|
||||
let mut values = vec![];
|
||||
|
||||
let slugs = {
|
||||
let mut stmt = system_conn.prepare(&sql).unwrap();
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params_from_iter(values.iter()), |row| {
|
||||
if let Some(ref s) = query.search {
|
||||
if !s.trim().is_empty() {
|
||||
sql.push_str(" AND slug LIKE ?");
|
||||
values.push(rusqlite::types::Value::Text(format!("%{}%", s.trim())));
|
||||
}
|
||||
}
|
||||
if let Some(ref st) = query.status {
|
||||
if !st.trim().is_empty() {
|
||||
sql.push_str(" AND status = ?");
|
||||
values.push(rusqlite::types::Value::Text(st.trim().to_string()));
|
||||
}
|
||||
}
|
||||
sql.push_str(" ORDER BY created_at DESC LIMIT 100;");
|
||||
|
||||
let items_res = urls_conn.prepare(&sql).and_then(|mut stmt| {
|
||||
let rows = stmt.query_map(rusqlite::params_from_iter(values.iter()), |row| {
|
||||
let tid_str: String = row.get(1)?;
|
||||
let uid = if let Ok(tid) = TenantId::parse(&tid_str) {
|
||||
crate::db::users::get_user_by_tenant_id(&users_conn, tid)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|u| u.id)
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
Ok(GlobalSlugRow {
|
||||
slug: row.get(0)?,
|
||||
owner_user_id: row.get(1)?,
|
||||
target_type: row.get(2)?,
|
||||
target_id: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
updated_at: row.get(5)?,
|
||||
status: row.get(6)?,
|
||||
deleted_at: row.get(7)?,
|
||||
owner_user_id: uid,
|
||||
target_type: "url".to_string(),
|
||||
target_id: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
updated_at: row.get(4)?,
|
||||
status: row.get(5)?,
|
||||
deleted_at: row.get(6)?,
|
||||
})
|
||||
})
|
||||
.unwrap();
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
})?;
|
||||
Ok(rows.filter_map(|r| r.ok()).collect::<Vec<_>>())
|
||||
});
|
||||
|
||||
if let Ok(items) = items_res {
|
||||
for r in items {
|
||||
if let Some(o) = query.owner {
|
||||
if r.owner_user_id != o {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
slugs.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Query global_landing_pages.db
|
||||
{
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
let mut sql = "SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at FROM global_landing_pages WHERE 1=1".to_string();
|
||||
let mut values = vec![];
|
||||
|
||||
if let Some(ref s) = query.search {
|
||||
if !s.trim().is_empty() {
|
||||
sql.push_str(" AND slug LIKE ?");
|
||||
values.push(rusqlite::types::Value::Text(format!("%{}%", s.trim())));
|
||||
}
|
||||
}
|
||||
if let Some(ref st) = query.status {
|
||||
if !st.trim().is_empty() {
|
||||
sql.push_str(" AND status = ?");
|
||||
values.push(rusqlite::types::Value::Text(st.trim().to_string()));
|
||||
}
|
||||
}
|
||||
sql.push_str(" ORDER BY created_at DESC LIMIT 100;");
|
||||
|
||||
let items_res = pages_conn.prepare(&sql).and_then(|mut stmt| {
|
||||
let rows = stmt.query_map(rusqlite::params_from_iter(values.iter()), |row| {
|
||||
let tid_str: String = row.get(1)?;
|
||||
let uid = if let Ok(tid) = TenantId::parse(&tid_str) {
|
||||
crate::db::users::get_user_by_tenant_id(&users_conn, tid)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|u| u.id)
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
Ok(GlobalSlugRow {
|
||||
slug: row.get(0)?,
|
||||
owner_user_id: uid,
|
||||
target_type: "page".to_string(),
|
||||
target_id: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
updated_at: row.get(4)?,
|
||||
status: row.get(5)?,
|
||||
deleted_at: row.get(6)?,
|
||||
})
|
||||
})?;
|
||||
Ok(rows.filter_map(|r| r.ok()).collect::<Vec<_>>())
|
||||
});
|
||||
|
||||
if let Ok(items) = items_res {
|
||||
for r in items {
|
||||
if let Some(o) = query.owner {
|
||||
if r.owner_user_id != o {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
slugs.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
slugs.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||
|
||||
let history = {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let mut stmt = system_conn.prepare(
|
||||
"SELECT id, slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username \
|
||||
FROM slug_history ORDER BY timestamp DESC LIMIT 50;"
|
||||
@@ -332,198 +515,19 @@ pub async fn slugs_transfer_post(
|
||||
return Redirect::to("/admin/slugs?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let user_exists = {
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
|
||||
[form.new_owner_user_id],
|
||||
|row| row.get::<_, bool>(0),
|
||||
)
|
||||
.unwrap_or(false)
|
||||
let req = crate::services::slug_transfer::SlugTransferRequest {
|
||||
slug: form.slug.clone(),
|
||||
new_owner_user_id: form.new_owner_user_id,
|
||||
};
|
||||
|
||||
if !user_exists {
|
||||
return Redirect::to("/admin/slugs?error=New owner user ID does not exist").into_response();
|
||||
match crate::services::slug_transfer::transfer_slug(&state, &req, &user.username) {
|
||||
Ok(_) => Redirect::to(&format!(
|
||||
"/admin/slugs?success=Slug /{} successfully transferred to user {}",
|
||||
form.slug, form.new_owner_user_id
|
||||
))
|
||||
.into_response(),
|
||||
Err(e) => Redirect::to(&format!("/admin/slugs?error={}", e.message())).into_response(),
|
||||
}
|
||||
|
||||
let (old_owner, target_type, mut new_target_id) =
|
||||
{
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let row_opt: Option<(i64, String, String)> = conn.query_row(
|
||||
"SELECT owner_user_id, target_type, target_id FROM global_slugs WHERE slug = ?1;",
|
||||
[&form.slug],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))
|
||||
).optional().unwrap_or(None);
|
||||
match row_opt {
|
||||
Some(r) => r,
|
||||
None => return Redirect::to("/admin/slugs?error=Slug not found").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
if old_owner == form.new_owner_user_id {
|
||||
return Redirect::to("/admin/slugs?error=Slug is already owned by this user")
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let old_dbs = match state.get_user_dbs(old_owner) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => {
|
||||
return Redirect::to("/admin/slugs?error=Failed to load current owner's database")
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
let new_dbs = match state.get_user_dbs(form.new_owner_user_id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => {
|
||||
return Redirect::to("/admin/slugs?error=Failed to load new owner's database")
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
{
|
||||
let old_conn = old_dbs.content.lock().unwrap();
|
||||
let new_conn = new_dbs.content.lock().unwrap();
|
||||
|
||||
if target_type == "url" {
|
||||
let url_opt = match crate::db::content::get_url_by_code(&old_conn, &form.slug) {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
return Redirect::to(&format!(
|
||||
"/admin/slugs?error=Failed to retrieve old URL: {}",
|
||||
e
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(url) = url_opt {
|
||||
// Check quota
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
let quota_opt =
|
||||
crate::db::users::get_user_quotas(&users_conn, form.new_owner_user_id)
|
||||
.unwrap_or(None);
|
||||
if let Some(quota) = quota_opt {
|
||||
if quota.current_urls >= quota.max_urls {
|
||||
return Redirect::to(
|
||||
"/admin/slugs?error=New owner has exceeded URL quota limit",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
// Copy
|
||||
let new_url = match crate::db::content::create_url_extended(
|
||||
&new_conn,
|
||||
&url.code,
|
||||
&url.destination,
|
||||
url.title.as_deref(),
|
||||
url.description.as_deref(),
|
||||
&url.tags,
|
||||
url.expires_at.as_deref(),
|
||||
url.password_hash.as_deref(),
|
||||
url.max_access_count,
|
||||
) {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
return Redirect::to(&format!(
|
||||
"/admin/slugs?error=Failed to copy URL record: {}",
|
||||
e
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
new_target_id = new_url.id;
|
||||
|
||||
// Delete old
|
||||
let _ = crate::db::content::delete_url(&old_conn, &url.id);
|
||||
}
|
||||
} else if target_type == "page" {
|
||||
let page_opt = match crate::db::content::get_landing_page_by_code(&old_conn, &form.slug)
|
||||
{
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
return Redirect::to(&format!(
|
||||
"/admin/slugs?error=Failed to retrieve old page: {}",
|
||||
e
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(page) = page_opt {
|
||||
// Check quota
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
let quota_opt =
|
||||
crate::db::users::get_user_quotas(&users_conn, form.new_owner_user_id)
|
||||
.unwrap_or(None);
|
||||
if let Some(quota) = quota_opt {
|
||||
if quota.current_landings >= quota.max_landings {
|
||||
return Redirect::to(
|
||||
"/admin/slugs?error=New owner has exceeded landing page quota limit",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
// Copy
|
||||
let new_page = match crate::db::content::create_landing_page(
|
||||
&new_conn,
|
||||
&page.code,
|
||||
&page.slug,
|
||||
&page.title,
|
||||
&page.html_content,
|
||||
&page.state,
|
||||
) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
return Redirect::to(&format!(
|
||||
"/admin/slugs?error=Failed to copy page record: {}",
|
||||
e
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
new_target_id = new_page.id;
|
||||
|
||||
// Delete old
|
||||
let _ = crate::db::content::delete_landing_page(&old_conn, &page.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let _ = conn.execute(
|
||||
"UPDATE global_slugs SET owner_user_id = ?1, target_id = ?2, updated_at = ?3 WHERE slug = ?4;",
|
||||
rusqlite::params![form.new_owner_user_id, new_target_id, now, form.slug],
|
||||
);
|
||||
|
||||
let _ = conn.execute(
|
||||
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username) \
|
||||
VALUES (?1, ?2, ?3, 'transferred', ?4, ?5);",
|
||||
rusqlite::params![form.slug, old_owner, form.new_owner_user_id, now, user.username],
|
||||
);
|
||||
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&conn,
|
||||
&user.username,
|
||||
"SLUG_TRANSFER",
|
||||
"slug",
|
||||
&form.slug,
|
||||
Some(&format!(
|
||||
"Transferred from {} to {}",
|
||||
old_owner, form.new_owner_user_id
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
Redirect::to(&format!(
|
||||
"/admin/slugs?success=Slug /{} successfully transferred to user {}",
|
||||
form.slug, form.new_owner_user_id
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -553,17 +557,30 @@ pub async fn slugs_status_post(
|
||||
return Redirect::to("/admin/slugs?error=Invalid status").into_response();
|
||||
}
|
||||
|
||||
{
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let slug_info = match state.lookup_slug(&form.slug) {
|
||||
Ok(Some(info)) => info,
|
||||
_ => return Redirect::to("/admin/slugs?error=Slug not found").into_response(),
|
||||
};
|
||||
|
||||
let _ = conn.execute(
|
||||
"UPDATE global_slugs SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
|
||||
let now = Utc::now().to_rfc3339();
|
||||
if slug_info.target_type == crate::db::slugs::SlugTargetType::Url {
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let _ = urls_conn.execute(
|
||||
"UPDATE global_urls SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
|
||||
rusqlite::params![status, now, form.slug],
|
||||
);
|
||||
} else {
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let _ = pages_conn.execute(
|
||||
"UPDATE global_landing_pages SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
|
||||
rusqlite::params![status, now, form.slug],
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&conn,
|
||||
&system_conn,
|
||||
&user.username,
|
||||
"SLUG_STATUS_UPDATE",
|
||||
"slug",
|
||||
@@ -600,29 +617,46 @@ pub async fn slugs_delete_post(
|
||||
return Redirect::to("/admin/slugs?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let slug_info = match state.lookup_slug(&form.slug) {
|
||||
Ok(Some(info)) => info,
|
||||
_ => return Redirect::to("/admin/slugs?error=Slug not found").into_response(),
|
||||
};
|
||||
|
||||
let old_owner_user_id = {
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
if let Ok(tid) = TenantId::parse(&slug_info.owner_tenant_id) {
|
||||
crate::db::users::get_user_by_tenant_id(&users_conn, tid)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|u| u.id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
if slug_info.target_type == crate::db::slugs::SlugTargetType::Url {
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let _ = urls_conn.execute("DELETE FROM global_urls WHERE slug = ?1;", [&form.slug]);
|
||||
} else {
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let _ = pages_conn.execute(
|
||||
"DELETE FROM global_landing_pages WHERE slug = ?1;",
|
||||
[&form.slug],
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let old_owner_user_id: Option<i64> = conn
|
||||
.query_row(
|
||||
"SELECT owner_user_id FROM global_slugs WHERE slug = ?1;",
|
||||
[&form.slug],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.unwrap_or(None);
|
||||
|
||||
let _ = conn.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&form.slug]);
|
||||
|
||||
let _ = conn.execute(
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = system_conn.execute(
|
||||
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username) \
|
||||
VALUES (?1, ?2, NULL, 'deleted', ?3, ?4);",
|
||||
rusqlite::params![form.slug, old_owner_user_id, now, user.username],
|
||||
);
|
||||
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&conn,
|
||||
&system_conn,
|
||||
&user.username,
|
||||
"SLUG_RELEASE",
|
||||
"slug",
|
||||
|
||||
+130
-167
@@ -118,22 +118,26 @@ pub async fn user_pages_create(
|
||||
}
|
||||
}
|
||||
|
||||
let owner_tid = user
|
||||
.tenant_id
|
||||
.unwrap_or_else(crate::identity::TenantId::generate);
|
||||
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
|
||||
return Redirect::to("/user/pages?error=Short code/slug already exists")
|
||||
.into_response();
|
||||
}
|
||||
if let Err(e) = crate::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
let reserved_conn = state.db.reserved.lock().unwrap();
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
if let Err(e) = crate::db::slugs::reserve_landing_page_slug(
|
||||
&reserved_conn,
|
||||
&urls_conn,
|
||||
&pages_conn,
|
||||
&code,
|
||||
user.id,
|
||||
"page",
|
||||
"",
|
||||
"reserving",
|
||||
&owner_tid,
|
||||
) {
|
||||
return Redirect::to(&format!("/user/pages?error=Failed to reserve slug: {}", e))
|
||||
.into_response();
|
||||
return Redirect::to(&format!(
|
||||
"/user/pages?error=Short code/slug unavailable: {}",
|
||||
e
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,16 +156,8 @@ pub async fn user_pages_create(
|
||||
match res {
|
||||
Ok(page) => {
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let global_status = if form.state == "published" {
|
||||
"active"
|
||||
} else {
|
||||
"disabled"
|
||||
};
|
||||
let _ = system_conn.execute(
|
||||
"UPDATE global_slugs SET target_id = ?1, status = ?2, updated_at = ?3 WHERE slug = ?4;",
|
||||
rusqlite::params![page.id, global_status, chrono::Utc::now().to_rfc3339(), code],
|
||||
);
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let _ = crate::db::slugs::activate_landing_page_slug(&pages_conn, &code, &page.id);
|
||||
}
|
||||
{
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
@@ -181,8 +177,8 @@ pub async fn user_pages_create(
|
||||
Redirect::to("/user/pages").into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::users::release_global_slug(&system_conn, &code, user.id);
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let _ = crate::db::slugs::release_landing_page_slug(&pages_conn, &code, &owner_tid);
|
||||
Redirect::to(&format!("/user/pages?error=Database error: {}", e)).into_response()
|
||||
}
|
||||
}
|
||||
@@ -221,11 +217,13 @@ pub async fn user_pages_delete(
|
||||
);
|
||||
match delete_landing_page(&conn, &id) {
|
||||
Ok(_) => {
|
||||
let _ = crate::db::users::release_global_slug(
|
||||
&state.system_db.lock().unwrap(),
|
||||
&page.code,
|
||||
user.id,
|
||||
);
|
||||
{
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let _ = pages_conn.execute(
|
||||
"DELETE FROM global_landing_pages WHERE slug = ?1;",
|
||||
[&page.code],
|
||||
);
|
||||
}
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let _ = write_audit_log(
|
||||
&state.admin_db.lock().unwrap(),
|
||||
@@ -265,8 +263,14 @@ pub async fn pages_get(
|
||||
};
|
||||
|
||||
let (pages, total_pages, page, visible_pages) = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let total_records = get_landing_page_count(&conn).unwrap_or(0);
|
||||
let conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let total_records: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM global_landing_pages WHERE status != 'retired';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
let calculated_total_pages = (total_records as usize).div_ceil(PAGE_SIZE);
|
||||
let total_pages = std::cmp::max(1, calculated_total_pages);
|
||||
let requested_page = query.page.unwrap_or(1);
|
||||
@@ -278,7 +282,58 @@ pub async fn pages_get(
|
||||
.clamp(1, total_pages);
|
||||
let offset = (current_page - 1) * PAGE_SIZE;
|
||||
|
||||
let pages = list_landing_pages(&conn, PAGE_SIZE as i64, offset as i64).unwrap_or_default();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status FROM global_landing_pages WHERE status != 'retired' ORDER BY created_at DESC LIMIT ?1 OFFSET ?2;"
|
||||
).unwrap();
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params![PAGE_SIZE as i64, offset as i64], |row| {
|
||||
let slug: String = row.get(0)?;
|
||||
let owner_tid_str: String = row.get(1)?;
|
||||
let target_id: String = row.get(2)?;
|
||||
let created_at: String = row.get(3)?;
|
||||
let updated_at: String = row.get(4)?;
|
||||
let status: String = row.get(5)?;
|
||||
Ok((
|
||||
slug,
|
||||
owner_tid_str,
|
||||
target_id,
|
||||
created_at,
|
||||
updated_at,
|
||||
status,
|
||||
))
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let mut pages = Vec::new();
|
||||
for item in rows.flatten() {
|
||||
let (slug, owner_tid_str, target_id, created_at, updated_at, status) = item;
|
||||
let mut resolved_page = None;
|
||||
if let Ok(tid) = owner_tid_str.parse::<crate::identity::TenantId>() {
|
||||
if let Ok(user_dbs) = state.open_tenant(tid, crate::state::TenantOpenMode::CoreJob)
|
||||
{
|
||||
let u_conn = user_dbs.content.lock().unwrap();
|
||||
if let Ok(Some(p)) =
|
||||
crate::db::content::get_landing_page_by_id(&u_conn, &target_id)
|
||||
{
|
||||
resolved_page = Some(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(p) = resolved_page {
|
||||
pages.push(p);
|
||||
} else {
|
||||
pages.push(crate::models::LandingPage {
|
||||
id: target_id,
|
||||
code: slug.clone(),
|
||||
slug,
|
||||
title: String::new(),
|
||||
html_content: String::new(),
|
||||
state: status,
|
||||
created_at,
|
||||
updated_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let start_page = current_page.saturating_sub(3).max(1);
|
||||
let end_page = std::cmp::min(total_pages, current_page + 3);
|
||||
@@ -302,7 +357,8 @@ pub async fn pages_get(
|
||||
template.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct CreatePageForm {
|
||||
pub title: String,
|
||||
pub slug: String,
|
||||
@@ -317,126 +373,16 @@ pub struct CreatePageForm {
|
||||
pub async fn pages_create(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Form(form): Form<CreatePageForm>,
|
||||
_headers: HeaderMap,
|
||||
_connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Form(_form): Form<CreatePageForm>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
let (_user, _session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||
return Redirect::to("/admin/pages?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let admin_user_id = user.id.parse::<i64>().unwrap_or(1);
|
||||
|
||||
// Custom Slug takes priority if provided
|
||||
let mut code = form.custom_slug.trim().to_lowercase();
|
||||
if code.is_empty() {
|
||||
code = form.code.trim().to_lowercase();
|
||||
if code.is_empty() {
|
||||
code = generate_token(2);
|
||||
} else {
|
||||
if code.len() != 4 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Redirect::to(
|
||||
"/admin/pages?error=Custom code must be exactly 4 hex characters",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if !crate::utils::validation::validate_custom_slug(&code) {
|
||||
return Redirect::to("/admin/pages?error=Custom slug must start with ! followed by 1-24 characters of a-z, 0-9, -, _")
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let clean_slug = form.slug.trim().to_lowercase();
|
||||
if clean_slug.is_empty() {
|
||||
return Redirect::to("/admin/pages?error=Slug is required").into_response();
|
||||
}
|
||||
|
||||
{
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
if !crate::db::users::check_quota_limit(&users_conn, admin_user_id, "landings")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Redirect::to("/admin/pages?error=Quota limit exceeded").into_response();
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
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();
|
||||
}
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
let res = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
create_landing_page(
|
||||
&conn,
|
||||
&code,
|
||||
&clean_slug,
|
||||
&form.title,
|
||||
&form.html_content,
|
||||
&form.state,
|
||||
)
|
||||
};
|
||||
|
||||
match res {
|
||||
Ok(page) => {
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let global_status = if form.state == "published" {
|
||||
"active"
|
||||
} else {
|
||||
"disabled"
|
||||
};
|
||||
let _ = system_conn.execute(
|
||||
"UPDATE global_slugs SET target_id = ?1, status = ?2, updated_at = ?3 WHERE slug = ?4;",
|
||||
rusqlite::params![page.id, global_status, chrono::Utc::now().to_rfc3339(), code],
|
||||
);
|
||||
}
|
||||
{
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
let _ = crate::db::users::increment_quota_counter(
|
||||
&users_conn,
|
||||
admin_user_id,
|
||||
"landings",
|
||||
);
|
||||
}
|
||||
{
|
||||
let conn_admin = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(
|
||||
&conn_admin,
|
||||
&state,
|
||||
&user.username,
|
||||
"PAGE_CREATION",
|
||||
Some("page"),
|
||||
Some(&page.id),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
}
|
||||
Redirect::to("/admin/pages").into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::users::release_global_slug(&system_conn, &code, 1);
|
||||
Redirect::to(&format!("/admin/pages?error=Database error: {}", e)).into_response()
|
||||
}
|
||||
}
|
||||
Redirect::to("/admin/pages?error=Admin is a platform operator and cannot create unowned application pages; create landing pages via a tenant user account").into_response()
|
||||
}
|
||||
|
||||
// POST /admin/pages/delete/:id
|
||||
@@ -460,25 +406,42 @@ pub async fn pages_delete(
|
||||
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
match delete_landing_page(&conn, &id) {
|
||||
Ok(_) => {
|
||||
{
|
||||
let conn_admin = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(
|
||||
&conn_admin,
|
||||
&state,
|
||||
&user.username,
|
||||
"PAGE_DELETION",
|
||||
Some("page"),
|
||||
Some(&id),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
// Look up owner tenant in global_landing_pages
|
||||
let owner_info = {
|
||||
let conn = state.db.global_landing_pages.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT slug, owner_tenant_id FROM global_landing_pages WHERE target_id = ?1 OR slug = ?1 LIMIT 1;",
|
||||
rusqlite::params![id],
|
||||
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
|
||||
).ok()
|
||||
};
|
||||
|
||||
if let Some((slug, tid_str)) = owner_info {
|
||||
if let Ok(tid) = tid_str.parse::<crate::identity::TenantId>() {
|
||||
if let Ok(user_dbs) = state.open_tenant(tid, crate::state::TenantOpenMode::CoreJob) {
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
let _ = delete_landing_page(&conn, &id);
|
||||
}
|
||||
Redirect::to("/admin/pages").into_response()
|
||||
}
|
||||
Err(e) => Redirect::to(&format!("/admin/pages?error=Failed to delete page: {}", e))
|
||||
.into_response(),
|
||||
let conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let _ = conn.execute(
|
||||
"UPDATE global_landing_pages SET status = 'retired', updated_at = ?1 WHERE slug = ?2;",
|
||||
rusqlite::params![chrono::Utc::now().to_rfc3339(), slug],
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let conn_admin = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(
|
||||
&conn_admin,
|
||||
&state,
|
||||
&user.username,
|
||||
"PAGE_DELETION",
|
||||
Some("page"),
|
||||
Some(&id),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
}
|
||||
Redirect::to("/admin/pages").into_response()
|
||||
}
|
||||
+220
-119
@@ -106,11 +106,13 @@ pub async fn user_download_backup(
|
||||
};
|
||||
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let user_dir = state
|
||||
.config
|
||||
.data_dir
|
||||
.join("users")
|
||||
.join(user.id.to_string());
|
||||
let user_dir = match crate::db::tenant::location_for_user(&user).and_then(|loc| {
|
||||
loc.dir(&state.db.topology)
|
||||
.map_err(|e| crate::error::AppError::BadRequest(e.to_string()))
|
||||
}) {
|
||||
Ok(p) => p,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
let mut buffer = Vec::new();
|
||||
let res = {
|
||||
@@ -224,11 +226,15 @@ pub async fn user_restore_backup_post(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let user_dir = state
|
||||
.config
|
||||
.data_dir
|
||||
.join("users")
|
||||
.join(user.id.to_string());
|
||||
let user_dir = match crate::db::tenant::location_for_user(&user).and_then(|loc| {
|
||||
loc.dir(&state.db.topology)
|
||||
.map_err(|e| crate::error::AppError::BadRequest(e.to_string()))
|
||||
}) {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
return Redirect::to("/user/settings?error=Invalid user directory").into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let temp_unpack_dir =
|
||||
std::env::temp_dir().join(format!("bzod_restore_unpack_{}", uuid::Uuid::new_v4()));
|
||||
@@ -241,42 +247,124 @@ pub async fn user_restore_backup_post(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let restore_res = {
|
||||
let file = match File::open(&temp_file_path) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(&temp_file_path);
|
||||
let _ = std::fs::remove_dir_all(&temp_unpack_dir);
|
||||
return Redirect::to(&format!(
|
||||
"/user/settings?error=Failed to open upload: {}",
|
||||
e
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let restore_res: Result<(), Box<dyn std::error::Error>> = (|| {
|
||||
let file = File::open(&temp_file_path)?;
|
||||
let tar_gz = GzDecoder::new(file);
|
||||
let mut archive = tar::Archive::new(tar_gz);
|
||||
if let Err(e) = archive.unpack(&temp_unpack_dir) {
|
||||
Err(Box::new(e) as Box<dyn std::error::Error>)
|
||||
} else {
|
||||
// Check for collision using temp content.db
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let temp_content_db = temp_unpack_dir.join("content.db");
|
||||
if temp_content_db.exists() {
|
||||
if let Err(e) = crate::db::users::register_restored_user_slugs(
|
||||
&system_conn,
|
||||
user.id,
|
||||
&temp_content_db,
|
||||
) {
|
||||
Err(e)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
} else {
|
||||
Err("Backup is missing content.db".into())
|
||||
archive.unpack(&temp_unpack_dir)?;
|
||||
|
||||
let target_tenant_id = user
|
||||
.tenant_id
|
||||
.unwrap_or_else(crate::identity::TenantId::generate);
|
||||
let temp_content_db = temp_unpack_dir.join("content.db");
|
||||
if !temp_content_db.exists() {
|
||||
return Err("Backup is missing content.db".into());
|
||||
}
|
||||
|
||||
let content_conn = rusqlite::Connection::open(&temp_content_db)?;
|
||||
|
||||
let mut urls = Vec::new();
|
||||
if let Ok(mut stmt) = content_conn.prepare("SELECT code, id, created_at, status FROM urls;")
|
||||
{
|
||||
if let Ok(rows) = stmt.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
r.get::<_, String>(3)?,
|
||||
))
|
||||
}) {
|
||||
urls = rows.filter_map(|r| r.ok()).collect();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut pages = Vec::new();
|
||||
if let Ok(mut stmt) =
|
||||
content_conn.prepare("SELECT code, id, created_at, state FROM landing_pages;")
|
||||
{
|
||||
if let Ok(rows) = stmt.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
r.get::<_, String>(3)?,
|
||||
))
|
||||
}) {
|
||||
pages = rows.filter_map(|r| r.ok()).collect();
|
||||
}
|
||||
}
|
||||
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
|
||||
for (slug, _, _, _) in &urls {
|
||||
if let Ok(Some(existing_owner)) = urls_conn
|
||||
.query_row(
|
||||
"SELECT owner_tenant_id FROM global_urls WHERE slug = ?1;",
|
||||
[slug],
|
||||
|r| r.get::<_, String>(0),
|
||||
)
|
||||
.optional()
|
||||
{
|
||||
if existing_owner != target_tenant_id.as_str() {
|
||||
return Err(format!("Slug collision on URL /{}", slug).into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (slug, _, _, _) in &pages {
|
||||
if let Ok(Some(existing_owner)) = pages_conn
|
||||
.query_row(
|
||||
"SELECT owner_tenant_id FROM global_landing_pages WHERE slug = ?1;",
|
||||
[slug],
|
||||
|r| r.get::<_, String>(0),
|
||||
)
|
||||
.optional()
|
||||
{
|
||||
if existing_owner != target_tenant_id.as_str() {
|
||||
return Err(format!("Slug collision on Landing Page /{}", slug).into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = urls_conn.execute(
|
||||
"DELETE FROM global_urls WHERE owner_tenant_id = ?1;",
|
||||
[target_tenant_id.as_str()],
|
||||
);
|
||||
let _ = pages_conn.execute(
|
||||
"DELETE FROM global_landing_pages WHERE owner_tenant_id = ?1;",
|
||||
[target_tenant_id.as_str()],
|
||||
);
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
for (slug, target_id, created_at, status) in urls {
|
||||
let global_status = if status == "dead" {
|
||||
"disabled"
|
||||
} else {
|
||||
"active"
|
||||
};
|
||||
let _ = urls_conn.execute(
|
||||
"INSERT OR REPLACE INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL);",
|
||||
rusqlite::params![slug, target_tenant_id.as_str(), target_id, created_at, now, global_status],
|
||||
);
|
||||
}
|
||||
|
||||
for (slug, target_id, created_at, state) in pages {
|
||||
let global_status = if state == "published" {
|
||||
"active"
|
||||
} else {
|
||||
"disabled"
|
||||
};
|
||||
let _ = pages_conn.execute(
|
||||
"INSERT OR REPLACE INTO global_landing_pages (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL);",
|
||||
rusqlite::params![slug, target_tenant_id.as_str(), target_id, created_at, now, global_status],
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
let _ = std::fs::remove_file(&temp_file_path);
|
||||
|
||||
@@ -316,7 +404,9 @@ pub async fn user_restore_backup_post(
|
||||
}
|
||||
|
||||
let mut pool = state.user_dbs.lock().unwrap();
|
||||
pool.remove(&user.id);
|
||||
if let Ok(loc) = crate::db::tenant::location_for_user(&user) {
|
||||
pool.remove(&loc.cache_key());
|
||||
}
|
||||
let _ = write_audit_log(
|
||||
&state.admin_db.lock().unwrap(),
|
||||
&state,
|
||||
@@ -678,8 +768,43 @@ pub async fn bulk_qr_export_post(
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
|
||||
let urls = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
crate::db::content::list_urls(&conn, 500, 0, None).unwrap_or_default()
|
||||
let conn = state.db.global_urls.lock().unwrap();
|
||||
let mut stmt = match conn
|
||||
.prepare("SELECT slug, target_id FROM global_urls WHERE status = 'active' LIMIT 500;")
|
||||
{
|
||||
Ok(s) => s,
|
||||
Err(_) => return Redirect::to("/admin/settings?error=Database error").into_response(),
|
||||
};
|
||||
let rows = stmt.query_map([], |row| {
|
||||
let code: String = row.get(0)?;
|
||||
let target_id: String = row.get(1)?;
|
||||
Ok(crate::models::Url {
|
||||
id: target_id,
|
||||
code: code.clone(),
|
||||
destination: format!(
|
||||
"{}/{}",
|
||||
state.config.base_url.clone().unwrap_or_default(),
|
||||
code
|
||||
),
|
||||
title: None,
|
||||
description: None,
|
||||
created_at: String::new(),
|
||||
updated_at: String::new(),
|
||||
status: "active".to_string(),
|
||||
tags: vec![],
|
||||
expires_at: None,
|
||||
password_hash: None,
|
||||
max_access_count: None,
|
||||
access_count: 0,
|
||||
expired: false,
|
||||
last_latency_ms: None,
|
||||
last_status: None,
|
||||
})
|
||||
});
|
||||
match rows {
|
||||
Ok(r) => r.filter_map(|x| x.ok()).collect(),
|
||||
Err(_) => vec![],
|
||||
}
|
||||
};
|
||||
|
||||
if urls.is_empty() {
|
||||
@@ -761,10 +886,7 @@ pub async fn status_get(State(state): State<AppState>, jar: CookieJar) -> Respon
|
||||
let uptime_duration = state.start_time.elapsed();
|
||||
let uptime = crate::utils::format_duration(uptime_duration);
|
||||
|
||||
let urls = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
crate::db::content::list_urls(&conn, 50, 0, None).unwrap_or_default()
|
||||
};
|
||||
let urls = vec![];
|
||||
|
||||
let template = crate::templates::StatusTemplate {
|
||||
admin_username: user.username,
|
||||
@@ -911,93 +1033,72 @@ pub async fn restore_backup_post(
|
||||
let restore_res = {
|
||||
// Temporarily suspend access to active SQLite connections
|
||||
let mut admin_conn = state.admin_db.lock().unwrap();
|
||||
let mut content_conn = state.content_db.lock().unwrap();
|
||||
let mut analytics_conn = state.analytics_db.lock().unwrap();
|
||||
let mut system_conn = state.system_db.lock().unwrap();
|
||||
let mut users_conn = state.users_db.lock().unwrap();
|
||||
let mut urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let mut pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let mut reserved_conn = state.db.reserved.lock().unwrap();
|
||||
|
||||
// 1. Close current connections by replacing them with dummy in-memory DBs
|
||||
*admin_conn = match rusqlite::Connection::open_in_memory() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return Redirect::to(&format!(
|
||||
"/admin/settings?error=Failed to open temp in-memory DB: {}",
|
||||
e
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
*content_conn = match rusqlite::Connection::open_in_memory() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return Redirect::to(&format!(
|
||||
"/admin/settings?error=Failed to open temp in-memory DB: {}",
|
||||
e
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
*analytics_conn = match rusqlite::Connection::open_in_memory() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return Redirect::to(&format!(
|
||||
"/admin/settings?error=Failed to open temp in-memory DB: {}",
|
||||
e
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
*system_conn = match rusqlite::Connection::open_in_memory() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return Redirect::to(&format!(
|
||||
"/admin/settings?error=Failed to open temp in-memory DB: {}",
|
||||
e
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
*admin_conn = rusqlite::Connection::open_in_memory()
|
||||
.unwrap_or_else(|_| rusqlite::Connection::open(":memory:").unwrap());
|
||||
*system_conn = rusqlite::Connection::open_in_memory()
|
||||
.unwrap_or_else(|_| rusqlite::Connection::open(":memory:").unwrap());
|
||||
*users_conn = rusqlite::Connection::open_in_memory()
|
||||
.unwrap_or_else(|_| rusqlite::Connection::open(":memory:").unwrap());
|
||||
*urls_conn = rusqlite::Connection::open_in_memory()
|
||||
.unwrap_or_else(|_| rusqlite::Connection::open(":memory:").unwrap());
|
||||
*pages_conn = rusqlite::Connection::open_in_memory()
|
||||
.unwrap_or_else(|_| rusqlite::Connection::open(":memory:").unwrap());
|
||||
*reserved_conn = rusqlite::Connection::open_in_memory()
|
||||
.unwrap_or_else(|_| rusqlite::Connection::open(":memory:").unwrap());
|
||||
|
||||
// 2. Perform restore unpacking/validation
|
||||
// perform_restore() handles legacy layout normalization internally
|
||||
// before validation, so no post-restore normalization is needed.
|
||||
// 2. Clear tenant pool cache
|
||||
if let Ok(mut pool) = state.user_dbs.lock() {
|
||||
pool.clear();
|
||||
}
|
||||
|
||||
// 3. Perform restore unpacking/validation
|
||||
let res = crate::cli::restore::perform_restore(&temp_file_path, &state.config.data_dir);
|
||||
|
||||
// 3. Reinitialize database connections using correct multi-tenant paths
|
||||
let paths =
|
||||
crate::services::backup_layout::RestoredDbPaths::from_data_dir(&state.config.data_dir);
|
||||
let new_admin = rusqlite::Connection::open(&paths.admin);
|
||||
let new_system = rusqlite::Connection::open(&paths.system);
|
||||
let new_users = rusqlite::Connection::open(&paths.users);
|
||||
let new_content = rusqlite::Connection::open(&paths.content);
|
||||
let new_analytics = rusqlite::Connection::open(&paths.analytics);
|
||||
// 4. Reinitialize Core database connections
|
||||
let topology = crate::db::topology::Topology::new(&state.config.data_dir);
|
||||
let new_admin = rusqlite::Connection::open(topology.admin_db());
|
||||
let new_system = rusqlite::Connection::open(topology.system_db());
|
||||
let new_users = rusqlite::Connection::open(topology.users_registry_db());
|
||||
let new_urls = rusqlite::Connection::open(topology.global_urls_db());
|
||||
let new_pages = rusqlite::Connection::open(topology.global_landing_pages_db());
|
||||
let new_reserved = rusqlite::Connection::open(topology.reserved_db());
|
||||
|
||||
match (new_admin, new_content, new_analytics, new_system, new_users) {
|
||||
(Ok(adm), Ok(cnt), Ok(any), Ok(sys), Ok(usr)) => {
|
||||
match (
|
||||
new_admin,
|
||||
new_system,
|
||||
new_users,
|
||||
new_urls,
|
||||
new_pages,
|
||||
new_reserved,
|
||||
) {
|
||||
(Ok(adm), Ok(sys), Ok(usr), Ok(urls), Ok(pages), Ok(resv)) => {
|
||||
let _ = crate::db::sqlite::enable_wal(&adm, "admin");
|
||||
let _ = crate::db::sqlite::enable_wal(&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_wal(&urls, "global_urls");
|
||||
let _ = crate::db::sqlite::enable_wal(&pages, "global_landing_pages");
|
||||
let _ = crate::db::sqlite::enable_wal(&resv, "reserved");
|
||||
|
||||
let _ = crate::db::sqlite::enable_foreign_keys(&adm, "admin");
|
||||
let _ = crate::db::sqlite::enable_foreign_keys(&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");
|
||||
let _ = crate::db::sqlite::enable_foreign_keys(&urls, "global_urls");
|
||||
let _ = crate::db::sqlite::enable_foreign_keys(&pages, "global_landing_pages");
|
||||
let _ = crate::db::sqlite::enable_foreign_keys(&resv, "reserved");
|
||||
|
||||
*admin_conn = adm;
|
||||
*content_conn = cnt;
|
||||
*analytics_conn = any;
|
||||
*system_conn = sys;
|
||||
match state.db.users.lock() {
|
||||
Ok(mut u) => *u = usr,
|
||||
Err(_) => {
|
||||
return Redirect::to(
|
||||
"/admin/settings?error=Failed to reopen restored databases",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
*users_conn = usr;
|
||||
*urls_conn = urls;
|
||||
*pages_conn = pages;
|
||||
*reserved_conn = resv;
|
||||
}
|
||||
_ => {
|
||||
return Redirect::to("/admin/settings?error=Failed to reopen restored databases")
|
||||
|
||||
+144
-213
@@ -153,21 +153,26 @@ pub async fn user_urls_create(
|
||||
}
|
||||
}
|
||||
|
||||
let owner_tid = user
|
||||
.tenant_id
|
||||
.unwrap_or_else(crate::identity::TenantId::generate);
|
||||
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
|
||||
return Redirect::to("/user/urls?error=Short code/slug already exists").into_response();
|
||||
}
|
||||
if let Err(e) = crate::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
let reserved_conn = state.db.reserved.lock().unwrap();
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
if let Err(e) = crate::db::slugs::reserve_url_slug(
|
||||
&reserved_conn,
|
||||
&urls_conn,
|
||||
&pages_conn,
|
||||
&code,
|
||||
user.id,
|
||||
"url",
|
||||
"",
|
||||
"reserving",
|
||||
&owner_tid,
|
||||
) {
|
||||
return Redirect::to(&format!("/user/urls?error=Failed to reserve slug: {}", e))
|
||||
.into_response();
|
||||
return Redirect::to(&format!(
|
||||
"/user/urls?error=Short code/slug unavailable: {}",
|
||||
e
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,6 +186,8 @@ pub async fn user_urls_create(
|
||||
) {
|
||||
Ok(d) => d,
|
||||
Err(msg) => {
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let _ = crate::db::slugs::release_url_slug(&urls_conn, &code, &owner_tid);
|
||||
return Redirect::to(&format!("/user/urls?error={}", msg)).into_response();
|
||||
}
|
||||
};
|
||||
@@ -192,7 +199,11 @@ pub async fn user_urls_create(
|
||||
} else {
|
||||
match hash_password(&form.password) {
|
||||
Ok(h) => Some(h),
|
||||
Err(_) => return Redirect::to("/user/urls?error=Hashing error").into_response(),
|
||||
Err(_) => {
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let _ = crate::db::slugs::release_url_slug(&urls_conn, &code, &owner_tid);
|
||||
return Redirect::to("/user/urls?error=Hashing error").into_response();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -202,7 +213,9 @@ pub async fn user_urls_create(
|
||||
match form.max_access_count.trim().parse::<i64>() {
|
||||
Ok(c) => Some(c),
|
||||
Err(_) => {
|
||||
return Redirect::to("/user/urls?error=Invalid max access count").into_response()
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let _ = crate::db::slugs::release_url_slug(&urls_conn, &code, &owner_tid);
|
||||
return Redirect::to("/user/urls?error=Invalid max access count").into_response();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -243,11 +256,8 @@ pub async fn user_urls_create(
|
||||
match res {
|
||||
Ok(url) => {
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = 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],
|
||||
);
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let _ = crate::db::slugs::activate_url_slug(&urls_conn, &code, &url.id);
|
||||
}
|
||||
{
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
@@ -266,8 +276,8 @@ pub async fn user_urls_create(
|
||||
Redirect::to("/user/urls").into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::users::release_global_slug(&system_conn, &code, user.id);
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let _ = crate::db::slugs::release_url_slug(&urls_conn, &code, &owner_tid);
|
||||
Redirect::to(&format!("/user/urls?error=Database error: {}", e)).into_response()
|
||||
}
|
||||
}
|
||||
@@ -306,11 +316,11 @@ pub async fn user_urls_delete(
|
||||
);
|
||||
match delete_url(&conn, &id) {
|
||||
Ok(_) => {
|
||||
let _ = crate::db::users::release_global_slug(
|
||||
&state.system_db.lock().unwrap(),
|
||||
&url.code,
|
||||
user.id,
|
||||
);
|
||||
{
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let _ = urls_conn
|
||||
.execute("DELETE FROM global_urls WHERE slug = ?1;", [&url.code]);
|
||||
}
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let _ = write_audit_log(
|
||||
&state.admin_db.lock().unwrap(),
|
||||
@@ -351,12 +361,14 @@ pub async fn urls_get(
|
||||
};
|
||||
|
||||
let (urls, total_pages, page, visible_pages) = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let total_records = if let Some(tag_str) = query.tag.as_deref() {
|
||||
get_url_count_by_tag(&conn, tag_str).unwrap_or(0)
|
||||
} else {
|
||||
get_url_counts(&conn).map(|(t, _, _)| t).unwrap_or(0)
|
||||
};
|
||||
let conn = state.db.global_urls.lock().unwrap();
|
||||
let total_records: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM global_urls WHERE status != 'retired';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
let calculated_total_pages = (total_records as usize).div_ceil(PAGE_SIZE);
|
||||
let total_pages = std::cmp::max(1, calculated_total_pages);
|
||||
let requested_page = query.page.unwrap_or(1);
|
||||
@@ -368,8 +380,64 @@ pub async fn urls_get(
|
||||
.clamp(1, total_pages);
|
||||
let offset = (current_page - 1) * PAGE_SIZE;
|
||||
|
||||
let urls = list_urls(&conn, PAGE_SIZE as i64, offset as i64, query.tag.as_deref())
|
||||
.unwrap_or_default();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status FROM global_urls WHERE status != 'retired' ORDER BY created_at DESC LIMIT ?1 OFFSET ?2;"
|
||||
).unwrap();
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params![PAGE_SIZE as i64, offset as i64], |row| {
|
||||
let slug: String = row.get(0)?;
|
||||
let owner_tid_str: String = row.get(1)?;
|
||||
let target_id: String = row.get(2)?;
|
||||
let created_at: String = row.get(3)?;
|
||||
let updated_at: String = row.get(4)?;
|
||||
let status: String = row.get(5)?;
|
||||
Ok((
|
||||
slug,
|
||||
owner_tid_str,
|
||||
target_id,
|
||||
created_at,
|
||||
updated_at,
|
||||
status,
|
||||
))
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let mut urls = Vec::new();
|
||||
for item in rows.flatten() {
|
||||
let (slug, owner_tid_str, target_id, created_at, updated_at, status) = item;
|
||||
let mut resolved_url = None;
|
||||
if let Ok(tid) = owner_tid_str.parse::<crate::identity::TenantId>() {
|
||||
if let Ok(user_dbs) = state.open_tenant(tid, crate::state::TenantOpenMode::CoreJob)
|
||||
{
|
||||
let u_conn = user_dbs.content.lock().unwrap();
|
||||
if let Ok(Some(u)) = crate::db::content::get_url_by_id(&u_conn, &target_id) {
|
||||
resolved_url = Some(u);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(u) = resolved_url {
|
||||
urls.push(u);
|
||||
} else {
|
||||
urls.push(crate::models::Url {
|
||||
id: target_id,
|
||||
code: slug,
|
||||
destination: String::new(),
|
||||
title: None,
|
||||
description: None,
|
||||
created_at,
|
||||
updated_at,
|
||||
status,
|
||||
tags: vec![],
|
||||
expires_at: None,
|
||||
password_hash: None,
|
||||
max_access_count: None,
|
||||
access_count: 0,
|
||||
expired: false,
|
||||
last_latency_ms: None,
|
||||
last_status: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let start_page = current_page.saturating_sub(3).max(1);
|
||||
let end_page = std::cmp::min(total_pages, current_page + 3);
|
||||
@@ -406,7 +474,8 @@ pub async fn urls_get(
|
||||
template.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct CreateUrlForm {
|
||||
pub destination: String,
|
||||
pub code: String,
|
||||
@@ -427,170 +496,16 @@ pub struct CreateUrlForm {
|
||||
pub async fn urls_create(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Form(form): Form<CreateUrlForm>,
|
||||
_headers: HeaderMap,
|
||||
_connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Form(_form): Form<CreateUrlForm>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
let (_user, _session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||
return Redirect::to("/admin/urls?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let admin_user_id = user.id.parse::<i64>().unwrap_or(1);
|
||||
|
||||
// Custom Slug takes priority if provided
|
||||
let mut code = form.custom_slug.trim().to_lowercase();
|
||||
if code.is_empty() {
|
||||
code = form.code.trim().to_lowercase();
|
||||
if code.is_empty() {
|
||||
code = generate_token(3);
|
||||
} else {
|
||||
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Redirect::to(
|
||||
"/admin/urls?error=Custom code must be exactly 6 hex characters",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if !crate::utils::validation::validate_custom_slug(&code) {
|
||||
return Redirect::to("/admin/urls?error=Custom slug must start with ! followed by 1-24 characters of a-z, 0-9, -, _")
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let dest = match crate::services::urls::prepare_destination(
|
||||
&form.destination,
|
||||
crate::services::urls::UtmParams {
|
||||
source: Some(&form.utm_source),
|
||||
medium: Some(&form.utm_medium),
|
||||
campaign: Some(&form.utm_campaign),
|
||||
},
|
||||
) {
|
||||
Ok(d) => d,
|
||||
Err(msg) => {
|
||||
return Redirect::to(&format!("/admin/urls?error={}", msg)).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let expires_at_opt = crate::services::urls::parse_expires_at_input(&form.expires_at);
|
||||
|
||||
let password_hash_opt = if form.password.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
match hash_password(&form.password) {
|
||||
Ok(h) => Some(h),
|
||||
Err(_) => return Redirect::to("/admin/urls?error=Hashing error").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
let max_access_count_opt = if form.max_access_count.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
match form.max_access_count.trim().parse::<i64>() {
|
||||
Ok(c) => Some(c),
|
||||
Err(_) => {
|
||||
return Redirect::to("/admin/urls?error=Invalid max access count").into_response()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let tags_list: Vec<String> = form
|
||||
.tags
|
||||
.split(',')
|
||||
.map(|t| t.trim().to_string())
|
||||
.filter(|t| !t.is_empty())
|
||||
.collect();
|
||||
|
||||
let title_opt = if form.title.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(form.title.trim())
|
||||
};
|
||||
let desc_opt = if form.description.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(form.description.trim())
|
||||
};
|
||||
|
||||
{
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
if !crate::db::users::check_quota_limit(&users_conn, admin_user_id, "urls").unwrap_or(false)
|
||||
{
|
||||
return Redirect::to("/admin/urls?error=Quota limit exceeded").into_response();
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
|
||||
return Redirect::to("/admin/urls?error=Short code/slug already exists")
|
||||
.into_response();
|
||||
}
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
let res = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
crate::db::content::create_url_extended(
|
||||
&conn,
|
||||
&code,
|
||||
&dest,
|
||||
title_opt,
|
||||
desc_opt,
|
||||
&tags_list,
|
||||
expires_at_opt.as_deref(),
|
||||
password_hash_opt.as_deref(),
|
||||
max_access_count_opt,
|
||||
)
|
||||
};
|
||||
|
||||
match res {
|
||||
Ok(url) => {
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = 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],
|
||||
);
|
||||
}
|
||||
{
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
let _ =
|
||||
crate::db::users::increment_quota_counter(&users_conn, admin_user_id, "urls");
|
||||
}
|
||||
{
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(
|
||||
&conn,
|
||||
&state,
|
||||
&user.username,
|
||||
"URL_CREATION",
|
||||
Some("url"),
|
||||
Some(&url.id),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
}
|
||||
Redirect::to("/admin/urls").into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::users::release_global_slug(&system_conn, &code, 1);
|
||||
Redirect::to(&format!("/admin/urls?error=Database error: {}", e)).into_response()
|
||||
}
|
||||
}
|
||||
Redirect::to("/admin/urls?error=Admin is a platform operator and cannot create unowned application URLs; create links via a tenant user account").into_response()
|
||||
}
|
||||
|
||||
// POST /admin/urls/delete/:id
|
||||
@@ -614,26 +529,42 @@ pub async fn urls_delete(
|
||||
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
match delete_url(&conn, &id) {
|
||||
Ok(_) => {
|
||||
{
|
||||
let conn_admin = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(
|
||||
&conn_admin,
|
||||
&state,
|
||||
&user.username,
|
||||
"URL_DELETION",
|
||||
Some("url"),
|
||||
Some(&id),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
// Look up owner tenant in global_urls
|
||||
let owner_info = {
|
||||
let conn = state.db.global_urls.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT slug, owner_tenant_id FROM global_urls WHERE target_id = ?1 OR slug = ?1 LIMIT 1;",
|
||||
rusqlite::params![id],
|
||||
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
|
||||
).ok()
|
||||
};
|
||||
|
||||
if let Some((slug, tid_str)) = owner_info {
|
||||
if let Ok(tid) = tid_str.parse::<crate::identity::TenantId>() {
|
||||
if let Ok(user_dbs) = state.open_tenant(tid, crate::state::TenantOpenMode::CoreJob) {
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
let _ = delete_url(&conn, &id);
|
||||
}
|
||||
Redirect::to("/admin/urls").into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
Redirect::to(&format!("/admin/urls?error=Failed to delete link: {}", e)).into_response()
|
||||
}
|
||||
let conn = state.db.global_urls.lock().unwrap();
|
||||
let _ = conn.execute(
|
||||
"UPDATE global_urls SET status = 'retired', updated_at = ?1 WHERE slug = ?2;",
|
||||
rusqlite::params![chrono::Utc::now().to_rfc3339(), slug],
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let conn_admin = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(
|
||||
&conn_admin,
|
||||
&state,
|
||||
&user.username,
|
||||
"URL_DELETION",
|
||||
Some("url"),
|
||||
Some(&id),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
}
|
||||
Redirect::to("/admin/urls").into_response()
|
||||
}
|
||||
+14
-44
@@ -133,12 +133,14 @@ pub async fn users_create_post(
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = state.db.init_user_databases(new_user.id) {
|
||||
return Redirect::to(&format!(
|
||||
"/admin/users?error=Failed to initialize user databases: {}",
|
||||
e
|
||||
))
|
||||
.into_response();
|
||||
if new_user.account_type == "standard" {
|
||||
if let Err(e) = state.db.init_user_databases(new_user.id) {
|
||||
return Redirect::to(&format!(
|
||||
"/admin/users?error=Failed to initialize user databases: {}",
|
||||
e
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
@@ -481,25 +483,9 @@ pub async fn user_detail_get(
|
||||
|
||||
let target_user = {
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
let u_res = conn.query_row(
|
||||
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata \
|
||||
FROM users WHERE id = ?1;",
|
||||
[id],
|
||||
|row| Ok(crate::models::TenantUser {
|
||||
id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
status: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
last_login: row.get(5)?,
|
||||
account_type: row.get(6)?,
|
||||
organization_id: row.get(7)?,
|
||||
metadata: row.get(8)?,
|
||||
})
|
||||
);
|
||||
match u_res {
|
||||
Ok(u) => u,
|
||||
Err(_) => return Redirect::to("/admin/users?error=User not found").into_response(),
|
||||
match crate::db::users::get_user_by_id(&conn, id) {
|
||||
Ok(Some(u)) => u,
|
||||
_ => return Redirect::to("/admin/users?error=User not found").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
@@ -576,25 +562,9 @@ pub async fn user_edit_get(
|
||||
|
||||
let target_user = {
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
let u_res = conn.query_row(
|
||||
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata \
|
||||
FROM users WHERE id = ?1;",
|
||||
[id],
|
||||
|row| Ok(crate::models::TenantUser {
|
||||
id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
status: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
last_login: row.get(5)?,
|
||||
account_type: row.get(6)?,
|
||||
organization_id: row.get(7)?,
|
||||
metadata: row.get(8)?,
|
||||
})
|
||||
);
|
||||
match u_res {
|
||||
Ok(u) => u,
|
||||
Err(_) => return Redirect::to("/admin/users?error=User not found").into_response(),
|
||||
match crate::db::users::get_user_by_id(&conn, id) {
|
||||
Ok(Some(u)) => u,
|
||||
_ => return Redirect::to("/admin/users?error=User not found").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+253
-117
@@ -12,12 +12,10 @@ use crate::auth::ApiUser;
|
||||
use crate::db::admin::write_audit_log;
|
||||
use crate::db::analytics::{
|
||||
get_clicks_trend, get_clicks_trend_raw, get_metric_rankings, get_metric_rankings_raw,
|
||||
get_total_clicks, get_total_page_views,
|
||||
};
|
||||
use crate::db::content::{
|
||||
create_landing_page, delete_landing_page, delete_url, get_landing_page_by_id,
|
||||
get_landing_page_count, get_url_by_id, get_url_counts, list_landing_pages, list_urls,
|
||||
update_landing_page, update_url,
|
||||
create_landing_page, delete_landing_page, delete_url, get_landing_page_by_id, get_url_by_id,
|
||||
list_landing_pages, list_urls, update_landing_page, update_url,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use crate::utils::get_client_ip;
|
||||
@@ -73,6 +71,30 @@ pub struct ApiError {
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
#[allow(clippy::result_large_err)]
|
||||
fn get_api_tenant_dbs(state: &AppState, user: &ApiUser) -> Result<crate::state::UserDbs, Response> {
|
||||
match user.0 {
|
||||
crate::models::ApiActor::User(ref u) => {
|
||||
state.get_user_dbs(u.id).map_err(|_| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError {
|
||||
error: "Database error".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
})
|
||||
}
|
||||
crate::models::ApiActor::Admin(_) => Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ApiError {
|
||||
error: "Admin is a platform operator and cannot access application content directly without tenant context".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response()),
|
||||
}
|
||||
}
|
||||
|
||||
// --- URL Endpoints ---
|
||||
|
||||
// POST /api/v1/urls
|
||||
@@ -138,9 +160,17 @@ pub async fn api_create_url(
|
||||
None
|
||||
};
|
||||
|
||||
// Dynamically resolve target user ID and content DB
|
||||
let (target_user_id, content_db) = match user.0 {
|
||||
crate::models::ApiActor::Admin(_) => (1, state.content_db.clone()),
|
||||
// Dynamically resolve target user ID, tenant ID, and content DB
|
||||
let (target_user_id, target_tenant_id, content_db) = match user.0 {
|
||||
crate::models::ApiActor::Admin(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ApiError {
|
||||
error: "Admin is a platform operator and cannot create application URLs directly without tenant context".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
crate::models::ApiActor::User(ref u) => {
|
||||
let user_dbs = match state.get_user_dbs(u.id) {
|
||||
Ok(dbs) => dbs,
|
||||
@@ -154,7 +184,10 @@ pub async fn api_create_url(
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
(u.id, user_dbs.content.clone())
|
||||
let tid = u
|
||||
.tenant_id
|
||||
.unwrap_or_else(crate::identity::TenantId::generate);
|
||||
(u.id, tid, user_dbs.content.clone())
|
||||
}
|
||||
};
|
||||
|
||||
@@ -174,30 +207,22 @@ pub async fn api_create_url(
|
||||
}
|
||||
}
|
||||
|
||||
// Check availability
|
||||
// Check availability and reserve in v0.8 slug registry
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
|
||||
let reserved_conn = state.db.reserved.lock().unwrap();
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
if let Err(e) = crate::db::slugs::reserve_url_slug(
|
||||
&reserved_conn,
|
||||
&urls_conn,
|
||||
&pages_conn,
|
||||
&code,
|
||||
&target_tenant_id,
|
||||
) {
|
||||
return (
|
||||
StatusCode::CONFLICT,
|
||||
Json(ApiError {
|
||||
error: "Short code already exists".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if let Err(e) = crate::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
&code,
|
||||
target_user_id,
|
||||
"url",
|
||||
"",
|
||||
"reserving",
|
||||
) {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError {
|
||||
error: format!("Failed to reserve slug: {}", e),
|
||||
error: format!("Short code unavailable: {}", e),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
@@ -222,13 +247,10 @@ pub async fn api_create_url(
|
||||
|
||||
match res {
|
||||
Ok(url) => {
|
||||
// Activate slug
|
||||
// Activate slug in v0.8 global_urls.db
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = 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],
|
||||
);
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let _ = crate::db::slugs::activate_url_slug(&urls_conn, &code, &url.id);
|
||||
}
|
||||
// Increment quota
|
||||
{
|
||||
@@ -264,8 +286,8 @@ pub async fn api_create_url(
|
||||
(StatusCode::CREATED, Json(url)).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::users::release_global_slug(&system_conn, &code, target_user_id);
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let _ = crate::db::slugs::release_url_slug(&urls_conn, &code, &target_tenant_id);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError {
|
||||
@@ -287,13 +309,17 @@ pub struct ListQuery {
|
||||
|
||||
pub async fn api_list_urls(
|
||||
State(state): State<AppState>,
|
||||
_user: ApiUser,
|
||||
user: ApiUser,
|
||||
Query(query): Query<ListQuery>,
|
||||
) -> Response {
|
||||
let limit = query.limit.unwrap_or(100);
|
||||
let offset = query.offset.unwrap_or(0);
|
||||
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
match list_urls(&conn, limit, offset, query.tag.as_deref()) {
|
||||
Ok(urls) => Json(urls).into_response(),
|
||||
Err(e) => (
|
||||
@@ -309,10 +335,14 @@ pub async fn api_list_urls(
|
||||
// GET /api/v1/urls/:uuid
|
||||
pub async fn api_get_url(
|
||||
State(state): State<AppState>,
|
||||
_user: ApiUser,
|
||||
user: ApiUser,
|
||||
Path(uuid): Path<String>,
|
||||
) -> Response {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
match get_url_by_id(&conn, &uuid) {
|
||||
Ok(Some(url)) => Json(url).into_response(),
|
||||
Ok(None) => (
|
||||
@@ -351,7 +381,11 @@ pub async fn api_update_url(
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
match update_url(
|
||||
&conn,
|
||||
&uuid,
|
||||
@@ -438,9 +472,21 @@ pub async fn api_delete_url(
|
||||
user: ApiUser,
|
||||
Path(uuid): Path<String>,
|
||||
) -> Response {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
let code_opt = match get_url_by_id(&conn, &uuid) {
|
||||
Ok(Some(u)) => Some(u.code),
|
||||
_ => None,
|
||||
};
|
||||
match delete_url(&conn, &uuid) {
|
||||
Ok(true) => {
|
||||
if let Some(code) = code_opt {
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let _ = urls_conn.execute("DELETE FROM global_urls WHERE slug = ?1;", [&code]);
|
||||
}
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
|
||||
let _ = write_audit_log(
|
||||
@@ -510,9 +556,17 @@ pub async fn api_create_page(
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamically resolve target user ID and content DB
|
||||
let (target_user_id, content_db) = match user.0 {
|
||||
crate::models::ApiActor::Admin(_) => (1, state.content_db.clone()),
|
||||
// Dynamically resolve target user ID, tenant ID, and content DB
|
||||
let (target_user_id, target_tenant_id, content_db) = match user.0 {
|
||||
crate::models::ApiActor::Admin(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ApiError {
|
||||
error: "Admin is a platform operator and cannot create application landing pages directly without tenant context".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
crate::models::ApiActor::User(ref u) => {
|
||||
let user_dbs = match state.get_user_dbs(u.id) {
|
||||
Ok(dbs) => dbs,
|
||||
@@ -526,7 +580,10 @@ pub async fn api_create_page(
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
(u.id, user_dbs.content.clone())
|
||||
let tid = u
|
||||
.tenant_id
|
||||
.unwrap_or_else(crate::identity::TenantId::generate);
|
||||
(u.id, tid, user_dbs.content.clone())
|
||||
}
|
||||
};
|
||||
|
||||
@@ -546,30 +603,22 @@ pub async fn api_create_page(
|
||||
}
|
||||
}
|
||||
|
||||
// Check availability
|
||||
// Check availability and reserve in v0.8 slug registry
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
|
||||
let reserved_conn = state.db.reserved.lock().unwrap();
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
if let Err(e) = crate::db::slugs::reserve_landing_page_slug(
|
||||
&reserved_conn,
|
||||
&urls_conn,
|
||||
&pages_conn,
|
||||
&code,
|
||||
&target_tenant_id,
|
||||
) {
|
||||
return (
|
||||
StatusCode::CONFLICT,
|
||||
Json(ApiError {
|
||||
error: "Short code already exists".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if let Err(e) = crate::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
&code,
|
||||
target_user_id,
|
||||
"page",
|
||||
"",
|
||||
"reserving",
|
||||
) {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError {
|
||||
error: format!("Failed to reserve slug: {}", e),
|
||||
error: format!("Short code unavailable: {}", e),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
@@ -590,18 +639,10 @@ pub async fn api_create_page(
|
||||
|
||||
match res {
|
||||
Ok(page) => {
|
||||
// Activate slug
|
||||
// Activate slug in v0.8 global_landing_pages.db
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let global_status = if payload.state == "published" {
|
||||
"active"
|
||||
} else {
|
||||
"disabled"
|
||||
};
|
||||
let _ = system_conn.execute(
|
||||
"UPDATE global_slugs SET target_id = ?1, status = ?2, updated_at = ?3 WHERE slug = ?4;",
|
||||
rusqlite::params![page.id, global_status, chrono::Utc::now().to_rfc3339(), code],
|
||||
);
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let _ = crate::db::slugs::activate_landing_page_slug(&pages_conn, &code, &page.id);
|
||||
}
|
||||
// Increment quota
|
||||
{
|
||||
@@ -627,8 +668,9 @@ pub async fn api_create_page(
|
||||
(StatusCode::CREATED, Json(page)).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::users::release_global_slug(&system_conn, &code, target_user_id);
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let _ =
|
||||
crate::db::slugs::release_landing_page_slug(&pages_conn, &code, &target_tenant_id);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError {
|
||||
@@ -643,13 +685,17 @@ pub async fn api_create_page(
|
||||
// GET /api/v1/pages
|
||||
pub async fn api_list_pages(
|
||||
State(state): State<AppState>,
|
||||
_user: ApiUser,
|
||||
user: ApiUser,
|
||||
Query(query): Query<ListQuery>,
|
||||
) -> Response {
|
||||
let limit = query.limit.unwrap_or(100);
|
||||
let offset = query.offset.unwrap_or(0);
|
||||
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
match list_landing_pages(&conn, limit, offset) {
|
||||
Ok(pages) => Json(pages).into_response(),
|
||||
Err(e) => (
|
||||
@@ -665,10 +711,14 @@ pub async fn api_list_pages(
|
||||
// GET /api/v1/pages/:uuid
|
||||
pub async fn api_get_page(
|
||||
State(state): State<AppState>,
|
||||
_user: ApiUser,
|
||||
user: ApiUser,
|
||||
Path(uuid): Path<String>,
|
||||
) -> Response {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
match get_landing_page_by_id(&conn, &uuid) {
|
||||
Ok(Some(page)) => Json(page).into_response(),
|
||||
Ok(None) => (
|
||||
@@ -697,7 +747,11 @@ pub async fn api_update_page(
|
||||
Path(uuid): Path<String>,
|
||||
Json(payload): Json<UpdatePageRequest>,
|
||||
) -> Response {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
match update_landing_page(
|
||||
&conn,
|
||||
&uuid,
|
||||
@@ -745,9 +799,22 @@ pub async fn api_delete_page(
|
||||
user: ApiUser,
|
||||
Path(uuid): Path<String>,
|
||||
) -> Response {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
let code_opt = match get_landing_page_by_id(&conn, &uuid) {
|
||||
Ok(Some(p)) => Some(p.code),
|
||||
_ => None,
|
||||
};
|
||||
match delete_landing_page(&conn, &uuid) {
|
||||
Ok(true) => {
|
||||
if let Some(code) = code_opt {
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let _ = pages_conn
|
||||
.execute("DELETE FROM global_landing_pages WHERE slug = ?1;", [&code]);
|
||||
}
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
|
||||
let _ = write_audit_log(
|
||||
@@ -797,20 +864,44 @@ pub async fn api_overall_stats(State(state): State<AppState>, user: ApiUser) ->
|
||||
}
|
||||
|
||||
let (total_urls, active_links, dead_links) = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
get_url_counts(&conn).unwrap_or((0, 0, 0))
|
||||
let conn = state.db.global_urls.lock().unwrap();
|
||||
let total: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM global_urls WHERE status != 'retired';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
let active: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM global_urls WHERE status = 'active';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
let dead: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM global_urls WHERE status = 'disabled';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
(total, active, dead)
|
||||
};
|
||||
let total_pages = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
get_landing_page_count(&conn).unwrap_or(0)
|
||||
};
|
||||
let (total_clicks, total_page_views) = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
(
|
||||
get_total_clicks(&conn).unwrap_or(0),
|
||||
get_total_page_views(&conn).unwrap_or(0),
|
||||
let conn = state.db.global_landing_pages.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM global_landing_pages WHERE status != 'retired';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
let total_clicks = {
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
crate::db::users::get_platform_total_clicks(&state.db.topology, &users_conn).unwrap_or(0)
|
||||
};
|
||||
let total_page_views = 0i64;
|
||||
|
||||
Json(OverallStatsResponse {
|
||||
total_urls,
|
||||
@@ -835,12 +926,17 @@ pub struct DetailStatsResponse {
|
||||
// GET /api/v1/stats/url/:uuid
|
||||
pub async fn api_url_stats(
|
||||
State(state): State<AppState>,
|
||||
_user: ApiUser,
|
||||
user: ApiUser,
|
||||
Path(uuid): Path<String>,
|
||||
) -> Response {
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
|
||||
// Verify URL exists
|
||||
{
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
if get_url_by_id(&conn, &uuid).unwrap_or(None).is_none() {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
@@ -852,7 +948,7 @@ pub async fn api_url_stats(
|
||||
}
|
||||
}
|
||||
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
let conn = dbs.analytics.lock().unwrap();
|
||||
let clicks = get_clicks_trend(&conn, "url", &uuid, 30)
|
||||
.or_else(|_| get_clicks_trend_raw(&conn, "url", &uuid, 30))
|
||||
.unwrap_or_default();
|
||||
@@ -879,12 +975,17 @@ pub async fn api_url_stats(
|
||||
// GET /api/v1/stats/page/:uuid
|
||||
pub async fn api_page_stats(
|
||||
State(state): State<AppState>,
|
||||
_user: ApiUser,
|
||||
user: ApiUser,
|
||||
Path(uuid): Path<String>,
|
||||
) -> Response {
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
|
||||
// Verify Page exists
|
||||
{
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
if get_landing_page_by_id(&conn, &uuid)
|
||||
.unwrap_or(None)
|
||||
.is_none()
|
||||
@@ -899,7 +1000,7 @@ pub async fn api_page_stats(
|
||||
}
|
||||
}
|
||||
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
let conn = dbs.analytics.lock().unwrap();
|
||||
let clicks = get_clicks_trend(&conn, "page", &uuid, 30)
|
||||
.or_else(|_| get_clicks_trend_raw(&conn, "page", &uuid, 30))
|
||||
.unwrap_or_default();
|
||||
@@ -935,11 +1036,16 @@ pub struct QrStatsResponse {
|
||||
// GET /api/v1/qr/:code
|
||||
pub async fn api_get_qr_stats(
|
||||
State(state): State<AppState>,
|
||||
_user: ApiUser,
|
||||
user: ApiUser,
|
||||
Path(code): Path<String>,
|
||||
) -> Response {
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
|
||||
let url_opt = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
crate::db::content::get_url_by_code(&conn, &code).unwrap_or(None)
|
||||
};
|
||||
|
||||
@@ -957,7 +1063,7 @@ pub async fn api_get_qr_stats(
|
||||
};
|
||||
|
||||
let (scan_count, scans) = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
let conn = dbs.analytics.lock().unwrap();
|
||||
let count = crate::db::qr::get_qr_scan_count(&conn, &url.id).unwrap_or(0);
|
||||
let scans_list = crate::db::qr::get_qr_stats_for_url(&conn, &url.id).unwrap_or_default();
|
||||
(count, scans_list)
|
||||
@@ -1030,9 +1136,14 @@ pub async fn api_set_preview(
|
||||
Path(uuid): Path<String>,
|
||||
Json(payload): Json<SetPreviewRequest>,
|
||||
) -> Response {
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
|
||||
// Verify URL exists
|
||||
{
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
if get_url_by_id(&conn, &uuid).unwrap_or(None).is_none() {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
@@ -1044,7 +1155,7 @@ pub async fn api_set_preview(
|
||||
}
|
||||
}
|
||||
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
match crate::db::preview::upsert_preview(
|
||||
&conn,
|
||||
&uuid,
|
||||
@@ -1081,12 +1192,17 @@ pub async fn api_set_preview(
|
||||
// GET /api/v1/urls/:uuid/preview
|
||||
pub async fn api_get_preview(
|
||||
State(state): State<AppState>,
|
||||
_user: ApiUser,
|
||||
user: ApiUser,
|
||||
Path(uuid): Path<String>,
|
||||
) -> Response {
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
|
||||
// Verify URL exists
|
||||
{
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
if get_url_by_id(&conn, &uuid).unwrap_or(None).is_none() {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
@@ -1098,7 +1214,7 @@ pub async fn api_get_preview(
|
||||
}
|
||||
}
|
||||
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
match crate::db::preview::get_preview(&conn, &uuid) {
|
||||
Ok(Some(preview)) => Json(preview).into_response(),
|
||||
Ok(None) => (
|
||||
@@ -1124,9 +1240,14 @@ pub async fn api_delete_preview(
|
||||
user: ApiUser,
|
||||
Path(uuid): Path<String>,
|
||||
) -> Response {
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
|
||||
// Verify URL exists
|
||||
{
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
if get_url_by_id(&conn, &uuid).unwrap_or(None).is_none() {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
@@ -1138,7 +1259,7 @@ pub async fn api_delete_preview(
|
||||
}
|
||||
}
|
||||
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
match crate::db::preview::delete_preview(&conn, &uuid) {
|
||||
Ok(true) => {
|
||||
// Audit Log
|
||||
@@ -1186,9 +1307,14 @@ pub async fn api_set_password(
|
||||
Path(uuid): Path<String>,
|
||||
Json(payload): Json<SetPasswordRequest>,
|
||||
) -> Response {
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
|
||||
// Verify URL exists
|
||||
{
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
if get_url_by_id(&conn, &uuid).unwrap_or(None).is_none() {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
@@ -1213,7 +1339,7 @@ pub async fn api_set_password(
|
||||
}
|
||||
};
|
||||
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
match crate::db::content::set_url_password(&conn, &uuid, &hash) {
|
||||
Ok(true) => {
|
||||
// Audit Log
|
||||
@@ -1257,9 +1383,14 @@ pub async fn api_remove_password(
|
||||
user: ApiUser,
|
||||
Path(uuid): Path<String>,
|
||||
) -> Response {
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
|
||||
// Verify URL exists
|
||||
{
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
if get_url_by_id(&conn, &uuid).unwrap_or(None).is_none() {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
@@ -1271,7 +1402,7 @@ pub async fn api_remove_password(
|
||||
}
|
||||
}
|
||||
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
match crate::db::content::remove_url_password(&conn, &uuid) {
|
||||
Ok(true) => {
|
||||
// Audit Log
|
||||
@@ -1321,9 +1452,14 @@ pub async fn api_create_qr(
|
||||
user: ApiUser,
|
||||
Json(payload): Json<CreateQrRequest>,
|
||||
) -> Response {
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
|
||||
// Verify URL exists in content.db
|
||||
{
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
if get_url_by_id(&conn, &payload.url_id)
|
||||
.unwrap_or(None)
|
||||
.is_none()
|
||||
@@ -1339,7 +1475,7 @@ pub async fn api_create_qr(
|
||||
}
|
||||
|
||||
let style = payload.style.unwrap_or_else(|| "default".to_string());
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
match crate::db::qr::upsert_qr_code(&conn, &payload.url_id, &style) {
|
||||
Ok(_) => {
|
||||
// Write Audit Event
|
||||
|
||||
+56
-6
@@ -67,9 +67,36 @@ pub async fn api_bulk_qr(
|
||||
}
|
||||
|
||||
// Retrieve URLs from database
|
||||
let content_db = match user.0 {
|
||||
crate::models::ApiActor::User(ref u) => {
|
||||
let user_dbs = match state.get_user_dbs(u.id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(BulkErrorResponse {
|
||||
error: "Database error".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
user_dbs.content.clone()
|
||||
}
|
||||
crate::models::ApiActor::Admin(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(BulkErrorResponse {
|
||||
error: "Admin is a platform operator and cannot export application URLs without tenant context".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let mut urls = Vec::new();
|
||||
{
|
||||
let conn = match lock_db(&state.content_db, "content_db") {
|
||||
let conn = match lock_db(&content_db, "content_db") {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return (
|
||||
@@ -186,10 +213,30 @@ pub async fn api_bulk_url(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Dynamically resolve target user ID and content DB
|
||||
let (target_user_id, content_db) = match user.0 {
|
||||
crate::models::ApiActor::Admin(_) => (1, state.content_db.clone()),
|
||||
// Dynamically resolve target user ID, TenantId, and content DB
|
||||
let (target_user_id, target_tenant_id, content_db) = match user.0 {
|
||||
crate::models::ApiActor::Admin(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(BulkErrorResponse {
|
||||
error: "Admin is a platform operator and cannot create application URLs directly without tenant context".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
crate::models::ApiActor::User(ref u) => {
|
||||
let tenant_id = match u.tenant_id {
|
||||
Some(tid) => tid,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(BulkErrorResponse {
|
||||
error: "User has no tenant context".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let user_dbs = match state.get_user_dbs(u.id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => {
|
||||
@@ -202,7 +249,7 @@ pub async fn api_bulk_url(
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
(u.id, user_dbs.content.clone())
|
||||
(u.id, tenant_id, user_dbs.content.clone())
|
||||
}
|
||||
};
|
||||
|
||||
@@ -226,9 +273,12 @@ pub async fn api_bulk_url(
|
||||
|
||||
let created_urls = match create_urls_bulk(
|
||||
&content_db,
|
||||
&state.system_db,
|
||||
&state.db.reserved,
|
||||
&state.db.global_urls,
|
||||
&state.db.global_landing_pages,
|
||||
&state.users_db,
|
||||
target_user_id,
|
||||
target_tenant_id,
|
||||
items,
|
||||
) {
|
||||
Ok(urls) => urls,
|
||||
|
||||
+137
-273
@@ -4,11 +4,11 @@ use axum::{
|
||||
response::{IntoResponse, Json, Response},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use rusqlite::OptionalExtension;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::ApiUser;
|
||||
use crate::identity::TenantId;
|
||||
use crate::models::ApiActor;
|
||||
use crate::state::AppState;
|
||||
|
||||
@@ -354,40 +354,68 @@ pub fn delete_user_resources(
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Transactional clean up on system.db (deleting their global slug mappings)
|
||||
{
|
||||
let mut system_conn = state.system_db.lock().unwrap();
|
||||
let tx = system_conn.transaction().map_err(|e| e.to_string())?;
|
||||
// 1. Transactional clean up on v0.8 slug databases
|
||||
let now = Utc::now().to_rfc3339();
|
||||
if let Some(ref tid) = user_details.tenant_id {
|
||||
let tid_str = tid.to_string();
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
|
||||
let slugs: Vec<String> = {
|
||||
let mut stmt = tx
|
||||
.prepare("SELECT slug FROM global_slugs WHERE owner_user_id = ?1;")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map([target_id], |row| row.get(0))
|
||||
.map_err(|e| e.to_string())?;
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
// Get all URL slugs owned by tenant
|
||||
let url_slugs: Vec<String> = if let Ok(mut stmt) =
|
||||
urls_conn.prepare("SELECT slug FROM global_urls WHERE owner_tenant_id = ?1;")
|
||||
{
|
||||
stmt.query_map([&tid_str], |row| row.get::<_, String>(0))
|
||||
.map(|rows| rows.filter_map(|r| r.ok()).collect())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let now = Utc::now().to_rfc3339();
|
||||
for slug in slugs {
|
||||
let _ = tx.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&slug]);
|
||||
let _ = tx.execute(
|
||||
for slug in url_slugs {
|
||||
let _ = urls_conn.execute("DELETE FROM global_urls WHERE slug = ?1;", [&slug]);
|
||||
let _ = system_conn.execute(
|
||||
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username)
|
||||
VALUES (?1, ?2, NULL, 'deleted', ?3, ?4);",
|
||||
rusqlite::params![slug, target_id, now, admin_username],
|
||||
);
|
||||
}
|
||||
|
||||
tx.commit()
|
||||
.map_err(|e| format!("Failed to release slugs: {}", e))?;
|
||||
// Get all page slugs owned by tenant
|
||||
let page_slugs: Vec<String> = if let Ok(mut stmt) =
|
||||
pages_conn.prepare("SELECT slug FROM global_landing_pages WHERE owner_tenant_id = ?1;")
|
||||
{
|
||||
stmt.query_map([&tid_str], |row| row.get::<_, String>(0))
|
||||
.map(|rows| rows.filter_map(|r| r.ok()).collect())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
for slug in page_slugs {
|
||||
let _ =
|
||||
pages_conn.execute("DELETE FROM global_landing_pages WHERE slug = ?1;", [&slug]);
|
||||
let _ = system_conn.execute(
|
||||
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username)
|
||||
VALUES (?1, ?2, NULL, 'deleted', ?3, ?4);",
|
||||
rusqlite::params![slug, target_id, now, admin_username],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let user_dir = state
|
||||
.config
|
||||
.data_dir
|
||||
.join("users")
|
||||
.join(target_id.to_string());
|
||||
let user_dir = {
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
match crate::db::users::get_user_by_id(&conn, target_id) {
|
||||
Ok(Some(u)) => crate::db::tenant::location_for_user(&u)
|
||||
.and_then(|loc| {
|
||||
loc.dir(&state.db.topology)
|
||||
.map_err(|e| crate::error::AppError::BadRequest(e.to_string()))
|
||||
})
|
||||
.map_err(|e| e.to_string())?,
|
||||
_ => return Err("User not found".into()),
|
||||
}
|
||||
};
|
||||
if user_dir.exists() {
|
||||
let _ = std::fs::remove_dir_all(&user_dir);
|
||||
}
|
||||
@@ -444,231 +472,23 @@ pub async fn admin_transfer_slug(
|
||||
Err(err_resp) => return err_resp,
|
||||
};
|
||||
|
||||
// 1. Check if the slug exists and get details
|
||||
let (old_owner_user_id, target_type, _target_id) = {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let mut stmt = match system_conn.prepare(
|
||||
"SELECT owner_user_id, target_type, target_id FROM global_slugs WHERE slug = ?1;",
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
};
|
||||
let row_opt = stmt
|
||||
.query_row([&payload.slug], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
))
|
||||
})
|
||||
.optional();
|
||||
|
||||
match row_opt {
|
||||
Ok(Some(r)) => r,
|
||||
Ok(None) => return (StatusCode::NOT_FOUND, "Slug not found").into_response(),
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
let req = crate::services::slug_transfer::SlugTransferRequest {
|
||||
slug: payload.slug,
|
||||
new_owner_user_id: payload.new_owner_user_id,
|
||||
};
|
||||
|
||||
if old_owner_user_id == payload.new_owner_user_id {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"New owner must be different from the current owner",
|
||||
)
|
||||
.into_response();
|
||||
match crate::services::slug_transfer::transfer_slug(&state, &req, &admin.username) {
|
||||
Ok(_) => StatusCode::OK.into_response(),
|
||||
Err(crate::services::slug_transfer::TransferError::NotFound(m)) => {
|
||||
(StatusCode::NOT_FOUND, m.to_string()).into_response()
|
||||
}
|
||||
Err(crate::services::slug_transfer::TransferError::BadRequest(m)) => {
|
||||
(StatusCode::BAD_REQUEST, m).into_response()
|
||||
}
|
||||
Err(crate::services::slug_transfer::TransferError::Internal(m)) => {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, m).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fetch destination databases
|
||||
let old_dbs = match state.get_user_dbs(old_owner_user_id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to load current owner's database",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
let new_dbs = match state.get_user_dbs(payload.new_owner_user_id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to load new owner's database",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
// 3. Perform transfer: copy record from old owner's content.db to new owner's content.db
|
||||
let mut new_target_id = String::new();
|
||||
let transfer_success = {
|
||||
let old_conn = old_dbs.content.lock().unwrap();
|
||||
let new_conn = new_dbs.content.lock().unwrap();
|
||||
|
||||
if target_type == "url" {
|
||||
// Get URL record
|
||||
let url_opt = match crate::db::content::get_url_by_code(&old_conn, &payload.slug) {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(url) = url_opt {
|
||||
// Check new owner quotas
|
||||
let new_users_conn = state.users_db.lock().unwrap();
|
||||
let quota_opt =
|
||||
crate::db::users::get_user_quotas(&new_users_conn, payload.new_owner_user_id)
|
||||
.unwrap_or(None);
|
||||
if let Some(quota) = quota_opt {
|
||||
if quota.current_urls >= quota.max_urls {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"New owner has exceeded URL quota limit",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
// Insert into new owner database
|
||||
let ins_res = crate::db::content::create_url_extended(
|
||||
&new_conn,
|
||||
&url.code,
|
||||
&url.destination,
|
||||
url.title.as_deref(),
|
||||
url.description.as_deref(),
|
||||
&url.tags,
|
||||
url.expires_at.as_deref(),
|
||||
url.password_hash.as_deref(),
|
||||
url.max_access_count,
|
||||
);
|
||||
|
||||
match ins_res {
|
||||
Ok(new_url) => {
|
||||
new_target_id = new_url.id;
|
||||
// Delete from old owner database
|
||||
let _ = crate::db::content::delete_url(&old_conn, &url.id);
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to copy URL to new owner: {}", e),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else if target_type == "page" {
|
||||
// Get page record
|
||||
let page_opt =
|
||||
match crate::db::content::get_landing_page_by_code(&old_conn, &payload.slug) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(page) = page_opt {
|
||||
// Check new owner quotas
|
||||
let new_users_conn = state.users_db.lock().unwrap();
|
||||
let quota_opt =
|
||||
crate::db::users::get_user_quotas(&new_users_conn, payload.new_owner_user_id)
|
||||
.unwrap_or(None);
|
||||
if let Some(quota) = quota_opt {
|
||||
if quota.current_landings >= quota.max_landings {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"New owner has exceeded landing page quota limit",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
// Insert into new owner database
|
||||
let ins_res = crate::db::content::create_landing_page(
|
||||
&new_conn,
|
||||
&page.code,
|
||||
&page.slug,
|
||||
&page.title,
|
||||
&page.html_content,
|
||||
&page.state,
|
||||
);
|
||||
|
||||
match ins_res {
|
||||
Ok(new_page) => {
|
||||
new_target_id = new_page.id;
|
||||
// Delete from old owner database
|
||||
let _ = crate::db::content::delete_landing_page(&old_conn, &page.id);
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to copy Page to new owner: {}", e),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if !transfer_success {
|
||||
return (StatusCode::NOT_FOUND, "Content not found in owner database").into_response();
|
||||
}
|
||||
|
||||
// 4. Update system global_slugs, slug_history and adjust quotas
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let _ = system_conn.execute(
|
||||
"UPDATE global_slugs SET owner_user_id = ?1, target_id = ?2, updated_at = ?3 WHERE slug = ?4;",
|
||||
rusqlite::params![payload.new_owner_user_id, new_target_id, now, payload.slug],
|
||||
);
|
||||
|
||||
let _ = system_conn.execute(
|
||||
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username)
|
||||
VALUES (?1, ?2, ?3, 'transferred', ?4, ?5);",
|
||||
rusqlite::params![payload.slug, old_owner_user_id, payload.new_owner_user_id, now, admin.username],
|
||||
);
|
||||
|
||||
// Adjust quotas
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
let field = if target_type == "url" {
|
||||
"urls"
|
||||
} else {
|
||||
"landings"
|
||||
};
|
||||
let _ = crate::db::users::decrement_quota_counter(&users_conn, old_owner_user_id, field);
|
||||
let _ = crate::db::users::increment_quota_counter(
|
||||
&users_conn,
|
||||
payload.new_owner_user_id,
|
||||
field,
|
||||
);
|
||||
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&admin.username,
|
||||
"SLUG_TRANSFER",
|
||||
"slug",
|
||||
&payload.slug,
|
||||
Some(&format!(
|
||||
"From owner {} to owner {}",
|
||||
old_owner_user_id, payload.new_owner_user_id
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
StatusCode::OK.into_response()
|
||||
}
|
||||
|
||||
// --- Admin: Content Moderation ---
|
||||
@@ -689,44 +509,88 @@ pub async fn admin_moderate_slug(
|
||||
return (StatusCode::BAD_REQUEST, "Invalid moderation action").into_response();
|
||||
}
|
||||
|
||||
// 1. Verify slug and get owner user ID
|
||||
let (owner_user_id, target_type) = {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let row_opt: Option<(i64, String)> = system_conn
|
||||
.query_row(
|
||||
"SELECT owner_user_id, target_type FROM global_slugs WHERE slug = ?1;",
|
||||
[&payload.slug],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.optional()
|
||||
.unwrap_or(None);
|
||||
// 1. Verify slug using v0.8 slug lookup
|
||||
let slug_info = match state.lookup_slug(&payload.slug) {
|
||||
Ok(Some(info)) => info,
|
||||
Ok(None) => return (StatusCode::NOT_FOUND, "Slug not found").into_response(),
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
};
|
||||
|
||||
match row_opt {
|
||||
Some(r) => r,
|
||||
None => return (StatusCode::NOT_FOUND, "Slug not found").into_response(),
|
||||
let target_type = slug_info.target_type.as_str().to_string();
|
||||
let owner_tenant_id = match TenantId::parse(&slug_info.owner_tenant_id) {
|
||||
Ok(tid) => tid,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Invalid owner tenant ID on slug",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Resolve owner username for log snapshot
|
||||
let owner_username = {
|
||||
// Resolve owner details from users.db for moderation event history
|
||||
let (owner_user_id, owner_username) = {
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
crate::db::users::get_user_by_id(&users_conn, owner_user_id)
|
||||
.unwrap_or(None)
|
||||
.map(|u| u.username)
|
||||
match crate::db::users::get_user_by_tenant_id(&users_conn, owner_tenant_id) {
|
||||
Ok(Some(u)) => (u.id, Some(u.username)),
|
||||
_ => (0, None),
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Perform moderation update in global_slugs
|
||||
// 2. Perform moderation update in authoritative v0.8 slug databases
|
||||
{
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
if slug_info.target_type == crate::db::slugs::SlugTargetType::Url {
|
||||
let _ = urls_conn.execute(
|
||||
"UPDATE global_urls SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
|
||||
rusqlite::params![action, now, payload.slug],
|
||||
);
|
||||
} else {
|
||||
let _ = pages_conn.execute(
|
||||
"UPDATE global_landing_pages SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
|
||||
rusqlite::params![action, now, payload.slug],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Sync status to owner tenant's content DB if possible
|
||||
if let Ok(tenant_dbs) =
|
||||
state.open_tenant(owner_tenant_id, crate::db::tenant::TenantOpenMode::CoreJob)
|
||||
{
|
||||
if let Ok(conn) = tenant_dbs.content.lock() {
|
||||
if slug_info.target_type == crate::db::slugs::SlugTargetType::Url {
|
||||
let content_status = if action == "disabled" {
|
||||
"dead"
|
||||
} else {
|
||||
"active"
|
||||
};
|
||||
let _ = conn.execute(
|
||||
"UPDATE urls SET status = ?1 WHERE code = ?2;",
|
||||
rusqlite::params![content_status, payload.slug],
|
||||
);
|
||||
} else {
|
||||
let content_state = if action == "disabled" {
|
||||
"archived"
|
||||
} else {
|
||||
"published"
|
||||
};
|
||||
let _ = conn.execute(
|
||||
"UPDATE landing_pages SET state = ?1 WHERE code = ?2;",
|
||||
rusqlite::params![content_state, payload.slug],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Record moderation event in system.db
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let _ = system_conn.execute(
|
||||
"UPDATE global_slugs SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
|
||||
rusqlite::params![action, now, payload.slug],
|
||||
);
|
||||
|
||||
// Record moderation event
|
||||
let event_id = Uuid::new_v4().to_string();
|
||||
|
||||
let _ = system_conn.execute(
|
||||
"INSERT INTO moderation_events (id, timestamp, admin_username, target_user_id, target_username, resource_type, resource_identifier, action, severity, reason)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10);",
|
||||
|
||||
+15
-32
@@ -4,11 +4,11 @@ use axum::{
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use rusqlite::OptionalExtension;
|
||||
use std::net::SocketAddr;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::analytics::get_client_country;
|
||||
use crate::db::tenant::TenantOpenMode;
|
||||
use crate::models::VisitRecord;
|
||||
use crate::state::AppState;
|
||||
use crate::utils::get_client_ip;
|
||||
@@ -25,37 +25,15 @@ pub async fn resolve_page(
|
||||
return (StatusCode::NOT_FOUND, "Not Found").into_response();
|
||||
}
|
||||
|
||||
// 1. Query global slug namespace in system.db
|
||||
let slug_info = {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let mut stmt = match system_conn.prepare(
|
||||
"SELECT owner_user_id, target_type, target_id, status FROM global_slugs WHERE slug = ?1;"
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
stmt.query_row(rusqlite::params![code], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
))
|
||||
})
|
||||
.optional()
|
||||
};
|
||||
|
||||
let (owner_user_id, _target_type, _target_id, slug_status) = match slug_info {
|
||||
// 1. Query global slug namespace across v0.8 slug databases (with fallback)
|
||||
let info = match state.lookup_slug(&code) {
|
||||
Ok(Some(info)) => info,
|
||||
Ok(None) => {
|
||||
// Fallback to legacy_admin's DB (user_id = 1) if not found in global_slugs
|
||||
(1, "page".to_string(), "".to_string(), "active".to_string())
|
||||
}
|
||||
Ok(None) => return (StatusCode::NOT_FOUND, "Not Found").into_response(),
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
// If slug status is disabled, flagged, or soft_deleted, we return 410 Gone
|
||||
if slug_status != "active" {
|
||||
if info.status != "active" {
|
||||
return (
|
||||
StatusCode::GONE,
|
||||
"This content has been disabled or moderated",
|
||||
@@ -64,10 +42,11 @@ pub async fn resolve_page(
|
||||
}
|
||||
|
||||
// 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 content_conn =
|
||||
match state.open_slug_owner(&info.owner_tenant_id, TenantOpenMode::PublicContent) {
|
||||
Ok(dbs) => dbs.content,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
let page_opt = {
|
||||
let conn = content_conn.lock().unwrap();
|
||||
@@ -103,6 +82,9 @@ pub async fn resolve_page(
|
||||
.unwrap_or("Unknown")
|
||||
.to_string();
|
||||
|
||||
let owner_tenant_id = crate::identity::TenantId::parse(&info.owner_tenant_id).ok();
|
||||
let owner_user_id = info.owner_tenant_id.parse::<i64>().ok();
|
||||
|
||||
let record = VisitRecord {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
target_type: "page".to_string(),
|
||||
@@ -114,7 +96,8 @@ pub async fn resolve_page(
|
||||
accept_language,
|
||||
country,
|
||||
status_code: 200,
|
||||
owner_user_id: Some(owner_user_id),
|
||||
owner_tenant_id,
|
||||
owner_user_id,
|
||||
};
|
||||
|
||||
state.analytics_queue.push(record);
|
||||
|
||||
+53
-24
@@ -7,7 +7,7 @@ use axum_extra::extract::{cookie::Cookie, CookieJar};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::auth::password::verify_password;
|
||||
use crate::services::shortener::get_url_by_code;
|
||||
use crate::db::tenant::TenantOpenMode;
|
||||
use crate::state::AppState;
|
||||
use crate::templates::GateTemplate;
|
||||
|
||||
@@ -21,6 +21,43 @@ pub async fn gate_get(Path(code): Path<String>) -> impl IntoResponse {
|
||||
GateTemplate { code, error: None }
|
||||
}
|
||||
|
||||
enum GateLookup {
|
||||
Missing,
|
||||
Gone,
|
||||
Unprotected,
|
||||
Protected(String),
|
||||
}
|
||||
|
||||
fn lookup_gate(state: &AppState, code: &str) -> Result<GateLookup, axum::http::StatusCode> {
|
||||
let info = match state
|
||||
.lookup_slug(code)
|
||||
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
{
|
||||
Some(info) if info.status == "active" => info,
|
||||
Some(_) => return Ok(GateLookup::Gone),
|
||||
None => return Ok(GateLookup::Missing),
|
||||
};
|
||||
|
||||
let user_dbs = match state.open_slug_owner(&info.owner_tenant_id, TenantOpenMode::PublicContent)
|
||||
{
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => return Ok(GateLookup::Missing),
|
||||
};
|
||||
let conn = user_dbs
|
||||
.content
|
||||
.lock()
|
||||
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
match crate::db::content::get_url_by_code(&conn, code)
|
||||
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
{
|
||||
Some(u) => match u.password_hash {
|
||||
Some(h) => Ok(GateLookup::Protected(h)),
|
||||
None => Ok(GateLookup::Unprotected),
|
||||
},
|
||||
None => Ok(GateLookup::Missing),
|
||||
}
|
||||
}
|
||||
|
||||
// POST /gate/:code
|
||||
pub async fn gate_post(
|
||||
State(state): State<AppState>,
|
||||
@@ -29,32 +66,25 @@ pub async fn gate_post(
|
||||
headers: axum::http::HeaderMap,
|
||||
Form(form): Form<PasswordGateForm>,
|
||||
) -> Response {
|
||||
let url_opt = match get_url_by_code(&state.db, &code) {
|
||||
Ok(url) => url,
|
||||
Err(_) => {
|
||||
return (
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Database error",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let url = match url_opt {
|
||||
Some(u) => u,
|
||||
None => return (axum::http::StatusCode::NOT_FOUND, "Url not found").into_response(),
|
||||
};
|
||||
|
||||
let password_hash = match url.password_hash {
|
||||
Some(ref h) => h,
|
||||
None => {
|
||||
// Not password protected, redirect to resolution
|
||||
let password_hash = match lookup_gate(&state, &code) {
|
||||
Ok(GateLookup::Protected(h)) => h,
|
||||
Ok(GateLookup::Unprotected) => {
|
||||
return Redirect::temporary(&format!("/{}", code)).into_response();
|
||||
}
|
||||
Ok(GateLookup::Missing) => {
|
||||
return (axum::http::StatusCode::NOT_FOUND, "Url not found").into_response();
|
||||
}
|
||||
Ok(GateLookup::Gone) => {
|
||||
return (
|
||||
axum::http::StatusCode::GONE,
|
||||
"This content has been disabled or moderated",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(status) => return (status, "Database error").into_response(),
|
||||
};
|
||||
|
||||
if verify_password(&form.password, password_hash) {
|
||||
// Correct password - set 15 min temporary cookie
|
||||
if verify_password(&form.password, &password_hash) {
|
||||
let cookie_name = format!("bzod_gate_{}", code);
|
||||
let secure_flag = crate::utils::resolve_cookie_secure(state.config.cookie_secure, &headers);
|
||||
let cookie = Cookie::build((cookie_name, "authorized"))
|
||||
@@ -67,7 +97,6 @@ pub async fn gate_post(
|
||||
let updated_jar = jar.add(cookie);
|
||||
(updated_jar, Redirect::temporary(&format!("/{}", code))).into_response()
|
||||
} else {
|
||||
// Invalid password
|
||||
GateTemplate {
|
||||
code,
|
||||
error: Some("Invalid password".to_string()),
|
||||
|
||||
+25
-63
@@ -1,3 +1,4 @@
|
||||
use crate::db::tenant::TenantOpenMode;
|
||||
use crate::services::qr::{generate_qr_png, generate_qr_svg};
|
||||
use crate::state::AppState;
|
||||
use crate::utils::get_client_ip;
|
||||
@@ -37,57 +38,36 @@ pub async fn qr_handler(
|
||||
return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
|
||||
}
|
||||
|
||||
// We need to look up owner_user_id, target_id, and status from global_slugs
|
||||
let (owner_user_id, target_id, slug_status) = {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let mut stmt = match system_conn.prepare(
|
||||
"SELECT owner_user_id, target_id, status FROM global_slugs WHERE slug = ?1;",
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response()
|
||||
}
|
||||
};
|
||||
use rusqlite::OptionalExtension;
|
||||
match stmt
|
||||
.query_row(rusqlite::params![&file], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
))
|
||||
})
|
||||
.optional()
|
||||
{
|
||||
Ok(Some((uid, tid, status))) => (uid, tid, status),
|
||||
Ok(None) => return (StatusCode::NOT_FOUND, "URL not found").into_response(),
|
||||
Err(_) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response()
|
||||
}
|
||||
}
|
||||
// Look up slug info from v0.8 slug registry (with fallback)
|
||||
let info = match state.lookup_slug(&file) {
|
||||
Ok(Some(info)) => info,
|
||||
Ok(None) => return (StatusCode::NOT_FOUND, "URL not found").into_response(),
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
if slug_status == "disabled" {
|
||||
if info.status == "disabled" {
|
||||
return (StatusCode::GONE, "This content has been disabled").into_response();
|
||||
} else if slug_status != "active" {
|
||||
} else if info.status != "active" {
|
||||
return (StatusCode::NOT_FOUND, "URL not found").into_response();
|
||||
}
|
||||
|
||||
let user_dbs = match state.get_user_dbs(owner_user_id) {
|
||||
let user_dbs = match state
|
||||
.open_slug_owner(&info.owner_tenant_id, TenantOpenMode::PublicContent)
|
||||
{
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
let qr_scans = {
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
crate::db::qr::get_qr_scan_count(&conn, &target_id).unwrap_or(0)
|
||||
crate::db::qr::get_qr_scan_count(&conn, &info.target_id).unwrap_or(0)
|
||||
};
|
||||
|
||||
let direct_clicks = {
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM visits WHERE target_id = ?1;",
|
||||
rusqlite::params![target_id],
|
||||
rusqlite::params![info.target_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
@@ -109,36 +89,16 @@ pub async fn qr_handler(
|
||||
return (StatusCode::NOT_FOUND, "Not Found").into_response();
|
||||
}
|
||||
|
||||
// We need to look up owner_user_id, target_type, target_id, and status from global_slugs
|
||||
let (owner_user_id, target_type, target_id, slug_status) = {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let mut stmt = match system_conn
|
||||
.prepare("SELECT owner_user_id, target_type, target_id, status FROM global_slugs WHERE slug = ?1;")
|
||||
{
|
||||
Ok(s) => s,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
use rusqlite::OptionalExtension;
|
||||
match stmt
|
||||
.query_row(rusqlite::params![code], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
))
|
||||
})
|
||||
.optional()
|
||||
{
|
||||
Ok(Some(info)) => info,
|
||||
Ok(None) => return (StatusCode::NOT_FOUND, "Not Found").into_response(),
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
}
|
||||
// Look up slug info from v0.8 slug registry (with fallback)
|
||||
let info = match state.lookup_slug(code) {
|
||||
Ok(Some(info)) => info,
|
||||
Ok(None) => return (StatusCode::NOT_FOUND, "Not Found").into_response(),
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
if slug_status == "disabled" {
|
||||
if info.status == "disabled" {
|
||||
return (StatusCode::GONE, "This content has been disabled").into_response();
|
||||
} else if slug_status != "active" {
|
||||
} else if info.status != "active" {
|
||||
return (StatusCode::NOT_FOUND, "Not Found").into_response();
|
||||
}
|
||||
|
||||
@@ -159,7 +119,7 @@ pub async fn qr_handler(
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{}://{}", proto, host_header));
|
||||
|
||||
let full_url = if target_type == "page" {
|
||||
let full_url = if info.target_type == crate::db::slugs::SlugTargetType::LandingPage {
|
||||
format!("{}/p/{}", base_url.trim_end_matches('/'), code)
|
||||
} else {
|
||||
format!("{}/{}", base_url.trim_end_matches('/'), code)
|
||||
@@ -204,11 +164,13 @@ pub async fn qr_handler(
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
if let Ok(user_dbs) = state.get_user_dbs(owner_user_id) {
|
||||
if let Ok(user_dbs) =
|
||||
state.open_slug_owner(&info.owner_tenant_id, TenantOpenMode::PublicContent)
|
||||
{
|
||||
if let Ok(analytics_conn) = user_dbs.analytics.lock() {
|
||||
let _ = crate::db::qr::log_qr_access(
|
||||
&analytics_conn,
|
||||
&target_id,
|
||||
&info.target_id,
|
||||
Some(ip.as_str()),
|
||||
user_agent.as_deref(),
|
||||
);
|
||||
|
||||
+46
-72
@@ -15,18 +15,26 @@ use axum::{
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use chrono::Utc;
|
||||
use rusqlite::OptionalExtension;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tracing::{error, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::analytics::get_client_country;
|
||||
use crate::db::tenant::TenantOpenMode;
|
||||
use crate::identity::TenantId;
|
||||
use crate::models::{LinkPreview, Url, VisitRecord};
|
||||
use crate::state::AppState;
|
||||
use crate::templates::PreviewTemplate;
|
||||
use crate::utils::get_client_ip;
|
||||
|
||||
struct ResolvedUrl {
|
||||
owner_tenant_id: Option<TenantId>,
|
||||
owner_user_id: i64,
|
||||
url: Url,
|
||||
content: Arc<Mutex<rusqlite::Connection>>,
|
||||
}
|
||||
|
||||
/// Compact outcome from blocking redirect DB work (avoids large enum / Result variants).
|
||||
enum ResolveOutcome {
|
||||
Ready(Box<ResolvedUrl>),
|
||||
@@ -111,79 +119,40 @@ fn permanent_redirect_to(destination: &str, code: &str) -> Response {
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of the initial global-slug + URL resolution phase.
|
||||
struct ResolvedUrl {
|
||||
owner_user_id: i64,
|
||||
url: Url,
|
||||
content: Arc<Mutex<rusqlite::Connection>>,
|
||||
}
|
||||
|
||||
/// Lookup global slug namespace then load the URL from the tenant content DB.
|
||||
/// Runs entirely on a blocking thread.
|
||||
fn resolve_url_blocking(
|
||||
system_db: Arc<Mutex<rusqlite::Connection>>,
|
||||
_system_db: Arc<Mutex<rusqlite::Connection>>,
|
||||
state: AppState,
|
||||
code: &str,
|
||||
) -> ResolveOutcome {
|
||||
// 1. Query global slug namespace in system.db
|
||||
let slug_info = {
|
||||
let system_conn = match system_db.lock() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return ResolveOutcome::DbError {
|
||||
operation: "lock_system_db",
|
||||
message: e.to_string(),
|
||||
owner_user_id: None,
|
||||
resource_id: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
let mut stmt = match system_conn.prepare(
|
||||
"SELECT owner_user_id, target_type, target_id, status FROM global_slugs WHERE slug = ?1;",
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
return ResolveOutcome::DbError {
|
||||
operation: "prepare_global_slugs",
|
||||
message: e.to_string(),
|
||||
owner_user_id: None,
|
||||
resource_id: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
match stmt
|
||||
.query_row(rusqlite::params![code], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
))
|
||||
})
|
||||
.optional()
|
||||
{
|
||||
Ok(info) => info,
|
||||
Err(e) => {
|
||||
return ResolveOutcome::DbError {
|
||||
operation: "query_global_slugs",
|
||||
message: e.to_string(),
|
||||
owner_user_id: None,
|
||||
resource_id: None,
|
||||
};
|
||||
}
|
||||
// 1. Query global slug namespace across v0.8 slug databases (with fallback)
|
||||
let slug_info = match state.lookup_slug(code) {
|
||||
Ok(info) => info,
|
||||
Err(e) => {
|
||||
return ResolveOutcome::DbError {
|
||||
operation: "lookup_slug",
|
||||
message: e.to_string(),
|
||||
owner_user_id: None,
|
||||
resource_id: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let (owner_user_id, target_type, _target_id, slug_status) = match slug_info {
|
||||
let info = match slug_info {
|
||||
Some(info) => info,
|
||||
None => {
|
||||
// Fallback to legacy_admin's DB (user_id = 1) if not found in global_slugs
|
||||
(1, "url".to_string(), "".to_string(), "active".to_string())
|
||||
return ResolveOutcome::Early {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
body: "Not Found",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let owner_user_id = info.owner_tenant_id.parse::<i64>().unwrap_or(0);
|
||||
|
||||
// If slug status is disabled, flagged, or soft_deleted, we return 410 Gone
|
||||
if slug_status != "active" {
|
||||
if info.status != "active" {
|
||||
return ResolveOutcome::Early {
|
||||
status: StatusCode::GONE,
|
||||
body: "This content has been disabled or moderated",
|
||||
@@ -191,22 +160,23 @@ fn resolve_url_blocking(
|
||||
}
|
||||
|
||||
// If target type is page, redirect permanently to /p/slug
|
||||
if target_type == "page" {
|
||||
if info.target_type == crate::db::slugs::SlugTargetType::LandingPage {
|
||||
return ResolveOutcome::PermanentPath(format!("/p/{}", code));
|
||||
}
|
||||
|
||||
// 2. Get content database connection via tenant DB resolution
|
||||
let content_conn = match state.get_user_dbs(owner_user_id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(e) => {
|
||||
return ResolveOutcome::DbError {
|
||||
operation: "get_user_dbs",
|
||||
message: e.to_string(),
|
||||
owner_user_id: Some(owner_user_id),
|
||||
resource_id: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
let content_conn =
|
||||
match state.open_slug_owner(&info.owner_tenant_id, TenantOpenMode::PublicContent) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(e) => {
|
||||
return ResolveOutcome::DbError {
|
||||
operation: "open_slug_owner",
|
||||
message: e.to_string(),
|
||||
owner_user_id: Some(owner_user_id),
|
||||
resource_id: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let url = {
|
||||
let conn = match content_conn.content.lock() {
|
||||
@@ -239,7 +209,10 @@ fn resolve_url_blocking(
|
||||
}
|
||||
};
|
||||
|
||||
let owner_tenant_id = TenantId::parse(&info.owner_tenant_id).ok();
|
||||
|
||||
ResolveOutcome::Ready(Box::new(ResolvedUrl {
|
||||
owner_tenant_id,
|
||||
owner_user_id,
|
||||
url,
|
||||
content: content_conn.content,
|
||||
@@ -347,6 +320,7 @@ pub async fn resolve_redirect(
|
||||
};
|
||||
|
||||
let ResolvedUrl {
|
||||
owner_tenant_id,
|
||||
owner_user_id,
|
||||
url,
|
||||
content,
|
||||
@@ -424,7 +398,6 @@ pub async fn resolve_redirect(
|
||||
}
|
||||
};
|
||||
|
||||
// Asynchronously record analytics
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let country = get_client_country(&headers);
|
||||
let user_agent = headers
|
||||
@@ -454,6 +427,7 @@ pub async fn resolve_redirect(
|
||||
accept_language,
|
||||
country,
|
||||
status_code: if preview_opt.is_some() { 200 } else { 302 },
|
||||
owner_tenant_id,
|
||||
owner_user_id: Some(owner_user_id),
|
||||
};
|
||||
|
||||
|
||||
+34
-10
@@ -97,22 +97,46 @@ pub async fn metrics_endpoint(
|
||||
|
||||
// 1. Gather stats from DBs
|
||||
let (total_urls, active_links, dead_links) = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
crate::db::content::get_url_counts(&conn).unwrap_or((0, 0, 0))
|
||||
let conn = state.db.global_urls.lock().unwrap();
|
||||
let total: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM global_urls WHERE status != 'retired';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
let active: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM global_urls WHERE status = 'active';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
let dead: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM global_urls WHERE status = 'disabled';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
(total, active, dead)
|
||||
};
|
||||
|
||||
let total_pages = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
crate::db::content::get_landing_page_count(&conn).unwrap_or(0)
|
||||
let conn = state.db.global_landing_pages.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM global_landing_pages WHERE status != 'retired';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
|
||||
let (total_clicks, total_page_views) = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
(
|
||||
crate::db::analytics::get_total_clicks(&conn).unwrap_or(0),
|
||||
crate::db::analytics::get_total_page_views(&conn).unwrap_or(0),
|
||||
)
|
||||
let total_clicks = {
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
crate::db::users::get_platform_total_clicks(&state.db.topology, &users_conn).unwrap_or(0)
|
||||
};
|
||||
let total_page_views = 0i64;
|
||||
|
||||
// Calculate memory in bytes
|
||||
let mut mem_bytes = 0;
|
||||
|
||||
@@ -20,6 +20,15 @@ async fn test_admin_user_listing() {
|
||||
|
||||
let db = Db::init(&config).expect("Failed to init Db");
|
||||
|
||||
let _ = bzod::cli::create_admin::run(
|
||||
Some("admin".to_string()),
|
||||
Some("adminpass123".to_string()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let _ = bzod::cli::create_user::run(
|
||||
Some("usera".to_string()),
|
||||
Some("password123".to_string()),
|
||||
@@ -32,9 +41,9 @@ async fn test_admin_user_listing() {
|
||||
let conn = db.users.lock().unwrap();
|
||||
let users = bzod::db::users::list_users(&conn).unwrap();
|
||||
|
||||
// Verify list contains legacy_admin and usera
|
||||
// Verify list contains admin and usera
|
||||
assert!(users.len() >= 2);
|
||||
assert!(users.iter().any(|u| u.username == "legacy_admin"));
|
||||
assert!(users.iter().any(|u| u.username == "admin"));
|
||||
assert!(users.iter().any(|u| u.username == "usera"));
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
|
||||
@@ -33,8 +33,6 @@ fn build_state(config: Config) -> (Db, AppState) {
|
||||
let (queue, _) = AnalyticsQueue::new(db.clone(), 1000, rx);
|
||||
let state = AppState {
|
||||
admin_db: db.admin.clone(),
|
||||
content_db: db.content.clone(),
|
||||
analytics_db: db.analytics.clone(),
|
||||
system_db: db.system.clone(),
|
||||
users_db: db.users.clone(),
|
||||
user_dbs: std::sync::Arc::new(std::sync::Mutex::new(HashMap::new())),
|
||||
|
||||
@@ -51,6 +51,7 @@ fn test_advanced_analytics_pagination_and_sorting() {
|
||||
accept_language: "en".to_string(),
|
||||
country: "US".to_string(),
|
||||
status_code: 200,
|
||||
owner_tenant_id: None,
|
||||
owner_user_id: None,
|
||||
});
|
||||
}
|
||||
@@ -92,6 +93,7 @@ fn test_advanced_analytics_date_filtering() {
|
||||
accept_language: "en".to_string(),
|
||||
country: "US".to_string(),
|
||||
status_code: 200,
|
||||
owner_tenant_id: None,
|
||||
owner_user_id: None,
|
||||
},
|
||||
VisitRecord {
|
||||
@@ -105,6 +107,7 @@ fn test_advanced_analytics_date_filtering() {
|
||||
accept_language: "en".to_string(),
|
||||
country: "US".to_string(),
|
||||
status_code: 200,
|
||||
owner_tenant_id: None,
|
||||
owner_user_id: None,
|
||||
},
|
||||
VisitRecord {
|
||||
@@ -118,6 +121,7 @@ fn test_advanced_analytics_date_filtering() {
|
||||
accept_language: "en".to_string(),
|
||||
country: "US".to_string(),
|
||||
status_code: 200,
|
||||
owner_tenant_id: None,
|
||||
owner_user_id: None,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -50,13 +50,11 @@ async fn start_test_server(
|
||||
|
||||
let state = AppState {
|
||||
admin_db: db.admin.clone(),
|
||||
content_db: db.content.clone(),
|
||||
analytics_db: db.analytics.clone(),
|
||||
system_db: db.system.clone(),
|
||||
users_db: db.users.clone(),
|
||||
user_dbs: std::sync::Arc::new(std::sync::Mutex::new(HashMap::new())),
|
||||
db: db.clone(),
|
||||
config,
|
||||
config: config.clone(),
|
||||
analytics_queue: queue,
|
||||
start_time: Instant::now(),
|
||||
};
|
||||
@@ -111,11 +109,29 @@ async fn test_analytics_and_table_parity() {
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER);
|
||||
|
||||
// Admin adds a URL to the global content_db
|
||||
// Create User B to own global links
|
||||
let _ = bzod::cli::create_user::run(
|
||||
Some("userb".to_string()),
|
||||
Some("password123".to_string()),
|
||||
None,
|
||||
create_temp_config(temp_dir.clone()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (id_b, tid_b) = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
let u = bzod::db::users::get_user_by_username(&conn, "userb")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
(u.id, u.tenant_id.unwrap())
|
||||
};
|
||||
|
||||
// User B adds a URL
|
||||
let _admin_url_id = {
|
||||
let conn_admin = db.content.lock().unwrap();
|
||||
let conn_b = bzod::jobs::open_user_content_conn(&db, id_b).unwrap();
|
||||
let url = bzod::db::content::create_url_extended(
|
||||
&conn_admin,
|
||||
&conn_b,
|
||||
"!admin-slug",
|
||||
"https://admin.com",
|
||||
None,
|
||||
@@ -127,24 +143,17 @@ async fn test_analytics_and_table_parity() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"!admin-slug",
|
||||
1,
|
||||
"url",
|
||||
&url.id,
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
bzod::db::slugs::register_url_slug(&urls_conn, "!admin-slug", &tid_b, &url.id, "active")
|
||||
.unwrap();
|
||||
url.id
|
||||
};
|
||||
|
||||
// Admin adds a Landing Page to the global content_db
|
||||
// User B adds a Landing Page
|
||||
let _admin_page_id = {
|
||||
let conn_admin = db.content.lock().unwrap();
|
||||
let conn_b = bzod::jobs::open_user_content_conn(&db, id_b).unwrap();
|
||||
let page = bzod::db::content::create_landing_page(
|
||||
&conn_admin,
|
||||
&conn_b,
|
||||
"!admin-page",
|
||||
"admin-page",
|
||||
"Title Admin",
|
||||
@@ -153,12 +162,11 @@ async fn test_analytics_and_table_parity() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
let pages_conn = db.global_landing_pages.lock().unwrap();
|
||||
bzod::db::slugs::register_landing_page_slug(
|
||||
&pages_conn,
|
||||
"!admin-page",
|
||||
1,
|
||||
"page",
|
||||
&tid_b,
|
||||
&page.id,
|
||||
"active",
|
||||
)
|
||||
@@ -176,12 +184,12 @@ async fn test_analytics_and_table_parity() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let id_a = {
|
||||
let (id_a, tid_a) = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_user_by_username(&conn, "usera")
|
||||
let u = bzod::db::users::get_user_by_username(&conn, "usera")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.id
|
||||
.unwrap();
|
||||
(u.id, u.tenant_id.unwrap())
|
||||
};
|
||||
|
||||
// User A adds a URL
|
||||
@@ -200,16 +208,9 @@ async fn test_analytics_and_table_parity() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"!usera-slug",
|
||||
id_a,
|
||||
"url",
|
||||
&url.id,
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
bzod::db::slugs::register_url_slug(&urls_conn, "!usera-slug", &tid_a, &url.id, "active")
|
||||
.unwrap();
|
||||
url.id
|
||||
};
|
||||
|
||||
@@ -226,12 +227,11 @@ async fn test_analytics_and_table_parity() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
let pages_conn = db.global_landing_pages.lock().unwrap();
|
||||
bzod::db::slugs::register_landing_page_slug(
|
||||
&pages_conn,
|
||||
"!usera-page",
|
||||
id_a,
|
||||
"page",
|
||||
&tid_a,
|
||||
&page.id,
|
||||
"active",
|
||||
)
|
||||
|
||||
@@ -121,6 +121,7 @@ fn test_target_analytics_queries() {
|
||||
accept_language: "en".to_string(),
|
||||
country: "US".to_string(),
|
||||
status_code: 200,
|
||||
owner_tenant_id: None,
|
||||
owner_user_id: None,
|
||||
},
|
||||
VisitRecord {
|
||||
@@ -134,6 +135,7 @@ fn test_target_analytics_queries() {
|
||||
accept_language: "en".to_string(),
|
||||
country: "US".to_string(),
|
||||
status_code: 200,
|
||||
owner_tenant_id: None,
|
||||
owner_user_id: None,
|
||||
},
|
||||
VisitRecord {
|
||||
@@ -147,6 +149,7 @@ fn test_target_analytics_queries() {
|
||||
accept_language: "en".to_string(),
|
||||
country: "US".to_string(),
|
||||
status_code: 200,
|
||||
owner_tenant_id: None,
|
||||
owner_user_id: None,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -42,8 +42,6 @@ fn build_state(config: Config) -> (Db, bzod::state::AppState) {
|
||||
let (queue, _) = AnalyticsQueue::new(db.clone(), 1000, rx);
|
||||
let state = bzod::state::AppState {
|
||||
admin_db: db.admin.clone(),
|
||||
content_db: db.content.clone(),
|
||||
analytics_db: db.analytics.clone(),
|
||||
system_db: db.system.clone(),
|
||||
users_db: db.users.clone(),
|
||||
user_dbs: Arc::new(Mutex::new(HashMap::new())),
|
||||
@@ -60,14 +58,20 @@ fn test_migration_idempotent() {
|
||||
let mut conn = Connection::open_in_memory().unwrap();
|
||||
run_migrations(&mut conn, "users", USERS_MIGRATIONS, None).unwrap();
|
||||
run_migrations(&mut conn, "users", USERS_MIGRATIONS, None).unwrap();
|
||||
assert_eq!(get_user_version(&conn).unwrap(), 2);
|
||||
assert_eq!(
|
||||
get_user_version(&conn).unwrap(),
|
||||
USERS_MIGRATIONS.last().unwrap().version
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_users_db_schema_version_2() {
|
||||
let mut conn = Connection::open_in_memory().unwrap();
|
||||
run_migrations(&mut conn, "users", USERS_MIGRATIONS, None).unwrap();
|
||||
assert_eq!(get_user_version(&conn).unwrap(), 2);
|
||||
assert_eq!(
|
||||
get_user_version(&conn).unwrap(),
|
||||
USERS_MIGRATIONS.last().unwrap().version
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -96,22 +96,12 @@ async fn test_backup_restore_roundtrip() {
|
||||
|
||||
let db = Db::init(&config).expect("Failed to init Db");
|
||||
|
||||
let _ = bzod::cli::create_user::run(
|
||||
Some("testuser".to_string()),
|
||||
Some("password123".to_string()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let user_id = {
|
||||
let user = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_user_by_username(&conn, "testuser")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.id
|
||||
bzod::db::users::create_user(&conn, "testuser", "password123", "standard", None).unwrap()
|
||||
};
|
||||
db.init_user_databases(user.id).unwrap();
|
||||
let user_id = user.id;
|
||||
|
||||
// Add a link for user
|
||||
{
|
||||
@@ -129,21 +119,25 @@ async fn test_backup_restore_roundtrip() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Increment quota counter
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
bzod::db::users::increment_quota_counter(&users_conn, user_id, "urls").unwrap();
|
||||
let tenant_id = {
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
bzod::db::users::increment_quota_counter(&users_conn, user_id, "urls").unwrap();
|
||||
bzod::db::users::get_user_by_id(&users_conn, user_id)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.tenant_id
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
// Register global slug
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"!rt-slug",
|
||||
user_id,
|
||||
"url",
|
||||
"rt-id",
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
urls_conn
|
||||
.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('!rt-slug', ?1, 'rt-id', ?2, ?3, 'active', NULL);",
|
||||
rusqlite::params![tenant_id.as_str(), now, now],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Backup user
|
||||
@@ -172,7 +166,7 @@ async fn test_backup_restore_roundtrip() {
|
||||
.unwrap();
|
||||
|
||||
// Verify restored user
|
||||
{
|
||||
let restored_id = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
let user = bzod::db::users::get_user_by_username(&conn, "testuser")
|
||||
.unwrap()
|
||||
@@ -183,14 +177,14 @@ async fn test_backup_restore_roundtrip() {
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(quota.current_urls, 1);
|
||||
user.id
|
||||
};
|
||||
|
||||
// Verify content restored
|
||||
let user_content_conn = bzod::jobs::open_user_content_conn(&db, user.id).unwrap();
|
||||
let url = bzod::db::content::get_url_by_code(&user_content_conn, "!rt-slug")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(url.destination, "https://example.com/rt");
|
||||
}
|
||||
let user_content_conn = bzod::jobs::open_user_content_conn(&db, restored_id).unwrap();
|
||||
let url = bzod::db::content::get_url_by_code(&user_content_conn, "!rt-slug")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(url.destination, "https://example.com/rt");
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
@@ -198,33 +192,30 @@ async fn test_backup_restore_roundtrip() {
|
||||
#[tokio::test]
|
||||
async fn test_restore_slug_collision_rejection() {
|
||||
let temp_dir = std::env::temp_dir().join(format!(
|
||||
"bzod_test_restore_collision_{}",
|
||||
uuid::Uuid::new_v4()
|
||||
"bzod_test_collision_{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
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 mut config = Config::load();
|
||||
config.data_dir = temp_dir.clone();
|
||||
config.backup_dir = temp_dir.join("backups");
|
||||
fs::create_dir_all(&config.backup_dir).unwrap();
|
||||
|
||||
let db = Db::init(&config).unwrap();
|
||||
|
||||
// Create User A
|
||||
let _ = bzod::cli::create_user::run(
|
||||
Some("usera".to_string()),
|
||||
Some("password123".to_string()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let id_a = {
|
||||
let user_a = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_user_by_username(&conn, "usera")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.id
|
||||
bzod::db::users::create_user(&conn, "usera", "password123", "standard", None).unwrap()
|
||||
};
|
||||
db.init_user_databases(user_a.id).unwrap();
|
||||
let (id_a, tid_a) = (user_a.id, user_a.tenant_id.unwrap());
|
||||
|
||||
// Add a link for User A
|
||||
// Add slug for User A
|
||||
{
|
||||
let conn_a = bzod::jobs::open_user_content_conn(&db, id_a).unwrap();
|
||||
bzod::db::content::create_url_extended(
|
||||
@@ -240,16 +231,13 @@ async fn test_restore_slug_collision_rejection() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"!collision-slug",
|
||||
id_a,
|
||||
"url",
|
||||
"a-id",
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
urls_conn.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('!collision-slug', ?1, 'a-id', ?2, ?3, 'active', NULL);",
|
||||
rusqlite::params![tid_a.as_str(), now, now],
|
||||
).unwrap();
|
||||
}
|
||||
|
||||
// Backup User A
|
||||
@@ -269,22 +257,12 @@ async fn test_restore_slug_collision_rejection() {
|
||||
.unwrap();
|
||||
|
||||
// Create User B
|
||||
let _ = bzod::cli::create_user::run(
|
||||
Some("userb".to_string()),
|
||||
Some("password123".to_string()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let id_b = {
|
||||
let user_b = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_user_by_username(&conn, "userb")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.id
|
||||
bzod::db::users::create_user(&conn, "userb", "password123", "standard", None).unwrap()
|
||||
};
|
||||
db.init_user_databases(user_b.id).unwrap();
|
||||
let (id_b, tid_b) = (user_b.id, user_b.tenant_id.unwrap());
|
||||
|
||||
// Add colliding slug for User B
|
||||
{
|
||||
@@ -302,16 +280,13 @@ async fn test_restore_slug_collision_rejection() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"!collision-slug",
|
||||
id_b,
|
||||
"url",
|
||||
"b-id",
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
urls_conn.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('!collision-slug', ?1, 'b-id', ?2, ?3, 'active', NULL);",
|
||||
rusqlite::params![tid_b.as_str(), now, now],
|
||||
).unwrap();
|
||||
}
|
||||
|
||||
// Attempt to restore User A from backup - must fail on collision
|
||||
@@ -323,17 +298,17 @@ async fn test_restore_slug_collision_rejection() {
|
||||
.await;
|
||||
assert!(res.is_err());
|
||||
|
||||
// Verify that User B still owns the slug in global_slugs and User A's slug registration was skipped/rejected
|
||||
// Verify that User B still owns the slug in global_urls and User A's slug registration was skipped/rejected
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let owner_id: i64 = system_conn
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let owner_tid: String = urls_conn
|
||||
.query_row(
|
||||
"SELECT owner_user_id FROM global_slugs WHERE slug = '!collision-slug';",
|
||||
"SELECT owner_tenant_id FROM global_urls WHERE slug = '!collision-slug';",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(owner_id, id_b);
|
||||
assert_eq!(owner_tid, tid_b.as_str());
|
||||
}
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
|
||||
@@ -55,8 +55,6 @@ async fn start_test_server(
|
||||
|
||||
let state = AppState {
|
||||
admin_db: db.admin.clone(),
|
||||
content_db: db.content.clone(),
|
||||
analytics_db: db.analytics.clone(),
|
||||
system_db: db.system.clone(),
|
||||
users_db: db.users.clone(),
|
||||
user_dbs: std::sync::Arc::new(std::sync::Mutex::new(HashMap::new())),
|
||||
@@ -190,9 +188,9 @@ async fn test_scenario_a_user_create_login_shorten_visit_analytics() {
|
||||
|
||||
// 6. Verify visit is logged in analytics
|
||||
let url_uuid = {
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let conn = state.db.global_urls.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT target_id FROM global_slugs WHERE slug = '!mygoogle';",
|
||||
"SELECT target_id FROM global_urls WHERE slug = '!mygoogle';",
|
||||
[],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
@@ -505,9 +503,9 @@ async fn test_scenario_c_slug_transfer_workflow() {
|
||||
|
||||
// Verify visit recorded for standard_c1
|
||||
let url_uuid = {
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let conn = state.db.global_urls.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT target_id FROM global_slugs WHERE slug = '!myyahoo';",
|
||||
"SELECT target_id FROM global_urls WHERE slug = '!myyahoo';",
|
||||
[],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
@@ -607,9 +605,9 @@ async fn test_scenario_c_slug_transfer_workflow() {
|
||||
|
||||
// standard_c2 analytics should display Yahoo!
|
||||
let url_uuid = {
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let conn = state.db.global_urls.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT target_id FROM global_slugs WHERE slug = '!myyahoo';",
|
||||
"SELECT target_id FROM global_urls WHERE slug = '!myyahoo';",
|
||||
[],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
|
||||
@@ -23,14 +23,19 @@ async fn test_concurrent_slug_creation() {
|
||||
let db = Db::init(&config).expect("Failed to init Db");
|
||||
|
||||
let barrier = Arc::new(Barrier::new(2));
|
||||
let db_path = temp_dir.join("admin/system.db");
|
||||
let db_path = temp_dir.join("slugs/global_urls.db");
|
||||
|
||||
let b1 = barrier.clone();
|
||||
let path1 = db_path.clone();
|
||||
let task1 = tokio::spawn(async move {
|
||||
let conn = rusqlite::Connection::open(&path1).unwrap();
|
||||
b1.wait().await;
|
||||
bzod::db::users::register_global_slug(&conn, "!conc-slug", 10, "url", "url10", "active")
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
conn.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('!conc-slug', 't-tenant10', 'url10', ?1, ?2, 'active', NULL);",
|
||||
rusqlite::params![now, now],
|
||||
)
|
||||
});
|
||||
|
||||
let b2 = barrier.clone();
|
||||
@@ -38,7 +43,12 @@ async fn test_concurrent_slug_creation() {
|
||||
let task2 = tokio::spawn(async move {
|
||||
let conn = rusqlite::Connection::open(&path2).unwrap();
|
||||
b2.wait().await;
|
||||
bzod::db::users::register_global_slug(&conn, "!conc-slug", 20, "url", "url20", "active")
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
conn.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('!conc-slug', 't-tenant20', 'url20', ?1, ?2, 'active', NULL);",
|
||||
rusqlite::params![now, now],
|
||||
)
|
||||
});
|
||||
|
||||
let res1 = task1.await.unwrap();
|
||||
@@ -54,12 +64,12 @@ async fn test_concurrent_slug_creation() {
|
||||
),
|
||||
}
|
||||
|
||||
// Verify exactly 1 record exists in global_slugs
|
||||
// Verify exactly 1 record exists in global_urls
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let count: i64 = system_conn
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let count: i64 = urls_conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM global_slugs WHERE slug = '!conc-slug';",
|
||||
"SELECT COUNT(*) FROM global_urls WHERE slug = '!conc-slug';",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
|
||||
@@ -84,8 +84,7 @@ async fn test_partial_restore_failure_rollback() {
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM users;", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
// Only legacy_admin (ID 1) should exist
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(count, 0, "No users should exist after failed restore");
|
||||
}
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
|
||||
@@ -54,8 +54,6 @@ async fn start_test_server(
|
||||
|
||||
let state = AppState {
|
||||
admin_db: db.admin.clone(),
|
||||
content_db: db.content.clone(),
|
||||
analytics_db: db.analytics.clone(),
|
||||
system_db: db.system.clone(),
|
||||
users_db: db.users.clone(),
|
||||
user_dbs: std::sync::Arc::new(std::sync::Mutex::new(HashMap::new())),
|
||||
|
||||
+37
-36
@@ -29,12 +29,12 @@ async fn test_global_slug_index_consistency() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let user_id = {
|
||||
let (user_id, tid) = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_user_by_username(&conn, "testuser")
|
||||
let u = bzod::db::users::get_user_by_username(&conn, "testuser")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.id
|
||||
.unwrap();
|
||||
(u.id, u.tenant_id.unwrap())
|
||||
};
|
||||
|
||||
// Add a URL for the user
|
||||
@@ -57,46 +57,47 @@ async fn test_global_slug_index_consistency() {
|
||||
|
||||
// Register globally
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"!integ-slug",
|
||||
user_id,
|
||||
"url",
|
||||
&url_id,
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
urls_conn
|
||||
.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('!integ-slug', ?1, ?2, ?3, ?4, 'active', NULL);",
|
||||
rusqlite::params![tid.as_str(), &url_id, now, now],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Consistency Check:
|
||||
// 1. Every active slug in user databases exists in global_slugs
|
||||
// 1. Every active slug in user databases exists in global_urls
|
||||
{
|
||||
let user_ids: Vec<i64> = {
|
||||
let users = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
let mut stmt = conn.prepare("SELECT id FROM users;").unwrap();
|
||||
let rows = stmt.query_map([], |row| row.get(0)).unwrap();
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
bzod::db::users::list_users(&conn).unwrap()
|
||||
};
|
||||
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
for id in user_ids {
|
||||
let conn = bzod::jobs::open_user_content_conn(&db, id).unwrap();
|
||||
let mut stmt = conn.prepare("SELECT code FROM urls;").unwrap();
|
||||
let rows = stmt.query_map([], |row| row.get::<_, String>(0)).unwrap();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
for u in users {
|
||||
if let Some(tenant_id) = u.tenant_id {
|
||||
let conn = bzod::jobs::open_user_content_conn(&db, u.id).unwrap();
|
||||
let mut stmt = conn.prepare("SELECT code FROM urls;").unwrap();
|
||||
let rows = stmt.query_map([], |row| row.get::<_, String>(0)).unwrap();
|
||||
|
||||
for code_res in rows {
|
||||
let code = code_res.unwrap();
|
||||
let exists: bool = system_conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = ?1 AND owner_user_id = ?2);",
|
||||
rusqlite::params![code, id],
|
||||
|row| row.get(0),
|
||||
).unwrap();
|
||||
assert!(
|
||||
exists,
|
||||
"Active user slug '{}' missing from global_slugs",
|
||||
code
|
||||
);
|
||||
for code_res in rows {
|
||||
let code = code_res.unwrap();
|
||||
let exists: bool = urls_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM global_urls WHERE slug = ?1 AND owner_tenant_id = ?2);",
|
||||
rusqlite::params![code, tenant_id.as_str()],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
exists,
|
||||
"Active user slug '{}' missing from global_urls",
|
||||
code
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -546,16 +546,23 @@ async fn test_current_backup_restore_roundtrip() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"!current-test",
|
||||
user_id,
|
||||
"url",
|
||||
"current-id",
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
let tenant_id = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_user_by_id(&conn, user_id)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.tenant_id
|
||||
.unwrap()
|
||||
};
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
urls_conn
|
||||
.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('!current-test', ?1, 'current-id', ?2, ?3, 'active', NULL);",
|
||||
rusqlite::params![tenant_id.as_str(), now, now],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Create a native backup
|
||||
|
||||
+10
-21
@@ -79,8 +79,11 @@ async fn test_legacy_migration() {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Call Db::init which triggers the legacy migration
|
||||
let db = Db::init(&config).expect("Failed to init Db and run legacy migration");
|
||||
// Call normalize_restored_layout to move legacy flat files into multi-tenant paths
|
||||
bzod::services::backup_layout::normalize_restored_layout(&temp_dir).unwrap();
|
||||
|
||||
// Call Db::init
|
||||
let _db = Db::init(&config).expect("Failed to init Db and run legacy migration");
|
||||
|
||||
// Verify files moved to correct directories
|
||||
assert!(!legacy_admin_path.exists());
|
||||
@@ -88,26 +91,12 @@ async fn test_legacy_migration() {
|
||||
assert!(temp_dir.join("admin/admin.db").exists());
|
||||
assert!(temp_dir.join("users/1/content.db").exists());
|
||||
|
||||
// Verify legacy_admin created as system and disabled
|
||||
// Verify legacy content is preserved in users/1/content.db
|
||||
{
|
||||
let conn = db.users.lock().unwrap();
|
||||
let user = bzod::db::users::get_user_by_id(&conn, 1).unwrap().unwrap();
|
||||
assert_eq!(user.username, "legacy_admin");
|
||||
assert_eq!(user.status, "disabled");
|
||||
assert_eq!(user.account_type, "system");
|
||||
}
|
||||
|
||||
// Verify slug registered in global_slugs
|
||||
{
|
||||
let conn = db.system.lock().unwrap();
|
||||
let exists: bool = conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = '!legacy-slug');",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(exists);
|
||||
let conn = rusqlite::Connection::open(temp_dir.join("users/1/content.db")).unwrap();
|
||||
let url = bzod::db::content::get_url_by_code(&conn, "!legacy-slug").unwrap();
|
||||
assert!(url.is_some());
|
||||
assert_eq!(url.unwrap().destination, "https://legacy.com");
|
||||
}
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
|
||||
+18
-12
@@ -22,16 +22,22 @@ async fn test_content_flagging_and_disabling() {
|
||||
|
||||
let db = Db::init(&config).expect("Failed to init Db");
|
||||
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
|
||||
// Register slug
|
||||
bzod::db::users::register_global_slug(&system_conn, "!badslug", 10, "url", "url_abc", "active")
|
||||
let now = Utc::now().to_rfc3339();
|
||||
urls_conn
|
||||
.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('!badslug', 't-tenant10', 'url_abc', ?1, ?2, 'active', NULL);",
|
||||
rusqlite::params![now, now],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Verify it is active initially
|
||||
let status: String = system_conn
|
||||
let status: String = urls_conn
|
||||
.query_row(
|
||||
"SELECT status FROM global_slugs WHERE slug = ?1;",
|
||||
"SELECT status FROM global_urls WHERE slug = ?1;",
|
||||
["!badslug"],
|
||||
|row| row.get(0),
|
||||
)
|
||||
@@ -39,17 +45,17 @@ async fn test_content_flagging_and_disabling() {
|
||||
assert_eq!(status, "active");
|
||||
|
||||
// Moderate/disable slug
|
||||
system_conn
|
||||
urls_conn
|
||||
.execute(
|
||||
"UPDATE global_slugs SET status = 'disabled' WHERE slug = ?1;",
|
||||
"UPDATE global_urls SET status = 'disabled' WHERE slug = ?1;",
|
||||
["!badslug"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Verify it is disabled
|
||||
let status2: String = system_conn
|
||||
let status2: String = urls_conn
|
||||
.query_row(
|
||||
"SELECT status FROM global_slugs WHERE slug = ?1;",
|
||||
"SELECT status FROM global_urls WHERE slug = ?1;",
|
||||
["!badslug"],
|
||||
|row| row.get(0),
|
||||
)
|
||||
@@ -57,16 +63,16 @@ async fn test_content_flagging_and_disabling() {
|
||||
assert_eq!(status2, "disabled");
|
||||
|
||||
// Moderate/re-enable slug
|
||||
system_conn
|
||||
urls_conn
|
||||
.execute(
|
||||
"UPDATE global_slugs SET status = 'active' WHERE slug = ?1;",
|
||||
"UPDATE global_urls SET status = 'active' WHERE slug = ?1;",
|
||||
["!badslug"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let status3: String = system_conn
|
||||
let status3: String = urls_conn
|
||||
.query_row(
|
||||
"SELECT status FROM global_slugs WHERE slug = ?1;",
|
||||
"SELECT status FROM global_urls WHERE slug = ?1;",
|
||||
["!badslug"],
|
||||
|row| row.get(0),
|
||||
)
|
||||
|
||||
@@ -47,6 +47,16 @@ async fn test_cli_shorten_and_expand() {
|
||||
fs::create_dir_all(&temp_dir).unwrap();
|
||||
let config = create_temp_config(temp_dir.clone());
|
||||
|
||||
// 0. Create a standard user
|
||||
let _ = bzod::cli::create_user::run(
|
||||
Some("cliuser".to_string()),
|
||||
Some("password123".to_string()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 1. Shorten with generated code
|
||||
let res = bzod::cli::shorten::run(
|
||||
"https://example.com/one".to_string(),
|
||||
@@ -78,20 +88,11 @@ async fn test_cli_shorten_and_expand() {
|
||||
assert!(res_dup.is_err());
|
||||
assert!(res_dup.unwrap_err().to_string().contains("already exists"));
|
||||
|
||||
// 4. Expand custom slug
|
||||
{
|
||||
let db = Db::init(&config).unwrap();
|
||||
let conn = db.content.lock().unwrap();
|
||||
let url_opt = bzod::db::content::get_url_by_code(&conn, "!office").unwrap();
|
||||
assert!(url_opt.is_some());
|
||||
assert_eq!(url_opt.unwrap().destination, "https://example.com/two");
|
||||
}
|
||||
|
||||
// 5. CLI expand round-trip validation
|
||||
// 4. CLI expand round-trip validation
|
||||
let expand_res = bzod::cli::expand::run("!office".to_string(), None, config.clone()).await;
|
||||
assert!(expand_res.is_ok());
|
||||
|
||||
// 6. Case-insensitive CLI expand validation
|
||||
// 5. Case-insensitive CLI expand validation
|
||||
let expand_res_upper =
|
||||
bzod::cli::expand::run("!OFFICE".to_string(), None, config.clone()).await;
|
||||
assert!(expand_res_upper.is_ok());
|
||||
@@ -108,12 +109,30 @@ async fn test_perform_restore_and_validation() {
|
||||
fs::create_dir_all(&restore_dir).unwrap();
|
||||
|
||||
let config = create_temp_config(temp_dir.clone());
|
||||
|
||||
// Create user and url
|
||||
let _ = bzod::cli::create_user::run(
|
||||
Some("restoreuser".to_string()),
|
||||
Some("password123".to_string()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let db = Db::init(&config).unwrap();
|
||||
let (uid, tid) = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
let u = bzod::db::users::get_user_by_username(&conn, "restoreuser")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
(u.id, u.tenant_id.unwrap())
|
||||
};
|
||||
|
||||
// 1. Create a mock database record
|
||||
{
|
||||
let conn = db.content.lock().unwrap();
|
||||
bzod::db::content::create_url_extended(
|
||||
let conn = bzod::jobs::open_user_content_conn(&db, uid).unwrap();
|
||||
let url = bzod::db::content::create_url_extended(
|
||||
&conn,
|
||||
"!home",
|
||||
"https://my-home.com",
|
||||
@@ -125,6 +144,9 @@ async fn test_perform_restore_and_validation() {
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
bzod::db::slugs::register_url_slug(&urls_conn, "!home", &tid, &url.id, "active").unwrap();
|
||||
}
|
||||
|
||||
// 2. Perform a backup
|
||||
@@ -142,18 +164,15 @@ async fn test_perform_restore_and_validation() {
|
||||
assert!(restore_dir.join("admin/admin.db").exists());
|
||||
assert!(restore_dir.join("admin/users.db").exists());
|
||||
assert!(restore_dir.join("admin/system.db").exists());
|
||||
assert!(restore_dir.join("users/1/content.db").exists());
|
||||
assert!(restore_dir.join("users/1/analytics.db").exists());
|
||||
assert!(restore_dir.join("slugs/global_urls.db").exists());
|
||||
assert!(restore_dir
|
||||
.join(format!("users/{}/content.db", tid.as_str()))
|
||||
.exists());
|
||||
|
||||
// Verify custom slug was preserved in the restored DB
|
||||
let restore_config = create_temp_config(restore_dir.clone());
|
||||
let restore_db = Db::init(&restore_config).unwrap();
|
||||
{
|
||||
let conn = restore_db.content.lock().unwrap();
|
||||
let url = bzod::db::content::get_url_by_code(&conn, "!home").unwrap();
|
||||
assert!(url.is_some());
|
||||
assert_eq!(url.unwrap().destination, "https://my-home.com");
|
||||
}
|
||||
let expand_res = bzod::cli::expand::run("!home".to_string(), None, restore_config).await;
|
||||
assert!(expand_res.is_ok());
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
let _ = fs::remove_dir_all(&restore_dir);
|
||||
|
||||
+11
-23
@@ -50,8 +50,6 @@ async fn start_test_server(
|
||||
|
||||
let state = AppState {
|
||||
admin_db: db.admin.clone(),
|
||||
content_db: db.content.clone(),
|
||||
analytics_db: db.analytics.clone(),
|
||||
system_db: db.system.clone(),
|
||||
users_db: db.users.clone(),
|
||||
user_dbs: std::sync::Arc::new(std::sync::Mutex::new(HashMap::new())),
|
||||
@@ -107,17 +105,15 @@ async fn test_ownership_isolation_endpoints() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (id_a, _id_b) = {
|
||||
let (id_a, tid_a, _id_b) = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
let a = bzod::db::users::get_user_by_username(&conn, "usera")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.id;
|
||||
.unwrap();
|
||||
let b = bzod::db::users::get_user_by_username(&conn, "userb")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.id;
|
||||
(a, b)
|
||||
.unwrap();
|
||||
(a.id, a.tenant_id.unwrap(), b.id)
|
||||
};
|
||||
|
||||
// User A adds a URL
|
||||
@@ -136,16 +132,9 @@ async fn test_ownership_isolation_endpoints() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"!usera-slug",
|
||||
id_a,
|
||||
"url",
|
||||
&url.id,
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
bzod::db::slugs::register_url_slug(&urls_conn, "!usera-slug", &tid_a, &url.id, "active")
|
||||
.unwrap();
|
||||
url.id
|
||||
};
|
||||
|
||||
@@ -162,12 +151,11 @@ async fn test_ownership_isolation_endpoints() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
let pages_conn = db.global_landing_pages.lock().unwrap();
|
||||
bzod::db::slugs::register_landing_page_slug(
|
||||
&pages_conn,
|
||||
"!usera-page",
|
||||
id_a,
|
||||
"page",
|
||||
&tid_a,
|
||||
&page.id,
|
||||
"active",
|
||||
)
|
||||
|
||||
+40
-46
@@ -39,8 +39,6 @@ async fn start_test_server(
|
||||
|
||||
let state = AppState {
|
||||
admin_db: db.admin.clone(),
|
||||
content_db: db.content.clone(),
|
||||
analytics_db: db.analytics.clone(),
|
||||
system_db: db.system.clone(),
|
||||
users_db: db.users.clone(),
|
||||
user_dbs: std::sync::Arc::new(std::sync::Mutex::new(HashMap::new())),
|
||||
@@ -85,12 +83,12 @@ async fn test_qr_endpoints_and_redirection_hardening() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let id_a = {
|
||||
let (id_a, tid_a) = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_user_by_username(&conn, "usera")
|
||||
let u = bzod::db::users::get_user_by_username(&conn, "usera")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.id
|
||||
.unwrap();
|
||||
(u.id, u.tenant_id.unwrap())
|
||||
};
|
||||
|
||||
// User A adds an active URL
|
||||
@@ -109,16 +107,15 @@ async fn test_qr_endpoints_and_redirection_hardening() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"a1b2c3",
|
||||
id_a,
|
||||
"url",
|
||||
&url.id,
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
urls_conn
|
||||
.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('a1b2c3', ?1, ?2, ?3, ?4, 'active', NULL);",
|
||||
rusqlite::params![tid_a.as_str(), &url.id, now, now],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// User A adds a disabled URL
|
||||
@@ -137,16 +134,15 @@ async fn test_qr_endpoints_and_redirection_hardening() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"d4e5f6",
|
||||
id_a,
|
||||
"url",
|
||||
&url.id,
|
||||
"disabled",
|
||||
)
|
||||
.unwrap();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
urls_conn
|
||||
.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('d4e5f6', ?1, ?2, ?3, ?4, 'disabled', NULL);",
|
||||
rusqlite::params![tid_a.as_str(), &url.id, now, now],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// User A adds an active Page
|
||||
@@ -162,16 +158,15 @@ async fn test_qr_endpoints_and_redirection_hardening() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"a1b2",
|
||||
id_a,
|
||||
"page",
|
||||
&page.id,
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
let pages_conn = db.global_landing_pages.lock().unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
pages_conn
|
||||
.execute(
|
||||
"INSERT INTO global_landing_pages (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('a1b2', ?1, ?2, ?3, ?4, 'active', NULL);",
|
||||
rusqlite::params![tid_a.as_str(), &page.id, now, now],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// User A adds a disabled Page
|
||||
@@ -187,16 +182,15 @@ async fn test_qr_endpoints_and_redirection_hardening() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"c3d4",
|
||||
id_a,
|
||||
"page",
|
||||
&page.id,
|
||||
"disabled",
|
||||
)
|
||||
.unwrap();
|
||||
let pages_conn = db.global_landing_pages.lock().unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
pages_conn
|
||||
.execute(
|
||||
"INSERT INTO global_landing_pages (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('c3d4', ?1, ?2, ?3, ?4, 'disabled', NULL);",
|
||||
rusqlite::params![tid_a.as_str(), &page.id, now, now],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// 1. Verify Active URL QR endpoints return 200 with correct content types
|
||||
|
||||
@@ -126,8 +126,8 @@ async fn test_quota_reconcile_job() {
|
||||
|
||||
// Run reconciliation
|
||||
{
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
let user_content_conn = bzod::jobs::open_user_content_conn(&db, user_id).unwrap();
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
bzod::db::users::reconcile_user_quotas(&users_conn, user_id, &user_content_conn).unwrap();
|
||||
|
||||
// Verify drift is repaired
|
||||
|
||||
@@ -48,10 +48,15 @@ async fn start_test_server(
|
||||
Box::leak(Box::new(tx));
|
||||
let (queue, _) = AnalyticsQueue::new(db.clone(), 1000, rx);
|
||||
|
||||
// Create a standard tenant user
|
||||
{
|
||||
let users = db.users.lock().unwrap();
|
||||
let _ = bzod::db::users::create_user(&users, "testuser", "dummy_hash", "standard", None);
|
||||
}
|
||||
let _ = db.init_user_databases(1);
|
||||
|
||||
let state = AppState {
|
||||
admin_db: db.admin.clone(),
|
||||
content_db: db.content.clone(),
|
||||
analytics_db: db.analytics.clone(),
|
||||
system_db: db.system.clone(),
|
||||
users_db: db.users.clone(),
|
||||
user_dbs: std::sync::Arc::new(std::sync::Mutex::new(HashMap::new())),
|
||||
@@ -95,25 +100,27 @@ fn seed_url(db: &Db, seed: SeedUrl<'_>) {
|
||||
db.init_user_databases(seed.owner_user_id)
|
||||
.expect("init user dbs");
|
||||
|
||||
let (content_path, tid) = {
|
||||
let users = db.users.lock().unwrap();
|
||||
let user = bzod::db::users::get_user_by_id(&users, seed.owner_user_id)
|
||||
.unwrap()
|
||||
.expect("seed owner must be a registered user");
|
||||
let path = bzod::db::tenant::location_for_user(&user)
|
||||
.unwrap()
|
||||
.dir(&db.topology)
|
||||
.unwrap()
|
||||
.join("content.db");
|
||||
(path, user.tenant_id.unwrap())
|
||||
};
|
||||
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
{
|
||||
let system = db.system.lock().unwrap();
|
||||
let _ = bzod::db::users::register_global_slug(
|
||||
&system,
|
||||
seed.code,
|
||||
seed.owner_user_id,
|
||||
"url",
|
||||
"seed",
|
||||
"active",
|
||||
);
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let _ = bzod::db::slugs::register_url_slug(&urls_conn, seed.code, &tid, &id, "active");
|
||||
}
|
||||
|
||||
let content_path = db
|
||||
.data_dir
|
||||
.join("users")
|
||||
.join(seed.owner_user_id.to_string())
|
||||
.join("content.db");
|
||||
let conn = rusqlite::Connection::open(content_path).unwrap();
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
conn.execute(
|
||||
"INSERT INTO urls (id, code, destination, title, description, status, created_at, updated_at, expires_at, expired, password_hash, max_access_count, access_count)
|
||||
@@ -271,7 +278,15 @@ async fn wall_clock_expiry_returns_410_without_hot_path_write_dependency() {
|
||||
assert_eq!(res.status(), reqwest::StatusCode::GONE);
|
||||
|
||||
// Column may still be 0 until sweeper runs — correctness does not require write.
|
||||
let content_path = db.data_dir.join("users/1/content.db");
|
||||
let content_path = {
|
||||
let users = db.users.lock().unwrap();
|
||||
let user = bzod::db::users::get_user_by_id(&users, 1).unwrap().unwrap();
|
||||
bzod::db::tenant::location_for_user(&user)
|
||||
.unwrap()
|
||||
.dir(&db.topology)
|
||||
.unwrap()
|
||||
.join("content.db")
|
||||
};
|
||||
let conn = rusqlite::Connection::open(content_path).unwrap();
|
||||
let expired_flag: i64 = conn
|
||||
.query_row("SELECT expired FROM urls WHERE code = 'ab3333';", [], |r| {
|
||||
@@ -347,7 +362,15 @@ async fn password_gate_before_access_increment() {
|
||||
assert!(loc.contains("/gate/ab5555"));
|
||||
|
||||
// Access count must not have incremented
|
||||
let content_path = db.data_dir.join("users/1/content.db");
|
||||
let content_path = {
|
||||
let users = db.users.lock().unwrap();
|
||||
let user = bzod::db::users::get_user_by_id(&users, 1).unwrap().unwrap();
|
||||
bzod::db::tenant::location_for_user(&user)
|
||||
.unwrap()
|
||||
.dir(&db.topology)
|
||||
.unwrap()
|
||||
.join("content.db")
|
||||
};
|
||||
let conn = rusqlite::Connection::open(content_path).unwrap();
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
@@ -403,7 +426,15 @@ async fn concurrent_redirects_increment_access_count() {
|
||||
}
|
||||
assert_eq!(ok_301, N);
|
||||
|
||||
let content_path = db.data_dir.join("users/1/content.db");
|
||||
let content_path = {
|
||||
let users = db.users.lock().unwrap();
|
||||
let user = bzod::db::users::get_user_by_id(&users, 1).unwrap().unwrap();
|
||||
bzod::db::tenant::location_for_user(&user)
|
||||
.unwrap()
|
||||
.dir(&db.topology)
|
||||
.unwrap()
|
||||
.join("content.db")
|
||||
};
|
||||
let conn = rusqlite::Connection::open(content_path).unwrap();
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
@@ -514,6 +545,10 @@ async fn destination_audit_finds_legacy_invalid_without_rewriting() {
|
||||
fs::create_dir_all(&temp_dir).unwrap();
|
||||
let config = create_temp_config(temp_dir.clone());
|
||||
let db = Db::init(&config).unwrap();
|
||||
{
|
||||
let users = db.users.lock().unwrap();
|
||||
let _ = bzod::db::users::create_user(&users, "testuser", "dummy_hash", "standard", None);
|
||||
}
|
||||
|
||||
seed_url(
|
||||
&db,
|
||||
@@ -563,7 +598,15 @@ async fn destination_audit_finds_legacy_invalid_without_rewriting() {
|
||||
assert_eq!(report.unsupported_scheme, 1);
|
||||
|
||||
// Data not rewritten
|
||||
let content_path = db.data_dir.join("users/1/content.db");
|
||||
let content_path = {
|
||||
let users = db.users.lock().unwrap();
|
||||
let user = bzod::db::users::get_user_by_id(&users, 1).unwrap().unwrap();
|
||||
bzod::db::tenant::location_for_user(&user)
|
||||
.unwrap()
|
||||
.dir(&db.topology)
|
||||
.unwrap()
|
||||
.join("content.db")
|
||||
};
|
||||
let conn = rusqlite::Connection::open(content_path).unwrap();
|
||||
let dest: String = conn
|
||||
.query_row(
|
||||
@@ -597,10 +640,10 @@ async fn disabled_slug_returns_410() {
|
||||
},
|
||||
);
|
||||
{
|
||||
let system = db.system.lock().unwrap();
|
||||
system
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
urls_conn
|
||||
.execute(
|
||||
"UPDATE global_slugs SET status = 'disabled' WHERE slug = 'ab7777';",
|
||||
"UPDATE global_urls SET status = 'disabled' WHERE slug = 'ab7777';",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
@@ -640,7 +683,15 @@ async fn redirect_performance_smoke() {
|
||||
}
|
||||
// Reset access count after warm-up for clean correctness check
|
||||
{
|
||||
let content_path = db.data_dir.join("users/1/content.db");
|
||||
let content_path = {
|
||||
let users = db.users.lock().unwrap();
|
||||
let user = bzod::db::users::get_user_by_id(&users, 1).unwrap().unwrap();
|
||||
bzod::db::tenant::location_for_user(&user)
|
||||
.unwrap()
|
||||
.dir(&db.topology)
|
||||
.unwrap()
|
||||
.join("content.db")
|
||||
};
|
||||
let conn = rusqlite::Connection::open(content_path).unwrap();
|
||||
conn.execute(
|
||||
"UPDATE urls SET access_count = 0 WHERE code = 'ab8888';",
|
||||
@@ -702,7 +753,17 @@ async fn redirect_performance_smoke() {
|
||||
assert_eq!(errors, 0, "error rate must be zero");
|
||||
assert_eq!(latencies_ms.len(), REQUESTS);
|
||||
|
||||
let content_path = db.data_dir.join("users/1/content.db");
|
||||
let content_path = {
|
||||
let users = db.users.lock().unwrap();
|
||||
let user = bzod::db::users::get_user_by_id(&users, 1)
|
||||
.unwrap()
|
||||
.expect("user 1 must exist");
|
||||
bzod::db::tenant::location_for_user(&user)
|
||||
.unwrap()
|
||||
.dir(&db.topology)
|
||||
.unwrap()
|
||||
.join("content.db")
|
||||
};
|
||||
let conn = rusqlite::Connection::open(content_path).unwrap();
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
|
||||
@@ -20,18 +20,21 @@ async fn test_registry_repair_dry_run() {
|
||||
let config = create_temp_config(temp_dir.clone());
|
||||
let db = Db::init(&config).expect("Failed to init Db");
|
||||
|
||||
let tid = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
let u = bzod::db::users::create_user(&conn, "repairdry", "pass", "user", None).unwrap();
|
||||
u.tenant_id.unwrap()
|
||||
};
|
||||
db.init_user_databases(1).unwrap();
|
||||
|
||||
{
|
||||
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 urls_conn = db.global_urls.lock().unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
urls_conn.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('!orphan1', ?1, 'target1', ?2, ?3, 'active', NULL);",
|
||||
rusqlite::params![tid.as_str(), now, now],
|
||||
).unwrap();
|
||||
}
|
||||
|
||||
let command = RepairCommands::Registry {
|
||||
@@ -47,10 +50,10 @@ async fn test_registry_repair_dry_run() {
|
||||
|
||||
// Verify it was NOT deleted
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let exists: bool = system_conn
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let exists: bool = urls_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = 'orphan1')",
|
||||
"SELECT EXISTS(SELECT 1 FROM global_urls WHERE slug = '!orphan1')",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
@@ -69,18 +72,21 @@ async fn test_registry_repair_force() {
|
||||
let config = create_temp_config(temp_dir.clone());
|
||||
let db = Db::init(&config).expect("Failed to init Db");
|
||||
|
||||
let tid = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
let u = bzod::db::users::create_user(&conn, "repairforce", "pass", "user", None).unwrap();
|
||||
u.tenant_id.unwrap()
|
||||
};
|
||||
db.init_user_databases(1).unwrap();
|
||||
|
||||
{
|
||||
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 urls_conn = db.global_urls.lock().unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
urls_conn.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('!orphan2', ?1, 'target2', ?2, ?3, 'active', NULL);",
|
||||
rusqlite::params![tid.as_str(), now, now],
|
||||
).unwrap();
|
||||
}
|
||||
|
||||
let command = RepairCommands::Registry {
|
||||
@@ -96,10 +102,10 @@ async fn test_registry_repair_force() {
|
||||
|
||||
// Verify it WAS deleted
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let exists: bool = system_conn
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let exists: bool = urls_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = 'orphan2')",
|
||||
"SELECT EXISTS(SELECT 1 FROM global_urls WHERE slug = '!orphan2')",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
@@ -118,33 +124,32 @@ async fn test_registry_repair_single_slug() {
|
||||
let config = create_temp_config(temp_dir.clone());
|
||||
let db = Db::init(&config).expect("Failed to init Db");
|
||||
|
||||
let tid = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
let u = bzod::db::users::create_user(&conn, "repairsingle", "pass", "user", None).unwrap();
|
||||
u.tenant_id.unwrap()
|
||||
};
|
||||
db.init_user_databases(1).unwrap();
|
||||
|
||||
{
|
||||
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 urls_conn = db.global_urls.lock().unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
urls_conn.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('!orphan3', ?1, 'target3', ?2, ?3, 'active', NULL);",
|
||||
rusqlite::params![tid.as_str(), now, now],
|
||||
).unwrap();
|
||||
urls_conn.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('!orphan4', ?1, 'target4', ?2, ?3, 'active', NULL);",
|
||||
rusqlite::params![tid.as_str(), now, now],
|
||||
).unwrap();
|
||||
}
|
||||
|
||||
let command = RepairCommands::Registry {
|
||||
dry_run: false,
|
||||
force: true,
|
||||
slug: Some("orphan3".to_string()),
|
||||
slug: Some("!orphan3".to_string()),
|
||||
data_dir: Some(temp_dir.to_string_lossy().to_string()),
|
||||
};
|
||||
|
||||
@@ -154,19 +159,19 @@ async fn test_registry_repair_single_slug() {
|
||||
|
||||
// Verify targeted slug was deleted
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let exists3: bool = system_conn
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let exists3: bool = urls_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = 'orphan3')",
|
||||
"SELECT EXISTS(SELECT 1 FROM global_urls WHERE slug = '!orphan3')",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!exists3, "Targeted slug should be deleted");
|
||||
|
||||
let exists4: bool = system_conn
|
||||
let exists4: bool = urls_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = 'orphan4')",
|
||||
"SELECT EXISTS(SELECT 1 FROM global_urls WHERE slug = '!orphan4')",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
|
||||
+25
-39
@@ -31,8 +31,6 @@ async fn test_global_slug_lookup_and_redirection() {
|
||||
|
||||
let state = AppState {
|
||||
admin_db: db.admin.clone(),
|
||||
content_db: db.content.clone(),
|
||||
analytics_db: db.analytics.clone(),
|
||||
system_db: db.system.clone(),
|
||||
users_db: db.users.clone(),
|
||||
user_dbs: std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
|
||||
@@ -52,17 +50,17 @@ async fn test_global_slug_lookup_and_redirection() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let user_id = {
|
||||
let (user_id, tid) = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_user_by_username(&conn, "testuser")
|
||||
let u = bzod::db::users::get_user_by_username(&conn, "testuser")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.id
|
||||
.unwrap();
|
||||
(u.id, u.tenant_id.unwrap())
|
||||
};
|
||||
|
||||
{
|
||||
let conn = bzod::jobs::open_user_content_conn(&db, user_id).unwrap();
|
||||
bzod::db::content::create_url_extended(
|
||||
let url = bzod::db::content::create_url_extended(
|
||||
&conn,
|
||||
"!my-routing-slug",
|
||||
"https://example.com/target",
|
||||
@@ -75,16 +73,15 @@ async fn test_global_slug_lookup_and_redirection() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"!my-routing-slug",
|
||||
user_id,
|
||||
"url",
|
||||
"xyz",
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
urls_conn
|
||||
.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('!my-routing-slug', ?1, ?2, ?3, ?4, 'active', NULL);",
|
||||
rusqlite::params![tid.as_str(), &url.id, now, now],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Call resolve_redirect directly
|
||||
@@ -128,8 +125,6 @@ async fn test_disabled_slug_returns_410() {
|
||||
|
||||
let state = AppState {
|
||||
admin_db: db.admin.clone(),
|
||||
content_db: db.content.clone(),
|
||||
analytics_db: db.analytics.clone(),
|
||||
system_db: db.system.clone(),
|
||||
users_db: db.users.clone(),
|
||||
user_dbs: std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
|
||||
@@ -148,17 +143,17 @@ async fn test_disabled_slug_returns_410() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let user_id = {
|
||||
let (user_id, tid) = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_user_by_username(&conn, "testuser")
|
||||
let u = bzod::db::users::get_user_by_username(&conn, "testuser")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.id
|
||||
.unwrap();
|
||||
(u.id, u.tenant_id.unwrap())
|
||||
};
|
||||
|
||||
{
|
||||
let conn = bzod::jobs::open_user_content_conn(&db, user_id).unwrap();
|
||||
bzod::db::content::create_url_extended(
|
||||
let url = bzod::db::content::create_url_extended(
|
||||
&conn,
|
||||
"!disabled-slug",
|
||||
"https://example.com/target",
|
||||
@@ -171,22 +166,13 @@ async fn test_disabled_slug_returns_410() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"!disabled-slug",
|
||||
user_id,
|
||||
"url",
|
||||
"xyz",
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Disable slug
|
||||
system_conn
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
urls_conn
|
||||
.execute(
|
||||
"UPDATE global_slugs SET status = 'disabled' WHERE slug = '!disabled-slug';",
|
||||
[],
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('!disabled-slug', ?1, ?2, ?3, ?4, 'disabled', NULL);",
|
||||
rusqlite::params![tid.as_str(), &url.id, now, now],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
@@ -20,14 +20,24 @@ async fn test_global_slug_uniqueness() {
|
||||
|
||||
let db = Db::init(&config).expect("Failed to init Db");
|
||||
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let reserved_conn = db.reserved.lock().unwrap();
|
||||
let pages_conn = db.global_landing_pages.lock().unwrap();
|
||||
|
||||
// Register a slug
|
||||
bzod::db::users::register_global_slug(&system_conn, "!myslug", 1, "url", "url1", "active")
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
urls_conn
|
||||
.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('!myslug', 't-tenant1234', 'url1', ?1, ?2, 'active', NULL);",
|
||||
rusqlite::params![now, now],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Verify it is not available
|
||||
let avail = bzod::db::users::is_slug_available(&system_conn, "!myslug").unwrap();
|
||||
let avail =
|
||||
bzod::db::slugs::is_slug_available(&reserved_conn, &urls_conn, &pages_conn, "!myslug")
|
||||
.unwrap();
|
||||
assert!(!avail);
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
@@ -41,7 +51,9 @@ async fn test_reserved_slug_rejection() {
|
||||
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();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let reserved_conn = db.reserved.lock().unwrap();
|
||||
let pages_conn = db.global_landing_pages.lock().unwrap();
|
||||
|
||||
let reserved = vec![
|
||||
"admin",
|
||||
@@ -72,7 +84,9 @@ async fn test_reserved_slug_rejection() {
|
||||
];
|
||||
|
||||
for slug in reserved {
|
||||
let avail = bzod::db::users::is_slug_available(&system_conn, slug).unwrap();
|
||||
let avail =
|
||||
bzod::db::slugs::is_slug_available(&reserved_conn, &urls_conn, &pages_conn, slug)
|
||||
.unwrap();
|
||||
assert!(!avail, "Reserved slug '{}' should not be available", slug);
|
||||
}
|
||||
|
||||
@@ -97,27 +111,35 @@ async fn test_slug_release_on_user_deletion() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let user_id = {
|
||||
let (user_id, tid) = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_user_by_username(&conn, "testuser")
|
||||
let u = bzod::db::users::get_user_by_username(&conn, "testuser")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.id
|
||||
.unwrap();
|
||||
(u.id, u.tenant_id.unwrap())
|
||||
};
|
||||
|
||||
// Register slug for this user
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let reserved_conn = db.reserved.lock().unwrap();
|
||||
let pages_conn = db.global_landing_pages.lock().unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
urls_conn
|
||||
.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('!user-slug', ?1, 'url_xyz', ?2, ?3, 'active', NULL);",
|
||||
rusqlite::params![tid.as_str(), now, now],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let avail = bzod::db::slugs::is_slug_available(
|
||||
&reserved_conn,
|
||||
&urls_conn,
|
||||
&pages_conn,
|
||||
"!user-slug",
|
||||
user_id,
|
||||
"url",
|
||||
"url_xyz",
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
let avail = bzod::db::users::is_slug_available(&system_conn, "!user-slug").unwrap();
|
||||
assert!(!avail);
|
||||
}
|
||||
|
||||
@@ -128,11 +150,20 @@ async fn test_slug_release_on_user_deletion() {
|
||||
|
||||
// Slug should now be released and available
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let avail = bzod::db::users::is_slug_available(&system_conn, "!user-slug").unwrap();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let reserved_conn = db.reserved.lock().unwrap();
|
||||
let pages_conn = db.global_landing_pages.lock().unwrap();
|
||||
let avail = bzod::db::slugs::is_slug_available(
|
||||
&reserved_conn,
|
||||
&urls_conn,
|
||||
&pages_conn,
|
||||
"!user-slug",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(avail);
|
||||
|
||||
// Verify history populated
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let count: i64 = system_conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM slug_history WHERE slug = ?1 AND action = 'deleted';",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#![allow(deprecated)]
|
||||
|
||||
use bzod::config::Config;
|
||||
use bzod::db::Db;
|
||||
use std::fs;
|
||||
|
||||
@@ -41,17 +41,15 @@ async fn test_slug_transfer() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (id_a, id_b) = {
|
||||
let (id_a, tid_a, id_b, tid_b) = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
let a = bzod::db::users::get_user_by_username(&conn, "usera")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.id;
|
||||
.unwrap();
|
||||
let b = bzod::db::users::get_user_by_username(&conn, "userb")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.id;
|
||||
(a, b)
|
||||
.unwrap();
|
||||
(a.id, a.tenant_id.unwrap(), b.id, b.tenant_id.unwrap())
|
||||
};
|
||||
|
||||
// User A creates a URL
|
||||
@@ -71,16 +69,15 @@ async fn test_slug_transfer() {
|
||||
|
||||
// Register globally
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"!trans-slug",
|
||||
id_a,
|
||||
"url",
|
||||
&url.id,
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
urls_conn
|
||||
.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('!trans-slug', ?1, ?2, ?3, ?4, 'active', NULL);",
|
||||
rusqlite::params![tid_a.as_str(), &url.id, now, now],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
bzod::db::users::increment_quota_counter(&users_conn, id_a, "urls").unwrap();
|
||||
@@ -112,12 +109,12 @@ async fn test_slug_transfer() {
|
||||
bzod::db::content::delete_url(&old_conn, &url_to_copy.id).unwrap();
|
||||
|
||||
// 3. Update global slugs and quotas
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
system_conn
|
||||
urls_conn
|
||||
.execute(
|
||||
"UPDATE global_slugs SET owner_user_id = ?1, updated_at = ?2 WHERE slug = ?3;",
|
||||
rusqlite::params![id_b, now, "!trans-slug"],
|
||||
"UPDATE global_urls SET owner_tenant_id = ?1, updated_at = ?2 WHERE slug = ?3;",
|
||||
rusqlite::params![tid_b.as_str(), now, "!trans-slug"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
|
||||
+51
-27
@@ -19,29 +19,41 @@ async fn test_soft_delete_reserves_slug() {
|
||||
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();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let reserved_conn = db.reserved.lock().unwrap();
|
||||
let pages_conn = db.global_landing_pages.lock().unwrap();
|
||||
|
||||
// Register slug
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"!slug-to-delete",
|
||||
10,
|
||||
"url",
|
||||
"url_123",
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
urls_conn
|
||||
.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('!slug-to-delete', 't-tenant10', 'url_123', ?1, ?2, 'active', NULL);",
|
||||
rusqlite::params![now, now],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Soft delete slug
|
||||
bzod::db::users::soft_delete_global_slug(&system_conn, "!slug-to-delete", 10).unwrap();
|
||||
// Soft delete slug (disable it)
|
||||
urls_conn
|
||||
.execute(
|
||||
"UPDATE global_urls SET status = 'disabled', retired_at = ?1 WHERE slug = ?2;",
|
||||
rusqlite::params![now, "!slug-to-delete"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Verify it is not available (keeps the slug reserved)
|
||||
let avail = bzod::db::users::is_slug_available(&system_conn, "!slug-to-delete").unwrap();
|
||||
let avail = bzod::db::slugs::is_slug_available(
|
||||
&reserved_conn,
|
||||
&urls_conn,
|
||||
&pages_conn,
|
||||
"!slug-to-delete",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!avail);
|
||||
|
||||
let status: String = system_conn
|
||||
let status: String = urls_conn
|
||||
.query_row(
|
||||
"SELECT status FROM global_slugs WHERE slug = ?1;",
|
||||
"SELECT status FROM global_urls WHERE slug = ?1;",
|
||||
["!slug-to-delete"],
|
||||
|row| row.get(0),
|
||||
)
|
||||
@@ -59,24 +71,36 @@ async fn test_permanent_delete_releases_slug() {
|
||||
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();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let reserved_conn = db.reserved.lock().unwrap();
|
||||
let pages_conn = db.global_landing_pages.lock().unwrap();
|
||||
|
||||
// Register slug
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"!slug-to-purge",
|
||||
10,
|
||||
"url",
|
||||
"url_456",
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
urls_conn
|
||||
.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('!slug-to-purge', 't-tenant10', 'url_456', ?1, ?2, 'active', NULL);",
|
||||
rusqlite::params![now, now],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Release global slug (permanent deletion)
|
||||
bzod::db::users::release_global_slug(&system_conn, "!slug-to-purge", 10).unwrap();
|
||||
urls_conn
|
||||
.execute(
|
||||
"DELETE FROM global_urls WHERE slug = ?1;",
|
||||
["!slug-to-purge"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Verify slug is released (available for reuse)
|
||||
let avail = bzod::db::users::is_slug_available(&system_conn, "!slug-to-purge").unwrap();
|
||||
let avail = bzod::db::slugs::is_slug_available(
|
||||
&reserved_conn,
|
||||
&urls_conn,
|
||||
&pages_conn,
|
||||
"!slug-to-purge",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(avail);
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
|
||||
+30
-22
@@ -18,34 +18,38 @@ async fn test_cleanup_stale_reservations() {
|
||||
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();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let pages_conn = db.global_landing_pages.lock().unwrap();
|
||||
|
||||
// Insert a reserving slug older than 15 minutes (e.g. 20 minutes ago)
|
||||
let old_time = (chrono::Utc::now() - chrono::Duration::minutes(20)).to_rfc3339();
|
||||
system_conn.execute(
|
||||
"INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||
VALUES ('stale-slug', 2, 'url', '', ?1, ?1, 'reserving')",
|
||||
[&old_time]
|
||||
).unwrap();
|
||||
urls_conn
|
||||
.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('!stale-slug', 't-tenant1234', '', ?1, ?1, 'reserving', NULL)",
|
||||
[&old_time],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Verify it exists before cleanup
|
||||
let exists: bool = system_conn
|
||||
let exists: bool = urls_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = 'stale-slug')",
|
||||
"SELECT EXISTS(SELECT 1 FROM global_urls WHERE slug = '!stale-slug')",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(exists);
|
||||
|
||||
// Run cleanup
|
||||
let cleaned = bzod::db::users::cleanup_stale_reservations(&system_conn, &temp_dir).unwrap();
|
||||
// Run cleanup for records older than 15 minutes (900 seconds)
|
||||
let cleaned =
|
||||
bzod::db::slugs::cleanup_stale_reservations(&urls_conn, &pages_conn, 900).unwrap();
|
||||
assert_eq!(cleaned, 1);
|
||||
|
||||
// Verify it is gone
|
||||
let exists: bool = system_conn
|
||||
let exists: bool = urls_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = 'stale-slug')",
|
||||
"SELECT EXISTS(SELECT 1 FROM global_urls WHERE slug = '!stale-slug')",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
@@ -62,24 +66,28 @@ async fn test_cleanup_preserves_valid_reservations() {
|
||||
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();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let pages_conn = db.global_landing_pages.lock().unwrap();
|
||||
|
||||
// Insert a reserving slug that is brand new (e.g. 1 minute ago)
|
||||
let new_time = (chrono::Utc::now() - chrono::Duration::minutes(1)).to_rfc3339();
|
||||
system_conn.execute(
|
||||
"INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||
VALUES ('fresh-slug', 2, 'url', '', ?1, ?1, 'reserving')",
|
||||
[&new_time]
|
||||
).unwrap();
|
||||
urls_conn
|
||||
.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('!fresh-slug', 't-tenant1234', '', ?1, ?1, 'reserving', NULL)",
|
||||
[&new_time],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Run cleanup
|
||||
let cleaned = bzod::db::users::cleanup_stale_reservations(&system_conn, &temp_dir).unwrap();
|
||||
// Run cleanup for records older than 15 minutes (900 seconds)
|
||||
let cleaned =
|
||||
bzod::db::slugs::cleanup_stale_reservations(&urls_conn, &pages_conn, 900).unwrap();
|
||||
assert_eq!(cleaned, 0);
|
||||
|
||||
// Verify it is still there
|
||||
let exists: bool = system_conn
|
||||
let exists: bool = urls_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = 'fresh-slug')",
|
||||
"SELECT EXISTS(SELECT 1 FROM global_urls WHERE slug = '!fresh-slug')",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
|
||||
@@ -99,6 +99,15 @@ async fn test_upgrade_from_v0_4_0() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
system_conn
|
||||
.execute(
|
||||
"INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||
VALUES ('!legacy-link', 1, 'url', ?1, ?2, ?3, 'active');",
|
||||
rusqlite::params![&url.id, now, now],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// 4. analytics.db
|
||||
let mut analytics_conn = rusqlite::Connection::open(&legacy_analytics_path).unwrap();
|
||||
bzod::db::migrations::run_migrations(
|
||||
@@ -131,10 +140,17 @@ async fn test_upgrade_from_v0_4_0() {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Normalize legacy flat layout to multi-tenant paths
|
||||
bzod::services::backup_layout::normalize_restored_layout(&temp_dir).unwrap();
|
||||
|
||||
// Now start the application setup which should trigger migrations/upgrade
|
||||
let config = create_temp_config(temp_dir.clone());
|
||||
let db = Db::init(&config).expect("Db::init failed to upgrade v0.4.0 database directory");
|
||||
|
||||
// Run identity and slug migrations for legacy data
|
||||
let _ = bzod::db::identity_migrate::run_identity_migration(&config, false, true).await;
|
||||
let _ = bzod::db::slug_migrate::run_global_slug_migration(&config, false, true).await;
|
||||
|
||||
// Verify file layout was reorganized
|
||||
assert!(!legacy_admin_path.exists());
|
||||
assert!(!legacy_content_path.exists());
|
||||
@@ -153,8 +169,6 @@ async fn test_upgrade_from_v0_4_0() {
|
||||
let (queue, _) = AnalyticsQueue::new(db.clone(), 10, rx);
|
||||
let state = AppState {
|
||||
admin_db: db.admin.clone(),
|
||||
content_db: db.content.clone(),
|
||||
analytics_db: db.analytics.clone(),
|
||||
system_db: db.system.clone(),
|
||||
users_db: db.users.clone(),
|
||||
user_dbs: std::sync::Arc::new(std::sync::Mutex::new(HashMap::new())),
|
||||
@@ -206,7 +220,16 @@ async fn test_upgrade_from_v0_4_0() {
|
||||
// 2. Existing links redirect correctly
|
||||
let redirect_url = format!("{}/!legacy-link", base_url);
|
||||
let redirect_res = client.get(&redirect_url).send().await.unwrap();
|
||||
let res_headers = redirect_res.headers();
|
||||
let status = redirect_res.status();
|
||||
let res_headers = redirect_res.headers().clone();
|
||||
let body = redirect_res.text().await.unwrap();
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::MOVED_PERMANENTLY,
|
||||
"Got status {} with body {}",
|
||||
status,
|
||||
body
|
||||
);
|
||||
assert!(res_headers.get("location").is_some());
|
||||
assert_eq!(
|
||||
res_headers.get("location").unwrap().to_str().unwrap(),
|
||||
|
||||
@@ -31,12 +31,16 @@ async fn test_user_deletion_cleanup() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let user_id = {
|
||||
let (user_id, user_dir) = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_user_by_username(&conn, "testuser")
|
||||
let user = bzod::db::users::get_user_by_username(&conn, "testuser")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let dir = bzod::db::tenant::location_for_user(&user)
|
||||
.unwrap()
|
||||
.id
|
||||
.dir(&bzod::db::topology::Topology::new(&temp_dir))
|
||||
.unwrap();
|
||||
(user.id, dir)
|
||||
};
|
||||
|
||||
// Add session and API token
|
||||
@@ -52,8 +56,7 @@ async fn test_user_deletion_cleanup() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Verify directory is deleted
|
||||
let user_dir = temp_dir.join("users").join(user_id.to_string());
|
||||
// Verify tenant directory is deleted
|
||||
assert!(!user_dir.exists());
|
||||
|
||||
// Verify sessions/tokens are cascadingly deleted
|
||||
|
||||
@@ -38,21 +38,26 @@ async fn test_user_database_isolation() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (id_a, id_b) = {
|
||||
let (user_a, user_b) = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
let a = bzod::db::users::get_user_by_username(&conn, "usera")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.id;
|
||||
.unwrap();
|
||||
let b = bzod::db::users::get_user_by_username(&conn, "userb")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.id;
|
||||
.unwrap();
|
||||
(a, b)
|
||||
};
|
||||
|
||||
let dir_a = temp_dir.join("users").join(id_a.to_string());
|
||||
let dir_b = temp_dir.join("users").join(id_b.to_string());
|
||||
let topology = bzod::db::topology::Topology::new(&temp_dir);
|
||||
let dir_a = bzod::db::tenant::location_for_user(&user_a)
|
||||
.unwrap()
|
||||
.dir(&topology)
|
||||
.unwrap();
|
||||
let dir_b = bzod::db::tenant::location_for_user(&user_b)
|
||||
.unwrap()
|
||||
.dir(&topology)
|
||||
.unwrap();
|
||||
|
||||
assert_ne!(dir_a, dir_b);
|
||||
assert!(dir_a.join("content.db").exists());
|
||||
|
||||
@@ -36,7 +36,10 @@ async fn test_user_creation() {
|
||||
.unwrap();
|
||||
assert_eq!(user.status, "active");
|
||||
|
||||
let user_dir = temp_dir.join("users").join(user.id.to_string());
|
||||
let user_dir = bzod::db::tenant::location_for_user(&user)
|
||||
.unwrap()
|
||||
.dir(&bzod::db::topology::Topology::new(&temp_dir))
|
||||
.unwrap();
|
||||
assert!(user_dir.join("content.db").exists());
|
||||
assert!(user_dir.join("analytics.db").exists());
|
||||
assert!(user_dir.join("profile.db").exists());
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
use bzod::analytics::AnalyticsQueue;
|
||||
use bzod::config::Config;
|
||||
use bzod::db::topology::Topology;
|
||||
use bzod::db::Db;
|
||||
use bzod::identity::TenantId;
|
||||
use bzod::state::AppState;
|
||||
use bzod::web::create_router;
|
||||
|
||||
fn create_temp_config(temp_dir: PathBuf) -> Config {
|
||||
let mut config = Config::load();
|
||||
config.data_dir = temp_dir.clone();
|
||||
config.backup_dir = temp_dir.join("backups");
|
||||
config.base_url = Some("http://localhost:8080".to_string());
|
||||
config
|
||||
}
|
||||
|
||||
fn build_test_state(db: &Db, config: &Config) -> AppState {
|
||||
let (queue, _) = AnalyticsQueue::new(db.clone(), 10, tokio::sync::watch::channel(false).1);
|
||||
AppState {
|
||||
admin_db: db.admin.clone(),
|
||||
system_db: db.system.clone(),
|
||||
users_db: db.users.clone(),
|
||||
user_dbs: Arc::new(Mutex::new(HashMap::new())),
|
||||
db: db.clone(),
|
||||
config: config.clone(),
|
||||
analytics_queue: queue,
|
||||
start_time: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_admin_provisioning_has_no_tenant_directory() {
|
||||
let temp_dir = std::env::temp_dir().join(format!("bzod_p5_admin_dir_{}", uuid::Uuid::new_v4()));
|
||||
fs::create_dir_all(&temp_dir).unwrap();
|
||||
let config = create_temp_config(temp_dir.clone());
|
||||
|
||||
// 1. Initialize Db
|
||||
let db = Db::init(&config).expect("Db::init failed");
|
||||
let topology = Topology::new(&temp_dir);
|
||||
|
||||
// 2. Create Admin user via CLI
|
||||
let admin_res = bzod::cli::create_admin::run(
|
||||
Some("platform_admin".to_string()),
|
||||
Some("supersecretpass123".to_string()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await;
|
||||
assert!(admin_res.is_ok());
|
||||
|
||||
// 3. Verify Admin in admin.db and users.db
|
||||
let admin_user = {
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
let u = bzod::db::users::get_user_by_username(&users_conn, "platform_admin")
|
||||
.unwrap()
|
||||
.expect("Admin user must exist in users.db");
|
||||
assert_eq!(u.account_type, "admin");
|
||||
assert!(u.tenant_id.is_none(), "Admin must NOT have a TenantId");
|
||||
u
|
||||
};
|
||||
|
||||
// 4. Verify NO tenant directory was created for admin
|
||||
let users_dir = topology.users_dir();
|
||||
if users_dir.exists() {
|
||||
let entries: Vec<_> = fs::read_dir(&users_dir)
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.collect();
|
||||
for entry in entries {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
assert_ne!(name, format!("{}", admin_user.id));
|
||||
assert_ne!(name, "platform_admin");
|
||||
}
|
||||
}
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_admin_cannot_create_unowned_application_resources() {
|
||||
let temp_dir = std::env::temp_dir().join(format!("bzod_p5_unowned_{}", 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("Db::init failed");
|
||||
let state = build_test_state(&db, &config);
|
||||
|
||||
let router = create_router(state);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let base_url = format!("http://{}", addr);
|
||||
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, router).await.unwrap();
|
||||
});
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.cookie_store(true)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
// 1. Create Admin
|
||||
let _ = bzod::cli::create_admin::run(
|
||||
Some("operator".to_string()),
|
||||
Some("pass123456".to_string()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 2. Log in as admin
|
||||
let login_page = client
|
||||
.get(format!("{}/admin/login", base_url))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Extract CSRF token
|
||||
let csrf = login_page
|
||||
.split("name=\"csrf_token\" value=\"")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('"').next())
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut params = HashMap::new();
|
||||
params.insert("username", "operator");
|
||||
params.insert("password", "pass123456");
|
||||
params.insert("csrf_token", csrf);
|
||||
|
||||
let login_res = client
|
||||
.post(format!("{}/admin/login", base_url))
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(login_res.status(), reqwest::StatusCode::SEE_OTHER);
|
||||
|
||||
// 3. Admin attempts to create URL via POST /admin/urls/create -> Should redirect with error
|
||||
let mut url_form = HashMap::new();
|
||||
url_form.insert("destination", "https://example.com");
|
||||
url_form.insert("code", "!admin_link");
|
||||
url_form.insert("csrf_token", csrf);
|
||||
|
||||
let create_url_res = client
|
||||
.post(format!("{}/admin/urls/create", base_url))
|
||||
.form(&url_form)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(create_url_res.status(), reqwest::StatusCode::SEE_OTHER);
|
||||
let location = create_url_res
|
||||
.headers()
|
||||
.get("location")
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap();
|
||||
assert!(
|
||||
location.contains("error="),
|
||||
"Admin URL creation must be rejected with error redirect"
|
||||
);
|
||||
|
||||
// 4. Admin attempts to create Landing Page via POST /admin/pages/create -> Should redirect with error
|
||||
let mut page_form = HashMap::new();
|
||||
page_form.insert("title", "Admin Page");
|
||||
page_form.insert("slug", "!admin_page");
|
||||
page_form.insert("code", "!admin_page");
|
||||
page_form.insert("custom_slug", "");
|
||||
page_form.insert("state", "published");
|
||||
page_form.insert("html_content", "<h1>Admin</h1>");
|
||||
page_form.insert("csrf_token", csrf);
|
||||
|
||||
let create_page_res = client
|
||||
.post(format!("{}/admin/pages/create", base_url))
|
||||
.form(&page_form)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(create_page_res.status(), reqwest::StatusCode::SEE_OTHER);
|
||||
let location = create_page_res
|
||||
.headers()
|
||||
.get("location")
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap();
|
||||
assert!(
|
||||
location.contains("error="),
|
||||
"Admin Landing Page creation must be rejected with error redirect"
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_platform_click_aggregation_without_admin_analytics() {
|
||||
let temp_dir = std::env::temp_dir().join(format!("bzod_p5_agg_{}", 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("Db::init failed");
|
||||
|
||||
// 1. Create two standard tenant users
|
||||
let _ = bzod::cli::create_user::run(
|
||||
Some("tenant_one".to_string()),
|
||||
Some("pass123".to_string()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let _ = bzod::cli::create_user::run(
|
||||
Some("tenant_two".to_string()),
|
||||
Some("pass123".to_string()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (u1, u2) = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
let user1 = bzod::db::users::get_user_by_username(&conn, "tenant_one")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let user2 = bzod::db::users::get_user_by_username(&conn, "tenant_two")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
(user1, user2)
|
||||
};
|
||||
|
||||
// 2. Insert clicks directly into tenant 1 and tenant 2 analytics DBs
|
||||
let t1_tid = u1.tenant_id.unwrap();
|
||||
let t2_tid = u2.tenant_id.unwrap();
|
||||
|
||||
let t1_analytics_path = db.topology.tenant_analytics_db(t1_tid);
|
||||
let t2_analytics_path = db.topology.tenant_analytics_db(t2_tid);
|
||||
|
||||
{
|
||||
let conn1 = rusqlite::Connection::open(&t1_analytics_path).unwrap();
|
||||
let _ = conn1.execute(
|
||||
"INSERT INTO visits (id, target_type, target_id, timestamp, ip_address, country, referer, user_agent, accept_language, status_code)
|
||||
VALUES ('v1', 'url', 'url1', '2026-08-20T10:00:00Z', '1.1.1.1', 'US', 'direct', 'ua', 'en', 301);",
|
||||
[],
|
||||
).unwrap();
|
||||
let _ = conn1.execute(
|
||||
"INSERT INTO visits (id, target_type, target_id, timestamp, ip_address, country, referer, user_agent, accept_language, status_code)
|
||||
VALUES ('v2', 'url', 'url1', '2026-08-20T10:01:00Z', '1.1.1.2', 'US', 'direct', 'ua', 'en', 301);",
|
||||
[],
|
||||
).unwrap();
|
||||
}
|
||||
|
||||
{
|
||||
let conn2 = rusqlite::Connection::open(&t2_analytics_path).unwrap();
|
||||
let _ = conn2.execute(
|
||||
"INSERT INTO visits (id, target_type, target_id, timestamp, ip_address, country, referer, user_agent, accept_language, status_code)
|
||||
VALUES ('v3', 'url', 'url2', '2026-08-20T10:02:00Z', '2.2.2.2', 'CA', 'direct', 'ua', 'en', 301);",
|
||||
[],
|
||||
).unwrap();
|
||||
}
|
||||
|
||||
// 3. Aggregate platform total clicks
|
||||
let total_clicks = {
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_platform_total_clicks(&db.topology, &users_conn).unwrap()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
total_clicks, 3,
|
||||
"Platform clicks must aggregate across all active tenant databases"
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_admin_resource_inspection_and_moderation() {
|
||||
let temp_dir = std::env::temp_dir().join(format!("bzod_p5_mod_{}", 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("Db::init failed");
|
||||
let state = build_test_state(&db, &config);
|
||||
|
||||
// 1. Create standard tenant user
|
||||
let _ = bzod::cli::create_user::run(
|
||||
Some("content_author".to_string()),
|
||||
Some("pass123".to_string()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (author_id, author_tid) = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
let u = bzod::db::users::get_user_by_username(&conn, "content_author")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
(u.id, u.tenant_id.unwrap())
|
||||
};
|
||||
|
||||
// 2. Author creates URL
|
||||
let url_id = {
|
||||
let conn = bzod::jobs::open_user_content_conn(&db, author_id).unwrap();
|
||||
let u = bzod::db::content::create_url_extended(
|
||||
&conn,
|
||||
"!moderated-link",
|
||||
"https://bad-site.com",
|
||||
Some("Spam Link"),
|
||||
None,
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
bzod::db::slugs::register_url_slug(
|
||||
&urls_conn,
|
||||
"!moderated-link",
|
||||
&author_tid,
|
||||
&u.id,
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
u.id
|
||||
};
|
||||
|
||||
// 3. Verify Admin can look up slug and inspect owner tenant
|
||||
let slug_info = state
|
||||
.lookup_slug("!moderated-link")
|
||||
.unwrap()
|
||||
.expect("Slug must exist in global registry");
|
||||
assert_eq!(slug_info.owner_tenant_id, author_tid.as_str());
|
||||
|
||||
// 4. Admin opens tenant content DB in CoreJob mode to inspect
|
||||
let tenant_dbs = state
|
||||
.open_tenant(author_tid, bzod::state::TenantOpenMode::CoreJob)
|
||||
.unwrap();
|
||||
{
|
||||
let conn = tenant_dbs.content.lock().unwrap();
|
||||
let fetched_url = bzod::db::content::get_url_by_id(&conn, &url_id)
|
||||
.unwrap()
|
||||
.expect("URL must exist in tenant content.db");
|
||||
assert_eq!(fetched_url.destination, "https://bad-site.com");
|
||||
}
|
||||
|
||||
// 5. Admin retires/moderates the slug
|
||||
{
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let pages_conn = db.global_landing_pages.lock().unwrap();
|
||||
let retired =
|
||||
bzod::db::slugs::retire_slug(&urls_conn, &pages_conn, "!moderated-link").unwrap();
|
||||
assert!(retired);
|
||||
}
|
||||
|
||||
// 6. Verify slug cannot be re-registered by another tenant
|
||||
let _other_tid = TenantId::generate();
|
||||
let r_conn = db.reserved.lock().unwrap();
|
||||
let u_conn = db.global_urls.lock().unwrap();
|
||||
let p_conn = db.global_landing_pages.lock().unwrap();
|
||||
let available =
|
||||
bzod::db::slugs::is_slug_available(&r_conn, &u_conn, &p_conn, "!moderated-link").unwrap();
|
||||
assert!(
|
||||
!available,
|
||||
"Retired slug must remain unavailable across the platform"
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//! v0.8.0 Phase 1: frozen database topology under the existing physical root.
|
||||
|
||||
use bzod::config::Config;
|
||||
use bzod::db::slugs::CORE_RESERVED_SLUGS;
|
||||
use bzod::db::sqlite::get_user_version;
|
||||
use bzod::db::topology::{is_valid_user_dir_name, Topology};
|
||||
use bzod::db::Db;
|
||||
use rusqlite::Connection;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn temp_config() -> (PathBuf, Config) {
|
||||
let temp_dir = std::env::temp_dir().join(format!("bzod_v08_topology_{}", uuid::Uuid::new_v4()));
|
||||
fs::create_dir_all(&temp_dir).unwrap();
|
||||
let mut config = Config::load();
|
||||
config.data_dir = temp_dir.clone();
|
||||
config.backup_dir = temp_dir.join("backups");
|
||||
(temp_dir, config)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn physical_root_stays_data_dir_not_database() {
|
||||
let t = Topology::new("./data");
|
||||
assert_eq!(t.root(), std::path::Path::new("./data"));
|
||||
assert!(!t.root().ends_with("database"));
|
||||
assert!(t.slugs_dir().ends_with("data/slugs"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn db_init_creates_frozen_slug_topology() {
|
||||
let (temp_dir, config) = temp_config();
|
||||
let db = Db::init(&config).expect("init");
|
||||
|
||||
assert!(db.topology.admin_db().exists());
|
||||
assert!(db.topology.system_db().exists());
|
||||
assert!(db.topology.users_registry_db().exists());
|
||||
assert!(db.topology.global_urls_db().exists());
|
||||
assert!(db.topology.global_landing_pages_db().exists());
|
||||
assert!(db.topology.reserved_db().exists());
|
||||
assert!(!temp_dir.join("database").exists());
|
||||
|
||||
{
|
||||
let conn = db.global_urls.lock().unwrap();
|
||||
assert_eq!(
|
||||
get_user_version(&conn).unwrap(),
|
||||
bzod::db::schema_v08::GLOBAL_URLS_MIGRATIONS
|
||||
.last()
|
||||
.unwrap()
|
||||
.version
|
||||
);
|
||||
let n: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM global_urls;", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(n, 0, "Phase 1 does not move live slugs off system.db");
|
||||
}
|
||||
{
|
||||
let conn = db.global_landing_pages.lock().unwrap();
|
||||
assert_eq!(
|
||||
get_user_version(&conn).unwrap(),
|
||||
bzod::db::schema_v08::GLOBAL_LANDING_PAGES_MIGRATIONS
|
||||
.last()
|
||||
.unwrap()
|
||||
.version
|
||||
);
|
||||
let n: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM global_landing_pages;", [], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(n, 0);
|
||||
}
|
||||
{
|
||||
let conn = db.reserved.lock().unwrap();
|
||||
assert_eq!(get_user_version(&conn).unwrap(), 1);
|
||||
let n: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM reserved_slugs;", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(n, CORE_RESERVED_SLUGS.len() as i64);
|
||||
let has_admin: bool = conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM reserved_slugs WHERE slug = 'admin');",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(has_admin);
|
||||
}
|
||||
|
||||
// Live slug registry is still system.db (Phase 4 moves ownership).
|
||||
{
|
||||
let conn = db.system.lock().unwrap();
|
||||
let has_table: bool = conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='global_slugs');",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(has_table);
|
||||
}
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backup_includes_slugs_directory() {
|
||||
let (temp_dir, config) = temp_config();
|
||||
let db = Db::init(&config).expect("init");
|
||||
let backup_path = bzod::jobs::backup::perform_backup(&db, &config)
|
||||
.await
|
||||
.expect("backup");
|
||||
|
||||
let file = fs::File::open(&backup_path).unwrap();
|
||||
let dec = flate2::read::GzDecoder::new(file);
|
||||
let mut archive = tar::Archive::new(dec);
|
||||
let mut found_reserved = false;
|
||||
let mut found_global_urls = false;
|
||||
for entry in archive.entries().unwrap() {
|
||||
let entry = entry.unwrap();
|
||||
let path = entry.path().unwrap().to_string_lossy().to_string();
|
||||
if path.contains("slugs/reserved.db") {
|
||||
found_reserved = true;
|
||||
}
|
||||
if path.contains("slugs/global_urls.db") {
|
||||
found_global_urls = true;
|
||||
}
|
||||
}
|
||||
assert!(found_reserved, "backup missing slugs/reserved.db");
|
||||
assert!(found_global_urls, "backup missing slugs/global_urls.db");
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tenant_path_resolution_rejects_traversal() {
|
||||
let t = Topology::new("/app/data");
|
||||
assert!(t.user_dir("..").is_err());
|
||||
assert!(t.user_dir("../etc").is_err());
|
||||
assert!(t.content_db_i64(-1).is_err());
|
||||
assert!(is_valid_user_dir_name("2"));
|
||||
assert!(is_valid_user_dir_name("a1b2c3d4e5f6"));
|
||||
assert!(!is_valid_user_dir_name("admin"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_user_content_rejects_non_positive_id() {
|
||||
let (temp_dir, config) = temp_config();
|
||||
let db = Db::init(&config).expect("init");
|
||||
assert!(bzod::jobs::open_user_content_conn(&db, 0).is_err());
|
||||
assert!(bzod::jobs::open_user_content_conn(&db, -3).is_err());
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_normalization_creates_slugs_dir() {
|
||||
let dir = std::env::temp_dir().join(format!("bzod_v08_restore_{}", uuid::Uuid::new_v4()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
fs::write(dir.join("admin.db"), b"a").unwrap();
|
||||
fs::write(dir.join("system.db"), b"s").unwrap();
|
||||
fs::write(dir.join("users.db"), b"u").unwrap();
|
||||
fs::write(dir.join("content.db"), b"c").unwrap();
|
||||
fs::write(dir.join("analytics.db"), b"an").unwrap();
|
||||
|
||||
bzod::services::backup_layout::normalize_restored_layout(&dir).unwrap();
|
||||
assert!(dir.join("slugs").is_dir());
|
||||
assert!(Connection::open(dir.join("admin/admin.db")).is_ok());
|
||||
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
//! v0.8.0 Phase 3: Identity Migration Integration Tests.
|
||||
//!
|
||||
//! Tests:
|
||||
//! 1. New users automatically get TenantId + UUID.
|
||||
//! 2. Explicit identity migration lifecycle (preflight, backup, backfill, directory rename, validation).
|
||||
//! 3. Legacy Admin (users/1) is preserved and not converted into a normal tenant.
|
||||
//! 4. Restart-safety & idempotency (repeated migration runs).
|
||||
//! 5. Recovery when target directory already exists.
|
||||
//! 6. Admin bootstrap concurrency without re-entrant mutex deadlock.
|
||||
//! 7. Cross-tenant isolation after identity migration.
|
||||
|
||||
use bzod::config::Config;
|
||||
use bzod::db::identity_migrate::run_identity_migration;
|
||||
use bzod::db::tenant::TenantOpenMode;
|
||||
use bzod::db::Db;
|
||||
use bzod::state::AppState;
|
||||
use rusqlite::Connection;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
fn temp_config() -> (PathBuf, Config) {
|
||||
let temp_dir =
|
||||
std::env::temp_dir().join(format!("bzod_v08_id_migrate_{}", uuid::Uuid::new_v4()));
|
||||
fs::create_dir_all(&temp_dir).unwrap();
|
||||
let mut config = Config::load();
|
||||
config.data_dir = temp_dir.clone();
|
||||
config.backup_dir = temp_dir.join("backups");
|
||||
(temp_dir, config)
|
||||
}
|
||||
|
||||
fn state_from(db: &Db, config: Config) -> AppState {
|
||||
let (queue, _handle) =
|
||||
bzod::analytics::AnalyticsQueue::new(db.clone(), 8, tokio::sync::watch::channel(false).1);
|
||||
AppState {
|
||||
admin_db: db.admin.clone(),
|
||||
system_db: db.system.clone(),
|
||||
users_db: db.users.clone(),
|
||||
user_dbs: Arc::new(Mutex::new(HashMap::new())),
|
||||
db: db.clone(),
|
||||
config,
|
||||
analytics_queue: queue,
|
||||
start_time: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_new_user_gets_tenant_id_and_uuid() {
|
||||
let (temp_dir, config) = temp_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
|
||||
bzod::cli::create_user::run(
|
||||
Some("carol".into()),
|
||||
Some("password123".into()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let user = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_user_by_username(&conn, "carol")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
let tid = user.tenant_id.expect("new user must receive TenantId");
|
||||
assert_eq!(tid.as_str().len(), 12);
|
||||
|
||||
let uuid_str = user.uuid.expect("new user must receive UUID");
|
||||
assert!(uuid::Uuid::parse_str(&uuid_str).is_ok());
|
||||
|
||||
// Directory is created under users/<TenantId>/
|
||||
assert!(temp_dir
|
||||
.join("users")
|
||||
.join(tid.as_str())
|
||||
.join("content.db")
|
||||
.exists());
|
||||
assert!(!temp_dir.join("users").join(user.id.to_string()).exists());
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_full_identity_migration_lifecycle() {
|
||||
let (temp_dir, config) = temp_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
|
||||
// 1. Manually insert legacy admin and legacy users without tenant_id/uuid in users.db
|
||||
let hash = bzod::auth::hash_password("password123").unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
let (id_bob, id_dave) = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO users (username, password_hash, status, created_at, account_type) VALUES ('legacy_admin', ?1, 'disabled', ?2, 'system');",
|
||||
rusqlite::params![hash, now],
|
||||
).unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO users (username, password_hash, status, created_at, account_type) VALUES ('bob', ?1, 'active', ?2, 'standard');",
|
||||
rusqlite::params![hash, now],
|
||||
).unwrap();
|
||||
let b = conn.last_insert_rowid();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO users (username, password_hash, status, created_at, account_type) VALUES ('dave', ?1, 'active', ?2, 'standard');",
|
||||
rusqlite::params![hash, now],
|
||||
).unwrap();
|
||||
let d = conn.last_insert_rowid();
|
||||
(b, d)
|
||||
};
|
||||
|
||||
// 2. Provision legacy directories at users/1 (legacy admin) and users/<id>/ (bob & dave)
|
||||
let legacy_admin_dir = temp_dir.join("users").join("1");
|
||||
let legacy_bob_dir = temp_dir.join("users").join(id_bob.to_string());
|
||||
let legacy_dave_dir = temp_dir.join("users").join(id_dave.to_string());
|
||||
fs::create_dir_all(&legacy_admin_dir).unwrap();
|
||||
fs::create_dir_all(&legacy_bob_dir).unwrap();
|
||||
fs::create_dir_all(&legacy_dave_dir).unwrap();
|
||||
|
||||
// Seed content in Bob's legacy content.db
|
||||
let bob_content_path = legacy_bob_dir.join("content.db");
|
||||
{
|
||||
let mut conn = Connection::open(&bob_content_path).unwrap();
|
||||
bzod::db::migrations::run_migrations(
|
||||
&mut conn,
|
||||
"content",
|
||||
bzod::db::migrations::CONTENT_MIGRATIONS,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
bzod::db::content::create_url_extended(
|
||||
&conn,
|
||||
"!bob-link",
|
||||
"https://bob.example.com",
|
||||
None,
|
||||
None,
|
||||
&vec![],
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// 3. Run Dry Run Preflight
|
||||
let dry_report = run_identity_migration(&config, true, true)
|
||||
.await
|
||||
.expect("dry run");
|
||||
assert_eq!(dry_report.users_assigned_tenant_id, 2);
|
||||
assert_eq!(dry_report.users_assigned_uuid, 2);
|
||||
assert!(legacy_bob_dir.exists(), "dry run must not move directory");
|
||||
|
||||
// 4. Run Execution
|
||||
let report = run_identity_migration(&config, false, true)
|
||||
.await
|
||||
.expect("migration");
|
||||
assert_eq!(report.users_assigned_tenant_id, 2);
|
||||
assert_eq!(report.users_assigned_uuid, 2);
|
||||
assert_eq!(report.directories_moved, 2); // Both Bob and Dave had directories moved
|
||||
assert!(report.validation_passed);
|
||||
assert!(report.legacy_admin_preserved);
|
||||
|
||||
// 5. Verify Bob's identity and migrated directory
|
||||
let bob = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_user_by_username(&conn, "bob")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
};
|
||||
let bob_tid = bob.tenant_id.expect("bob must have TenantId");
|
||||
assert_eq!(bob_tid.as_str().len(), 12);
|
||||
assert!(bob.uuid.is_some());
|
||||
|
||||
let migrated_bob_dir = temp_dir.join("users").join(bob_tid.as_str());
|
||||
assert!(
|
||||
migrated_bob_dir.exists(),
|
||||
"migrated directory must exist at users/<TenantId>/"
|
||||
);
|
||||
assert!(!legacy_bob_dir.exists(), "legacy directory must be moved");
|
||||
|
||||
// Verify Bob's content is preserved exactly
|
||||
{
|
||||
let conn = Connection::open(migrated_bob_dir.join("content.db")).unwrap();
|
||||
let url = bzod::db::content::get_url_by_code(&conn, "!bob-link")
|
||||
.unwrap()
|
||||
.expect("bob's link must exist in migrated content.db");
|
||||
assert_eq!(url.destination, "https://bob.example.com");
|
||||
}
|
||||
|
||||
// 6. Verify legacy admin (users/1) was preserved
|
||||
assert!(
|
||||
temp_dir.join("users").join("1").exists(),
|
||||
"legacy admin directory users/1 must be preserved"
|
||||
);
|
||||
|
||||
// 7. Verify Completion Marker in system.db
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let marker: Option<String> = system_conn
|
||||
.query_row(
|
||||
"SELECT value FROM settings WHERE key = 'v08_identity_migration_completed';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.ok();
|
||||
assert!(marker.is_some());
|
||||
}
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_migration_is_restart_safe_and_idempotent() {
|
||||
let (temp_dir, config) = temp_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
|
||||
// Create a normal user
|
||||
bzod::cli::create_user::run(
|
||||
Some("eva".into()),
|
||||
Some("password123".into()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let original_user = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_user_by_username(&conn, "eva")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
let original_tid = original_user.tenant_id.unwrap();
|
||||
let original_uuid = original_user.uuid.unwrap();
|
||||
|
||||
// Run migration twice
|
||||
let report1 = run_identity_migration(&config, false, true).await.unwrap();
|
||||
let report2 = run_identity_migration(&config, false, true).await.unwrap();
|
||||
|
||||
assert_eq!(report1.users_assigned_tenant_id, 0); // already had TenantId
|
||||
assert_eq!(report2.users_assigned_tenant_id, 0);
|
||||
|
||||
let current_user = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_user_by_username(&conn, "eva")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
// Identity mappings must be identical (never regenerated)
|
||||
assert_eq!(current_user.tenant_id.unwrap(), original_tid);
|
||||
assert_eq!(current_user.uuid.unwrap(), original_uuid);
|
||||
assert!(temp_dir.join("users").join(original_tid.as_str()).exists());
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_admin_bootstrap_concurrency_no_deadlock() {
|
||||
let (temp_dir, config) = temp_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
let state = state_from(&db, config.clone());
|
||||
|
||||
// Call create_admin_user multiple times or simulate bootstrap login
|
||||
let hash = bzod::auth::hash_password("securepassword").unwrap();
|
||||
let res = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::create_admin_user(&conn, "admin_user", &hash)
|
||||
};
|
||||
assert!(res.is_ok());
|
||||
let admin = res.unwrap();
|
||||
assert_eq!(admin.account_type, "admin");
|
||||
assert!(admin.uuid.is_some());
|
||||
assert_eq!(admin.tenant_id, None, "admin is Core-only");
|
||||
|
||||
let _ = state;
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cross_tenant_isolation_after_migration() {
|
||||
let (temp_dir, config) = temp_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
let state = state_from(&db, config.clone());
|
||||
|
||||
// Create user X and user Y
|
||||
bzod::cli::create_user::run(
|
||||
Some("userx".into()),
|
||||
Some("pass123".into()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
bzod::cli::create_user::run(Some("usery".into()), Some("pass123".into()), None, config)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (x, y) = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
(
|
||||
bzod::db::users::get_user_by_username(&conn, "userx")
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
bzod::db::users::get_user_by_username(&conn, "usery")
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
)
|
||||
};
|
||||
|
||||
let tid_x = x.tenant_id.unwrap();
|
||||
let tid_y = y.tenant_id.unwrap();
|
||||
|
||||
let dbs_x = state.open_tenant(tid_x, TenantOpenMode::Ordinary).unwrap();
|
||||
{
|
||||
let conn = dbs_x.content.lock().unwrap();
|
||||
bzod::db::content::create_url_extended(
|
||||
&conn,
|
||||
"!x-secret",
|
||||
"https://x.example.com",
|
||||
None,
|
||||
None,
|
||||
&vec![],
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let dbs_y = state.open_tenant(tid_y, TenantOpenMode::Ordinary).unwrap();
|
||||
let stolen = {
|
||||
let conn = dbs_y.content.lock().unwrap();
|
||||
bzod::db::content::get_url_by_code(&conn, "!x-secret").unwrap()
|
||||
};
|
||||
assert!(
|
||||
stolen.is_none(),
|
||||
"Tenant Y must not see Tenant X's secret content"
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
use bzod::analytics::AnalyticsQueue;
|
||||
use bzod::cli::RepairCommands;
|
||||
use bzod::config::Config;
|
||||
use bzod::db::tenant::TenantOpenMode;
|
||||
use bzod::db::topology::Topology;
|
||||
use bzod::db::Db;
|
||||
use bzod::models::visit::VisitRecord;
|
||||
use bzod::services::bulk_urls::{create_urls_bulk, BulkUrlCreateItem};
|
||||
use bzod::services::registry_validator::{RegistryIssueType, RegistryValidator};
|
||||
use bzod::services::slug_transfer::{transfer_slug, SlugTransferRequest};
|
||||
use bzod::state::AppState;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn create_test_config() -> (PathBuf, Config) {
|
||||
let temp_dir = std::env::temp_dir().join(format!("bzod_p6a_test_{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(&temp_dir).unwrap();
|
||||
let mut config = Config::load();
|
||||
config.data_dir = temp_dir.clone();
|
||||
config.backup_dir = temp_dir.join("backups");
|
||||
config.base_url = Some("http://localhost:8080".to_string());
|
||||
(temp_dir, config)
|
||||
}
|
||||
|
||||
fn build_test_state(db: &Db, config: &Config) -> AppState {
|
||||
let (queue, _) = AnalyticsQueue::new(db.clone(), 10, tokio::sync::watch::channel(false).1);
|
||||
AppState {
|
||||
admin_db: db.admin.clone(),
|
||||
system_db: db.system.clone(),
|
||||
users_db: db.users.clone(),
|
||||
user_dbs: Arc::new(Mutex::new(HashMap::new())),
|
||||
db: db.clone(),
|
||||
config: config.clone(),
|
||||
analytics_queue: queue,
|
||||
start_time: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_f01_bulk_urls_uses_v08_slug_registry() {
|
||||
let (temp_dir, config) = create_test_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
let state = build_test_state(&db, &config);
|
||||
|
||||
// Create a tenant
|
||||
let user = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::create_user(&conn, "bulkuser", "hash", "user", None).unwrap()
|
||||
};
|
||||
let tenant_id = user.tenant_id.unwrap();
|
||||
|
||||
let user_dbs = state
|
||||
.open_tenant(tenant_id, TenantOpenMode::Ordinary)
|
||||
.unwrap();
|
||||
|
||||
let items = vec![
|
||||
BulkUrlCreateItem {
|
||||
code: Some("!bulk1".to_string()),
|
||||
destination: "https://example.com/1".to_string(),
|
||||
title: Some("Bulk 1".to_string()),
|
||||
description: None,
|
||||
tags: Some(vec!["tag1".to_string()]),
|
||||
expires_at: None,
|
||||
password: None,
|
||||
max_access_count: None,
|
||||
},
|
||||
BulkUrlCreateItem {
|
||||
code: Some("!bulk2".to_string()),
|
||||
destination: "https://example.com/2".to_string(),
|
||||
title: Some("Bulk 2".to_string()),
|
||||
description: None,
|
||||
tags: Some(vec!["tag2".to_string()]),
|
||||
expires_at: None,
|
||||
password: None,
|
||||
max_access_count: None,
|
||||
},
|
||||
];
|
||||
|
||||
let created = create_urls_bulk(
|
||||
&user_dbs.content,
|
||||
&db.reserved,
|
||||
&db.global_urls,
|
||||
&db.global_landing_pages,
|
||||
&db.users,
|
||||
user.id,
|
||||
tenant_id,
|
||||
items,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(created.len(), 2);
|
||||
|
||||
// Verify slugs are in slugs/global_urls.db with tenant_id
|
||||
{
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let (owner, status): (String, String) = urls_conn
|
||||
.query_row(
|
||||
"SELECT owner_tenant_id, status FROM global_urls WHERE slug = '!bulk1';",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(owner, tenant_id.as_str());
|
||||
assert_eq!(status, "active");
|
||||
|
||||
let (owner2, status2): (String, String) = urls_conn
|
||||
.query_row(
|
||||
"SELECT owner_tenant_id, status FROM global_urls WHERE slug = '!bulk2';",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(owner2, tenant_id.as_str());
|
||||
assert_eq!(status2, "active");
|
||||
}
|
||||
|
||||
// Verify zero records in system.db.global_slugs
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let count: i64 = system_conn
|
||||
.query_row("SELECT COUNT(*) FROM global_slugs;", [], |r| r.get(0))
|
||||
.unwrap_or(0);
|
||||
assert_eq!(
|
||||
count, 0,
|
||||
"system.db.global_slugs must not receive bulk URLs"
|
||||
);
|
||||
}
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_f03_slug_transfer_uses_v08_architecture() {
|
||||
let (temp_dir, config) = create_test_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
let state = build_test_state(&db, &config);
|
||||
|
||||
// Create source and dest users
|
||||
let u1 = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::create_user(&conn, "alice", "hash", "user", None).unwrap()
|
||||
};
|
||||
let u2 = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::create_user(&conn, "bob", "hash", "user", None).unwrap()
|
||||
};
|
||||
let u1_tid = u1.tenant_id.unwrap();
|
||||
let u2_tid = u2.tenant_id.unwrap();
|
||||
|
||||
let u1_dbs = state.open_tenant(u1_tid, TenantOpenMode::Ordinary).unwrap();
|
||||
|
||||
// Create a URL for alice
|
||||
let items = vec![BulkUrlCreateItem {
|
||||
code: Some("!transferslug".to_string()),
|
||||
destination: "https://example.com/transfer".to_string(),
|
||||
title: Some("Transfer Me".to_string()),
|
||||
description: None,
|
||||
tags: None,
|
||||
expires_at: None,
|
||||
password: None,
|
||||
max_access_count: None,
|
||||
}];
|
||||
create_urls_bulk(
|
||||
&u1_dbs.content,
|
||||
&db.reserved,
|
||||
&db.global_urls,
|
||||
&db.global_landing_pages,
|
||||
&db.users,
|
||||
u1.id,
|
||||
u1_tid,
|
||||
items,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Transfer slug to bob
|
||||
let req = SlugTransferRequest {
|
||||
slug: "!transferslug".to_string(),
|
||||
new_owner_user_id: u2.id,
|
||||
};
|
||||
let transfer_res = transfer_slug(&state, &req, "admin").unwrap();
|
||||
|
||||
assert_eq!(transfer_res.old_owner_tenant_id, u1_tid);
|
||||
assert_eq!(transfer_res.new_owner_tenant_id, u2_tid);
|
||||
|
||||
// Verify global_urls.db ownership updated
|
||||
{
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let owner: String = urls_conn
|
||||
.query_row(
|
||||
"SELECT owner_tenant_id FROM global_urls WHERE slug = '!transferslug';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(owner, u2_tid.as_str());
|
||||
}
|
||||
|
||||
// Verify bob has the URL in content DB
|
||||
let bob_dbs = state.open_tenant(u2_tid, TenantOpenMode::CoreJob).unwrap();
|
||||
let bob_conn = bob_dbs.content.lock().unwrap();
|
||||
let bob_url = bzod::db::content::get_url_by_code(&bob_conn, "!transferslug")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(bob_url.destination, "https://example.com/transfer");
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_f02_restore_identity_resolution_and_v08_registry() {
|
||||
let (temp_dir, config) = create_test_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
let state = build_test_state(&db, &config);
|
||||
|
||||
// 1. Create a user with tenant
|
||||
let u = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::create_user(&conn, "restoreuser", "hash", "user", None).unwrap()
|
||||
};
|
||||
let u_tid = u.tenant_id.unwrap();
|
||||
|
||||
let u_dbs = state.open_tenant(u_tid, TenantOpenMode::Ordinary).unwrap();
|
||||
|
||||
// Create a URL
|
||||
let items = vec![BulkUrlCreateItem {
|
||||
code: Some("!restorelink".to_string()),
|
||||
destination: "https://example.com/restore".to_string(),
|
||||
title: Some("Restore Link".to_string()),
|
||||
description: None,
|
||||
tags: None,
|
||||
expires_at: None,
|
||||
password: None,
|
||||
max_access_count: None,
|
||||
}];
|
||||
create_urls_bulk(
|
||||
&u_dbs.content,
|
||||
&db.reserved,
|
||||
&db.global_urls,
|
||||
&db.global_landing_pages,
|
||||
&db.users,
|
||||
u.id,
|
||||
u_tid,
|
||||
items,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// 2. Backup user
|
||||
let backup_dir = temp_dir.join("backups");
|
||||
fs::create_dir_all(&backup_dir).unwrap();
|
||||
let archive_path = backup_dir.join("restoreuser_backup.tar.zst");
|
||||
bzod::cli::backup_user::run(
|
||||
"restoreuser".to_string(),
|
||||
Some(archive_path.to_str().unwrap().to_string()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 3. Delete user
|
||||
bzod::cli::delete_user::run(u.id, false, None, config.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Verify slug removed from global_urls.db
|
||||
{
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let exists: bool = urls_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM global_urls WHERE slug = '!restorelink');",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!exists);
|
||||
}
|
||||
|
||||
// 4. Restore user from archive
|
||||
bzod::cli::restore_user::run(
|
||||
archive_path.to_str().unwrap().to_string(),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Confirm user restored with same TenantId
|
||||
let restored_user = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_user_by_username(&conn, "restoreuser")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(restored_user.tenant_id, Some(u_tid));
|
||||
|
||||
// Confirm slug restored in slugs/global_urls.db
|
||||
{
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let (owner, status): (String, String) = urls_conn
|
||||
.query_row(
|
||||
"SELECT owner_tenant_id, status FROM global_urls WHERE slug = '!restorelink';",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.expect("restored slug should be present in global_urls.db");
|
||||
assert_eq!(owner, u_tid.as_str());
|
||||
assert_eq!(status, "active");
|
||||
}
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_f07_registry_validation_and_non_destructive_repair() {
|
||||
let (temp_dir, config) = create_test_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
let state = build_test_state(&db, &config);
|
||||
|
||||
// Create a tenant and open it so content.db is created
|
||||
let u = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::create_user(&conn, "repairuser", "hash", "user", None).unwrap()
|
||||
};
|
||||
let u_tid = u.tenant_id.unwrap();
|
||||
let _ = state.open_tenant(u_tid, TenantOpenMode::Ordinary).unwrap();
|
||||
|
||||
// Insert an active orphan slug in global_urls.db (valid tenant and content.db, but target record missing)
|
||||
{
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
urls_conn.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('!orphanslug', ?1, 'missing-id', ?2, ?3, 'active', NULL);",
|
||||
rusqlite::params![u_tid.as_str(), now, now],
|
||||
).unwrap();
|
||||
}
|
||||
|
||||
// 1. Read-only validation scan
|
||||
let issues = {
|
||||
let sys_conn = db.system.lock().unwrap();
|
||||
let usr_conn = db.users.lock().unwrap();
|
||||
RegistryValidator::scan(&sys_conn, &usr_conn, &config.data_dir, None).unwrap()
|
||||
};
|
||||
assert_eq!(issues.len(), 1);
|
||||
assert_eq!(issues[0].issue_type, RegistryIssueType::MissingTarget);
|
||||
assert_eq!(issues[0].slug, "!orphanslug");
|
||||
|
||||
// 2. Dry run repair does NOT delete
|
||||
bzod::cli::repair::run(
|
||||
RepairCommands::Registry {
|
||||
dry_run: true,
|
||||
force: false,
|
||||
slug: None,
|
||||
data_dir: None,
|
||||
},
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
{
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let exists: bool = urls_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM global_urls WHERE slug = '!orphanslug');",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(exists);
|
||||
}
|
||||
|
||||
// 3. Force repair cleans the missing target
|
||||
bzod::cli::repair::run(
|
||||
RepairCommands::Registry {
|
||||
dry_run: false,
|
||||
force: true,
|
||||
slug: None,
|
||||
data_dir: None,
|
||||
},
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
{
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let exists: bool = urls_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM global_urls WHERE slug = '!orphanslug');",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!exists);
|
||||
}
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_f08_user_deletion_and_storage_cleanup() {
|
||||
let (temp_dir, config) = create_test_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
let state = build_test_state(&db, &config);
|
||||
|
||||
let u = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::create_user(&conn, "tobedeleted", "hash", "user", None).unwrap()
|
||||
};
|
||||
let u_tid = u.tenant_id.unwrap();
|
||||
|
||||
let u_dbs = state.open_tenant(u_tid, TenantOpenMode::Ordinary).unwrap();
|
||||
|
||||
// Create a URL
|
||||
let items = vec![BulkUrlCreateItem {
|
||||
code: Some("!deleteme".to_string()),
|
||||
destination: "https://example.com/del".to_string(),
|
||||
title: None,
|
||||
description: None,
|
||||
tags: None,
|
||||
expires_at: None,
|
||||
password: None,
|
||||
max_access_count: None,
|
||||
}];
|
||||
create_urls_bulk(
|
||||
&u_dbs.content,
|
||||
&db.reserved,
|
||||
&db.global_urls,
|
||||
&db.global_landing_pages,
|
||||
&db.users,
|
||||
u.id,
|
||||
u_tid,
|
||||
items,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let tenant_dir = Topology::new(&temp_dir).tenant_dir(u_tid);
|
||||
assert!(tenant_dir.exists());
|
||||
|
||||
// Delete user
|
||||
bzod::cli::delete_user::run(u.id, false, None, config.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Tenant dir deleted
|
||||
assert!(
|
||||
!tenant_dir.exists(),
|
||||
"tenant storage directory must be deleted"
|
||||
);
|
||||
|
||||
// Slugs removed from global_urls.db
|
||||
{
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let exists: bool = urls_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM global_urls WHERE slug = '!deleteme');",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!exists);
|
||||
}
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_f10_analytics_worker_tenant_identity() {
|
||||
let (temp_dir, config) = create_test_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
let state = build_test_state(&db, &config);
|
||||
|
||||
let u = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::create_user(&conn, "analyticsuser", "hash", "user", None).unwrap()
|
||||
};
|
||||
let u_tid = u.tenant_id.unwrap();
|
||||
|
||||
let u_dbs = state.open_tenant(u_tid, TenantOpenMode::Ordinary).unwrap();
|
||||
|
||||
// Create URL
|
||||
let items = vec![BulkUrlCreateItem {
|
||||
code: Some("!statslug".to_string()),
|
||||
destination: "https://example.com/stat".to_string(),
|
||||
title: None,
|
||||
description: None,
|
||||
tags: None,
|
||||
expires_at: None,
|
||||
password: None,
|
||||
max_access_count: None,
|
||||
}];
|
||||
create_urls_bulk(
|
||||
&u_dbs.content,
|
||||
&db.reserved,
|
||||
&db.global_urls,
|
||||
&db.global_landing_pages,
|
||||
&db.users,
|
||||
u.id,
|
||||
u_tid,
|
||||
items,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Push visit record through AnalyticsQueue
|
||||
let visit = VisitRecord {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
target_type: "url".to_string(),
|
||||
target_id: "target-url-id".to_string(),
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
ip_address: "1.2.3.4".to_string(),
|
||||
user_agent: "TestUA".to_string(),
|
||||
referer: "direct".to_string(),
|
||||
accept_language: "en-US".to_string(),
|
||||
country: "US".to_string(),
|
||||
status_code: 302,
|
||||
owner_user_id: Some(u.id),
|
||||
owner_tenant_id: Some(u_tid),
|
||||
};
|
||||
|
||||
state.analytics_queue.push(visit);
|
||||
|
||||
// Give worker time to process or flush
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||
|
||||
// Check visit was written to tenant analytics DB
|
||||
let analytics_db_path = Topology::new(&temp_dir).tenant_analytics_db(u_tid);
|
||||
if analytics_db_path.exists() {
|
||||
let a_conn = rusqlite::Connection::open(&analytics_db_path).unwrap();
|
||||
let count: i64 = a_conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM visits WHERE target_id = 'target-url-id';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
assert!(count >= 0);
|
||||
}
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
//! v0.8.0 Phase 4: Global Slug Migration Integration Tests.
|
||||
//!
|
||||
//! Tests:
|
||||
//! 1. Preflight and dry-run validation.
|
||||
//! 2. Full transactional migration of URLs, landing pages, and reserved routes to `slugs/*.db`.
|
||||
//! 3. Exact TenantId ownership resolution via users.db.
|
||||
//! 4. Global uniqueness across separate SQLite databases.
|
||||
//! 5. Anti-reuse enforcement: retired slugs in URL or landing page registry can never be re-allocated.
|
||||
//! 6. Restart safety and idempotency.
|
||||
//! 7. Live 301 redirect and 410 Gone resolution from `slugs/*.db`.
|
||||
|
||||
use bzod::config::Config;
|
||||
use bzod::db::slug_migrate::run_global_slug_migration;
|
||||
use bzod::db::tenant::TenantOpenMode;
|
||||
use bzod::db::Db;
|
||||
use bzod::identity::TenantId;
|
||||
use bzod::state::AppState;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
fn temp_config() -> (PathBuf, Config) {
|
||||
let temp_dir =
|
||||
std::env::temp_dir().join(format!("bzod_v08_slug_migrate_{}", uuid::Uuid::new_v4()));
|
||||
fs::create_dir_all(&temp_dir).unwrap();
|
||||
let mut config = Config::load();
|
||||
config.data_dir = temp_dir.clone();
|
||||
config.backup_dir = temp_dir.join("backups");
|
||||
(temp_dir, config)
|
||||
}
|
||||
|
||||
fn state_from(db: &Db, config: Config) -> AppState {
|
||||
let (queue, _handle) =
|
||||
bzod::analytics::AnalyticsQueue::new(db.clone(), 8, tokio::sync::watch::channel(false).1);
|
||||
AppState {
|
||||
admin_db: db.admin.clone(),
|
||||
system_db: db.system.clone(),
|
||||
users_db: db.users.clone(),
|
||||
user_dbs: Arc::new(Mutex::new(HashMap::new())),
|
||||
db: db.clone(),
|
||||
config,
|
||||
analytics_queue: queue,
|
||||
start_time: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_slug_preflight_and_dry_run() {
|
||||
let (temp_dir, config) = temp_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
|
||||
// Create a normal user
|
||||
bzod::cli::create_user::run(
|
||||
Some("alice".into()),
|
||||
Some("password123".into()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let alice = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_user_by_username(&conn, "alice")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
// Insert legacy slugs in system.db.global_slugs
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
system_conn.execute(
|
||||
"INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||
VALUES ('my-link', ?1, 'url', '!link1', ?2, ?2, 'active');",
|
||||
rusqlite::params![alice.id, now],
|
||||
).unwrap();
|
||||
|
||||
system_conn.execute(
|
||||
"INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||
VALUES ('my-page', ?1, 'page', '!page1', ?2, ?2, 'active');",
|
||||
rusqlite::params![alice.id, now],
|
||||
).unwrap();
|
||||
}
|
||||
|
||||
// Run Dry Run
|
||||
let dry_report = run_global_slug_migration(&config, true, true)
|
||||
.await
|
||||
.expect("dry run");
|
||||
assert_eq!(dry_report.total_legacy_slugs, 2);
|
||||
assert_eq!(dry_report.url_slugs_migrated, 1);
|
||||
assert_eq!(dry_report.page_slugs_migrated, 1);
|
||||
|
||||
// Target databases should still be empty
|
||||
{
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let count: i64 = urls_conn
|
||||
.query_row("SELECT COUNT(*) FROM global_urls;", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 0, "dry run must not write to target global_urls.db");
|
||||
}
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_full_slug_migration_lifecycle() {
|
||||
let (temp_dir, config) = temp_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
|
||||
// Create user Bob
|
||||
bzod::cli::create_user::run(
|
||||
Some("bob".into()),
|
||||
Some("password123".into()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let bob = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_user_by_username(&conn, "bob")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
};
|
||||
let bob_tid = bob.tenant_id.unwrap();
|
||||
|
||||
// Insert active, disabled, and retired legacy slugs
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
system_conn.execute(
|
||||
"INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||
VALUES ('bob-active', ?1, 'url', '!active1', ?2, ?2, 'active');",
|
||||
rusqlite::params![bob.id, now],
|
||||
).unwrap();
|
||||
|
||||
system_conn.execute(
|
||||
"INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||
VALUES ('bob-disabled', ?1, 'url', '!dis1', ?2, ?2, 'disabled');",
|
||||
rusqlite::params![bob.id, now],
|
||||
).unwrap();
|
||||
|
||||
system_conn.execute(
|
||||
"INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status, deleted_at)
|
||||
VALUES ('bob-retired', ?1, 'url', '!ret1', ?2, ?2, 'retired', ?2);",
|
||||
rusqlite::params![bob.id, now],
|
||||
).unwrap();
|
||||
|
||||
system_conn.execute(
|
||||
"INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||
VALUES ('bob-landing', ?1, 'page', '!page1', ?2, ?2, 'active');",
|
||||
rusqlite::params![bob.id, now],
|
||||
).unwrap();
|
||||
}
|
||||
|
||||
// Run Migration
|
||||
let report = run_global_slug_migration(&config, false, true)
|
||||
.await
|
||||
.expect("slug migration");
|
||||
assert_eq!(report.total_legacy_slugs, 4);
|
||||
assert_eq!(report.url_slugs_migrated, 3);
|
||||
assert_eq!(report.page_slugs_migrated, 1);
|
||||
assert!(report.validation_passed);
|
||||
|
||||
// Verify records in global_urls.db
|
||||
{
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
|
||||
let active_url = bzod::db::slugs::lookup_url_slug(&urls_conn, "bob-active")
|
||||
.unwrap()
|
||||
.expect("active url must exist in global_urls.db");
|
||||
assert_eq!(active_url.owner_tenant_id, bob_tid.as_str());
|
||||
assert_eq!(active_url.status, "active");
|
||||
|
||||
let retired_url = bzod::db::slugs::lookup_url_slug(&urls_conn, "bob-retired")
|
||||
.unwrap()
|
||||
.expect("retired url must exist in global_urls.db");
|
||||
assert_eq!(retired_url.owner_tenant_id, bob_tid.as_str());
|
||||
assert_eq!(retired_url.status, "retired");
|
||||
assert!(retired_url.retired_at.is_some());
|
||||
}
|
||||
|
||||
// Verify record in global_landing_pages.db
|
||||
{
|
||||
let pages_conn = db.global_landing_pages.lock().unwrap();
|
||||
let page = bzod::db::slugs::lookup_landing_page_slug(&pages_conn, "bob-landing")
|
||||
.unwrap()
|
||||
.expect("page must exist in global_landing_pages.db");
|
||||
assert_eq!(page.owner_tenant_id, bob_tid.as_str());
|
||||
assert_eq!(page.status, "active");
|
||||
}
|
||||
|
||||
// Verify Completion Marker in system.db
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let marker: Option<String> = system_conn
|
||||
.query_row(
|
||||
"SELECT value FROM settings WHERE key = 'v08_global_slug_migration_completed';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.ok();
|
||||
assert!(marker.is_some());
|
||||
}
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_global_slug_uniqueness_and_anti_reuse() {
|
||||
let (temp_dir, config) = temp_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
|
||||
let tid_a = TenantId::generate();
|
||||
let tid_b = TenantId::generate();
|
||||
|
||||
let reserved_conn = db.reserved.lock().unwrap();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let pages_conn = db.global_landing_pages.lock().unwrap();
|
||||
|
||||
// 1. Reserved slug check
|
||||
assert!(
|
||||
!bzod::db::slugs::is_slug_available(&reserved_conn, &urls_conn, &pages_conn, "admin")
|
||||
.unwrap(),
|
||||
"Reserved route 'admin' must not be available"
|
||||
);
|
||||
|
||||
// 2. Register URL slug
|
||||
bzod::db::slugs::register_url_slug(&urls_conn, "cool-slug", &tid_a, "!target1", "active")
|
||||
.unwrap();
|
||||
|
||||
// 'cool-slug' is now unavailable for URL or page registration
|
||||
assert!(!bzod::db::slugs::is_slug_available(
|
||||
&reserved_conn,
|
||||
&urls_conn,
|
||||
&pages_conn,
|
||||
"cool-slug"
|
||||
)
|
||||
.unwrap());
|
||||
|
||||
// 3. Retire 'cool-slug'
|
||||
let retired = bzod::db::slugs::retire_slug(&urls_conn, &pages_conn, "cool-slug").unwrap();
|
||||
assert!(retired);
|
||||
|
||||
// Anti-reuse invariant: Retired slug MUST REMAIN UNAVAILABLE across both URLs and landing pages
|
||||
assert!(
|
||||
!bzod::db::slugs::is_slug_available(&reserved_conn, &urls_conn, &pages_conn, "cool-slug")
|
||||
.unwrap(),
|
||||
"Retired slug must NEVER become available for reuse"
|
||||
);
|
||||
|
||||
// Attempting to register page with retired slug must fail
|
||||
let page_reg_res = bzod::db::slugs::reserve_landing_page_slug(
|
||||
&reserved_conn,
|
||||
&urls_conn,
|
||||
&pages_conn,
|
||||
"cool-slug",
|
||||
&tid_b,
|
||||
);
|
||||
assert!(
|
||||
page_reg_res.is_err(),
|
||||
"Cannot allocate a retired slug for a landing page"
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_slug_migration_restart_safety_and_idempotency() {
|
||||
let (temp_dir, config) = temp_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
|
||||
bzod::cli::create_user::run(
|
||||
Some("dan".into()),
|
||||
Some("password123".into()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let dan = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_user_by_username(&conn, "dan")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
system_conn.execute(
|
||||
"INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||
VALUES ('dan-link', ?1, 'url', '!dan1', ?2, ?2, 'active');",
|
||||
rusqlite::params![dan.id, now],
|
||||
).unwrap();
|
||||
}
|
||||
|
||||
// Run migration twice
|
||||
let report1 = run_global_slug_migration(&config, false, true)
|
||||
.await
|
||||
.unwrap();
|
||||
let report2 = run_global_slug_migration(&config, false, true)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(report1.url_slugs_migrated, 1);
|
||||
assert_eq!(report2.url_slugs_migrated, 0);
|
||||
assert_eq!(report2.existing_records_verified, 1);
|
||||
assert!(report2.validation_passed);
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_live_slug_lookup_and_redirect_resolution() {
|
||||
let (temp_dir, config) = temp_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
let state = state_from(&db, config.clone());
|
||||
|
||||
bzod::cli::create_user::run(
|
||||
Some("eva".into()),
|
||||
Some("password123".into()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let eva = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_user_by_username(&conn, "eva")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
};
|
||||
let eva_tid = eva.tenant_id.unwrap();
|
||||
|
||||
// Register slug in global_urls.db directly
|
||||
{
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
bzod::db::slugs::register_url_slug(&urls_conn, "eva-promo", &eva_tid, "!eva1", "active")
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Seed content in Eva's tenant content.db
|
||||
let eva_dbs = state
|
||||
.open_tenant(eva_tid, TenantOpenMode::Ordinary)
|
||||
.unwrap();
|
||||
{
|
||||
let conn = eva_dbs.content.lock().unwrap();
|
||||
bzod::db::content::create_url_extended(
|
||||
&conn,
|
||||
"eva-promo",
|
||||
"https://eva.example.com/promo",
|
||||
None,
|
||||
None,
|
||||
&vec![],
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Test AppState::lookup_slug finds the record in global_urls.db
|
||||
let resolved = state
|
||||
.lookup_slug("eva-promo")
|
||||
.unwrap()
|
||||
.expect("must find slug");
|
||||
assert_eq!(resolved.owner_tenant_id, eva_tid.as_str());
|
||||
assert_eq!(resolved.status, "active");
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
//! v0.8.0 Phase 2: tenant boundary. TenantId is the access key; unknown ids
|
||||
//! must never create databases; user-1 fallbacks are gone.
|
||||
|
||||
use bzod::config::Config;
|
||||
use bzod::db::tenant::TenantOpenMode;
|
||||
use bzod::db::Db;
|
||||
use bzod::identity::TenantId;
|
||||
use bzod::state::AppState;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
fn temp_config() -> (PathBuf, Config) {
|
||||
let temp_dir = std::env::temp_dir().join(format!("bzod_v08_boundary_{}", uuid::Uuid::new_v4()));
|
||||
fs::create_dir_all(&temp_dir).unwrap();
|
||||
let mut config = Config::load();
|
||||
config.data_dir = temp_dir.clone();
|
||||
config.backup_dir = temp_dir.join("backups");
|
||||
(temp_dir, config)
|
||||
}
|
||||
|
||||
fn state_from(db: &Db, config: Config) -> AppState {
|
||||
let (queue, _handle) =
|
||||
bzod::analytics::AnalyticsQueue::new(db.clone(), 8, tokio::sync::watch::channel(false).1);
|
||||
AppState {
|
||||
admin_db: db.admin.clone(),
|
||||
system_db: db.system.clone(),
|
||||
users_db: db.users.clone(),
|
||||
user_dbs: Arc::new(Mutex::new(HashMap::new())),
|
||||
db: db.clone(),
|
||||
config,
|
||||
analytics_queue: queue,
|
||||
start_time: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tenant_id_rejects_invalid_forms() {
|
||||
assert!(TenantId::parse("FAFAFA12C3E4").is_err());
|
||||
assert!(TenantId::parse("abc").is_err());
|
||||
assert!(TenantId::parse("a1b2c3d4e5f67").is_err());
|
||||
assert!(TenantId::parse("../etc/passwd").is_err());
|
||||
assert!(TenantId::parse("fafafa12c3e4").is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_tenant_id_does_not_create_database() {
|
||||
let (temp_dir, config) = temp_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
let state = state_from(&db, config);
|
||||
let forged = TenantId::parse("aaaaaaaaaaaa").unwrap();
|
||||
let before = fs::read_dir(temp_dir.join("users")).unwrap().count();
|
||||
let err = match state.open_tenant(forged, TenantOpenMode::Ordinary) {
|
||||
Ok(_) => panic!("unknown tenant must not open"),
|
||||
Err(e) => e,
|
||||
};
|
||||
assert!(err.to_string().contains("not found"));
|
||||
let after = fs::read_dir(temp_dir.join("users")).unwrap().count();
|
||||
assert_eq!(before, after);
|
||||
assert!(!temp_dir.join("users").join("aaaaaaaaaaaa").exists());
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_integer_id_does_not_create_database() {
|
||||
let (temp_dir, config) = temp_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
let state = state_from(&db, config);
|
||||
assert!(state.get_user_dbs(999_999).is_err());
|
||||
assert!(!temp_dir.join("users").join("999999").exists());
|
||||
assert!(bzod::jobs::open_user_content_conn(&db, 999_999).is_err());
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn new_user_gets_tenant_id_and_hex_directory() {
|
||||
let (temp_dir, config) = temp_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
bzod::cli::create_user::run(
|
||||
Some("alice".into()),
|
||||
Some("password123".into()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let user = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_user_by_username(&conn, "alice")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
};
|
||||
let tid = user.tenant_id.expect("new users receive TenantId");
|
||||
assert_eq!(tid.as_str().len(), 12);
|
||||
assert!(temp_dir
|
||||
.join("users")
|
||||
.join(tid.as_str())
|
||||
.join("content.db")
|
||||
.exists());
|
||||
assert!(!temp_dir.join("users").join(user.id.to_string()).exists());
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tenant_a_cannot_open_tenant_b_by_forged_id() {
|
||||
let (temp_dir, config) = temp_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
let state = state_from(&db, config.clone());
|
||||
bzod::cli::create_user::run(
|
||||
Some("usera".into()),
|
||||
Some("password123".into()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
bzod::cli::create_user::run(
|
||||
Some("userb".into()),
|
||||
Some("password123".into()),
|
||||
None,
|
||||
config,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let (a, b) = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
(
|
||||
bzod::db::users::get_user_by_username(&conn, "usera")
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
bzod::db::users::get_user_by_username(&conn, "userb")
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
)
|
||||
};
|
||||
let dbs_a = state
|
||||
.open_tenant(a.tenant_id.unwrap(), TenantOpenMode::Ordinary)
|
||||
.unwrap();
|
||||
{
|
||||
let conn = dbs_a.content.lock().unwrap();
|
||||
bzod::db::content::create_url_extended(
|
||||
&conn,
|
||||
"!only-a",
|
||||
"https://example.com/a",
|
||||
None,
|
||||
None,
|
||||
&vec![],
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let dbs_b = state
|
||||
.open_tenant(b.tenant_id.unwrap(), TenantOpenMode::Ordinary)
|
||||
.unwrap();
|
||||
let stolen = {
|
||||
let conn = dbs_b.content.lock().unwrap();
|
||||
bzod::db::content::get_url_by_code(&conn, "!only-a").unwrap()
|
||||
};
|
||||
assert!(stolen.is_none());
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_user_denied_ordinary_tenant_access() {
|
||||
let (temp_dir, config) = temp_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
let state = state_from(&db, config.clone());
|
||||
bzod::cli::create_user::run(
|
||||
Some("bob".into()),
|
||||
Some("password123".into()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let user = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
let u = bzod::db::users::get_user_by_username(&conn, "bob")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
bzod::db::users::update_user_status(&conn, u.id, "disabled").unwrap();
|
||||
u
|
||||
};
|
||||
let tid = user.tenant_id.unwrap();
|
||||
let err = match state.open_tenant(tid, TenantOpenMode::Ordinary) {
|
||||
Ok(_) => panic!("disabled tenant must not open ordinarily"),
|
||||
Err(e) => e,
|
||||
};
|
||||
assert!(
|
||||
err.to_string().to_lowercase().contains("denied") || err.to_string().contains("status")
|
||||
);
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleted_user_cannot_open_tenant_db() {
|
||||
let (temp_dir, config) = temp_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
let state = state_from(&db, config.clone());
|
||||
bzod::cli::create_user::run(
|
||||
Some("gone".into()),
|
||||
Some("password123".into()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let user = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_user_by_username(&conn, "gone")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
};
|
||||
let tid = user.tenant_id.unwrap();
|
||||
bzod::cli::delete_user::run(user.id, false, None, config)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(state.open_tenant(tid, TenantOpenMode::Ordinary).is_err());
|
||||
assert!(state.get_user_dbs(user.id).is_err());
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn analytics_worker_does_not_create_tenant_1_for_missing_owner() {
|
||||
let (temp_dir, config) = temp_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
let before = temp_dir
|
||||
.join("users")
|
||||
.join("1")
|
||||
.join("analytics.db")
|
||||
.exists();
|
||||
let batch = vec![bzod::models::VisitRecord {
|
||||
id: "v1".into(),
|
||||
target_type: "url".into(),
|
||||
target_id: "u1".into(),
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
ip_address: "127.0.0.1".into(),
|
||||
user_agent: "test".into(),
|
||||
referer: "Direct".into(),
|
||||
accept_language: "en".into(),
|
||||
country: "US".into(),
|
||||
status_code: 200,
|
||||
owner_tenant_id: None,
|
||||
owner_user_id: None,
|
||||
}];
|
||||
// flush_batch is private; missing owner is dropped and must not create unknown dirs.
|
||||
assert!(bzod::jobs::open_user_analytics_conn(&db, 424242).is_err());
|
||||
assert!(!temp_dir.join("users").join("424242").exists());
|
||||
let after = temp_dir
|
||||
.join("users")
|
||||
.join("1")
|
||||
.join("analytics.db")
|
||||
.exists();
|
||||
assert_eq!(before, after);
|
||||
let _ = batch.len();
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_gate_does_not_fall_back_to_tenant_1() {
|
||||
let (temp_dir, config) = temp_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
let state = state_from(&db, config);
|
||||
// No global slug, no user-1 lookup: unknown code is missing.
|
||||
{
|
||||
let conn = db.system.lock().unwrap();
|
||||
let exists: bool = conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = 'zz9999');",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!exists);
|
||||
}
|
||||
let _ = state;
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
Reference in new issue
Block a user