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
@@ -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,
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user