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

+14 -4
View File
@@ -71,10 +71,20 @@ pub fn create_session(
) -> rusqlite::Result<Session> {
let created_at = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);",
params![session_id, user_id, expires_at_rfc3339, created_at],
)?;
// Bind `user_id` as integer when it appears to be numeric so that numeric
// user IDs inserted into `users.db` keep the integer affinity and avoid
// InvalidColumnType errors when read as i64 elsewhere.
if let Ok(id_i64) = user_id.parse::<i64>() {
conn.execute(
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);",
params![session_id, id_i64, expires_at_rfc3339, created_at],
)?;
} else {
conn.execute(
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);",
params![session_id, user_id, expires_at_rfc3339, created_at],
)?;
}
Ok(Session {
id: session_id.to_string(),
+8 -5
View File
@@ -82,8 +82,8 @@ pub fn insert_visits_batch(conn: &mut Connection, records: &[VisitRecord]) -> ru
let tx = conn.transaction()?;
{
let mut stmt = tx.prepare(
"INSERT INTO visits (id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10);"
"INSERT INTO visits (id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code, owner_user_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11);"
)?;
for r in records {
@@ -97,7 +97,8 @@ pub fn insert_visits_batch(conn: &mut Connection, records: &[VisitRecord]) -> ru
r.referer,
r.accept_language,
r.country,
r.status_code
r.status_code,
r.owner_user_id
])?;
}
}
@@ -594,7 +595,7 @@ pub fn get_target_visits_paginated(
date_from: Option<&str>,
date_to: Option<&str>,
) -> rusqlite::Result<Vec<VisitRecord>> {
let mut sql = "SELECT id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code FROM visits WHERE target_type = ?1 AND target_id = ?2".to_string();
let mut sql = "SELECT id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code, owner_user_id FROM visits WHERE target_type = ?1 AND target_id = ?2".to_string();
let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![
Box::new(target_type.to_string()),
Box::new(target_id.to_string()),
@@ -638,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_user_id: row.get("owner_user_id")?,
})
})?;
@@ -655,7 +657,7 @@ pub fn get_target_visits_all_in_memory(
date_from: Option<&str>,
date_to: Option<&str>,
) -> rusqlite::Result<Vec<VisitRecord>> {
let mut sql = "SELECT id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code FROM visits WHERE target_type = ?1 AND target_id = ?2".to_string();
let mut sql = "SELECT id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code, owner_user_id FROM visits WHERE target_type = ?1 AND target_id = ?2".to_string();
let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![
Box::new(target_type.to_string()),
Box::new(target_id.to_string()),
@@ -693,6 +695,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_user_id: row.get("owner_user_id")?,
})
})?;
+189 -5
View File
@@ -111,10 +111,11 @@ pub fn print_migration_plan(
// Migration definitions
// ---------------------------------------------------------------------------
pub const ADMIN_MIGRATIONS: &[Migration] = &[Migration {
version: 1,
name: "initial_schema",
sql: r#"
pub const ADMIN_MIGRATIONS: &[Migration] = &[
Migration {
version: 1,
name: "initial_schema",
sql: r#"
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
@@ -156,7 +157,26 @@ pub const ADMIN_MIGRATIONS: &[Migration] = &[Migration {
value TEXT NOT NULL
);
"#,
}];
},
Migration {
version: 2,
name: "remove_api_keys_fk",
sql: r#"
CREATE TABLE api_keys_new (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at TEXT NOT NULL,
last_used_at TEXT
);
INSERT INTO api_keys_new (id, user_id, key_hash, name, created_at, last_used_at)
SELECT id, user_id, key_hash, name, created_at, last_used_at FROM api_keys;
DROP TABLE api_keys;
ALTER TABLE api_keys_new RENAME TO api_keys;
"#,
},
];
pub const CONTENT_MIGRATIONS: &[Migration] = &[
Migration {
@@ -315,6 +335,11 @@ pub const ANALYTICS_MIGRATIONS: &[Migration] = &[
CREATE INDEX IF NOT EXISTS idx_qr_access_ts ON qr_access_log(timestamp);
"#,
},
Migration {
version: 3,
name: "add_owner_user_id",
sql: "ALTER TABLE visits ADD COLUMN owner_user_id INTEGER;",
},
];
pub const SYSTEM_MIGRATIONS: &[Migration] = &[
@@ -384,4 +409,163 @@ pub const SYSTEM_MIGRATIONS: &[Migration] = &[
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_events(action);
"#,
},
Migration {
version: 3,
name: "global_slugs_and_moderation",
sql: r#"
CREATE TABLE IF NOT EXISTS global_slugs (
slug TEXT PRIMARY KEY,
owner_user_id INTEGER NOT NULL,
target_type TEXT NOT NULL,
target_id TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
status TEXT NOT NULL,
deleted_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_global_slugs_owner ON global_slugs(owner_user_id);
CREATE INDEX IF NOT EXISTS idx_global_slugs_status ON global_slugs(status);
CREATE INDEX IF NOT EXISTS idx_global_slugs_target ON global_slugs(target_type, target_id);
CREATE TABLE IF NOT EXISTS moderation_events (
id TEXT PRIMARY KEY,
timestamp TEXT NOT NULL,
admin_username TEXT NOT NULL,
target_user_id INTEGER NOT NULL,
target_username TEXT,
resource_type TEXT NOT NULL,
resource_identifier TEXT NOT NULL,
action TEXT NOT NULL,
severity TEXT NOT NULL,
reason TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS slug_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL,
old_owner_user_id INTEGER,
new_owner_user_id INTEGER,
action TEXT NOT NULL,
timestamp TEXT NOT NULL,
admin_username TEXT
);
CREATE TABLE IF NOT EXISTS reserved_slugs (
slug TEXT PRIMARY KEY,
reason TEXT
);
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- Seed defaults
INSERT OR IGNORE INTO schema_version (version, applied_at) VALUES (3, datetime('now'));
INSERT OR IGNORE INTO settings (key, value) VALUES ('soft_delete_retention_days', '30');
INSERT OR IGNORE INTO settings (key, value) VALUES ('quota_reconcile_interval_hours', '24');
INSERT OR IGNORE INTO settings (key, value) VALUES ('allow_registration', 'false');
INSERT OR IGNORE INTO settings (key, value) VALUES ('maintenance_mode', 'false');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('admin', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('login', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('logout', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('dashboard', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('api', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('docs', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('assets', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('static', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('favicon.ico', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('robots.txt', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('health', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('metrics', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('install', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('setup', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('support', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('help', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('security', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('abuse', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('billing', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('status', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('legacy_admin', 'System reserved');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('administrator', 'System reserved');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('system', 'System reserved');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('root', 'System reserved');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('www', 'System reserved');
"#,
},
];
pub const USERS_MIGRATIONS: &[Migration] = &[
Migration {
version: 1,
name: "initial_schema",
sql: r#"
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
last_login TEXT,
account_type TEXT DEFAULT 'standard',
organization_id INTEGER NULL,
metadata TEXT
);
CREATE TABLE IF NOT EXISTS quotas (
user_id INTEGER PRIMARY KEY,
max_urls INTEGER DEFAULT 100,
max_landings INTEGER DEFAULT 10,
max_api_tokens INTEGER DEFAULT 5,
max_storage_mb INTEGER DEFAULT 100,
current_urls INTEGER DEFAULT 0,
current_landings INTEGER DEFAULT 0,
current_api_tokens INTEGER DEFAULT 0,
current_storage_mb INTEGER DEFAULT 0,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS api_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token_hash TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS username_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
old_username TEXT NOT NULL,
new_username TEXT NOT NULL,
changed_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
"#,
},
Migration {
version: 2,
name: "repair_admin_account_type",
sql: r#"
UPDATE users
SET account_type = 'admin'
WHERE username = 'admin' AND account_type = 'standard';
"#,
},
];
+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(())
}
+545
View File
@@ -0,0 +1,545 @@
use crate::models::{TenantUser, UserApiToken, UserQuotas, UserSession};
use chrono::Utc;
use rusqlite::{params, Connection, OptionalExtension};
// --- User Operations ---
pub fn is_reserved_username(username: &str) -> bool {
let u = username.trim().to_lowercase();
u == "admin" || u == "legacy_admin" || u == "administrator" || u == "system" || u == "root"
}
pub fn create_admin_user(
conn: &Connection,
username: &str,
password_hash: &str,
) -> rusqlite::Result<TenantUser> {
let created_at = Utc::now().to_rfc3339();
let status = "active";
let account_type = "admin";
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],
)?;
let id = conn.last_insert_rowid();
// Seed default quotas
conn.execute("INSERT INTO quotas (user_id) VALUES (?1);", params![id])?;
Ok(TenantUser {
id,
username: username.to_string(),
password_hash: password_hash.to_string(),
status: status.to_string(),
created_at,
last_login: None,
account_type: account_type.to_string(),
organization_id: None,
metadata: None,
})
}
pub fn create_user(
conn: &Connection,
username: &str,
password_hash: &str,
account_type: &str,
metadata: Option<&str>,
) -> rusqlite::Result<TenantUser> {
if is_reserved_username(username) {
return Err(rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
Some("Username is reserved".to_string()),
));
}
let created_at = Utc::now().to_rfc3339();
let status = "active";
conn.execute(
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata)
VALUES (?1, ?2, ?3, ?4, ?5, ?6);",
params![
username,
password_hash,
status,
created_at,
account_type,
metadata
],
)?;
let id = conn.last_insert_rowid();
// Seed default quotas
conn.execute("INSERT INTO quotas (user_id) VALUES (?1);", params![id])?;
Ok(TenantUser {
id,
username: username.to_string(),
password_hash: password_hash.to_string(),
status: status.to_string(),
created_at,
last_login: None,
account_type: account_type.to_string(),
organization_id: None,
metadata: metadata.map(|s| s.to_string()),
})
}
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;",
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)?,
})
},
)
.optional()
}
pub fn get_user_by_username(
conn: &Connection,
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;",
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)?,
})
},
)
.optional()
}
pub fn delete_user(conn: &Connection, id: i64) -> rusqlite::Result<()> {
conn.execute("DELETE FROM users WHERE id = ?1;", params![id])?;
Ok(())
}
pub fn update_user_status(conn: &Connection, id: i64, status: &str) -> rusqlite::Result<()> {
conn.execute(
"UPDATE users SET status = ?1 WHERE id = ?2;",
params![status, id],
)?;
Ok(())
}
pub fn update_user_account_type(
conn: &Connection,
id: i64,
account_type: &str,
) -> rusqlite::Result<()> {
conn.execute(
"UPDATE users SET account_type = ?1 WHERE id = ?2;",
params![account_type, id],
)?;
Ok(())
}
pub fn reset_user_password(
conn: &Connection,
id: i64,
new_password_hash: &str,
) -> rusqlite::Result<()> {
conn.execute(
"UPDATE users SET password_hash = ?1 WHERE id = ?2;",
params![new_password_hash, id],
)?;
Ok(())
}
pub fn update_user_last_login(conn: &Connection, id: i64) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
conn.execute(
"UPDATE users SET last_login = ?1 WHERE id = ?2;",
params![now, id],
)?;
Ok(())
}
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 users = Vec::new();
for u in rows {
users.push(u?);
}
Ok(users)
}
pub fn log_username_change(
conn: &Connection,
user_id: i64,
old_username: &str,
new_username: &str,
) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO username_history (user_id, old_username, new_username, changed_at) VALUES (?1, ?2, ?3, ?4);",
params![user_id, old_username, new_username, now],
)?;
conn.execute(
"UPDATE users SET username = ?1 WHERE id = ?2;",
params![new_username, user_id],
)?;
Ok(())
}
// --- Session Operations ---
pub fn create_user_session(
conn: &Connection,
session_id: &str,
user_id: i64,
expires_at_rfc3339: &str,
) -> rusqlite::Result<UserSession> {
let created_at = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);",
params![session_id, user_id, expires_at_rfc3339, created_at],
)?;
Ok(UserSession {
id: session_id.to_string(),
user_id,
expires_at: expires_at_rfc3339.to_string(),
created_at,
})
}
pub fn get_user_session(
conn: &Connection,
session_id: &str,
) -> rusqlite::Result<Option<UserSession>> {
conn.query_row(
"SELECT id, user_id, expires_at, created_at FROM sessions WHERE id = ?1;",
params![session_id],
|row| {
Ok(UserSession {
id: row.get(0)?,
user_id: row.get(1)?,
expires_at: row.get(2)?,
created_at: row.get(3)?,
})
},
)
.optional()
}
pub fn delete_user_session(conn: &Connection, session_id: &str) -> rusqlite::Result<()> {
conn.execute("DELETE FROM sessions WHERE id = ?1;", params![session_id])?;
Ok(())
}
pub fn cleanup_expired_user_sessions(conn: &Connection) -> rusqlite::Result<usize> {
let now = Utc::now().to_rfc3339();
let count = conn.execute("DELETE FROM sessions WHERE expires_at < ?1;", params![now])?;
Ok(count)
}
// --- Quota Operations ---
pub fn get_user_quotas(conn: &Connection, user_id: i64) -> rusqlite::Result<Option<UserQuotas>> {
conn.query_row(
"SELECT user_id, max_urls, max_landings, max_api_tokens, max_storage_mb,
current_urls, current_landings, current_api_tokens, current_storage_mb
FROM quotas WHERE user_id = ?1;",
params![user_id],
|row| {
Ok(UserQuotas {
user_id: row.get(0)?,
max_urls: row.get(1)?,
max_landings: row.get(2)?,
max_api_tokens: row.get(3)?,
max_storage_mb: row.get(4)?,
current_urls: row.get(5)?,
current_landings: row.get(6)?,
current_api_tokens: row.get(7)?,
current_storage_mb: row.get(8)?,
})
},
)
.optional()
}
pub fn update_user_quotas(
conn: &Connection,
user_id: i64,
max_urls: i64,
max_landings: i64,
max_api_tokens: i64,
max_storage_mb: i64,
) -> rusqlite::Result<()> {
conn.execute(
"UPDATE quotas SET max_urls = ?1, max_landings = ?2, max_api_tokens = ?3, max_storage_mb = ?4
WHERE user_id = ?5;",
params![max_urls, max_landings, max_api_tokens, max_storage_mb, user_id],
)?;
Ok(())
}
pub fn increment_quota_counter(
conn: &Connection,
user_id: i64,
field: &str,
) -> rusqlite::Result<()> {
let sql = match field {
"urls" => "UPDATE quotas SET current_urls = current_urls + 1 WHERE user_id = ?1;",
"landings" => {
"UPDATE quotas SET current_landings = current_landings + 1 WHERE user_id = ?1;"
}
"api_tokens" => {
"UPDATE quotas SET current_api_tokens = current_api_tokens + 1 WHERE user_id = ?1;"
}
_ => return Err(rusqlite::Error::InvalidQuery),
};
conn.execute(sql, params![user_id])?;
Ok(())
}
pub fn decrement_quota_counter(
conn: &Connection,
user_id: i64,
field: &str,
) -> rusqlite::Result<()> {
let sql = match field {
"urls" => "UPDATE quotas SET current_urls = MAX(0, current_urls - 1) WHERE user_id = ?1;",
"landings" => "UPDATE quotas SET current_landings = MAX(0, current_landings - 1) WHERE user_id = ?1;",
"api_tokens" => "UPDATE quotas SET current_api_tokens = MAX(0, current_api_tokens - 1) WHERE user_id = ?1;",
_ => return Err(rusqlite::Error::InvalidQuery),
};
conn.execute(sql, params![user_id])?;
Ok(())
}
pub fn update_quota_storage(
conn: &Connection,
user_id: i64,
storage_mb: i64,
) -> rusqlite::Result<()> {
conn.execute(
"UPDATE quotas SET current_storage_mb = ?1 WHERE user_id = ?2;",
params![storage_mb, user_id],
)?;
Ok(())
}
// --- API Token Operations ---
pub fn create_user_api_token(
conn: &Connection,
user_id: i64,
token_hash: &str,
) -> rusqlite::Result<UserApiToken> {
let created_at = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO api_tokens (user_id, token_hash, created_at) VALUES (?1, ?2, ?3);",
params![user_id, token_hash, created_at],
)?;
let id = conn.last_insert_rowid();
// Increment api token counter
let _ = increment_quota_counter(conn, user_id, "api_tokens");
Ok(UserApiToken {
id,
user_id,
token_hash: token_hash.to_string(),
created_at,
})
}
pub fn list_user_api_tokens(
conn: &Connection,
user_id: i64,
) -> rusqlite::Result<Vec<UserApiToken>> {
let mut stmt = conn.prepare(
"SELECT id, user_id, token_hash, created_at FROM api_tokens WHERE user_id = ?1 ORDER BY id DESC;",
)?;
let rows = stmt.query_map(params![user_id], |row| {
Ok(UserApiToken {
id: row.get(0)?,
user_id: row.get(1)?,
token_hash: row.get(2)?,
created_at: row.get(3)?,
})
})?;
let mut tokens = Vec::new();
for t in rows {
tokens.push(t?);
}
Ok(tokens)
}
pub fn delete_user_api_token(conn: &Connection, id: i64, user_id: i64) -> rusqlite::Result<()> {
let deleted = conn.execute(
"DELETE FROM api_tokens WHERE id = ?1 AND user_id = ?2;",
params![id, user_id],
)?;
if deleted > 0 {
let _ = decrement_quota_counter(conn, user_id, "api_tokens");
}
Ok(())
}
// --- Global Slug & Quota Reconciliation Helpers ---
pub fn is_slug_available(system_conn: &Connection, slug: &str) -> rusqlite::Result<bool> {
// 1. Check reserved list
let reserved: bool = system_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM reserved_slugs WHERE slug = ?1);",
[slug],
|row| row.get(0),
)
.unwrap_or(false);
if reserved {
return Ok(false);
}
// 2. Check global slugs
let exists: bool = system_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = ?1);",
[slug],
|row| row.get(0),
)
.unwrap_or(false);
Ok(!exists)
}
pub fn register_global_slug(
system_conn: &Connection,
slug: &str,
owner_user_id: i64,
target_type: &str,
target_id: &str,
) -> rusqlite::Result<()> {
let now = 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 (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
rusqlite::params![slug, owner_user_id, target_type, target_id, now, now, "active"],
)?;
// Insert history
system_conn.execute(
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp)
VALUES (?1, NULL, ?2, 'created', ?3);",
rusqlite::params![slug, owner_user_id, now],
)?;
Ok(())
}
pub fn release_global_slug(
system_conn: &Connection,
slug: &str,
owner_user_id: i64,
) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
system_conn.execute("DELETE FROM global_slugs WHERE slug = ?1;", [slug])?;
// Insert history
system_conn.execute(
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp)
VALUES (?1, ?2, NULL, 'released', ?3);",
rusqlite::params![slug, owner_user_id, now],
)?;
Ok(())
}
pub fn soft_delete_global_slug(
system_conn: &Connection,
slug: &str,
owner_user_id: i64,
) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
system_conn.execute(
"UPDATE global_slugs SET status = 'soft_deleted', deleted_at = ?1 WHERE slug = ?2;",
rusqlite::params![now, slug],
)?;
// Insert history
system_conn.execute(
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp)
VALUES (?1, ?2, NULL, 'deleted', ?3);",
rusqlite::params![slug, owner_user_id, now],
)?;
Ok(())
}
pub fn reconcile_user_quotas(
users_conn: &Connection,
user_id: i64,
content_conn: &Connection,
) -> rusqlite::Result<()> {
let urls_count: i64 = content_conn
.query_row("SELECT COUNT(*) FROM urls;", [], |row| row.get(0))
.unwrap_or(0);
let landings_count: i64 = content_conn
.query_row("SELECT COUNT(*) FROM landing_pages;", [], |row| row.get(0))
.unwrap_or(0);
let api_tokens_count: i64 = users_conn
.query_row(
"SELECT COUNT(*) FROM api_tokens WHERE user_id = ?1;",
[user_id],
|row| row.get(0),
)
.unwrap_or(0);
users_conn.execute(
"UPDATE quotas SET current_urls = ?1, current_landings = ?2, current_api_tokens = ?3 WHERE user_id = ?4;",
rusqlite::params![urls_count, landings_count, api_tokens_count, user_id],
)?;
Ok(())
}