refactor: complete v0.8 core architecture

This commit is contained in:
thakares committed 2026-08-21 13:56:36 +05:30
1 parent f138a645f8
commit d7e0ac7679
98 files changed
+8413 -3044

No files matched your search

+18 -7
View File
@@ -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
View File
@@ -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}"
+7 -10
View File
@@ -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) {
+7 -7
View File
@@ -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
View File
@@ -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(())
}
}
+4 -13
View File
@@ -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)
}
+85 -36
View File
@@ -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,
})