BZOD v0.5.0 RC2: multi-user platform, dashboards, analytics, backups and validation

This commit is contained in:
thakares committed 2026-06-19 14:51:38 +05:30
1 parent 743502b183
commit 7dfb8c0f1b
100 files changed
+15588 -309

No files matched your search

+377 -46
View File
@@ -1,6 +1,7 @@
use crate::config::Config;
use crate::db::migrations::{
run_migrations, ADMIN_MIGRATIONS, ANALYTICS_MIGRATIONS, CONTENT_MIGRATIONS, SYSTEM_MIGRATIONS,
USERS_MIGRATIONS,
};
use crate::db::sqlite::{enable_foreign_keys, enable_wal};
use rusqlite::Connection;
@@ -15,6 +16,7 @@ pub mod migrations;
pub mod preview;
pub mod qr;
pub mod sqlite;
pub mod users;
#[derive(Clone)]
pub struct Db {
@@ -22,70 +24,88 @@ pub struct Db {
pub content: Arc<Mutex<Connection>>,
pub analytics: Arc<Mutex<Connection>>,
pub system: Arc<Mutex<Connection>>,
pub users: Arc<Mutex<Connection>>,
pub data_dir: std::path::PathBuf,
}
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 admin_path = config.data_dir.join("admin.db");
let content_path = config.data_dir.join("content.db");
let analytics_path = config.data_dir.join("analytics.db");
let system_path = config.data_dir.join("system.db");
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)?;
use tracing::info;
// 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");
// 1. If legacy admin.db exists at root, move admin/system DBs to config.data_dir/admin/
if legacy_admin_db.exists() {
info!("Legacy admin.db found at root. Moving administrative databases to admin/ subfolder...");
let files = vec![
"admin.db",
"admin.db-wal",
"admin.db-shm",
"system.db",
"system.db-wal",
"system.db-shm",
];
for f in files {
let src = config.data_dir.join(f);
if src.exists() {
let dst = admin_dir.join(f);
let _ = fs::rename(&src, &dst);
}
}
}
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 content.db");
let mut content_conn = Connection::open(content_path)?;
info!("Opening analytics.db");
let mut analytics_conn = Connection::open(analytics_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 mode for better concurrency and write performance
info!(database = "admin", "Enabling WAL mode on admin.db");
enable_wal(&admin_conn, "admin")?;
info!(database = "content", "Enabling WAL mode on content.db");
enable_wal(&content_conn, "content")?;
info!(database = "analytics", "Enabling WAL mode on analytics.db");
enable_wal(&analytics_conn, "analytics")?;
info!(database = "system", "Enabling WAL mode on system.db");
enable_wal(&system_conn, "system")?;
enable_wal(&users_conn, "users")?;
// Enable foreign key support
info!(
database = "admin",
"Enabling foreign key enforcement on admin.db"
);
enable_foreign_keys(&admin_conn, "admin")?;
info!(
database = "content",
"Enabling foreign key enforcement on content.db"
);
enable_foreign_keys(&content_conn, "content")?;
info!(
database = "analytics",
"Enabling foreign key enforcement on analytics.db"
);
enable_foreign_keys(&analytics_conn, "analytics")?;
info!(
database = "system",
"Enabling foreign key enforcement on system.db"
);
enable_foreign_keys(&system_conn, "system")?;
enable_foreign_keys(&users_conn, "users")?;
// 1. Run migrations for system.db first, as it receives secondary audit records
// Run migrations for system.db first
info!("Running system migrations");
run_migrations(&mut system_conn, "system", SYSTEM_MIGRATIONS, None)?;
let system_arc = Arc::new(Mutex::new(system_conn));
// 2. Run migrations for other databases with system.db logging
// Pre-migration detection of admin account repair
let repair_needed = {
let stmt = users_conn.prepare(
"SELECT EXISTS(SELECT 1 FROM users WHERE username = 'admin' AND account_type = 'standard');"
);
match stmt {
Ok(mut s) => s
.query_row([], |row| row.get::<_, bool>(0))
.unwrap_or(false),
Err(_) => false,
}
};
// Run migrations for admin.db and users.db
info!("Running admin migrations");
run_migrations(
&mut admin_conn,
@@ -93,14 +113,131 @@ impl Db {
ADMIN_MIGRATIONS,
Some(&system_arc),
)?;
info!("Running content migrations");
info!("Running users migrations");
run_migrations(
&mut users_conn,
"users",
USERS_MIGRATIONS,
Some(&system_arc),
)?;
// Post-migration: audit log if repaired
if repair_needed {
let admin_is_now_admin: bool = users_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM users WHERE username = 'admin' AND account_type = 'admin');",
[],
|row| row.get(0),
)
.unwrap_or(false);
if admin_is_now_admin {
let system_conn = system_arc.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
"admin",
"migration_repair",
"users",
"admin",
Some("Repaired standard account type to admin"),
);
}
}
// Clean up expired sessions from users.db on startup
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();
// 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() {
info!("Legacy content/analytics databases found at root. Moving to 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,
Some(&system_arc),
)?;
info!("Running analytics migrations");
run_migrations(
&mut analytics_conn,
"analytics",
@@ -108,26 +245,220 @@ impl Db {
Some(&system_arc),
)?;
Ok(Self {
// 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.");
}
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)),
data_dir: config.data_dir.clone(),
};
let _ = db.reconcile_global_slugs(config);
Ok(db)
}
pub fn compact(&self) -> Result<(), rusqlite::Error> {
let admin = self.admin.lock().unwrap();
admin.execute("VACUUM;", [])?;
let _ = admin.execute("VACUUM;", []);
let content = self.content.lock().unwrap();
content.execute("VACUUM;", [])?;
let _ = content.execute("VACUUM;", []);
let analytics = self.analytics.lock().unwrap();
analytics.execute("VACUUM;", [])?;
let _ = analytics.execute("VACUUM;", []);
let system = self.system.lock().unwrap();
system.execute("VACUUM;", [])?;
let _ = system.execute("VACUUM;", []);
let users = self.users.lock().unwrap();
let _ = users.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 content_path = user_dir.join("content.db");
let analytics_path = user_dir.join("analytics.db");
let profile_path = user_dir.join("profile.db");
let mut content_conn = Connection::open(content_path)?;
let mut analytics_conn = Connection::open(analytics_path)?;
let profile_conn = Connection::open(profile_path)?;
enable_wal(&content_conn, "content")?;
enable_wal(&analytics_conn, "analytics")?;
enable_wal(&profile_conn, "profile")?;
enable_foreign_keys(&content_conn, "content")?;
enable_foreign_keys(&analytics_conn, "analytics")?;
enable_foreign_keys(&profile_conn, "profile")?;
run_migrations(
&mut content_conn,
"content",
CONTENT_MIGRATIONS,
Some(&self.system),
)?;
run_migrations(
&mut analytics_conn,
"analytics",
ANALYTICS_MIGRATIONS,
Some(&self.system),
)?;
profile_conn.execute_batch(
"CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);",
)?;
Ok(())
}
pub fn reconcile_global_slugs(
&self,
config: &Config,
) -> Result<(), Box<dyn std::error::Error>> {
use chrono::Utc;
let system_conn = self.system.lock().unwrap();
let users_conn = self.users.lock().unwrap();
// Get all user IDs
let mut stmt = users_conn.prepare("SELECT id FROM users;")?;
let mut rows = stmt.query([])?;
let mut user_ids = vec![1i64]; // Start with legacy admin
while let Some(row) = rows.next()? {
user_ids.push(row.get(0)?);
}
drop(rows);
drop(stmt);
for user_id in user_ids {
let user_dir = config.data_dir.join("users").join(user_id.to_string());
let content_path = if user_id == 1 {
config.data_dir.join("content.db") // legacy admin content db path
} else {
user_dir.join("content.db")
};
if content_path.exists() {
let content_conn = Connection::open(&content_path)?;
// Sync URLs
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 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"
};
let now = Utc::now().to_rfc3339();
let _ = system_conn.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![code, user_id, "url", target_id, created_at, now, global_status],
);
}
// Sync Landing Pages
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 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 now = Utc::now().to_rfc3339();
let _ = system_conn.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![code, user_id, "page", target_id, created_at, now, global_status],
);
}
}
}
Ok(())
}