diff --git a/Cargo.lock b/Cargo.lock index 242247c..0866b72 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -453,7 +453,7 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "bzod" -version = "0.4.0" +version = "0.5.0" dependencies = [ "argon2", "askama", @@ -481,6 +481,7 @@ dependencies = [ "tracing-subscriber", "uuid", "zip", + "zstd", ] [[package]] @@ -584,6 +585,24 @@ dependencies = [ "version_check", ] +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -691,6 +710,15 @@ dependencies = [ "syn", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -1419,6 +1447,12 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -1806,6 +1840,22 @@ dependencies = [ "syn", ] +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + [[package]] name = "pxfm" version = "0.1.29" @@ -2075,6 +2125,8 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", + "cookie", + "cookie_store", "futures-core", "http", "http-body", @@ -3453,6 +3505,34 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "zune-core" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index fcffbb5..9484408 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bzod" -version = "0.4.0" +version = "0.5.0" edition = "2021" license = "MIT OR Apache-2.0" @@ -20,7 +20,7 @@ askama = { version = "0.12" } argon2 = "0.5" sha2 = "0.10" rand = "0.8" -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "cookies"] } tar = "0.4" flate2 = "1.0" chrono = { version = "0.4", features = ["serde"] } @@ -31,4 +31,8 @@ qrcode = "0.14" image = "0.25" zip = { version = "2.1", default-features = false, features = ["deflate"] } futures-util = "0.3" +zstd = "0.13" +[lints.clippy] +let_unit_value = "allow" +useless_vec = "allow" diff --git a/src/analytics/worker.rs b/src/analytics/worker.rs index 392d625..1aeb1b2 100644 --- a/src/analytics/worker.rs +++ b/src/analytics/worker.rs @@ -46,11 +46,47 @@ fn flush_batch(db: &Db, batch: &mut Vec) { return; } - info!("Flushing {} visits to analytics database", batch.len()); - let mut conn_lock = db.analytics.lock().unwrap(); - if let Err(e) = insert_visits_batch(&mut conn_lock, batch) { - error!("Failed to write analytics batch to database: {:?}", e); - } else { - batch.clear(); + info!( + "Flushing {} visits to user analytics databases", + batch.len() + ); + + // Group visits by owner_user_id + let mut groups: std::collections::HashMap> = + std::collections::HashMap::new(); + for record in batch.drain(..) { + let user_id = record.owner_user_id.unwrap_or(1); // fallback to legacy_admin (user 1) + groups.entry(user_id).or_default().push(record); + } + + for (user_id, user_visits) in groups { + let db_path = db + .data_dir + .join("users") + .join(user_id.to_string()) + .join("analytics.db"); + if let Some(parent) = db_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + + match rusqlite::Connection::open(&db_path) { + Ok(mut conn) => { + let _ = crate::db::sqlite::enable_wal(&conn, "analytics"); + let _ = crate::db::sqlite::enable_foreign_keys(&conn, "analytics"); + + if let Err(e) = insert_visits_batch(&mut conn, &user_visits) { + error!( + "Failed to write analytics batch to user {} database: {:?}", + user_id, e + ); + } + } + Err(e) => { + error!( + "Failed to open analytics database for user {}: {:?}", + user_id, e + ); + } + } } } diff --git a/src/auth/middleware.rs b/src/auth/middleware.rs index f21eb80..6f9736c 100644 --- a/src/auth/middleware.rs +++ b/src/auth/middleware.rs @@ -1,5 +1,5 @@ use crate::auth::session::authenticate_api_key; -use crate::models::User; +use crate::models::ApiActor; use crate::state::AppState; use axum::{ extract::{FromRef, FromRequestParts}, @@ -7,7 +7,7 @@ use axum::{ }; // Extractor: Authenticate API requests using Bearer token -pub struct ApiUser(pub User); +pub struct ApiUser(pub ApiActor); #[axum::async_trait] impl FromRequestParts for ApiUser @@ -25,9 +25,10 @@ where .and_then(|h| h.to_str().ok()) .ok_or((StatusCode::UNAUTHORIZED, "Missing Authorization header"))?; - let conn = app_state.admin_db.lock().unwrap(); - match authenticate_api_key(&conn, auth_header) { - Ok(Some(user)) => Ok(ApiUser(user)), + let admin_conn = app_state.admin_db.lock().unwrap(); + let users_conn = app_state.users_db.lock().unwrap(); + match authenticate_api_key(&admin_conn, &users_conn, auth_header) { + Ok(Some(actor)) => Ok(ApiUser(actor)), Ok(None) => Err((StatusCode::UNAUTHORIZED, "Invalid API token")), Err(_) => Err((StatusCode::INTERNAL_SERVER_ERROR, "Database error")), } diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 17867a8..3e94738 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -6,4 +6,6 @@ pub mod session; pub use csrf::{generate_csrf_token, verify_csrf}; pub use middleware::ApiUser; pub use password::{hash_password, verify_password, verify_sha256}; -pub use session::{authenticate_api_key, authenticate_session, generate_token}; +pub use session::{ + authenticate_admin_session, authenticate_api_key, authenticate_user_session, generate_token, +}; diff --git a/src/auth/session.rs b/src/auth/session.rs index 2f74a30..4339de2 100644 --- a/src/auth/session.rs +++ b/src/auth/session.rs @@ -1,11 +1,11 @@ use crate::db::admin::{ - get_api_key_by_hash, get_session, get_user_by_id, update_api_key_last_used, + get_api_key_by_hash, get_user_by_id as get_admin_user_by_id, update_api_key_last_used, }; -use crate::models::User; +use crate::models::{ApiActor, Session as AdminSession, TenantUser, User, UserSession}; use axum_extra::extract::CookieJar; use chrono::Utc; use rand::{thread_rng, RngCore}; -use rusqlite::Connection; +use rusqlite::{Connection, OptionalExtension}; use sha2::{Digest, Sha256}; // Generate a secure random token (hex-encoded) @@ -15,8 +15,8 @@ pub fn generate_token(bytes_len: usize) -> String { hex::encode(key) } -// Authenticate session from cookies -pub fn authenticate_session( +// Authenticate administrator session from cookies +pub fn authenticate_admin_session( conn: &Connection, jar: &CookieJar, ) -> Result, rusqlite::Error> { @@ -26,7 +26,33 @@ pub fn authenticate_session( }; let session_id = cookie.value(); - let session = match get_session(conn, session_id)? { + let session_opt: Option = conn + .query_row( + "SELECT id, user_id, expires_at, created_at FROM sessions WHERE id = ?1;", + [session_id], + |row| { + let id: String = row.get(0)?; + // `user_id` may be stored as integer (users.db) or text (admin.db UUID). + let user_id_str: String = match row.get::<_, String>(1) { + Ok(s) => s, + Err(_) => { + let i: i64 = row.get(1)?; + i.to_string() + } + }; + let expires_at: String = row.get(2)?; + let created_at: String = row.get(3)?; + Ok(AdminSession { + id, + user_id: user_id_str, + expires_at, + created_at, + }) + }, + ) + .optional()?; + + let session = match session_opt { Some(s) => s, None => return Ok(None), }; @@ -41,19 +67,171 @@ pub fn authenticate_session( return Ok(None); } - // Get user - if let Some(user) = get_user_by_id(conn, &session.user_id)? { + // Get user (status must be 'active' and account_type = 'admin') + // If the session user_id looks numeric, bind as integer when querying users.db + // Try the extended lookup but catch errors (e.g., missing columns in legacy admin DB) + // Try the extended lookup; if it errors (legacy schema), perform a fallback lookup. + let (user_opt, extended_failed) = match if let Ok(id_i64) = session.user_id.parse::() { + conn.query_row( + "SELECT id, username, password_hash, created_at + FROM users WHERE id = ?1 AND status = 'active' AND account_type = 'admin';", + [id_i64], + |row| { + let id_str = row.get::<_, i64>(0)?.to_string(); + Ok(User { + id: id_str, + username: row.get(1)?, + password_hash: row.get(2)?, + created_at: row.get(3)?, + }) + }, + ) + .optional() + } else { + conn.query_row( + "SELECT id, username, password_hash, created_at + FROM users WHERE id = ?1 AND status = 'active' AND account_type = 'admin';", + [session.user_id.as_str()], + |row| { + // admin DB stores UUID string ids, so read as String + let id_str: String = row.get(0)?; + Ok(User { + id: id_str, + username: row.get(1)?, + password_hash: row.get(2)?, + created_at: row.get(3)?, + }) + }, + ) + .optional() + } { + Ok(opt) => (opt, false), + Err(_) => (None, true), + }; + + if let Some(user) = user_opt { + return Ok(Some((user, session.id))); + } + + if extended_failed { + // Fallback for legacy admin.db schemas which may not have `status`/`account_type` columns + // Try a simpler lookup by id only. + let fallback_user_opt = if let Ok(id_i64) = session.user_id.parse::() { + conn.query_row( + "SELECT id, username, password_hash, created_at FROM users WHERE id = ?1;", + [id_i64], + |row| { + Ok(User { + id: row.get::<_, i64>(0)?.to_string(), + username: row.get(1)?, + password_hash: row.get(2)?, + created_at: row.get(3)?, + }) + }, + ) + .optional() + .unwrap_or(None) + } else { + conn.query_row( + "SELECT id, username, password_hash, created_at FROM users WHERE id = ?1;", + [session.user_id.as_str()], + |row| { + Ok(User { + id: row.get(0)?, + username: row.get(1)?, + password_hash: row.get(2)?, + created_at: row.get(3)?, + }) + }, + ) + .optional() + .unwrap_or(None) + }; + + if let Some(user) = fallback_user_opt { + Ok(Some((user, session.id))) + } else { + Ok(None) + } + } else { + Ok(None) + } +} + +// Authenticate user session from cookies +pub fn authenticate_user_session( + users_conn: &Connection, + jar: &CookieJar, +) -> Result, rusqlite::Error> { + let cookie = match jar.get("bzod_user_session") { + Some(c) => c, + None => return Ok(None), + }; + + let session_id = cookie.value(); + + // Get session from sessions table in users.db + let mut stmt = users_conn + .prepare("SELECT id, user_id, expires_at, created_at FROM sessions WHERE id = ?1;")?; + let session_opt: Option = stmt + .query_row([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()?; + + let session = match session_opt { + Some(s) => s, + None => return Ok(None), + }; + + // Check expiration + if let Ok(expires) = chrono::DateTime::parse_from_rfc3339(&session.expires_at) { + if expires.with_timezone(&Utc) < Utc::now() { + return Ok(None); + } + } else { + return Ok(None); + } + + // Get tenant user (status must be 'active') + let mut stmt = users_conn.prepare( + "SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata + FROM users WHERE id = ?1 AND status = 'active';" + )?; + let user_opt = stmt + .query_row([session.user_id], |row| { + Ok(TenantUser { + id: row.get(0)?, + username: row.get(1)?, + password_hash: row.get(2)?, + status: row.get(3)?, + created_at: row.get(4)?, + last_login: row.get(5)?, + account_type: row.get(6)?, + organization_id: row.get(7)?, + metadata: row.get(8)?, + }) + }) + .optional()?; + + if let Some(user) = user_opt { Ok(Some((user, session.id))) } else { Ok(None) } } -// Authenticate API key from Authorization header +// Authenticate API key/token from Authorization header (unified) pub fn authenticate_api_key( - conn: &Connection, + admin_conn: &Connection, + users_conn: &Connection, auth_header: &str, -) -> Result, rusqlite::Error> { +) -> Result, rusqlite::Error> { if !auth_header.starts_with("Bearer ") { return Ok(None); } @@ -68,13 +246,73 @@ pub fn authenticate_api_key( hasher.update(key.as_bytes()); let hashed_key = hex::encode(hasher.finalize()); - if let Some(api_key_rec) = get_api_key_by_hash(conn, &hashed_key)? { - // Update last used timestamp - update_api_key_last_used(conn, &api_key_rec.id)?; + // 1. Check user API tokens in users.db + let mut stmt = users_conn.prepare("SELECT user_id FROM api_tokens WHERE token_hash = ?1;")?; + let user_id_opt: Option = stmt.query_row([&hashed_key], |row| row.get(0)).optional()?; - // Get user - if let Some(user) = get_user_by_id(conn, &api_key_rec.user_id)? { - return Ok(Some(user)); + if let Some(user_id) = user_id_opt { + let mut stmt = users_conn.prepare( + "SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata + FROM users WHERE id = ?1 AND status = 'active';" + )?; + let user_opt = stmt + .query_row([user_id], |row| { + Ok(TenantUser { + id: row.get(0)?, + username: row.get(1)?, + password_hash: row.get(2)?, + status: row.get(3)?, + created_at: row.get(4)?, + last_login: row.get(5)?, + account_type: row.get(6)?, + organization_id: row.get(7)?, + metadata: row.get(8)?, + }) + }) + .optional()?; + + if let Some(user) = user_opt { + return Ok(Some(ApiActor::User(user))); + } + } + + // 2. Check admin system API keys in admin.db + if let Some(api_key_rec) = get_api_key_by_hash(admin_conn, &hashed_key)? { + // Update last used timestamp + update_api_key_last_used(admin_conn, &api_key_rec.id)?; + + // Get admin user from users.db (users_conn) + // Try to interpret the api_key user_id as an integer referencing users.db + if let Ok(user_id_i64) = api_key_rec.user_id.parse::() { + let mut stmt = users_conn.prepare( + "SELECT id, username, password_hash, created_at + FROM users WHERE id = ?1 AND status = 'active' AND account_type = 'admin';", + )?; + let user_opt = stmt + .query_row([user_id_i64], |row| { + let id_i64: i64 = row.get(0)?; + Ok(User { + id: id_i64.to_string(), + username: row.get(1)?, + password_hash: row.get(2)?, + created_at: row.get(3)?, + }) + }) + .optional()?; + + if let Some(user) = user_opt { + return Ok(Some(ApiActor::Admin(user))); + } + } + + // Fallback: admin DB may store users with string UUIDs. Try looking up directly in admin_conn. + if let Ok(Some(admin_user)) = get_admin_user_by_id(admin_conn, &api_key_rec.user_id) { + return Ok(Some(ApiActor::Admin(User { + id: admin_user.id, + username: admin_user.username, + password_hash: admin_user.password_hash, + created_at: admin_user.created_at, + }))); } } diff --git a/src/cli/backup_user.rs b/src/cli/backup_user.rs new file mode 100644 index 0000000..304fef0 --- /dev/null +++ b/src/cli/backup_user.rs @@ -0,0 +1,150 @@ +use crate::config::Config; +use crate::db::Db; +use chrono::Utc; +use std::fs::File; +use std::path::{Path, PathBuf}; +use tar::{Builder, Header}; +use tracing::{error, info}; +use zstd::Encoder; + +#[derive(serde::Serialize, serde::Deserialize)] +struct UserBackupMetadata { + id: i64, + username: String, + password_hash: String, + status: String, + created_at: String, + account_type: String, + metadata: Option, + quotas: UserBackupQuotas, +} + +#[derive(serde::Serialize, serde::Deserialize)] +struct UserBackupQuotas { + max_urls: i64, + max_landings: i64, + max_api_tokens: i64, + max_storage_mb: i64, +} + +pub async fn run( + username: String, + out: Option, + data_dir: Option, + mut config: Config, +) -> Result<(), Box> { + if let Some(d) = data_dir { + config.data_dir = PathBuf::from(d); + } + let db = Db::init(&config)?; + + let username_clean = username.trim().to_lowercase(); + + // 1. Get user details from users.db + let user_details = { + let conn = db.users.lock().unwrap(); + crate::db::users::get_user_by_username(&conn, &username_clean)? + }; + + let user = match user_details { + Some(u) => u, + None => { + error!("User '{}' not found", username_clean); + return Ok(()); + } + }; + + let user_id = user.id; + + // 2. Fetch user's quotas + let quotas = { + let conn = db.users.lock().unwrap(); + conn.query_row( + "SELECT max_urls, max_landings, max_api_tokens, max_storage_mb FROM quotas WHERE user_id = ?1;", + [user_id], + |row| { + Ok(UserBackupQuotas { + max_urls: row.get(0)?, + max_landings: row.get(1)?, + max_api_tokens: row.get(2)?, + max_storage_mb: row.get(3)?, + }) + } + )? + }; + + // 3. Define output path + let tar_path = match out { + Some(p) => PathBuf::from(p), + None => { + if !config.backup_dir.exists() { + std::fs::create_dir_all(&config.backup_dir)?; + } + config.backup_dir.join(format!( + "{}-{}.tar.zst", + username_clean, + Utc::now().format("%Y%m%d") + )) + } + }; + + info!( + "Backing up user {} (ID: {}) to {:?}", + username_clean, user_id, tar_path + ); + + // 4. Force checkpoint on user's databases + let user_dir = config.data_dir.join("users").join(user_id.to_string()); + if let Ok(c) = rusqlite::Connection::open(user_dir.join("content.db")) { + let _ = c.execute("PRAGMA wal_checkpoint(TRUNCATE);", []); + } + if let Ok(c) = rusqlite::Connection::open(user_dir.join("analytics.db")) { + let _ = c.execute("PRAGMA wal_checkpoint(TRUNCATE);", []); + } + if let Ok(c) = rusqlite::Connection::open(user_dir.join("profile.db")) { + let _ = c.execute("PRAGMA wal_checkpoint(TRUNCATE);", []); + } + + // 5. Create tar.zst archive + let file = File::create(&tar_path)?; + let zst_enc = Encoder::new(file, 3)?; + let mut tar = Builder::new(zst_enc); + + // Write metadata.json directly into tar + let metadata_obj = UserBackupMetadata { + id: user.id, + username: user.username, + password_hash: user.password_hash, + status: user.status, + created_at: user.created_at, + account_type: user.account_type, + metadata: user.metadata, + quotas, + }; + let metadata_bytes = serde_json::to_vec_pretty(&metadata_obj)?; + let mut header = Header::new_gnu(); + header.set_size(metadata_bytes.len() as u64); + header.set_path("metadata.json")?; + header.set_mode(0o644); + header.set_cksum(); + tar.append(&header, &metadata_bytes[..])?; + + // Append database files + let mut append_file = + |name_in_archive: &str, path_on_disk: &Path| -> Result<(), Box> { + if path_on_disk.exists() { + let mut file = File::open(path_on_disk)?; + tar.append_file(name_in_archive, &mut file)?; + } + Ok(()) + }; + + append_file("content.db", &user_dir.join("content.db"))?; + append_file("analytics.db", &user_dir.join("analytics.db"))?; + append_file("profile.db", &user_dir.join("profile.db"))?; + + tar.into_inner()?.finish()?; + + info!("User backup generated successfully at {:?}", tar_path); + Ok(()) +} diff --git a/src/cli/create_admin.rs b/src/cli/create_admin.rs index f24a4b9..c730d01 100644 --- a/src/cli/create_admin.rs +++ b/src/cli/create_admin.rs @@ -32,8 +32,9 @@ pub async fn run( } let hash = hash_password(&password).map_err(|e| e.to_string())?; - let conn = db.admin.lock().unwrap(); - let u = crate::db::admin::create_user(&conn, &final_username, &hash)?; + let conn = db.users.lock().unwrap(); + let u = crate::db::users::create_admin_user(&conn, &final_username, &hash)?; + db.init_user_databases(u.id)?; info!( "Successfully created admin user: {} (ID: {})", u.username, u.id diff --git a/src/cli/create_user.rs b/src/cli/create_user.rs new file mode 100644 index 0000000..d5400b9 --- /dev/null +++ b/src/cli/create_user.rs @@ -0,0 +1,86 @@ +use crate::auth::hash_password; +use crate::config::Config; +use crate::db::Db; +use std::io::{self, Write}; +use std::path::PathBuf; +use tracing::{error, info}; + +pub async fn run( + username: Option, + password: Option, + data_dir: Option, + mut config: Config, +) -> Result<(), Box> { + if let Some(d) = data_dir { + config.data_dir = PathBuf::from(d); + } + let db = Db::init(&config)?; + + let final_username = match username { + Some(u) => u, + None => read_input("Enter username: "), + }; + + let username_clean = final_username.trim().to_lowercase(); + if username_clean.is_empty() { + error!("Username cannot be empty"); + return Ok(()); + } + + if username_clean.len() < 3 { + error!("Username must be at least 3 characters"); + return Ok(()); + } + + if !username_clean + .chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_') + { + error!("Username must contain only alphanumeric characters, hyphens, or underscores"); + return Ok(()); + } + + let final_password = match password { + Some(p) => p, + None => read_input("Enter password: "), + }; + if final_password.trim().is_empty() { + error!("Password cannot be empty"); + return Ok(()); + } + + let hash = hash_password(&final_password).map_err(|e| e.to_string())?; + + // Check if user already exists + { + let conn = db.users.lock().unwrap(); + if crate::db::users::get_user_by_username(&conn, &username_clean)?.is_some() { + error!("User already exists: {}", username_clean); + return Ok(()); + } + } + + // Create user in DB (this seeds default quotas too) + let new_user = { + let conn = db.users.lock().unwrap(); + crate::db::users::create_user(&conn, &username_clean, &hash, "standard", None)? + }; + + // Initialize their user specific directory and DB files (content.db, analytics.db, profile.db) + db.init_user_databases(new_user.id)?; + + info!( + "Successfully created standard user: {} (ID: {})", + new_user.username, new_user.id + ); + + Ok(()) +} + +fn read_input(prompt: &str) -> String { + print!("{}", prompt); + let _ = io::stdout().flush(); + let mut input = String::new(); + let _ = io::stdin().read_line(&mut input); + input.trim().to_string() +} diff --git a/src/cli/delete_user.rs b/src/cli/delete_user.rs new file mode 100644 index 0000000..1a8eb65 --- /dev/null +++ b/src/cli/delete_user.rs @@ -0,0 +1,92 @@ +use crate::config::Config; +use crate::db::Db; +use chrono::Utc; +use std::path::PathBuf; +use tracing::{error, info}; + +pub async fn run( + user_id: i64, + force: bool, + data_dir: Option, + mut config: Config, +) -> Result<(), Box> { + if let Some(d) = data_dir { + config.data_dir = PathBuf::from(d); + } + let db = Db::init(&config)?; + + if user_id == 1 && !force { + error!("Deleting legacy_admin system account requires --force flag"); + return Ok(()); + } + + // Capture user details + let user_details = { + let conn = db.users.lock().unwrap(); + match crate::db::users::get_user_by_id(&conn, user_id)? { + Some(u) => u, + None => { + error!("User ID {} not found", user_id); + return Ok(()); + } + } + }; + + // 1. Transactional clean up on system.db (deleting their global slug mappings) + { + let mut system_conn = db.system.lock().unwrap(); + let tx = system_conn.transaction()?; + + // Get all slugs owned by the user + let slugs: Vec = { + let mut stmt = tx.prepare("SELECT slug FROM global_slugs WHERE owner_user_id = ?1;")?; + let rows = stmt.query_map([user_id], |row| row.get(0))?; + rows.filter_map(|r| r.ok()).collect() + }; + + // Delete from global_slugs and write to history + let now = Utc::now().to_rfc3339(); + for slug in slugs { + let _ = tx.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&slug]); + let _ = tx.execute( + "INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username) + VALUES (?1, ?2, NULL, 'deleted', ?3, ?4);", + rusqlite::params![slug, user_id, now, "cli"], + ); + } + + tx.commit()?; + } + + // 2. Delete user folder and database files from disk + let user_dir = config.data_dir.join("users").join(user_id.to_string()); + if user_dir.exists() { + let _ = std::fs::remove_dir_all(&user_dir); + } + + // 3. Remove user entry from users.db (cascading deletes quotas/sessions/tokens) + { + let conn = db.users.lock().unwrap(); + crate::db::users::delete_user(&conn, user_id)?; + } + + // Write audit event + { + let system_conn = db.system.lock().unwrap(); + let _ = crate::db::audit_events::write_audit_event( + &system_conn, + "cli", + "USER_DELETION", + "user", + &user_id.to_string(), + Some(&format!("Username: {}", user_details.username)), + ); + } + + info!( + "Successfully deleted user {} (ID: {}) and all associated content", + user_details.username, user_id + ); + + Ok(()) +} diff --git a/src/cli/disable_user.rs b/src/cli/disable_user.rs new file mode 100644 index 0000000..a7332d5 --- /dev/null +++ b/src/cli/disable_user.rs @@ -0,0 +1,55 @@ +use crate::config::Config; +use crate::db::Db; +use std::path::PathBuf; +use tracing::{error, info}; + +pub async fn run( + user_id: i64, + data_dir: Option, + mut config: Config, +) -> Result<(), Box> { + if let Some(d) = data_dir { + config.data_dir = PathBuf::from(d); + } + let db = Db::init(&config)?; + + // Check user exists + let user = { + let conn = db.users.lock().unwrap(); + crate::db::users::get_user_by_id(&conn, user_id)? + }; + + let user = match user { + Some(u) => u, + None => { + error!("User ID {} not found", user_id); + return Ok(()); + } + }; + + if user.status == "disabled" { + info!("User {} is already disabled", user.username); + return Ok(()); + } + + { + let conn = db.users.lock().unwrap(); + crate::db::users::update_user_status(&conn, user_id, "disabled")?; + } + + // Write audit event + { + let system_conn = db.system.lock().unwrap(); + let _ = crate::db::audit_events::write_audit_event( + &system_conn, + "cli", + "USER_DISABLED", + "user", + &user_id.to_string(), + Some(&format!("Username: {}", user.username)), + ); + } + + info!("User {} (ID: {}) has been disabled", user.username, user_id); + Ok(()) +} diff --git a/src/cli/enable_user.rs b/src/cli/enable_user.rs new file mode 100644 index 0000000..a0daab4 --- /dev/null +++ b/src/cli/enable_user.rs @@ -0,0 +1,58 @@ +use crate::config::Config; +use crate::db::Db; +use std::path::PathBuf; +use tracing::{error, info}; + +pub async fn run( + user_id: i64, + data_dir: Option, + mut config: Config, +) -> Result<(), Box> { + if let Some(d) = data_dir { + config.data_dir = PathBuf::from(d); + } + let db = Db::init(&config)?; + + // Check user exists + let user = { + let conn = db.users.lock().unwrap(); + crate::db::users::get_user_by_id(&conn, user_id)? + }; + + let user = match user { + Some(u) => u, + None => { + error!("User ID {} not found", user_id); + return Ok(()); + } + }; + + if user.status == "active" { + info!("User {} is already active", user.username); + return Ok(()); + } + + { + let conn = db.users.lock().unwrap(); + crate::db::users::update_user_status(&conn, user_id, "active")?; + } + + // Write audit event + { + let system_conn = db.system.lock().unwrap(); + let _ = crate::db::audit_events::write_audit_event( + &system_conn, + "cli", + "USER_ENABLED", + "user", + &user_id.to_string(), + Some(&format!("Username: {}", user.username)), + ); + } + + info!( + "User {} (ID: {}) has been enabled (active)", + user.username, user_id + ); + Ok(()) +} diff --git a/src/cli/list_users.rs b/src/cli/list_users.rs new file mode 100644 index 0000000..9d77d43 --- /dev/null +++ b/src/cli/list_users.rs @@ -0,0 +1,36 @@ +use crate::config::Config; +use crate::db::Db; +use std::path::PathBuf; + +pub async fn run( + data_dir: Option, + mut config: Config, +) -> Result<(), Box> { + if let Some(d) = data_dir { + config.data_dir = PathBuf::from(d); + } + let db = Db::init(&config)?; + + let users = { + let conn = db.users.lock().unwrap(); + crate::db::users::list_users(&conn)? + }; + + println!( + "{:<6} | {:<20} | {:<10} | {:<12} | {:<24}", + "ID", "Username", "Status", "Type", "Created At" + ); + println!( + "{:-<6}-+-{:-<20}-+-{:-<10}-+-{:-<12}-+-{:-<24}", + "", "", "", "", "" + ); + + for u in users { + println!( + "{:<6} | {:<20} | {:<10} | {:<12} | {:<24}", + u.id, u.username, u.status, u.account_type, u.created_at + ); + } + + Ok(()) +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index ccd9a8a..293417e 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -11,6 +11,15 @@ pub mod shorten; pub mod stats; pub mod validate; +pub mod backup_user; +pub mod create_user; +pub mod delete_user; +pub mod disable_user; +pub mod enable_user; +pub mod list_users; +pub mod reset_password; +pub mod restore_user; + #[derive(Parser)] #[command(name = "bzod")] #[command(about = "BZOD - Personal Redirector & Landing Page Platform")] @@ -91,4 +100,69 @@ pub enum Commands { #[arg(long)] data_dir: Option, }, + /// Create a new standard user in the database + CreateUser { + #[arg(long)] + username: Option, + #[arg(long)] + password: Option, + #[arg(long)] + data_dir: Option, + }, + /// Delete a standard user and all their databases/slugs + DeleteUser { + /// User ID to delete + user_id: i64, + /// Force deletion of system account/legacy_admin + #[arg(long)] + force: bool, + #[arg(long)] + data_dir: Option, + }, + /// Disable a standard user + DisableUser { + /// User ID to disable + user_id: i64, + #[arg(long)] + data_dir: Option, + }, + /// Enable a standard user + EnableUser { + /// User ID to enable + user_id: i64, + #[arg(long)] + data_dir: Option, + }, + /// Reset standard user's password + ResetPassword { + /// User ID to reset + user_id: i64, + #[arg(long)] + password: Option, + #[arg(long)] + data_dir: Option, + }, + /// List all standard/system users + ListUsers { + #[arg(long)] + data_dir: Option, + }, + /// Backup a standard user's databases to a .tar.zst package + BackupUser { + /// Username to backup + username: String, + /// Output .tar.zst filepath + #[arg(long)] + out: Option, + #[arg(long)] + data_dir: Option, + }, + /// Restore a standard user's databases from a .tar.zst package + RestoreUser { + /// Input .tar.zst package path + #[arg(long, required = true)] + file: String, + #[arg(long)] + data_dir: Option, + }, } diff --git a/src/cli/reset_password.rs b/src/cli/reset_password.rs new file mode 100644 index 0000000..2b20781 --- /dev/null +++ b/src/cli/reset_password.rs @@ -0,0 +1,75 @@ +use crate::auth::hash_password; +use crate::config::Config; +use crate::db::Db; +use std::io::{self, Write}; +use std::path::PathBuf; +use tracing::{error, info}; + +pub async fn run( + user_id: i64, + password: Option, + data_dir: Option, + mut config: Config, +) -> Result<(), Box> { + if let Some(d) = data_dir { + config.data_dir = PathBuf::from(d); + } + let db = Db::init(&config)?; + + // Check user exists + let user = { + let conn = db.users.lock().unwrap(); + crate::db::users::get_user_by_id(&conn, user_id)? + }; + + let user = match user { + Some(u) => u, + None => { + error!("User ID {} not found", user_id); + return Ok(()); + } + }; + + let final_password = match password { + Some(p) => p, + None => read_input("Enter new password: "), + }; + if final_password.trim().is_empty() { + error!("Password cannot be empty"); + return Ok(()); + } + + let hash = hash_password(&final_password).map_err(|e| e.to_string())?; + + { + let conn = db.users.lock().unwrap(); + crate::db::users::reset_user_password(&conn, user_id, &hash)?; + } + + // Write audit event + { + let system_conn = db.system.lock().unwrap(); + let _ = crate::db::audit_events::write_audit_event( + &system_conn, + "cli", + "USER_PASSWORD_RESET", + "user", + &user_id.to_string(), + Some(&format!("Username: {}", user.username)), + ); + } + + info!( + "Password for user {} (ID: {}) has been reset successfully", + user.username, user_id + ); + Ok(()) +} + +fn read_input(prompt: &str) -> String { + print!("{}", prompt); + let _ = io::stdout().flush(); + let mut input = String::new(); + let _ = io::stdin().read_line(&mut input); + input.trim().to_string() +} diff --git a/src/cli/restore_user.rs b/src/cli/restore_user.rs new file mode 100644 index 0000000..e7c1d81 --- /dev/null +++ b/src/cli/restore_user.rs @@ -0,0 +1,269 @@ +use crate::config::Config; +use crate::db::Db; +use chrono::Utc; +use rusqlite::OptionalExtension; +use std::fs::File; +use std::path::PathBuf; +use tar::Archive; +use tracing::{error, info}; +use zstd::Decoder; + +#[derive(serde::Serialize, serde::Deserialize)] +struct UserBackupMetadata { + id: i64, + username: String, + password_hash: String, + status: String, + created_at: String, + account_type: String, + metadata: Option, + quotas: UserBackupQuotas, +} + +#[derive(serde::Serialize, serde::Deserialize)] +struct UserBackupQuotas { + max_urls: i64, + max_landings: i64, + max_api_tokens: i64, + max_storage_mb: i64, +} + +pub async fn run( + file: String, + data_dir: Option, + mut config: Config, +) -> Result<(), Box> { + if let Some(d) = data_dir { + config.data_dir = PathBuf::from(d); + } + let file_path = PathBuf::from(file); + + if !file_path.exists() { + error!("Backup file not found: {:?}", file_path); + return Ok(()); + } + + let db = Db::init(&config)?; + + // 1. Read metadata.json from the tar.zst archive + let f = File::open(&file_path)?; + let zst_dec = Decoder::new(f)?; + let mut archive = Archive::new(zst_dec); + + let mut metadata_opt: Option = None; + for entry_res in archive.entries()? { + let mut entry = entry_res?; + let path = entry.path()?; + let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + if file_name == "metadata.json" { + let meta: UserBackupMetadata = serde_json::from_reader(&mut entry)?; + metadata_opt = Some(meta); + break; + } + } + + let metadata = match metadata_opt { + Some(m) => m, + None => { + error!("Archive is missing metadata.json"); + return Ok(()); + } + }; + + info!("Restoring user {} from backup...", metadata.username); + + // 2. Resolve target user ID and upsert user record in users.db + let target_user_id = { + let users_conn = db.users.lock().unwrap(); + let existing_user = + crate::db::users::get_user_by_username(&users_conn, &metadata.username)?; + + match existing_user { + Some(u) => { + users_conn.execute( + "UPDATE users SET password_hash = ?1, status = ?2, account_type = ?3, metadata = ?4 WHERE id = ?5;", + rusqlite::params![metadata.password_hash, metadata.status, metadata.account_type, metadata.metadata, u.id], + )?; + users_conn.execute( + "INSERT OR REPLACE INTO quotas (user_id, max_urls, max_landings, max_api_tokens, max_storage_mb) + VALUES (?1, ?2, ?3, ?4, ?5);", + rusqlite::params![u.id, metadata.quotas.max_urls, metadata.quotas.max_landings, metadata.quotas.max_api_tokens, metadata.quotas.max_storage_mb], + )?; + u.id + } + None => { + let id_taken: bool = users_conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);", + [metadata.id], + |row| row.get(0), + ) + .unwrap_or(false); + + let new_id = if !id_taken { + users_conn.execute( + "INSERT INTO users (id, username, password_hash, status, created_at, account_type, metadata) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);", + rusqlite::params![metadata.id, metadata.username, metadata.password_hash, metadata.status, metadata.created_at, metadata.account_type, metadata.metadata], + )?; + metadata.id + } else { + users_conn.execute( + "INSERT INTO users (username, password_hash, status, created_at, account_type, metadata) + VALUES (?1, ?2, ?3, ?4, ?5, ?6);", + rusqlite::params![metadata.username, metadata.password_hash, metadata.status, metadata.created_at, metadata.account_type, metadata.metadata], + )?; + users_conn.last_insert_rowid() + }; + + users_conn.execute( + "INSERT OR REPLACE INTO quotas (user_id, max_urls, max_landings, max_api_tokens, max_storage_mb) + VALUES (?1, ?2, ?3, ?4, ?5);", + rusqlite::params![new_id, metadata.quotas.max_urls, metadata.quotas.max_landings, metadata.quotas.max_api_tokens, metadata.quotas.max_storage_mb], + )?; + new_id + } + } + }; + + // 3. Extract database files to /data/users// + let dest_dir = config + .data_dir + .join("users") + .join(target_user_id.to_string()); + std::fs::create_dir_all(&dest_dir)?; + + let f2 = File::open(&file_path)?; + let zst_dec2 = Decoder::new(f2)?; + let mut archive2 = Archive::new(zst_dec2); + + for entry_res in archive2.entries()? { + let mut entry = entry_res?; + let path = entry.path()?; + let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + match file_name { + "content.db" => { + let mut out_file = File::create(dest_dir.join("content.db"))?; + std::io::copy(&mut entry, &mut out_file)?; + } + "analytics.db" => { + let mut out_file = File::create(dest_dir.join("analytics.db"))?; + std::io::copy(&mut entry, &mut out_file)?; + } + "profile.db" => { + let mut out_file = File::create(dest_dir.join("profile.db"))?; + std::io::copy(&mut entry, &mut out_file)?; + } + _ => {} + } + } + + // 4. Register slugs in global_slugs + let restored_content_conn = rusqlite::Connection::open(dest_dir.join("content.db"))?; + { + let mut system_conn = db.system.lock().unwrap(); + let tx = system_conn.transaction()?; + + // Delete any existing global slugs owned by this user + tx.execute( + "DELETE FROM global_slugs WHERE owner_user_id = ?1;", + [target_user_id], + )?; + + // Register URLs + { + let mut stmt = + restored_content_conn.prepare("SELECT code, id, created_at, status FROM urls;")?; + let mut rows = stmt.query([])?; + while let Some(row) = rows.next()? { + let 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 now = Utc::now().to_rfc3339(); + + let existing_owner: Option = tx + .query_row( + "SELECT owner_user_id FROM global_slugs WHERE slug = ?1;", + [&slug], + |r| r.get(0), + ) + .optional()?; + + if let Some(owner) = existing_owner { + if owner != target_user_id { + error!( + "Conflict: Slug '{}' is already owned by user ID {}. Skipping.", + slug, owner + ); + continue; + } + } + + tx.execute( + "INSERT OR REPLACE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status) + VALUES (?1, ?2, 'url', ?3, ?4, ?5, ?6);", + rusqlite::params![slug, target_user_id, target_id, created_at, now, status], + )?; + } + } + + // Register Landing Pages + { + let mut stmt = restored_content_conn + .prepare("SELECT code, id, created_at, state FROM landing_pages;")?; + let mut rows = stmt.query([])?; + while let Some(row) = rows.next()? { + let 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 existing_owner: Option = tx + .query_row( + "SELECT owner_user_id FROM global_slugs WHERE slug = ?1;", + [&slug], + |r| r.get(0), + ) + .optional()?; + + if let Some(owner) = existing_owner { + if owner != target_user_id { + error!( + "Conflict: Slug '{}' is already owned by user ID {}. Skipping.", + slug, owner + ); + continue; + } + } + + tx.execute( + "INSERT OR REPLACE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status) + VALUES (?1, ?2, 'page', ?3, ?4, ?5, ?6);", + rusqlite::params![slug, target_user_id, target_id, created_at, now, status], + )?; + } + } + + tx.commit()?; + } + + // 5. Reconcile quotas for restored user + crate::db::users::reconcile_user_quotas( + &db.users.lock().unwrap(), + target_user_id, + &restored_content_conn, + )?; + + info!( + "User '{}' (ID: {}) successfully restored from backup.", + metadata.username, target_user_id + ); + Ok(()) +} diff --git a/src/cli/serve.rs b/src/cli/serve.rs index 74af7b9..fc35488 100644 --- a/src/cli/serve.rs +++ b/src/cli/serve.rs @@ -63,11 +63,29 @@ pub async fn run( crate::jobs::run_expiry_checker(expiry_db).await; }); + let reconcile_db = db.clone(); + let reconcile_interval_hours = { + let conn = db.system.lock().unwrap(); + conn.query_row( + "SELECT value FROM settings WHERE key = 'quota_reconcile_interval_hours';", + [], + |row| row.get::<_, String>(0), + ) + .ok() + .and_then(|val| val.parse::().ok()) + .unwrap_or(24) + }; + tokio::spawn(async move { + crate::jobs::run_quota_reconciliation(reconcile_db, reconcile_interval_hours).await; + }); + let state = AppState { admin_db: db.admin.clone(), content_db: db.content.clone(), analytics_db: db.analytics.clone(), system_db: db.system.clone(), + users_db: db.users.clone(), + user_dbs: std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())), db: db.clone(), config: config.clone(), analytics_queue: queue, diff --git a/src/db/admin.rs b/src/db/admin.rs index ba03eea..f7e9665 100644 --- a/src/db/admin.rs +++ b/src/db/admin.rs @@ -71,10 +71,20 @@ pub fn create_session( ) -> rusqlite::Result { 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::() { + 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(), diff --git a/src/db/analytics.rs b/src/db/analytics.rs index 3e295d9..7b9ce19 100644 --- a/src/db/analytics.rs +++ b/src/db/analytics.rs @@ -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> { - 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> = 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> { - 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> = 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")?, }) })?; diff --git a/src/db/migrations.rs b/src/db/migrations.rs index 5f8294c..5ce226b 100644 --- a/src/db/migrations.rs +++ b/src/db/migrations.rs @@ -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'; + "#, + }, ]; diff --git a/src/db/mod.rs b/src/db/mod.rs index 3abed56..f39b24b 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -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>, pub analytics: Arc>, pub system: Arc>, + pub users: Arc>, + pub data_dir: std::path::PathBuf, } impl Db { pub fn init(config: &Config) -> Result> { + 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> { + 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> { + 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(()) } diff --git a/src/db/users.rs b/src/db/users.rs new file mode 100644 index 0000000..920f6e8 --- /dev/null +++ b/src/db/users.rs @@ -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 { + 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 { + 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> { + 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> { + 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> { + 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 { + 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> { + 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 { + 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> { + 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 { + 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> { + 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 { + // 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(()) +} diff --git a/src/jobs/aggregate.rs b/src/jobs/aggregate.rs index 9286533..0a47732 100644 --- a/src/jobs/aggregate.rs +++ b/src/jobs/aggregate.rs @@ -10,23 +10,43 @@ pub async fn run_aggregator(db: Db, interval_mins: u64) { tokio::time::sleep(Duration::from_secs(interval_mins * 60)).await; info!("Running background analytics aggregator..."); + let user_ids: Vec = { + let conn = db.users.lock().unwrap(); + let mut stmt = match conn.prepare("SELECT id FROM users;") { + Ok(s) => s, + Err(_) => continue, + }; + let rows = match stmt.query_map([], |row| row.get(0)) { + Ok(r) => r, + Err(_) => continue, + }; + rows.filter_map(|r| r.ok()).collect() + }; + let job_id = log_job_start(&db.system, "analytics_aggregator"); - match perform_aggregation(&db).await { - Ok(_) => log_job_end(&db.system, &job_id, "success", None), - Err(e) => { - let err_str = e.to_string(); - error!("Error performing aggregation: {}", err_str); - log_job_end(&db.system, &job_id, "failed", Some(&err_str)); + let mut failed = false; + let mut err_msg = None; + + for user_id in user_ids { + if let Err(e) = perform_aggregation(&db, user_id).await { + failed = true; + err_msg = Some(e.to_string()); } } + + if failed { + let err_str = err_msg.unwrap_or_else(|| "Unknown error".to_string()); + error!("Error performing aggregation: {}", err_str); + log_job_end(&db.system, &job_id, "failed", Some(&err_str)); + } else { + log_job_end(&db.system, &job_id, "success", None); + } } } -pub async fn perform_aggregation(db: &Db) -> Result<(), Box> { - let date_range = { - let conn = db.analytics.lock().unwrap(); - crate::db::analytics::get_visits_date_range(&conn)? - }; +pub async fn perform_aggregation(db: &Db, user_id: i64) -> Result<(), Box> { + let mut conn = super::open_user_analytics_conn(db, user_id)?; + let date_range = crate::db::analytics::get_visits_date_range(&conn)?; if let Some((min_date, max_date)) = date_range { let min = chrono::NaiveDate::parse_from_str(&min_date, "%Y-%m-%d")?; @@ -35,10 +55,7 @@ pub async fn perform_aggregation(db: &Db) -> Result<(), Box(0)) { + let user_ids: Vec = rows.filter_map(|r| r.ok()).collect(); + for user_id in user_ids { + if let Ok(u_conn) = crate::jobs::open_user_content_conn(db, user_id) { + let _ = u_conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []); + } + if let Ok(u_conn) = crate::jobs::open_user_analytics_conn(db, user_id) { + let _ = u_conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []); + } + } + } + } + } let date_str = Utc::now().format("%Y-%m-%d-%H%M%S").to_string(); let tar_name = format!("{}-bzod-backup.tar.gz", date_str); @@ -73,12 +89,14 @@ pub async fn perform_backup( let enc = GzEncoder::new(file, Compression::default()); let mut tar = Builder::new(enc); - let files = vec!["admin.db", "content.db", "analytics.db", "system.db"]; - for f in files { - let db_file = config.data_dir.join(f); - if db_file.exists() { - tar.append_path_with_name(&db_file, f)?; - } + let admin_dir = config.data_dir.join("admin"); + if admin_dir.exists() { + tar.append_dir_all("admin", &admin_dir)?; + } + + let users_dir = config.data_dir.join("users"); + if users_dir.exists() { + tar.append_dir_all("users", &users_dir)?; } tar.into_inner()?.finish()?; diff --git a/src/jobs/expiry.rs b/src/jobs/expiry.rs index 91d37d9..b770689 100644 --- a/src/jobs/expiry.rs +++ b/src/jobs/expiry.rs @@ -10,13 +10,32 @@ pub async fn run_expiry_checker(db: Db) { loop { tokio::time::sleep(Duration::from_secs(60)).await; - let count = { - let conn = db.content.lock().unwrap(); - crate::db::content::expire_urls(&conn).unwrap_or(0) + let user_ids: Vec = { + let conn = db.users.lock().unwrap(); + let mut stmt = match conn.prepare("SELECT id FROM users;") { + Ok(s) => s, + Err(_) => continue, + }; + let rows = match stmt.query_map([], |row| row.get(0)) { + Ok(r) => r, + Err(_) => continue, + }; + rows.filter_map(|r| r.ok()).collect() }; - if count > 0 { - info!(expired_count = count, "Expired URLs marked"); + let mut total_expired = 0; + for user_id in user_ids { + if let Ok(conn) = super::open_user_content_conn(&db, user_id) { + let count = crate::db::content::expire_urls(&conn).unwrap_or(0); + total_expired += count; + } + } + + if total_expired > 0 { + info!( + expired_count = total_expired, + "Expired URLs marked across users" + ); } } } diff --git a/src/jobs/healthcheck.rs b/src/jobs/healthcheck.rs index 6549c7b..eb97b55 100644 --- a/src/jobs/healthcheck.rs +++ b/src/jobs/healthcheck.rs @@ -37,40 +37,50 @@ pub async fn perform_link_check( db: &Db, client: &Client, ) -> Result<(), Box> { - let urls = { - let conn = db.content.lock().unwrap(); - crate::db::content::list_urls_for_health_check(&conn)? + let user_ids: Vec = { + let conn = db.users.lock().unwrap(); + let mut stmt = conn.prepare("SELECT id FROM users;")?; + let rows = stmt.query_map([], |row| row.get(0))?; + rows.filter_map(|r| r.ok()).collect() }; - for (id, dest) in urls { - let (status, detail_status, status_code, latency_ms, err_msg) = - check_url_health(client, &dest).await; - { - let conn = db.content.lock().unwrap(); - crate::db::content::update_url_health_extended( - &conn, - &id, - &status, - &detail_status, - Some(latency_ms), - )?; - } + for user_id in user_ids { + let conn = match super::open_user_content_conn(db, user_id) { + Ok(c) => c, + Err(_) => continue, + }; - // Log to system.db.health_checks - { - let conn = db.system.lock().unwrap(); - let hc_id = Uuid::new_v4().to_string(); - let now = Utc::now().to_rfc3339(); - let is_healthy = if status == "healthy" { 1 } else { 0 }; - let _ = conn.execute( - "INSERT INTO health_checks (id, object_type, object_id, checked_at, status_code, error_message, is_healthy) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);", - params![hc_id, "url", id, now, status_code, err_msg, is_healthy], - ); - } + let urls = crate::db::content::list_urls_for_health_check(&conn)?; - // Rate limiting sleep between external requests - tokio::time::sleep(Duration::from_millis(200)).await; + for (id, dest) in urls { + let (status, detail_status, status_code, latency_ms, err_msg) = + check_url_health(client, &dest).await; + { + crate::db::content::update_url_health_extended( + &conn, + &id, + &status, + &detail_status, + Some(latency_ms), + )?; + } + + // Log to system.db.health_checks + { + let sys_conn = db.system.lock().unwrap(); + let hc_id = Uuid::new_v4().to_string(); + let now = Utc::now().to_rfc3339(); + let is_healthy = if status == "healthy" { 1 } else { 0 }; + let _ = sys_conn.execute( + "INSERT INTO health_checks (id, object_type, object_id, checked_at, status_code, error_message, is_healthy) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);", + params![hc_id, "url", id, now, status_code, err_msg, is_healthy], + ); + } + + // Rate limiting sleep between external requests + tokio::time::sleep(Duration::from_millis(200)).await; + } } Ok(()) } diff --git a/src/jobs/mod.rs b/src/jobs/mod.rs index 045c277..d245cf4 100644 --- a/src/jobs/mod.rs +++ b/src/jobs/mod.rs @@ -1,3 +1,4 @@ +use crate::db::Db; use chrono::Utc; use rusqlite::{params, Connection}; use std::sync::Mutex; @@ -35,3 +36,38 @@ pub fn log_job_end(conn: &Mutex, id: &str, status: &str, err_msg: Op ); } } + +pub mod quota_reconcile; +pub use quota_reconcile::run_quota_reconciliation; + +// --- Database Connection Helpers for User-specific Databases --- + +pub fn open_user_content_conn( + db: &Db, + user_id: i64, +) -> Result { + let db_path = db + .data_dir + .join("users") + .join(user_id.to_string()) + .join("content.db"); + let conn = rusqlite::Connection::open(db_path)?; + crate::db::sqlite::enable_wal(&conn, "content")?; + crate::db::sqlite::enable_foreign_keys(&conn, "content")?; + Ok(conn) +} + +pub fn open_user_analytics_conn( + db: &Db, + user_id: i64, +) -> Result { + let db_path = db + .data_dir + .join("users") + .join(user_id.to_string()) + .join("analytics.db"); + let conn = rusqlite::Connection::open(db_path)?; + crate::db::sqlite::enable_wal(&conn, "analytics")?; + crate::db::sqlite::enable_foreign_keys(&conn, "analytics")?; + Ok(conn) +} diff --git a/src/jobs/quota_reconcile.rs b/src/jobs/quota_reconcile.rs new file mode 100644 index 0000000..09ffdcd --- /dev/null +++ b/src/jobs/quota_reconcile.rs @@ -0,0 +1,42 @@ +use crate::db::Db; +use std::time::Duration; +use tracing::{error, info}; + +pub async fn run_quota_reconciliation(db: Db, interval_hours: u64) { + loop { + // Sleep first + tokio::time::sleep(Duration::from_secs(interval_hours * 3600)).await; + info!("Running background quota reconciliation..."); + + let user_ids: Vec = { + let conn = db.users.lock().unwrap(); + let mut stmt = match conn.prepare("SELECT id FROM users;") { + Ok(s) => s, + Err(e) => { + error!("Failed to prepare select user IDs: {:?}", e); + continue; + } + }; + let rows = match stmt.query_map([], |row| row.get(0)) { + Ok(r) => r, + Err(e) => { + error!("Failed to query user IDs: {:?}", e); + continue; + } + }; + rows.filter_map(|r| r.ok()).collect() + }; + + let users_conn = db.users.lock().unwrap(); + for user_id in user_ids { + if let Ok(content_conn) = super::open_user_content_conn(&db, user_id) { + if let Err(e) = + crate::db::users::reconcile_user_quotas(&users_conn, user_id, &content_conn) + { + error!("Failed to reconcile quotas for user {}: {:?}", user_id, e); + } + } + } + info!("Quota reconciliation finished."); + } +} diff --git a/src/jobs/retention.rs b/src/jobs/retention.rs index 6f6d2cd..84617b4 100644 --- a/src/jobs/retention.rs +++ b/src/jobs/retention.rs @@ -15,18 +15,50 @@ pub async fn run_retention_cleaner(db: Db, retention_days_opt: Option) { tokio::time::sleep(Duration::from_secs(24 * 3600)).await; info!("Running background data retention cleanup..."); + let user_ids: Vec = { + let conn = db.users.lock().unwrap(); + let mut stmt = match conn.prepare("SELECT id FROM users;") { + Ok(s) => s, + Err(_) => continue, + }; + let rows = match stmt.query_map([], |row| row.get(0)) { + Ok(r) => r, + Err(_) => continue, + }; + rows.filter_map(|r| r.ok()).collect() + }; + let job_id = log_job_start(&db.system, "retention_cleaner"); - let conn = db.analytics.lock().unwrap(); - match crate::db::analytics::retention_cleanup(&conn, retention_days) { - Ok(count) => { - info!("Cleaned up {} expired visits from database", count); - log_job_end(&db.system, &job_id, "success", None); - } - Err(e) => { - let err_str = e.to_string(); - error!("Error running retention cleaner: {:?}", err_str); - log_job_end(&db.system, &job_id, "failed", Some(&err_str)); + let mut total_cleaned = 0; + let mut failed = false; + let mut err_msg = None; + + for user_id in user_ids { + match super::open_user_analytics_conn(&db, user_id) { + Ok(conn) => match crate::db::analytics::retention_cleanup(&conn, retention_days) { + Ok(count) => total_cleaned += count, + Err(e) => { + failed = true; + err_msg = Some(e.to_string()); + } + }, + Err(e) => { + failed = true; + err_msg = Some(e.to_string()); + } } } + + if failed { + let err_str = err_msg.unwrap_or_else(|| "Unknown error".to_string()); + error!("Error running retention cleaner: {:?}", err_str); + log_job_end(&db.system, &job_id, "failed", Some(&err_str)); + } else { + info!( + "Cleaned up {} expired visits across all user databases", + total_cleaned + ); + log_job_end(&db.system, &job_id, "success", None); + } } } diff --git a/src/main.rs b/src/main.rs index 2bca880..75b62cb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -54,6 +54,46 @@ async fn main() -> Result<(), Box> { Commands::Expand { code, data_dir } => { bzod::cli::expand::run(code, data_dir, config).await?; } + Commands::CreateUser { + username, + password, + data_dir, + } => { + bzod::cli::create_user::run(username, password, data_dir, config).await?; + } + Commands::DeleteUser { + user_id, + force, + data_dir, + } => { + bzod::cli::delete_user::run(user_id, force, data_dir, config).await?; + } + Commands::DisableUser { user_id, data_dir } => { + bzod::cli::disable_user::run(user_id, data_dir, config).await?; + } + Commands::EnableUser { user_id, data_dir } => { + bzod::cli::enable_user::run(user_id, data_dir, config).await?; + } + Commands::ResetPassword { + user_id, + password, + data_dir, + } => { + bzod::cli::reset_password::run(user_id, password, data_dir, config).await?; + } + Commands::ListUsers { data_dir } => { + bzod::cli::list_users::run(data_dir, config).await?; + } + Commands::BackupUser { + username, + out, + data_dir, + } => { + bzod::cli::backup_user::run(username, out, data_dir, config).await?; + } + Commands::RestoreUser { file, data_dir } => { + bzod::cli::restore_user::run(file, data_dir, config).await?; + } } Ok(()) diff --git a/src/models/mod.rs b/src/models/mod.rs index b54b302..9c50bef 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -9,5 +9,8 @@ pub use api_key::ApiKey; pub use audit::AuditLog; pub use page::LandingPage; pub use url::{AuditEvent, LinkPreview, QrCode, Url}; -pub use user::{Session, User}; +pub use user::{ + AccountType, ApiActor, ModerationSeverity, Session, SlugStatus, TenantUser, User, UserApiToken, + UserQuotas, UserSession, UsernameHistory, +}; pub use visit::{SummaryEntry, VisitRecord}; diff --git a/src/models/user.rs b/src/models/user.rs index 464f212..4c78942 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -15,3 +15,193 @@ pub struct Session { pub expires_at: String, pub created_at: String, } + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct TenantUser { + pub id: i64, + pub username: String, + pub password_hash: String, + pub status: String, // 'active', 'disabled', 'suspended', 'pending', 'deleted' + pub created_at: String, + pub last_login: Option, + pub account_type: String, // 'system', 'admin', 'standard', 'organization', 'service' + pub organization_id: Option, + pub metadata: Option, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct UserQuotas { + pub user_id: i64, + pub max_urls: i64, + pub max_landings: i64, + pub max_api_tokens: i64, + pub max_storage_mb: i64, + pub current_urls: i64, + pub current_landings: i64, + pub current_api_tokens: i64, + pub current_storage_mb: i64, +} + +impl UserQuotas { + pub fn urls_pct(&self) -> f64 { + if self.max_urls <= 0 { + 0.0 + } else { + (self.current_urls as f64 / self.max_urls as f64 * 100.0).clamp(0.0, 100.0) + } + } + pub fn landings_pct(&self) -> f64 { + if self.max_landings <= 0 { + 0.0 + } else { + (self.current_landings as f64 / self.max_landings as f64 * 100.0).clamp(0.0, 100.0) + } + } + pub fn api_tokens_pct(&self) -> f64 { + if self.max_api_tokens <= 0 { + 0.0 + } else { + (self.current_api_tokens as f64 / self.max_api_tokens as f64 * 100.0).clamp(0.0, 100.0) + } + } + pub fn storage_pct(&self) -> f64 { + if self.max_storage_mb <= 0 { + 0.0 + } else { + (self.current_storage_mb as f64 / self.max_storage_mb as f64 * 100.0).clamp(0.0, 100.0) + } + } +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct UserApiToken { + pub id: i64, + pub user_id: i64, + pub token_hash: String, + pub created_at: String, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct UserSession { + pub id: String, + pub user_id: i64, + pub expires_at: String, + pub created_at: String, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct UsernameHistory { + pub id: i64, + pub user_id: i64, + pub old_username: String, + pub new_username: String, + pub changed_at: String, +} + +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)] +pub enum SlugStatus { + Active, + Flagged, + Disabled, + SoftDeleted, +} + +impl SlugStatus { + pub fn as_str(&self) -> &'static str { + match self { + Self::Active => "active", + Self::Flagged => "flagged", + Self::Disabled => "disabled", + Self::SoftDeleted => "soft_deleted", + } + } + + #[allow(clippy::should_implement_trait)] + pub fn from_str(s: &str) -> Option { + match s { + "active" => Some(Self::Active), + "flagged" => Some(Self::Flagged), + "disabled" => Some(Self::Disabled), + "soft_deleted" => Some(Self::SoftDeleted), + _ => None, + } + } +} + +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)] +pub enum AccountType { + System, + Admin, + Standard, + Organization, + Service, +} + +impl AccountType { + pub fn as_str(&self) -> &'static str { + match self { + Self::System => "system", + Self::Admin => "admin", + Self::Standard => "standard", + Self::Organization => "organization", + Self::Service => "service", + } + } + + #[allow(clippy::should_implement_trait)] + pub fn from_str(s: &str) -> Option { + match s { + "system" => Some(Self::System), + "admin" => Some(Self::Admin), + "standard" => Some(Self::Standard), + "organization" => Some(Self::Organization), + "service" => Some(Self::Service), + _ => None, + } + } +} + +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)] +pub enum ModerationSeverity { + Low, + Medium, + High, + Critical, +} + +impl ModerationSeverity { + pub fn as_str(&self) -> &'static str { + match self { + Self::Low => "low", + Self::Medium => "medium", + Self::High => "high", + Self::Critical => "critical", + } + } + + #[allow(clippy::should_implement_trait)] + pub fn from_str(s: &str) -> Option { + match s { + "low" => Some(Self::Low), + "medium" => Some(Self::Medium), + "high" => Some(Self::High), + "critical" => Some(Self::Critical), + _ => None, + } + } +} + +#[derive(Clone, Debug)] +pub enum ApiActor { + Admin(User), + User(TenantUser), +} + +impl ApiActor { + pub fn username(&self) -> &str { + match self { + Self::Admin(u) => &u.username, + Self::User(u) => &u.username, + } + } +} diff --git a/src/models/visit.rs b/src/models/visit.rs index a76d1a9..0d51423 100644 --- a/src/models/visit.rs +++ b/src/models/visit.rs @@ -12,6 +12,7 @@ pub struct VisitRecord { pub accept_language: String, pub country: String, pub status_code: u16, + pub owner_user_id: Option, } #[derive(Serialize, Deserialize, Clone, Debug)] diff --git a/src/state.rs b/src/state.rs index b82e43e..692c53f 100644 --- a/src/state.rs +++ b/src/state.rs @@ -2,15 +2,25 @@ use crate::analytics::queue::AnalyticsQueue; use crate::config::Config; use crate::db::Db; use rusqlite::Connection; +use std::collections::HashMap; use std::sync::{Arc, Mutex}; use std::time::Instant; +#[derive(Clone)] +pub struct UserDbs { + pub content: Arc>, + pub analytics: Arc>, + pub profile: Arc>, +} + #[derive(Clone)] pub struct AppState { pub admin_db: Arc>, pub content_db: Arc>, pub analytics_db: Arc>, pub system_db: Arc>, + pub users_db: Arc>, + pub user_dbs: Arc>>, pub db: Db, pub config: Config, pub analytics_queue: AnalyticsQueue, @@ -18,11 +28,71 @@ pub struct AppState { } impl AppState { + pub fn get_user_dbs(&self, user_id: i64) -> Result { + let mut pool = self.user_dbs.lock().unwrap(); + if let Some(dbs) = pool.get(&user_id) { + return Ok(dbs.clone()); + } + + // Open connection and run migrations + let user_dir = self.config.data_dir.join("users").join(user_id.to_string()); + std::fs::create_dir_all(&user_dir)?; + + 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)?; + + crate::db::sqlite::enable_wal(&content_conn, "content")?; + crate::db::sqlite::enable_wal(&analytics_conn, "analytics")?; + crate::db::sqlite::enable_wal(&profile_conn, "profile")?; + + crate::db::sqlite::enable_foreign_keys(&content_conn, "content")?; + crate::db::sqlite::enable_foreign_keys(&analytics_conn, "analytics")?; + crate::db::sqlite::enable_foreign_keys(&profile_conn, "profile")?; + + // Run migrations + crate::db::migrations::run_migrations( + &mut content_conn, + "content", + crate::db::migrations::CONTENT_MIGRATIONS, + Some(&self.system_db), + ) + .map_err(|e| crate::error::AppError::Internal(e.to_string()))?; + crate::db::migrations::run_migrations( + &mut analytics_conn, + "analytics", + crate::db::migrations::ANALYTICS_MIGRATIONS, + Some(&self.system_db), + ) + .map_err(|e| crate::error::AppError::Internal(e.to_string()))?; + + profile_conn.execute_batch( + "CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + );", + )?; + + let dbs = UserDbs { + content: Arc::new(Mutex::new(content_conn)), + analytics: Arc::new(Mutex::new(analytics_conn)), + profile: Arc::new(Mutex::new(profile_conn)), + }; + + pool.insert(user_id, dbs.clone()); + Ok(dbs) + } + pub fn db_compact(&self) -> Result<(), rusqlite::Error> { self.admin_db.lock().unwrap().execute("VACUUM;", [])?; self.content_db.lock().unwrap().execute("VACUUM;", [])?; self.analytics_db.lock().unwrap().execute("VACUUM;", [])?; self.system_db.lock().unwrap().execute("VACUUM;", [])?; + self.users_db.lock().unwrap().execute("VACUUM;", [])?; Ok(()) } } diff --git a/src/templates/mod.rs b/src/templates/mod.rs index 46761db..53d27f8 100644 --- a/src/templates/mod.rs +++ b/src/templates/mod.rs @@ -4,25 +4,38 @@ pub mod pages; pub mod settings; pub mod stats; pub mod urls; +pub mod user_dashboard; +pub mod user_pages; +pub mod user_settings; +pub mod user_urls; +pub mod users; pub use analytics::{PageAnalyticsTemplate, UrlAnalyticsTemplate, VisitorLogEntry}; -pub use dashboard::DashboardTemplate; -pub use pages::PagesTemplate; -pub use settings::SettingsTemplate; -pub use stats::{AuditTemplate, StatusTemplate}; -pub use urls::UrlsTemplate; - use askama::Template; use axum::{ http::StatusCode, response::{Html, IntoResponse, Response}, }; +pub use dashboard::DashboardTemplate; +pub use pages::PagesTemplate; +pub use settings::SettingsTemplate; +pub use stats::{AuditTemplate, StatusTemplate, UserAuditTemplate, UserStatusTemplate}; +pub use urls::UrlsTemplate; +pub use user_dashboard::UserDashboardTemplate; +pub use user_pages::UserPagesTemplate; +pub use user_settings::UserSettingsTemplate; +pub use user_urls::UserUrlsTemplate; +pub use users::UsersTemplate; #[derive(Template)] #[template(path = "login.html")] pub struct LoginTemplate { pub error: Option, pub csrf_token: String, + pub action: String, + pub title: String, + pub subtitle: String, + pub button_text: String, } impl IntoResponse for LoginTemplate { @@ -81,3 +94,327 @@ impl IntoResponse for PreviewTemplate { } } } + +#[derive(Template)] +#[template(path = "users_new.html")] +pub struct UsersNewTemplate { + pub admin_username: String, + pub csrf_token: String, + pub success: Option, + pub error: Option, +} + +impl IntoResponse for UsersNewTemplate { + fn into_response(self) -> Response { + match self.render() { + Ok(html) => Html(html).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Render error: {}", e), + ) + .into_response(), + } + } +} + +#[derive(Template)] +#[template(path = "user_detail.html")] +pub struct UserDetailTemplate { + pub admin_username: String, + pub target_user: crate::models::TenantUser, + pub stats: crate::web::admin::UserDetailStats, + pub sessions: Vec, + pub tokens: Vec, + pub csrf_token: String, + pub success: Option, + pub error: Option, +} + +impl IntoResponse for UserDetailTemplate { + fn into_response(self) -> Response { + match self.render() { + Ok(html) => Html(html).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Render error: {}", e), + ) + .into_response(), + } + } +} + +#[derive(Template)] +#[template(path = "user_edit.html")] +pub struct UserEditTemplate { + pub admin_username: String, + pub target_user: crate::models::TenantUser, + pub quotas: crate::models::UserQuotas, + pub csrf_token: String, + pub success: Option, + pub error: Option, +} + +impl IntoResponse for UserEditTemplate { + fn into_response(self) -> Response { + match self.render() { + Ok(html) => Html(html).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Render error: {}", e), + ) + .into_response(), + } + } +} + +#[derive(Template)] +#[template(path = "moderation.html")] +pub struct ModerationTemplate { + pub admin_username: String, + pub flagged_items: Vec, + pub logs: Vec, + pub csrf_token: String, + pub success: Option, + pub error: Option, +} + +impl IntoResponse for ModerationTemplate { + fn into_response(self) -> Response { + match self.render() { + Ok(html) => Html(html).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Render error: {}", e), + ) + .into_response(), + } + } +} + +#[derive(Template)] +#[template(path = "slugs.html")] +pub struct SlugsTemplate { + pub admin_username: String, + pub slugs: Vec, + pub history: Vec, + pub csrf_token: String, + pub search_filter: Option, + pub owner_filter: Option, + pub status_filter: Option, + pub success: Option, + pub error: Option, +} + +impl IntoResponse for SlugsTemplate { + fn into_response(self) -> Response { + match self.render() { + Ok(html) => Html(html).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Render error: {}", e), + ) + .into_response(), + } + } +} + +#[derive(Template)] +#[template(path = "sessions.html")] +pub struct SessionsTemplate { + pub admin_username: String, + pub sessions: Vec, + pub csrf_token: String, + pub success: Option, + pub error: Option, +} + +impl IntoResponse for SessionsTemplate { + fn into_response(self) -> Response { + match self.render() { + Ok(html) => Html(html).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Render error: {}", e), + ) + .into_response(), + } + } +} + +#[derive(Template)] +#[template(path = "quotas.html")] +pub struct QuotasTemplate { + pub admin_username: String, + pub quotas: Vec, + pub csrf_token: String, + pub success: Option, + pub error: Option, +} + +impl IntoResponse for QuotasTemplate { + fn into_response(self) -> Response { + match self.render() { + Ok(html) => Html(html).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Render error: {}", e), + ) + .into_response(), + } + } +} + +#[derive(Template)] +#[template(path = "health.html")] +pub struct HealthTemplate { + pub admin_username: String, + pub db_reports: Vec, + pub total_data_size: String, + pub system_db_size: String, + pub users_db_size: String, + pub admin_db_size: String, + pub tenants_db_size: String, + pub job_history: Vec, + pub health_checks: Vec, + pub csrf_token: String, + pub success: Option, + pub error: Option, +} + +impl IntoResponse for HealthTemplate { + fn into_response(self) -> Response { + match self.render() { + Ok(html) => Html(html).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Render error: {}", e), + ) + .into_response(), + } + } +} + +#[derive(Template)] +#[template(path = "backups.html")] +pub struct BackupsTemplate { + pub admin_username: String, + pub files: Vec, + pub history: Vec, + pub csrf_token: String, + pub success: Option, + pub error: Option, +} + +impl IntoResponse for BackupsTemplate { + fn into_response(self) -> Response { + match self.render() { + Ok(html) => Html(html).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Render error: {}", e), + ) + .into_response(), + } + } +} + +#[derive(Template)] +#[template(path = "api_tokens.html")] +pub struct ApiTokensTemplate { + pub admin_username: String, + pub username: String, + pub tokens: Vec, + pub new_token: Option, + pub csrf_token: String, + pub success: Option, + pub error: Option, +} + +impl IntoResponse for ApiTokensTemplate { + fn into_response(self) -> Response { + match self.render() { + Ok(html) => Html(html).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Render error: {}", e), + ) + .into_response(), + } + } +} + +#[derive(Template)] +#[template(path = "user_analytics.html")] +pub struct UserAnalyticsTemplate { + pub admin_username: String, + pub username: String, + pub total_clicks: i64, + pub unique_visitors: i64, + pub direct_clicks: i64, + pub referred_clicks: i64, + pub referrers_chart: String, + pub browsers_chart: String, + pub visits: Vec, + pub csrf_token: String, + pub success: Option, + pub error: Option, +} + +impl IntoResponse for UserAnalyticsTemplate { + fn into_response(self) -> Response { + match self.render() { + Ok(html) => Html(html).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Render error: {}", e), + ) + .into_response(), + } + } +} + +#[derive(Template)] +#[template(path = "user_url_analytics.html")] +pub struct UserUrlAnalyticsTemplate { + pub admin_username: String, + pub username: String, + pub url_code: String, + pub destination: String, + pub visits: Vec, +} + +impl IntoResponse for UserUrlAnalyticsTemplate { + fn into_response(self) -> Response { + match self.render() { + Ok(html) => Html(html).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Render error: {}", e), + ) + .into_response(), + } + } +} + +#[derive(Template)] +#[template(path = "user_page_analytics.html")] +pub struct UserPageAnalyticsTemplate { + pub admin_username: String, + pub username: String, + pub page_code: String, + pub title: String, + pub visits: Vec, +} + +impl IntoResponse for UserPageAnalyticsTemplate { + fn into_response(self) -> Response { + match self.render() { + Ok(html) => Html(html).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Render error: {}", e), + ) + .into_response(), + } + } +} diff --git a/src/templates/stats.rs b/src/templates/stats.rs index 4fbe95b..4e96ab6 100644 --- a/src/templates/stats.rs +++ b/src/templates/stats.rs @@ -51,3 +51,50 @@ impl IntoResponse for AuditTemplate { } } } + +#[derive(Template)] +#[template(path = "user_status.html")] +pub struct UserStatusTemplate { + pub admin_username: String, + pub app_status: &'static str, + pub db_status: String, + pub queue_size: usize, + pub memory_usage: String, + pub uptime: String, + pub version: &'static str, + pub git_commit: &'static str, + pub urls: Vec, +} + +#[derive(Template)] +#[template(path = "user_audit.html")] +pub struct UserAuditTemplate { + pub admin_username: String, + pub logs: Vec, +} + +impl IntoResponse for UserStatusTemplate { + fn into_response(self) -> Response { + match self.render() { + Ok(html) => Html(html).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Render error: {}", e), + ) + .into_response(), + } + } +} + +impl IntoResponse for UserAuditTemplate { + fn into_response(self) -> Response { + match self.render() { + Ok(html) => Html(html).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Render error: {}", e), + ) + .into_response(), + } + } +} diff --git a/src/templates/user_dashboard.rs b/src/templates/user_dashboard.rs new file mode 100644 index 0000000..8291bbe --- /dev/null +++ b/src/templates/user_dashboard.rs @@ -0,0 +1,33 @@ +use askama::Template; +use axum::{ + http::StatusCode, + response::{Html, IntoResponse, Response}, +}; + +#[derive(Template)] +#[template(path = "user_dashboard.html")] +pub struct UserDashboardTemplate { + pub admin_username: String, + pub total_urls: i64, + pub total_pages: i64, + pub total_clicks: i64, + pub active_links: i64, + pub dead_links: i64, + pub traffic_chart: String, + pub countries_chart: String, + pub browsers_chart: String, + pub referrers_chart: String, +} + +impl IntoResponse for UserDashboardTemplate { + fn into_response(self) -> Response { + match self.render() { + Ok(html) => Html(html).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Render error: {}", e), + ) + .into_response(), + } + } +} diff --git a/src/templates/user_pages.rs b/src/templates/user_pages.rs new file mode 100644 index 0000000..85e82bb --- /dev/null +++ b/src/templates/user_pages.rs @@ -0,0 +1,38 @@ +use crate::models::LandingPage; +use askama::Template; +use axum::{ + http::StatusCode, + response::{Html, IntoResponse, Response}, +}; + +#[derive(Template)] +#[template(path = "user_pages.html")] +pub struct UserPagesTemplate { + pub admin_username: String, + pub username: String, + pub pages: Vec, + pub csrf_token: String, + pub error: Option, + pub current_page: usize, + pub total_pages: usize, + pub visible_pages: Vec, +} + +impl UserPagesTemplate { + pub fn is_current(&self, page: &usize) -> bool { + *page == self.current_page + } +} + +impl IntoResponse for UserPagesTemplate { + fn into_response(self) -> Response { + match self.render() { + Ok(html) => Html(html).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Render error: {}", e), + ) + .into_response(), + } + } +} diff --git a/src/templates/user_settings.rs b/src/templates/user_settings.rs new file mode 100644 index 0000000..6b503f4 --- /dev/null +++ b/src/templates/user_settings.rs @@ -0,0 +1,28 @@ +use askama::Template; +use axum::{ + http::StatusCode, + response::{Html, IntoResponse, Response}, +}; + +#[derive(Template)] +#[template(path = "user_settings.html")] +pub struct UserSettingsTemplate { + pub admin_username: String, + pub username: String, + pub csrf_token: String, + pub success: Option, + pub error: Option, +} + +impl IntoResponse for UserSettingsTemplate { + fn into_response(self) -> Response { + match self.render() { + Ok(html) => Html(html).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Render error: {}", e), + ) + .into_response(), + } + } +} diff --git a/src/templates/user_urls.rs b/src/templates/user_urls.rs new file mode 100644 index 0000000..3913c5d --- /dev/null +++ b/src/templates/user_urls.rs @@ -0,0 +1,40 @@ +use crate::models::Url; +use askama::Template; +use axum::{ + http::StatusCode, + response::{Html, IntoResponse, Response}, +}; + +#[derive(Template)] +#[template(path = "user_urls.html")] +pub struct UserUrlsTemplate { + pub admin_username: String, + pub username: String, + pub urls: Vec, + pub csrf_token: String, + pub error: Option, + pub tag_filter: Option, + pub base_url: String, + pub current_page: usize, + pub total_pages: usize, + pub visible_pages: Vec, +} + +impl UserUrlsTemplate { + pub fn is_current(&self, page: &usize) -> bool { + *page == self.current_page + } +} + +impl IntoResponse for UserUrlsTemplate { + fn into_response(self) -> Response { + match self.render() { + Ok(html) => Html(html).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Render error: {}", e), + ) + .into_response(), + } + } +} diff --git a/src/templates/users.rs b/src/templates/users.rs new file mode 100644 index 0000000..9110bd1 --- /dev/null +++ b/src/templates/users.rs @@ -0,0 +1,26 @@ +use crate::models::TenantUser; +use askama::Template; +use axum::response::{Html, IntoResponse, Response}; + +#[derive(Template)] +#[template(path = "users.html")] +pub struct UsersTemplate { + pub admin_username: String, + pub users: Vec, + pub csrf_token: String, + pub success: Option, + pub error: Option, +} + +impl IntoResponse for UsersTemplate { + fn into_response(self) -> Response { + match self.render() { + Ok(html) => Html(html).into_response(), + Err(e) => ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + format!("Render error: {}", e), + ) + .into_response(), + } + } +} diff --git a/src/web/admin.rs b/src/web/admin.rs index 4d3a03e..3c5deaa 100644 --- a/src/web/admin.rs +++ b/src/web/admin.rs @@ -7,16 +7,19 @@ use axum::{ use axum_extra::extract::cookie::Cookie; use axum_extra::extract::CookieJar; use chrono::Utc; +use flate2::read::GzDecoder; use flate2::write::GzEncoder; use flate2::Compression; -use rusqlite::params; +use rusqlite::{params, OptionalExtension}; use serde::Deserialize; +use std::collections::HashMap; +use std::fs::File; use std::net::SocketAddr; use tar::Builder; +use uuid::Uuid; use crate::db::admin::{ - create_api_key, create_session, create_user, delete_api_key, delete_session, get_config, - get_user_by_username, get_user_count, list_api_keys, set_config, + create_api_key, delete_api_key, get_config, get_user_count, list_api_keys, set_config, write_audit_log as write_audit_log_legacy, }; @@ -57,8 +60,8 @@ fn write_audit_log( } use crate::auth::{ - authenticate_session, generate_csrf_token, generate_token, hash_password, verify_csrf, - verify_password, verify_sha256, + authenticate_admin_session, authenticate_user_session, generate_csrf_token, generate_token, + hash_password, verify_csrf, verify_password, verify_sha256, }; use crate::charts::{generate_bar_chart, generate_line_chart}; use crate::db::analytics::{ @@ -80,15 +83,27 @@ const PAGE_SIZE: usize = 25; const ANALYTICS_PAGE_SIZE: usize = 50; const MAX_JSON_EXPORT_ROWS: usize = 50_000; -// Helper: Verify session and return user or redirect to login +// Helper: Verify admin session and return user or redirect to login async fn require_auth(state: &AppState, jar: &CookieJar) -> Result<(User, String), Redirect> { - let conn = state.admin_db.lock().unwrap(); - match authenticate_session(&conn, jar) { + let conn = state.users_db.lock().unwrap(); + match authenticate_admin_session(&conn, jar) { Ok(Some((user, session_id))) => Ok((user, session_id)), _ => Err(Redirect::to("/admin/login")), } } +// Helper: Verify tenant user session and return user or redirect to login +async fn require_user_auth( + state: &AppState, + jar: &CookieJar, +) -> Result<(crate::models::TenantUser, String), Redirect> { + let conn = state.users_db.lock().unwrap(); + match authenticate_user_session(&conn, jar) { + Ok(Some((user, session_id))) => Ok((user, session_id)), + _ => Err(Redirect::to("/login")), + } +} + // GET /admin pub async fn admin_index(State(state): State, jar: CookieJar) -> Response { match require_auth(&state, &jar).await { @@ -101,22 +116,27 @@ pub async fn admin_index(State(state): State, jar: CookieJar) -> Respo pub async fn login_get( State(state): State, jar: CookieJar, - Query(params): Query>, + Query(params): Query>, ) -> Response { let error = params.get("error").cloned(); let csrf_token = generate_token(16); + let cookie = Cookie::build(("bzod_temp_csrf", csrf_token.clone())) + .path("/admin/login") + .secure(state.config.cookie_secure) + .http_only(true) + .same_site(axum_extra::extract::cookie::SameSite::Strict) + .max_age(time::Duration::minutes(10)) + .build(); - let mut new_jar = jar.clone(); - new_jar = new_jar.add( - Cookie::build(("bzod_temp_csrf", csrf_token.clone())) - .path("/admin/login") - .secure(state.config.cookie_secure) - .http_only(true) - .same_site(axum_extra::extract::cookie::SameSite::Strict) - .build(), - ); - - let template = crate::templates::LoginTemplate { error, csrf_token }; + let new_jar = jar.add(cookie); + let template = crate::templates::LoginTemplate { + error, + csrf_token, + action: "/admin/login".to_string(), + title: "Admin Login".to_string(), + subtitle: "Administrative Access".to_string(), + button_text: "Sign In".to_string(), + }; (new_jar, template).into_response() } @@ -145,56 +165,144 @@ pub async fn login_post( let ip = get_client_ip(&headers, connect_info); - let user_count = { - let conn = state.admin_db.lock().unwrap(); - get_user_count(&conn).unwrap_or(0) + let (user_count, admin_count, active_session_count) = { + let conn = state.users_db.lock().unwrap(); + let u_count: i64 = conn + .query_row("SELECT COUNT(*) FROM users;", [], |r| r.get(0)) + .unwrap_or(0); + let a_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM users WHERE account_type = 'admin';", + [], + |r| r.get(0), + ) + .unwrap_or(0); + let now = Utc::now().to_rfc3339(); + let s_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM sessions WHERE expires_at > ?1;", + [now], + |r| r.get(0), + ) + .unwrap_or(0); + (u_count, a_count, s_count) }; - let user_opt = if user_count == 0 { - // Bootstrap Phase using BOOTSTRAP_PASSWORD_SHA256 - if form.username == state.config.admin_username - && verify_sha256(&form.password, &state.config.bootstrap_password_sha256) - { - let hash = match hash_password(&form.password) { - Ok(h) => h, - Err(_) => { - return Redirect::to("/admin/login?error=Internal hashing error") - .into_response() - } - }; + let bootstrap_allowed = user_count <= 1 && admin_count == 0 && active_session_count == 0; - let conn = state.admin_db.lock().unwrap(); - match create_user(&conn, &form.username, &hash) { - Ok(u) => { - let _ = write_audit_log( - &conn, - &state, - &u.username, - "BOOTSTRAP_USER_PROVISIONED", - Some("user"), - Some(&u.id), - Some(&ip), - headers.get("user-agent").and_then(|h| h.to_str().ok()), + let user_opt = if bootstrap_allowed + && form.username == state.config.admin_username + && verify_sha256(&form.password, &state.config.bootstrap_password_sha256) + { + // Bootstrap Phase using BOOTSTRAP_PASSWORD_SHA256 + let hash = match hash_password(&form.password) { + Ok(h) => h, + Err(_) => { + return Redirect::to("/admin/login?error=Internal hashing error").into_response() + } + }; + + let conn = state.users_db.lock().unwrap(); + match crate::db::users::create_admin_user(&conn, &form.username, &hash) { + Ok(u) => { + // Initialize user specific directory and DB files + if let Err(e) = state.db.init_user_databases(u.id) { + tracing::error!( + "Failed to init user databases during admin bootstrap: {:?}", + e ); - Some(u) } - Err(_) => None, + + // Write audit event in system.db + let system_conn = state.system_db.lock().unwrap(); + let metadata = format!( + "IP: {:?}, UA: {:?}", + ip, + headers.get("user-agent").and_then(|h| h.to_str().ok()) + ); + let _ = crate::db::audit_events::write_audit_event( + &system_conn, + &u.username, + "BOOTSTRAP_USER_PROVISIONED", + "user", + &u.id.to_string(), + Some(&metadata), + ); + + Some(User { + id: u.id.to_string(), + username: u.username, + password_hash: u.password_hash, + created_at: u.created_at, + }) + } + Err(e) => { + tracing::error!("Failed to create admin user during bootstrap: {:?}", e); + None } - } else { - None } } else { // Standard DB Verification - let conn = state.admin_db.lock().unwrap(); - match get_user_by_username(&conn, &form.username) { + let conn = state.users_db.lock().unwrap(); + let user_res: Result, rusqlite::Error> = conn.query_row( + "SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata + FROM users WHERE username = ?1;", + [&form.username], + |row| { + Ok(crate::models::TenantUser { + id: row.get(0)?, + username: row.get(1)?, + password_hash: row.get(2)?, + status: row.get(3)?, + created_at: row.get(4)?, + last_login: row.get(5)?, + account_type: row.get(6)?, + organization_id: row.get(7)?, + metadata: row.get(8)?, + }) + } + ).optional(); + + match user_res { Ok(Some(u)) => { - if verify_password(&form.password, &u.password_hash) { - Some(u) + if u.status != "active" { + tracing::warn!( + username = form.username, + reason = "account_disabled", + "login rejected" + ); + None + } else if u.account_type != "admin" { + tracing::warn!( + username = form.username, + reason = "insufficient_privileges", + "login rejected" + ); + None + } else if verify_password(&form.password, &u.password_hash) { + Some(User { + id: u.id.to_string(), + username: u.username, + password_hash: u.password_hash, + created_at: u.created_at, + }) } else { + tracing::warn!( + username = form.username, + reason = "invalid_credentials", + "login rejected" + ); None } } - _ => None, + _ => { + tracing::warn!( + username = form.username, + reason = "user_not_found", + "login rejected" + ); + None + } } }; @@ -204,17 +312,28 @@ pub async fn login_post( let expires = (Utc::now() + chrono::Duration::days(30)).to_rfc3339(); { - let conn = state.admin_db.lock().unwrap(); - let _ = create_session(&conn, &session_token, &user.id, &expires); - let _ = write_audit_log( - &conn, - &state, + let conn = state.users_db.lock().unwrap(); + let user_id_i64 = user.id.parse::().unwrap_or(0); + let now = Utc::now().to_rfc3339(); + let _ = conn.execute( + "INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);", + rusqlite::params![session_token, user_id_i64, expires, now], + ); + + // Write audit event in system.db + let system_conn = state.system_db.lock().unwrap(); + let metadata = format!( + "IP: {:?}, UA: {:?}", + ip, + headers.get("user-agent").and_then(|h| h.to_str().ok()) + ); + let _ = crate::db::audit_events::write_audit_event( + &system_conn, &user.username, "USER_LOGIN", - Some("session"), - Some(&session_token), - Some(&ip), - headers.get("user-agent").and_then(|h| h.to_str().ok()), + "session", + &session_token, + Some(&metadata), ); } @@ -238,16 +357,19 @@ pub async fn login_post( } None => { { - let conn = state.admin_db.lock().unwrap(); - let _ = write_audit_log( - &conn, - &state, + let system_conn = state.system_db.lock().unwrap(); + let metadata = format!( + "IP: {:?}, UA: {:?}", + ip, + headers.get("user-agent").and_then(|h| h.to_str().ok()) + ); + let _ = crate::db::audit_events::write_audit_event( + &system_conn, "anonymous", "LOGIN_FAILED", - None, - None, - Some(&ip), - headers.get("user-agent").and_then(|h| h.to_str().ok()), + "login", + "", + Some(&metadata), ); } Redirect::to("/admin/login?error=Invalid username or password").into_response() @@ -255,11 +377,1090 @@ pub async fn login_post( } } +// GET /logout +pub async fn public_logout(State(_state): State, jar: CookieJar) -> Response { + let cookie = Cookie::build("bzod_user_session") + .path("/") + .max_age(time::Duration::ZERO) + .build(); + + let mut response_jar = jar.clone(); + response_jar = response_jar.add(cookie); + + (response_jar, Redirect::to("/login")).into_response() +} + +// GET /login +pub async fn public_login_get( + State(state): State, + jar: CookieJar, + Query(params): Query>, +) -> Response { + let error = params.get("error").cloned(); + let csrf_token = generate_token(16); + let cookie = Cookie::build(("bzod_temp_csrf", csrf_token.clone())) + .path("/login") + .secure(state.config.cookie_secure) + .http_only(true) + .same_site(axum_extra::extract::cookie::SameSite::Strict) + .max_age(time::Duration::minutes(10)) + .build(); + + let new_jar = jar.add(cookie); + let template = crate::templates::LoginTemplate { + error, + csrf_token, + action: "/login".to_string(), + title: "User Login".to_string(), + subtitle: "Standard account access".to_string(), + button_text: "Sign In".to_string(), + }; + (new_jar, template).into_response() +} + +// POST /login +pub async fn public_login_post( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + connect_info: Option>, + Form(form): Form, +) -> Response { + let temp_csrf = jar + .get("bzod_temp_csrf") + .map(|c| c.value().to_string()) + .unwrap_or_default(); + if temp_csrf.is_empty() || temp_csrf != form.csrf_token { + return Redirect::to("/login?error=Invalid CSRF token").into_response(); + } + + let ip = get_client_ip(&headers, connect_info); + + let user_opt: Option = { + let conn = state.users_db.lock().unwrap(); + let user_res: Result, rusqlite::Error> = conn.query_row( + "SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata \ + FROM users WHERE username = ?1;", + [&form.username], + |row| { + Ok(crate::models::TenantUser { + id: row.get(0)?, + username: row.get(1)?, + password_hash: row.get(2)?, + status: row.get(3)?, + created_at: row.get(4)?, + last_login: row.get(5)?, + account_type: row.get(6)?, + organization_id: row.get(7)?, + metadata: row.get(8)?, + }) + } + ).optional(); + + match user_res { + Ok(Some(u)) => { + if u.status != "active" { + None + } else if verify_password(&form.password, &u.password_hash) { + Some(u) + } else { + None + } + } + _ => None, + } + }; + + match user_opt { + Some(user) => { + let session_token = generate_token(32); + let expires = (Utc::now() + chrono::Duration::days(30)).to_rfc3339(); + + { + let conn = state.users_db.lock().unwrap(); + let _ = + crate::db::users::create_user_session(&conn, &session_token, user.id, &expires); + + let system_conn = state.system_db.lock().unwrap(); + let metadata = format!( + "IP: {:?}, UA: {:?}", + ip, + headers.get("user-agent").and_then(|h| h.to_str().ok()) + ); + let _ = crate::db::audit_events::write_audit_event( + &system_conn, + &user.username, + "USER_LOGIN", + "session", + &session_token, + Some(&metadata), + ); + } + + let cookie = Cookie::build(("bzod_user_session", session_token)) + .path("/") + .secure(state.config.cookie_secure) + .http_only(true) + .same_site(axum_extra::extract::cookie::SameSite::Strict) + .max_age(time::Duration::days(30)) + .build(); + + let clear_temp = Cookie::build("bzod_temp_csrf") + .path("/login") + .max_age(time::Duration::ZERO) + .build(); + + let mut response_jar = jar.clone(); + response_jar = response_jar.add(cookie).add(clear_temp); + + (response_jar, Redirect::to("/user/dashboard")).into_response() + } + None => { + let system_conn = state.system_db.lock().unwrap(); + let metadata = format!( + "IP: {:?}, UA: {:?}", + ip, + headers.get("user-agent").and_then(|h| h.to_str().ok()) + ); + let _ = crate::db::audit_events::write_audit_event( + &system_conn, + "anonymous", + "LOGIN_FAILED", + "login", + "", + Some(&metadata), + ); + Redirect::to("/login?error=Invalid username or password").into_response() + } + } +} + +// GET /user/dashboard +pub async fn user_dashboard_get(State(state): State, jar: CookieJar) -> Response { + let (user, _session_id) = match require_user_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let user_dbs = match state.get_user_dbs(user.id) { + Ok(dbs) => dbs, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), + }; + + let (total_urls, active_links, dead_links) = { + let conn = user_dbs.content.lock().unwrap(); + get_url_counts(&conn).unwrap_or((0, 0, 0)) + }; + + let total_pages = { + let conn = user_dbs.content.lock().unwrap(); + get_landing_page_count(&conn).unwrap_or(0) + }; + + let total_clicks = { + let conn = user_dbs.analytics.lock().unwrap(); + get_total_clicks(&conn).unwrap_or(0) + }; + + let clicks_data = { + let conn = user_dbs.analytics.lock().unwrap(); + get_clicks_trend(&conn, "url", "all", 30) + .or_else(|_| get_clicks_trend_raw(&conn, "url", "all", 30)) + .unwrap_or_default() + }; + + let mut trend_map = std::collections::BTreeMap::new(); + for i in (0..30).rev() { + let date_str = (Utc::now() - chrono::Duration::days(i)) + .format("%Y-%m-%d") + .to_string(); + trend_map.insert(date_str, 0i64); + } + for (d, c) in clicks_data { + trend_map.insert(d, c); + } + let formatted_trend: Vec<(String, i64)> = trend_map.into_iter().collect(); + let traffic_chart = generate_line_chart(&formatted_trend); + + let countries_data = { + let conn = user_dbs.analytics.lock().unwrap(); + get_metric_rankings(&conn, "url", "all", "country", 5) + .or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "country", 5)) + .unwrap_or_default() + }; + let countries_chart = generate_bar_chart(&countries_data); + + let referrers_data = { + let conn = user_dbs.analytics.lock().unwrap(); + get_metric_rankings(&conn, "url", "all", "referrer", 5) + .or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "referrer", 5)) + .unwrap_or_default() + }; + let referrers_chart = generate_bar_chart(&referrers_data); + + let browsers_data = { + let conn = user_dbs.analytics.lock().unwrap(); + get_metric_rankings(&conn, "url", "all", "browser", 5) + .or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "browser", 5)) + .unwrap_or_default() + }; + let browsers_chart = generate_bar_chart(&browsers_data); + + let template = crate::templates::UserDashboardTemplate { + admin_username: user.username, + total_urls, + total_pages, + total_clicks, + active_links, + dead_links, + traffic_chart, + countries_chart, + browsers_chart, + referrers_chart, + }; + + template.into_response() +} + +#[derive(Deserialize)] +pub struct UserUrlsQuery { + pub tag: Option, + pub error: Option, + pub page: Option, +} + +#[derive(Deserialize)] +pub struct CreateUserUrlForm { + pub destination: String, + #[serde(default)] + pub code: String, + #[serde(default)] + pub custom_slug: String, + #[serde(default)] + pub title: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub tags: String, + pub csrf_token: String, + #[serde(default)] + pub expires_at: String, + #[serde(default)] + pub password: String, + #[serde(default)] + pub max_access_count: String, + #[serde(default)] + pub utm_source: String, + #[serde(default)] + pub utm_medium: String, + #[serde(default)] + pub utm_campaign: String, +} + +pub async fn user_urls_get( + State(state): State, + jar: CookieJar, + Query(query): Query, +) -> Response { + let (user, session_id) = match require_user_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let user_dbs = match state.get_user_dbs(user.id) { + Ok(dbs) => dbs, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), + }; + + let (urls, total_pages, page, visible_pages) = { + let conn = user_dbs.content.lock().unwrap(); + let total_records = if let Some(tag_str) = query.tag.as_deref() { + get_url_count_by_tag(&conn, tag_str).unwrap_or(0) + } else { + get_url_counts(&conn).map(|(t, _, _)| t).unwrap_or(0) + }; + let calculated_total_pages = (total_records as usize).div_ceil(PAGE_SIZE); + let total_pages = std::cmp::max(1, calculated_total_pages); + let requested_page = query.page.unwrap_or(1); + let current_page = if requested_page == 0 { + 1 + } else { + requested_page + } + .clamp(1, total_pages); + let offset = (current_page - 1) * PAGE_SIZE; + + let urls = list_urls(&conn, PAGE_SIZE as i64, offset as i64, query.tag.as_deref()) + .unwrap_or_default(); + + let start_page = current_page.saturating_sub(3).max(1); + let end_page = std::cmp::min(total_pages, current_page + 3); + let visible_pages: Vec = (start_page..=end_page).collect(); + + (urls, total_pages, current_page, visible_pages) + }; + + let csrf_token = generate_csrf_token(&session_id); + + let proto = if state.config.cookie_secure { + "https" + } else { + "http" + }; + let base_url = state + .config + .base_url + .clone() + .unwrap_or_else(|| format!("{}://localhost:{}", proto, state.config.port)); + + let template = crate::templates::UserUrlsTemplate { + admin_username: user.username.clone(), + username: user.username, + urls, + csrf_token, + error: query.error, + tag_filter: query.tag, + base_url, + current_page: page, + total_pages, + visible_pages, + }; + + template.into_response() +} + +pub async fn user_urls_create( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + connect_info: Option>, + Form(form): Form, +) -> Response { + let (user, session_id) = match require_user_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + if !verify_csrf(&session_id, &form.csrf_token) { + return Redirect::to("/user/urls?error=Invalid CSRF token").into_response(); + } + + let user_dbs = match state.get_user_dbs(user.id) { + Ok(dbs) => dbs, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), + }; + + let ip = get_client_ip(&headers, connect_info); + + let mut code = form.custom_slug.trim().to_lowercase(); + if code.is_empty() { + code = form.code.trim().to_lowercase(); + if code.is_empty() { + code = generate_token(3); + } else if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) { + return Redirect::to("/user/urls?error=Custom code must be exactly 6 hex characters") + .into_response(); + } + } else if !crate::utils::validation::validate_custom_slug(&code) { + return Redirect::to( + "/user/urls?error=Custom slug must start with ! followed by 1-24 a-z, 0-9, -, _", + ) + .into_response(); + } + + let is_slug_avail = { + let system_conn = state.system_db.lock().unwrap(); + crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) + }; + if !is_slug_avail { + return Redirect::to("/user/urls?error=Short code/slug already exists").into_response(); + } + + let mut dest = form.destination.trim().to_string(); + if let Ok(mut parsed) = reqwest::Url::parse(&dest) { + let mut has_utm = false; + { + let mut query = parsed.query_pairs_mut(); + if !form.utm_source.trim().is_empty() { + query.append_pair("utm_source", form.utm_source.trim()); + has_utm = true; + } + if !form.utm_medium.trim().is_empty() { + query.append_pair("utm_medium", form.utm_medium.trim()); + has_utm = true; + } + if !form.utm_campaign.trim().is_empty() { + query.append_pair("utm_campaign", form.utm_campaign.trim()); + has_utm = true; + } + } + if has_utm { + dest = parsed.to_string(); + } + } + + let expires_at_opt = if form.expires_at.trim().is_empty() { + None + } else { + let mut rfc = form.expires_at.trim().to_string(); + if rfc.len() == 16 { + rfc.push_str(":00Z"); + } + Some(rfc) + }; + + let password_hash_opt = if form.password.trim().is_empty() { + None + } else { + match hash_password(&form.password) { + Ok(h) => Some(h), + Err(_) => return Redirect::to("/user/urls?error=Hashing error").into_response(), + } + }; + + let max_access_count_opt = if form.max_access_count.trim().is_empty() { + None + } else { + match form.max_access_count.trim().parse::() { + Ok(c) => Some(c), + Err(_) => { + return Redirect::to("/user/urls?error=Invalid max access count").into_response() + } + } + }; + + let tags_list: Vec = form + .tags + .split(',') + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) + .collect(); + + let title_opt = if form.title.trim().is_empty() { + None + } else { + Some(form.title.trim()) + }; + let desc_opt = if form.description.trim().is_empty() { + None + } else { + Some(form.description.trim()) + }; + + let res = { + let conn = user_dbs.content.lock().unwrap(); + crate::db::content::create_url_extended( + &conn, + &code, + &dest, + title_opt, + desc_opt, + &tags_list, + expires_at_opt.as_deref(), + password_hash_opt.as_deref(), + max_access_count_opt, + ) + }; + + match res { + Ok(url) => { + let _ = crate::db::users::increment_quota_counter( + &state.users_db.lock().unwrap(), + user.id, + "urls", + ); + let _ = crate::db::users::register_global_slug( + &state.system_db.lock().unwrap(), + &code, + user.id, + "url", + &url.id, + ); + let _ = write_audit_log( + &state.admin_db.lock().unwrap(), + &state, + &user.username, + "USER_URL_CREATION", + Some("url"), + Some(&url.id), + Some(&ip), + headers.get("user-agent").and_then(|h| h.to_str().ok()), + ); + Redirect::to("/user/urls").into_response() + } + Err(rusqlite::Error::SqliteFailure(err, _)) + if err.code == rusqlite::ErrorCode::ConstraintViolation => + { + Redirect::to("/user/urls?error=Short code/slug already exists").into_response() + } + Err(e) => Redirect::to(&format!("/user/urls?error=Database error: {}", e)).into_response(), + } +} + +pub async fn user_urls_delete( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + connect_info: Option>, + Path(id): Path, + Form(form): Form>, +) -> Response { + let (user, session_id) = match require_user_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let user_dbs = match state.get_user_dbs(user.id) { + Ok(dbs) => dbs, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), + }; + + let csrf_token = form.get("csrf_token").cloned().unwrap_or_default(); + if !verify_csrf(&session_id, &csrf_token) { + return Redirect::to("/user/urls?error=Invalid CSRF token").into_response(); + } + + let conn = user_dbs.content.lock().unwrap(); + match get_url_by_id(&conn, &id) { + Ok(Some(url)) => { + let _ = crate::db::users::decrement_quota_counter( + &state.users_db.lock().unwrap(), + user.id, + "urls", + ); + match delete_url(&conn, &id) { + Ok(_) => { + let _ = crate::db::users::release_global_slug( + &state.system_db.lock().unwrap(), + &url.code, + user.id, + ); + let ip = get_client_ip(&headers, connect_info); + let _ = write_audit_log( + &state.admin_db.lock().unwrap(), + &state, + &user.username, + "USER_URL_DELETION", + Some("url"), + Some(&id), + Some(&ip), + headers.get("user-agent").and_then(|h| h.to_str().ok()), + ); + Redirect::to("/user/urls").into_response() + } + Err(e) => Redirect::to(&format!("/user/urls?error=Failed to delete link: {}", e)) + .into_response(), + } + } + _ => Redirect::to("/user/urls?error=Link not found").into_response(), + } +} + +#[derive(Deserialize)] +pub struct UserPagesQuery { + pub error: Option, + pub page: Option, +} + +#[derive(Deserialize)] +pub struct CreateUserPageForm { + pub title: String, + pub slug: String, + pub code: String, + pub custom_slug: String, + pub state: String, + pub html_content: String, + pub csrf_token: String, +} + +pub async fn user_pages_get( + State(state): State, + jar: CookieJar, + Query(query): Query, +) -> Response { + let (user, session_id) = match require_user_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let user_dbs = match state.get_user_dbs(user.id) { + Ok(dbs) => dbs, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), + }; + + let (pages, total_pages, page, visible_pages) = { + let conn = user_dbs.content.lock().unwrap(); + let total_records = get_landing_page_count(&conn).unwrap_or(0); + let calculated_total_pages = (total_records as usize).div_ceil(PAGE_SIZE); + let total_pages = std::cmp::max(1, calculated_total_pages); + let requested_page = query.page.unwrap_or(1); + let current_page = if requested_page == 0 { + 1 + } else { + requested_page + } + .clamp(1, total_pages); + let offset = (current_page - 1) * PAGE_SIZE; + + let pages = list_landing_pages(&conn, PAGE_SIZE as i64, offset as i64).unwrap_or_default(); + let start_page = current_page.saturating_sub(3).max(1); + let end_page = std::cmp::min(total_pages, current_page + 3); + let visible_pages: Vec = (start_page..=end_page).collect(); + (pages, total_pages, current_page, visible_pages) + }; + + let csrf_token = generate_csrf_token(&session_id); + + let template = crate::templates::UserPagesTemplate { + admin_username: user.username.clone(), + username: user.username, + pages, + csrf_token, + error: query.error, + current_page: page, + total_pages, + visible_pages, + }; + + template.into_response() +} + +pub async fn user_pages_create( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + connect_info: Option>, + Form(form): Form, +) -> Response { + let (user, session_id) = match require_user_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + if !verify_csrf(&session_id, &form.csrf_token) { + return Redirect::to("/user/pages?error=Invalid CSRF token").into_response(); + } + + let user_dbs = match state.get_user_dbs(user.id) { + Ok(dbs) => dbs, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), + }; + + let mut code = form.custom_slug.trim().to_lowercase(); + if code.is_empty() { + code = form.code.trim().to_lowercase(); + if code.is_empty() { + code = generate_token(2); + } else if code.len() != 4 || !code.chars().all(|c| c.is_ascii_hexdigit()) { + return Redirect::to("/user/pages?error=Custom code must be exactly 4 hex characters") + .into_response(); + } + } else if !crate::utils::validation::validate_custom_slug(&code) { + return Redirect::to( + "/user/pages?error=Custom slug must start with ! followed by 1-24 a-z, 0-9, -, _", + ) + .into_response(); + } + + let clean_slug = form.slug.trim().to_lowercase(); + if clean_slug.is_empty() { + return Redirect::to("/user/pages?error=Slug is required").into_response(); + } + + let is_slug_avail = { + let system_conn = state.system_db.lock().unwrap(); + crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) + }; + if !is_slug_avail { + return Redirect::to("/user/pages?error=Short code/slug already exists").into_response(); + } + + let res = { + let conn = user_dbs.content.lock().unwrap(); + create_landing_page( + &conn, + &code, + &clean_slug, + &form.title, + &form.html_content, + &form.state, + ) + }; + + match res { + Ok(page) => { + let _ = crate::db::users::increment_quota_counter( + &state.users_db.lock().unwrap(), + user.id, + "landings", + ); + let _ = crate::db::users::register_global_slug( + &state.system_db.lock().unwrap(), + &code, + user.id, + "page", + &page.id, + ); + let ip = get_client_ip(&headers, connect_info); + let _ = write_audit_log( + &state.admin_db.lock().unwrap(), + &state, + &user.username, + "USER_PAGE_CREATION", + Some("page"), + Some(&page.id), + Some(&ip), + headers.get("user-agent").and_then(|h| h.to_str().ok()), + ); + Redirect::to("/user/pages").into_response() + } + Err(rusqlite::Error::SqliteFailure(err, _)) + if err.code == rusqlite::ErrorCode::ConstraintViolation => + { + Redirect::to("/user/pages?error=Short code already exists").into_response() + } + Err(e) => Redirect::to(&format!("/user/pages?error=Database error: {}", e)).into_response(), + } +} + +pub async fn user_pages_delete( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + connect_info: Option>, + Path(id): Path, + Form(form): Form>, +) -> Response { + let (user, session_id) = match require_user_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let user_dbs = match state.get_user_dbs(user.id) { + Ok(dbs) => dbs, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), + }; + + let csrf_token = form.get("csrf_token").cloned().unwrap_or_default(); + if !verify_csrf(&session_id, &csrf_token) { + return Redirect::to("/user/pages?error=Invalid CSRF token").into_response(); + } + + let conn = user_dbs.content.lock().unwrap(); + match get_landing_page_by_id(&conn, &id) { + Ok(Some(page)) => { + let _ = crate::db::users::decrement_quota_counter( + &state.users_db.lock().unwrap(), + user.id, + "landings", + ); + match delete_landing_page(&conn, &id) { + Ok(_) => { + let _ = crate::db::users::release_global_slug( + &state.system_db.lock().unwrap(), + &page.code, + user.id, + ); + let ip = get_client_ip(&headers, connect_info); + let _ = write_audit_log( + &state.admin_db.lock().unwrap(), + &state, + &user.username, + "USER_PAGE_DELETION", + Some("page"), + Some(&id), + Some(&ip), + headers.get("user-agent").and_then(|h| h.to_str().ok()), + ); + Redirect::to("/user/pages").into_response() + } + Err(e) => Redirect::to(&format!("/user/pages?error=Failed to delete page: {}", e)) + .into_response(), + } + } + _ => Redirect::to("/user/pages?error=Page not found").into_response(), + } +} + +#[derive(Deserialize)] +pub struct UserSettingsQuery { + pub success: Option, + pub error: Option, +} + +pub async fn user_settings_get( + State(state): State, + jar: CookieJar, + Query(query): Query, +) -> Response { + let (user, session_id) = match require_user_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let csrf_token = generate_csrf_token(&session_id); + let template = crate::templates::UserSettingsTemplate { + admin_username: user.username.clone(), + username: user.username, + csrf_token, + success: query.success, + error: query.error, + }; + + template.into_response() +} + +pub async fn user_change_password_post( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + connect_info: Option>, + Form(form): Form, +) -> Response { + let (user, session_id) = match require_user_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + if !verify_csrf(&session_id, &form.csrf_token) { + return Redirect::to("/user/settings?error=Invalid CSRF token").into_response(); + } + + if form.current_password.trim().is_empty() || form.new_password.trim().len() < 8 { + return Redirect::to("/user/settings?error=Password must be at least 8 characters") + .into_response(); + } + + let conn = state.users_db.lock().unwrap(); + if !verify_password(&form.current_password, &user.password_hash) { + let _ = write_audit_log( + &state.admin_db.lock().unwrap(), + &state, + &user.username, + "USER_PASSWORD_CHANGE_FAIL", + Some("user"), + Some(&user.id.to_string()), + Some(&get_client_ip(&headers, connect_info)), + headers.get("user-agent").and_then(|h| h.to_str().ok()), + ); + return Redirect::to("/user/settings?error=Incorrect current password").into_response(); + } + + let new_hash = match hash_password(&form.new_password) { + Ok(h) => h, + Err(_) => return Redirect::to("/user/settings?error=Hashing error").into_response(), + }; + + match conn.execute( + "UPDATE users SET password_hash = ?1 WHERE id = ?2;", + params![new_hash, user.id], + ) { + Ok(_) => { + let _ = write_audit_log( + &state.admin_db.lock().unwrap(), + &state, + &user.username, + "USER_PASSWORD_CHANGED", + Some("user"), + Some(&user.id.to_string()), + Some(&get_client_ip(&headers, connect_info)), + headers.get("user-agent").and_then(|h| h.to_str().ok()), + ); + Redirect::to("/user/settings?success=Password updated successfully").into_response() + } + Err(e) => Redirect::to(&format!( + "/user/settings?error=Failed to update password: {}", + e + )) + .into_response(), + } +} + +pub async fn user_download_backup( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + connect_info: Option>, +) -> Response { + let (user, _) = match require_user_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let ip = get_client_ip(&headers, connect_info); + let user_dir = state + .config + .data_dir + .join("users") + .join(user.id.to_string()); + + let mut buffer = Vec::new(); + let res = { + let enc = GzEncoder::new(&mut buffer, Compression::default()); + let mut tar = Builder::new(enc); + let files = vec!["content.db", "analytics.db", "profile.db"]; + let mut add_err = None; + for f in files { + let path = user_dir.join(f); + if path.exists() { + if let Err(e) = tar.append_path_with_name(&path, f) { + add_err = Some(e); + break; + } + } + } + match add_err { + Some(e) => Err(e), + None => match tar.into_inner().and_then(|encoder| encoder.finish()) { + Ok(_) => Ok(()), + Err(e) => Err(e), + }, + } + }; + + match res { + Ok(_) => { + let _ = write_audit_log( + &state.admin_db.lock().unwrap(), + &state, + &user.username, + "USER_BACKUP", + Some("user"), + Some(&user.id.to_string()), + Some(&ip), + headers.get("user-agent").and_then(|h| h.to_str().ok()), + ); + let date_str = Utc::now().format("%Y-%m-%d").to_string(); + let filename = format!("user-{}-bzod-backup.tar.gz", date_str); + ( + StatusCode::OK, + [ + ("Content-Type", "application/gzip"), + ( + "Content-Disposition", + &format!("attachment; filename=\"{}\"", filename), + ), + ], + buffer, + ) + .into_response() + } + Err(e) => { + Redirect::to(&format!("/user/settings?error=Backup failed: {}", e)).into_response() + } + } +} + +pub async fn user_restore_backup_post( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + connect_info: Option>, + mut multipart: axum::extract::Multipart, +) -> Response { + let (user, session_id) = match require_user_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let ip = get_client_ip(&headers, connect_info); + let mut file_bytes = Vec::new(); + let mut confirm_text = String::new(); + let mut csrf_token = String::new(); + + while let Ok(Some(field)) = multipart.next_field().await { + let name = field.name().unwrap_or("").to_string(); + if name == "backup_file" { + if let Ok(bytes) = field.bytes().await { + file_bytes = bytes.to_vec(); + } + } else if name == "confirm_text" { + if let Ok(text) = field.text().await { + confirm_text = text.trim().to_string(); + } + } else if name == "csrf_token" { + if let Ok(token) = field.text().await { + csrf_token = token.trim().to_string(); + } + } + } + + if !verify_csrf(&session_id, &csrf_token) { + return Redirect::to("/user/settings?error=Invalid CSRF token").into_response(); + } + if confirm_text != "RESTORE" { + return Redirect::to("/user/settings?error=Confirmation text must be exactly 'RESTORE'") + .into_response(); + } + if file_bytes.is_empty() { + return Redirect::to("/user/settings?error=No backup file uploaded").into_response(); + } + + let temp_file_path = + std::env::temp_dir().join(format!("bzod_user_restore_{}.tar.gz", uuid::Uuid::new_v4())); + if let Err(e) = std::fs::write(&temp_file_path, &file_bytes) { + return Redirect::to(&format!( + "/user/settings?error=Failed to write temp file: {}", + e + )) + .into_response(); + } + + let user_dir = state + .config + .data_dir + .join("users") + .join(user.id.to_string()); + let restore_res = { + let file = match File::open(&temp_file_path) { + Ok(f) => f, + Err(e) => { + return Redirect::to(&format!( + "/user/settings?error=Failed to open upload: {}", + e + )) + .into_response() + } + }; + let tar_gz = GzDecoder::new(file); + let mut archive = tar::Archive::new(tar_gz); + if let Err(e) = archive.unpack(&user_dir) { + Err(e) + } else { + Ok(()) + } + }; + + let _ = std::fs::remove_file(&temp_file_path); + + match restore_res { + Ok(_) => { + let mut pool = state.user_dbs.lock().unwrap(); + pool.remove(&user.id); + let _ = write_audit_log( + &state.admin_db.lock().unwrap(), + &state, + &user.username, + "USER_RESTORE", + Some("user"), + Some(&user.id.to_string()), + Some(&ip), + headers.get("user-agent").and_then(|h| h.to_str().ok()), + ); + Redirect::to("/user/settings?success=User database restored successfully") + .into_response() + } + Err(e) => { + Redirect::to(&format!("/user/settings?error=Restore failed: {}", e)).into_response() + } + } +} + // GET /admin/logout pub async fn logout(State(state): State, jar: CookieJar) -> Response { if let Ok((_, session_id)) = require_auth(&state, &jar).await { - let conn = state.admin_db.lock().unwrap(); - let _ = delete_session(&conn, &session_id); + let conn = state.users_db.lock().unwrap(); + let _ = conn.execute("DELETE FROM sessions WHERE id = ?1;", [&session_id]); } let cookie = Cookie::build("bzod_session") @@ -650,6 +1851,342 @@ pub struct PagesQuery { pub page: Option, } +#[derive(Deserialize)] +pub struct UsersQuery { + pub success: Option, + pub error: Option, +} + +#[derive(Deserialize)] +pub struct CreateUserForm { + pub username: String, + pub password: String, + pub account_type: String, + pub metadata: String, + pub csrf_token: String, +} + +#[derive(Deserialize)] +pub struct UpdateUserStatusForm { + pub status: String, + pub csrf_token: String, +} + +#[derive(Deserialize)] +pub struct UpdateUserTypeForm { + pub account_type: String, + pub csrf_token: String, +} + +#[derive(Deserialize)] +pub struct ResetPasswordForm { + pub new_password: String, + pub csrf_token: String, +} + +#[derive(Deserialize)] +pub struct DeleteUserForm { + pub csrf_token: String, +} + +pub async fn users_get( + State(state): State, + jar: CookieJar, + Query(query): Query, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let users = { + let conn = state.users_db.lock().unwrap(); + crate::db::users::list_users(&conn).unwrap_or_default() + }; + + let csrf_token = generate_csrf_token(&session_id); + + let template = crate::templates::UsersTemplate { + admin_username: user.username, + users, + csrf_token, + success: query.success, + error: query.error, + }; + + template.into_response() +} + +pub async fn users_create_post( + State(state): State, + jar: CookieJar, + Form(form): Form, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + if !verify_csrf(&session_id, &form.csrf_token) { + return Redirect::to("/admin/users?error=Invalid CSRF token").into_response(); + } + + let username = form.username.trim().to_lowercase(); + if username.len() < 3 { + return Redirect::to("/admin/users?error=Username must be at least 3 characters") + .into_response(); + } + if !username + .chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_') + { + return Redirect::to( + "/admin/users?error=Username must contain only alphanumeric characters, hyphens, or underscores", + ) + .into_response(); + } + + if form.password.trim().len() < 8 { + return Redirect::to("/admin/users?error=Password must be at least 8 characters") + .into_response(); + } + + let account_type = if form.account_type.trim().is_empty() { + "standard" + } else { + form.account_type.trim() + }; + + let metadata = if form.metadata.trim().is_empty() { + None + } else { + Some(form.metadata.trim()) + }; + + let hash = match hash_password(&form.password) { + Ok(h) => h, + Err(_) => return Redirect::to("/admin/users?error=Internal hashing error").into_response(), + }; + + let new_user = { + let conn = state.users_db.lock().unwrap(); + match crate::db::users::create_user(&conn, &username, &hash, account_type, metadata) { + Ok(u) => u, + Err(rusqlite::Error::SqliteFailure(err, _)) + if err.code == rusqlite::ErrorCode::ConstraintViolation => + { + return Redirect::to("/admin/users?error=Username already exists").into_response(); + } + Err(e) => { + return Redirect::to(&format!("/admin/users?error=Database error: {}", e)) + .into_response(); + } + } + }; + + if let Err(e) = state.db.init_user_databases(new_user.id) { + return Redirect::to(&format!( + "/admin/users?error=Failed to initialize user databases: {}", + e + )) + .into_response(); + } + + { + let conn = state.admin_db.lock().unwrap(); + let _ = write_audit_log( + &conn, + &state, + &user.username, + "USER_CREATION", + Some("user"), + Some(&new_user.id.to_string()), + None, + None, + ); + } + + Redirect::to("/admin/users?success=User created successfully").into_response() +} + +pub async fn users_update_status_post( + State(state): State, + jar: CookieJar, + Path(id): Path, + Form(form): Form, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + if !verify_csrf(&session_id, &form.csrf_token) { + return Redirect::to("/admin/users?error=Invalid CSRF token").into_response(); + } + + let status = form.status.trim().to_lowercase(); + if !["active", "disabled", "suspended", "pending", "deleted"].contains(&status.as_str()) { + return Redirect::to("/admin/users?error=Invalid user status").into_response(); + } + + let conn = state.users_db.lock().unwrap(); + match crate::db::users::update_user_status(&conn, id, &status) { + Ok(_) => { + let conn_admin = state.admin_db.lock().unwrap(); + let _ = write_audit_log( + &conn_admin, + &state, + &user.username, + "USER_STATUS_UPDATE", + Some("user"), + Some(&id.to_string()), + None, + None, + ); + Redirect::to("/admin/users?success=User status updated").into_response() + } + Err(e) => Redirect::to(&format!( + "/admin/users?error=Failed to update status: {}", + e + )) + .into_response(), + } +} + +pub async fn users_update_type_post( + State(state): State, + jar: CookieJar, + Path(id): Path, + Form(form): Form, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + if !verify_csrf(&session_id, &form.csrf_token) { + return Redirect::to("/admin/users?error=Invalid CSRF token").into_response(); + } + + let account_type = form.account_type.trim().to_lowercase(); + if !["admin", "standard", "organization", "service", "system"].contains(&account_type.as_str()) + { + return Redirect::to("/admin/users?error=Invalid account type").into_response(); + } + + let conn = state.users_db.lock().unwrap(); + match crate::db::users::update_user_account_type(&conn, id, &account_type) { + Ok(_) => { + let conn_admin = state.admin_db.lock().unwrap(); + let _ = write_audit_log( + &conn_admin, + &state, + &user.username, + "USER_ACCOUNT_TYPE_UPDATE", + Some("user"), + Some(&id.to_string()), + None, + None, + ); + Redirect::to("/admin/users?success=User account type updated").into_response() + } + Err(e) => Redirect::to(&format!( + "/admin/users?error=Failed to update account type: {}", + e + )) + .into_response(), + } +} + +pub async fn users_reset_password_post( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + connect_info: Option>, + Path(id): Path, + Form(form): Form, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + if !verify_csrf(&session_id, &form.csrf_token) { + return Redirect::to("/admin/users?error=Invalid CSRF token").into_response(); + } + + if form.new_password.trim().len() < 8 { + return Redirect::to("/admin/users?error=Password must be at least 8 characters") + .into_response(); + } + + let hash = match hash_password(&form.new_password) { + Ok(h) => h, + Err(_) => return Redirect::to("/admin/users?error=Internal hashing error").into_response(), + }; + + let conn = state.users_db.lock().unwrap(); + match crate::db::users::reset_user_password(&conn, id, &hash) { + Ok(_) => { + let ip = get_client_ip(&headers, connect_info); + let conn_admin = state.admin_db.lock().unwrap(); + let _ = write_audit_log( + &conn_admin, + &state, + &user.username, + "USER_PASSWORD_RESET", + Some("user"), + Some(&id.to_string()), + Some(&ip), + headers.get("user-agent").and_then(|h| h.to_str().ok()), + ); + Redirect::to("/admin/users?success=Password reset successfully").into_response() + } + Err(e) => Redirect::to(&format!( + "/admin/users?error=Failed to reset password: {}", + e + )) + .into_response(), + } +} + +pub async fn users_delete_post( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + connect_info: Option>, + Path(id): Path, + Form(form): Form, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + if !verify_csrf(&session_id, &form.csrf_token) { + return Redirect::to("/admin/users?error=Invalid CSRF token").into_response(); + } + + match crate::web::multi_user::delete_user_resources(&state, id, &user.username, false) { + Ok(_) => { + let ip = get_client_ip(&headers, connect_info); + let conn_admin = state.admin_db.lock().unwrap(); + let _ = write_audit_log( + &conn_admin, + &state, + &user.username, + "USER_DELETION", + Some("user"), + Some(&id.to_string()), + Some(&ip), + headers.get("user-agent").and_then(|h| h.to_str().ok()), + ); + Redirect::to("/admin/users?success=User deleted successfully").into_response() + } + Err(err) => Redirect::to(&format!("/admin/users?error={}", err)).into_response(), + } +} + pub async fn pages_get( State(state): State, jar: CookieJar, @@ -1410,6 +2947,121 @@ pub async fn status_get(State(state): State, jar: CookieJar) -> Respon template.into_response() } +// GET /user/audit +pub async fn user_audit_get(State(state): State, jar: CookieJar) -> Response { + let (user, _) = match require_user_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let logs = { + let conn = state.system_db.lock().unwrap(); + let events = + crate::db::audit_events::list_audit_events(&conn, 100, 0, Some(&user.username), None) + .unwrap_or_default(); + events + .into_iter() + .map(|e| { + let (ip, ua) = if let Some(ref m) = e.metadata { + if m.starts_with("IP: ") { + let parts: Vec<&str> = m.split(", UA: ").collect(); + let ip = parts[0] + .trim_start_matches("IP: ") + .trim_matches('"') + .trim_matches('\'') + .replace("Some(", "") + .replace(")", ""); + let ua = if parts.len() > 1 { + parts[1] + .trim_matches('"') + .trim_matches('\'') + .replace("Some(", "") + .replace(")", "") + } else { + "Unknown".to_string() + }; + (Some(ip), Some(ua)) + } else { + (None, None) + } + } else { + (None, None) + }; + + crate::models::AuditLog { + id: e.id, + timestamp: e.timestamp, + username: e.actor, + action: e.action, + object_type: Some(e.object_type), + object_id: Some(e.object_id), + ip_address: ip, + user_agent: ua, + } + }) + .collect() + }; + + let template = crate::templates::UserAuditTemplate { + admin_username: user.username, + logs, + }; + + template.into_response() +} + +// GET /user/status +pub async fn user_status_get(State(state): State, jar: CookieJar) -> Response { + let (user, _) = match require_user_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let user_dbs = match state.get_user_dbs(user.id) { + Ok(dbs) => dbs, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), + }; + + let app_status = "Healthy"; + + let db_status = { + let conn_ok = { + let conn = user_dbs.content.lock().unwrap(); + get_url_counts(&conn).is_ok() + }; + if conn_ok { + "Operational".to_string() + } else { + "Degraded (Database connection failed)".to_string() + } + }; + + let queue_size = 0; + let memory_usage = get_memory_usage(); + + let uptime_duration = state.start_time.elapsed(); + let uptime = crate::utils::format_duration(uptime_duration); + + let urls = { + let conn = user_dbs.content.lock().unwrap(); + crate::db::content::list_urls(&conn, 50, 0, None).unwrap_or_default() + }; + + let template = crate::templates::UserStatusTemplate { + admin_username: user.username, + app_status, + db_status, + queue_size, + memory_usage, + uptime, + version: "0.1.0", + git_commit: "unknown", + urls, + }; + + template.into_response() +} + // POST /admin/settings/restore pub async fn restore_backup_post( State(state): State, @@ -2347,3 +3999,2083 @@ pub async fn page_analytics_json_export( } perform_json_export(state, "page", id, query.date_from, query.date_to).await } + +// --------------------------------------------------------------------------- +// GA Hardening UI Handlers and Structs +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct UserDetailStats { + pub max_urls: i64, + pub max_landings: i64, + pub max_api_tokens: i64, + pub max_storage_mb: i64, + pub current_urls: i64, + pub current_landings: i64, + pub current_api_tokens: i64, + pub current_storage_mb: i64, + pub url_pct: i64, + pub landing_pct: i64, + pub token_pct: i64, + pub storage_pct: i64, + pub total_visits: i64, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct GlobalSlugRow { + pub slug: String, + pub owner_user_id: i64, + pub target_type: String, + pub target_id: String, + pub created_at: String, + pub updated_at: String, + pub status: String, + pub deleted_at: Option, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct ModerationLogEntry { + pub id: String, + pub timestamp: String, + pub admin_username: String, + pub target_user_id: i64, + pub target_username: Option, + pub resource_type: String, + pub resource_identifier: String, + pub action: String, + pub severity: String, + pub reason: String, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct SlugHistoryRow { + pub id: i64, + pub slug: String, + pub old_owner_user_id: Option, + pub new_owner_user_id: Option, + pub action: String, + pub timestamp: String, + pub admin_username: Option, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct JobHistoryRow { + pub id: String, + pub job_name: String, + pub status: String, + pub started_at: String, + pub finished_at: Option, + pub error_message: Option, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct HealthCheckRow { + pub id: String, + pub object_type: String, + pub object_id: String, + pub checked_at: String, + pub status_code: Option, + pub error_message: Option, + pub is_healthy: i64, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct BackupFileRow { + pub filename: String, + pub size_str: String, + pub created_str: String, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct BackupHistoryRow { + pub id: String, + pub backup_path: String, + pub status: String, + pub created_at: String, + pub size_bytes: i64, + pub error_message: Option, +} + +// Helpers +fn format_size(bytes: u64) -> String { + if bytes < 1024 { + format!("{} B", bytes) + } else if bytes < 1024 * 1024 { + format!("{:.2} KB", bytes as f64 / 1024.0) + } else { + format!("{:.2} MB", bytes as f64 / (1024.0 * 1024.0)) + } +} + +fn get_dir_size(dir: &std::path::Path) -> std::io::Result { + let mut total = 0; + if dir.is_dir() { + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + total += get_dir_size(&path)?; + } else { + total += entry.metadata()?.len(); + } + } + } + Ok(total) +} + +pub fn get_user_detail_stats( + state: &AppState, + user_id: i64, +) -> Result> { + use rusqlite::OptionalExtension; + let quotas = { + let conn = state.users_db.lock().unwrap(); + conn.query_row( + "SELECT 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;", + [user_id], + |row| Ok(crate::models::UserQuotas { + user_id, + max_urls: row.get(0)?, + max_landings: row.get(1)?, + max_api_tokens: row.get(2)?, + max_storage_mb: row.get(3)?, + current_urls: row.get(4)?, + current_landings: row.get(5)?, + current_api_tokens: row.get(6)?, + current_storage_mb: row.get(7)?, + }) + ).optional()? + }; + + let quotas = quotas.unwrap_or(crate::models::UserQuotas { + user_id, + max_urls: 100, + max_landings: 10, + max_api_tokens: 5, + max_storage_mb: 100, + current_urls: 0, + current_landings: 0, + current_api_tokens: 0, + current_storage_mb: 0, + }); + + let total_visits = { + if let Ok(dbs) = state.get_user_dbs(user_id) { + let conn = dbs.analytics.lock().unwrap(); + conn.query_row("SELECT COUNT(*) FROM visits;", [], |row| { + row.get::<_, i64>(0) + }) + .unwrap_or(0) + } else { + 0 + } + }; + + let url_pct = if quotas.max_urls > 0 { + (quotas.current_urls * 100) / quotas.max_urls + } else { + 0 + }; + let landing_pct = if quotas.max_landings > 0 { + (quotas.current_landings * 100) / quotas.max_landings + } else { + 0 + }; + let token_pct = if quotas.max_api_tokens > 0 { + (quotas.current_api_tokens * 100) / quotas.max_api_tokens + } else { + 0 + }; + let storage_pct = if quotas.max_storage_mb > 0 { + (quotas.current_storage_mb * 100) / quotas.max_storage_mb + } else { + 0 + }; + + Ok(UserDetailStats { + max_urls: quotas.max_urls, + max_landings: quotas.max_landings, + max_api_tokens: quotas.max_api_tokens, + max_storage_mb: quotas.max_storage_mb, + current_urls: quotas.current_urls, + current_landings: quotas.current_landings, + current_api_tokens: quotas.current_api_tokens, + current_storage_mb: quotas.current_storage_mb, + url_pct, + landing_pct, + token_pct, + storage_pct, + total_visits, + }) +} + +// GET /admin/users/new +pub async fn users_new_get( + State(state): State, + jar: CookieJar, + Query(params): Query>, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let csrf_token = generate_csrf_token(&session_id); + + let template = crate::templates::UsersNewTemplate { + admin_username: user.username, + csrf_token, + success: params.get("success").cloned(), + error: params.get("error").cloned(), + }; + + template.into_response() +} + +// GET /admin/users/:id +pub async fn user_detail_get( + State(state): State, + jar: CookieJar, + Path(id): Path, + Query(params): Query>, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let target_user = { + let conn = state.users_db.lock().unwrap(); + let u_res = conn.query_row( + "SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata + FROM users WHERE id = ?1;", + [id], + |row| Ok(crate::models::TenantUser { + id: row.get(0)?, + username: row.get(1)?, + password_hash: row.get(2)?, + status: row.get(3)?, + created_at: row.get(4)?, + last_login: row.get(5)?, + account_type: row.get(6)?, + organization_id: row.get(7)?, + metadata: row.get(8)?, + }) + ); + match u_res { + Ok(u) => u, + Err(_) => return Redirect::to("/admin/users?error=User not found").into_response(), + } + }; + + let stats = match get_user_detail_stats(&state, id) { + Ok(s) => s, + Err(_) => return Redirect::to("/admin/users?error=Database error").into_response(), + }; + + let sessions = { + let conn = state.users_db.lock().unwrap(); + let mut stmt = conn + .prepare("SELECT id, user_id, expires_at, created_at FROM sessions WHERE user_id = ?1;") + .unwrap(); + let rows = stmt + .query_map([id], |row| { + Ok(crate::models::UserSession { + id: row.get(0)?, + user_id: row.get(1)?, + expires_at: row.get(2)?, + created_at: row.get(3)?, + }) + }) + .unwrap(); + rows.filter_map(|r| r.ok()).collect() + }; + + let tokens = { + let conn = state.users_db.lock().unwrap(); + let mut stmt = conn + .prepare( + "SELECT id, user_id, token_hash, created_at FROM api_tokens WHERE user_id = ?1;", + ) + .unwrap(); + let rows = stmt + .query_map([id], |row| { + Ok(crate::models::UserApiToken { + id: row.get(0)?, + user_id: row.get(1)?, + token_hash: row.get(2)?, + created_at: row.get(3)?, + }) + }) + .unwrap(); + rows.filter_map(|r| r.ok()).collect() + }; + + let csrf_token = generate_csrf_token(&session_id); + + let template = crate::templates::UserDetailTemplate { + admin_username: user.username, + target_user, + stats, + sessions, + tokens, + csrf_token, + success: params.get("success").cloned(), + error: params.get("error").cloned(), + }; + + template.into_response() +} + +// GET /admin/users/:id/edit +pub async fn user_edit_get( + State(state): State, + jar: CookieJar, + Path(id): Path, + Query(params): Query>, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let target_user = { + let conn = state.users_db.lock().unwrap(); + let u_res = conn.query_row( + "SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata + FROM users WHERE id = ?1;", + [id], + |row| Ok(crate::models::TenantUser { + id: row.get(0)?, + username: row.get(1)?, + password_hash: row.get(2)?, + status: row.get(3)?, + created_at: row.get(4)?, + last_login: row.get(5)?, + account_type: row.get(6)?, + organization_id: row.get(7)?, + metadata: row.get(8)?, + }) + ); + match u_res { + Ok(u) => u, + Err(_) => return Redirect::to("/admin/users?error=User not found").into_response(), + } + }; + + let quotas = { + let conn = state.users_db.lock().unwrap(); + conn.query_row( + "SELECT 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;", + [id], + |row| Ok(crate::models::UserQuotas { + user_id: id, + max_urls: row.get(0)?, + max_landings: row.get(1)?, + max_api_tokens: row.get(2)?, + max_storage_mb: row.get(3)?, + current_urls: row.get(4)?, + current_landings: row.get(5)?, + current_api_tokens: row.get(6)?, + current_storage_mb: row.get(7)?, + }) + ).unwrap_or(crate::models::UserQuotas { + user_id: id, + max_urls: 100, + max_landings: 10, + max_api_tokens: 5, + max_storage_mb: 100, + current_urls: 0, + current_landings: 0, + current_api_tokens: 0, + current_storage_mb: 0, + }) + }; + + let csrf_token = generate_csrf_token(&session_id); + + let template = crate::templates::UserEditTemplate { + admin_username: user.username, + target_user, + quotas, + csrf_token, + success: params.get("success").cloned(), + error: params.get("error").cloned(), + }; + + template.into_response() +} + +#[derive(Deserialize)] +pub struct UserEditForm { + pub account_type: String, + pub metadata: String, + pub max_urls: i64, + pub max_landings: i64, + pub max_api_tokens: i64, + pub max_storage_mb: i64, + pub csrf_token: String, +} + +// POST /admin/users/:id/edit +pub async fn user_edit_post( + State(state): State, + jar: CookieJar, + Path(id): Path, + Form(form): Form, +) -> Response { + let (_user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + if !verify_csrf(&session_id, &form.csrf_token) { + return Redirect::to(&format!( + "/admin/users/{}/edit?error=Invalid CSRF token", + id + )) + .into_response(); + } + + let conn = state.users_db.lock().unwrap(); + let _ = conn.execute( + "UPDATE users SET account_type = ?1, metadata = ?2 WHERE id = ?3;", + rusqlite::params![form.account_type, form.metadata, id], + ); + + let _ = conn.execute( + "INSERT INTO quotas (user_id, max_urls, max_landings, max_api_tokens, max_storage_mb) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(user_id) DO UPDATE SET + max_urls = excluded.max_urls, + max_landings = excluded.max_landings, + max_api_tokens = excluded.max_api_tokens, + max_storage_mb = excluded.max_storage_mb;", + rusqlite::params![ + id, + form.max_urls, + form.max_landings, + form.max_api_tokens, + form.max_storage_mb + ], + ); + + Redirect::to(&format!( + "/admin/users/{}?success=User updated successfully", + id + )) + .into_response() +} + +// GET /admin/moderation +pub async fn moderation_get( + State(state): State, + jar: CookieJar, + Query(params): Query>, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let flagged_items = { + let conn = state.system_db.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT slug, owner_user_id, target_type, target_id, created_at, updated_at, status, deleted_at + FROM global_slugs WHERE status = 'flagged' OR status = 'disabled' ORDER BY updated_at DESC;" + ).unwrap(); + let rows = stmt + .query_map([], |row| { + Ok(GlobalSlugRow { + slug: row.get(0)?, + owner_user_id: row.get(1)?, + target_type: row.get(2)?, + target_id: row.get(3)?, + created_at: row.get(4)?, + updated_at: row.get(5)?, + status: row.get(6)?, + deleted_at: row.get(7)?, + }) + }) + .unwrap(); + rows.filter_map(|r| r.ok()).collect() + }; + + let logs = { + let conn = state.system_db.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, timestamp, admin_username, target_user_id, target_username, resource_type, resource_identifier, action, severity, reason + FROM moderation_events ORDER BY timestamp DESC LIMIT 50;" + ).unwrap(); + let rows = stmt + .query_map([], |row| { + Ok(ModerationLogEntry { + id: row.get(0)?, + timestamp: row.get(1)?, + admin_username: row.get(2)?, + target_user_id: row.get(3)?, + target_username: row.get(4)?, + resource_type: row.get(5)?, + resource_identifier: row.get(6)?, + action: row.get(7)?, + severity: row.get(8)?, + reason: row.get(9)?, + }) + }) + .unwrap(); + rows.filter_map(|r| r.ok()).collect() + }; + + let csrf_token = generate_csrf_token(&session_id); + + let template = crate::templates::ModerationTemplate { + admin_username: user.username, + flagged_items, + logs, + csrf_token, + success: params.get("success").cloned(), + error: params.get("error").cloned(), + }; + + template.into_response() +} + +#[derive(Deserialize)] +pub struct AdminModerateForm { + pub slug: String, + pub action: String, + pub severity: String, + pub reason: String, + pub csrf_token: String, +} + +// POST /admin/moderation +pub async fn moderation_post( + State(state): State, + jar: CookieJar, + Form(form): Form, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + if !verify_csrf(&session_id, &form.csrf_token) { + return Redirect::to("/admin/moderation?error=Invalid CSRF token").into_response(); + } + + let action = form.action.trim().to_lowercase(); + if !["flagged", "disabled", "active", "deleted"].contains(&action.as_str()) { + return Redirect::to("/admin/moderation?error=Invalid moderation action").into_response(); + } + + let (owner_user_id, target_type) = { + let system_conn = state.system_db.lock().unwrap(); + let row_opt: Option<(i64, String)> = system_conn + .query_row( + "SELECT owner_user_id, target_type FROM global_slugs WHERE slug = ?1;", + [&form.slug], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .unwrap_or(None); + + match row_opt { + Some(r) => r, + None => return Redirect::to("/admin/moderation?error=Slug not found").into_response(), + } + }; + + let owner_username = { + let users_conn = state.users_db.lock().unwrap(); + crate::db::users::get_user_by_id(&users_conn, owner_user_id) + .unwrap_or(None) + .map(|u| u.username) + }; + + { + let system_conn = state.system_db.lock().unwrap(); + let now = Utc::now().to_rfc3339(); + + if action == "deleted" { + let _ = system_conn.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&form.slug]); + } else { + let _ = system_conn.execute( + "UPDATE global_slugs SET status = ?1, updated_at = ?2 WHERE slug = ?3;", + rusqlite::params![action, now, form.slug], + ); + } + + let event_id = Uuid::new_v4().to_string(); + let _ = system_conn.execute( + "INSERT INTO moderation_events (id, timestamp, admin_username, target_user_id, target_username, resource_type, resource_identifier, action, severity, reason) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10);", + rusqlite::params![ + event_id, + now, + user.username, + owner_user_id, + owner_username, + target_type, + form.slug, + action, + form.severity, + form.reason + ], + ); + + let _ = crate::db::audit_events::write_audit_event( + &system_conn, + &user.username, + "CONTENT_MODERATION", + "slug", + &form.slug, + Some(&format!("Action: {}, Reason: {}", action, form.reason)), + ); + } + + Redirect::to(&format!( + "/admin/moderation?success=Moderation action '{}' applied", + action + )) + .into_response() +} + +#[derive(Deserialize)] +pub struct SlugsQuery { + pub search: Option, + pub owner: Option, + pub status: Option, + pub success: Option, + pub error: Option, +} + +// GET /admin/slugs +pub async fn slugs_get( + State(state): State, + jar: CookieJar, + Query(query): Query, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let system_conn = state.system_db.lock().unwrap(); + + let mut sql = "SELECT slug, owner_user_id, target_type, target_id, created_at, updated_at, status, deleted_at FROM global_slugs WHERE 1=1".to_string(); + let mut values = vec![]; + if let Some(ref s) = query.search { + if !s.trim().is_empty() { + sql.push_str(" AND slug LIKE ?"); + values.push(rusqlite::types::Value::Text(format!("%{}%", s.trim()))); + } + } + if let Some(o) = query.owner { + sql.push_str(" AND owner_user_id = ?"); + values.push(rusqlite::types::Value::Integer(o)); + } + if let Some(ref st) = query.status { + if !st.trim().is_empty() { + sql.push_str(" AND status = ?"); + values.push(rusqlite::types::Value::Text(st.trim().to_string())); + } + } + sql.push_str(" ORDER BY created_at DESC LIMIT 100;"); + + let slugs = { + let mut stmt = system_conn.prepare(&sql).unwrap(); + let rows = stmt + .query_map(rusqlite::params_from_iter(values.iter()), |row| { + Ok(GlobalSlugRow { + slug: row.get(0)?, + owner_user_id: row.get(1)?, + target_type: row.get(2)?, + target_id: row.get(3)?, + created_at: row.get(4)?, + updated_at: row.get(5)?, + status: row.get(6)?, + deleted_at: row.get(7)?, + }) + }) + .unwrap(); + rows.filter_map(|r| r.ok()).collect() + }; + + let history = { + let mut stmt = system_conn.prepare( + "SELECT id, slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username + FROM slug_history ORDER BY timestamp DESC LIMIT 50;" + ).unwrap(); + let rows = stmt + .query_map([], |row| { + Ok(SlugHistoryRow { + id: row.get(0)?, + slug: row.get(1)?, + old_owner_user_id: row.get(2)?, + new_owner_user_id: row.get(3)?, + action: row.get(4)?, + timestamp: row.get(5)?, + admin_username: row.get(6)?, + }) + }) + .unwrap(); + rows.filter_map(|r| r.ok()).collect() + }; + + let csrf_token = generate_csrf_token(&session_id); + + let template = crate::templates::SlugsTemplate { + admin_username: user.username, + slugs, + history, + csrf_token, + search_filter: query.search, + owner_filter: query.owner, + status_filter: query.status, + success: query.success, + error: query.error, + }; + + template.into_response() +} + +#[derive(Deserialize)] +pub struct AdminTransferForm { + pub slug: String, + pub new_owner_user_id: i64, + pub csrf_token: String, +} + +// POST /admin/slugs/transfer +pub async fn slugs_transfer_post( + State(state): State, + jar: CookieJar, + Form(form): Form, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + if !verify_csrf(&session_id, &form.csrf_token) { + return Redirect::to("/admin/slugs?error=Invalid CSRF token").into_response(); + } + + let user_exists = { + let conn = state.users_db.lock().unwrap(); + conn.query_row( + "SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);", + [form.new_owner_user_id], + |row| row.get::<_, bool>(0), + ) + .unwrap_or(false) + }; + + if !user_exists { + return Redirect::to("/admin/slugs?error=New owner user ID does not exist").into_response(); + } + + let (old_owner, target_type, mut new_target_id) = + { + let conn = state.system_db.lock().unwrap(); + let row_opt: Option<(i64, String, String)> = conn.query_row( + "SELECT owner_user_id, target_type, target_id FROM global_slugs WHERE slug = ?1;", + [&form.slug], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)) + ).optional().unwrap_or(None); + match row_opt { + Some(r) => r, + None => return Redirect::to("/admin/slugs?error=Slug not found").into_response(), + } + }; + + if old_owner == form.new_owner_user_id { + return Redirect::to("/admin/slugs?error=Slug is already owned by this user") + .into_response(); + } + + let old_dbs = match state.get_user_dbs(old_owner) { + Ok(dbs) => dbs, + Err(_) => { + return Redirect::to("/admin/slugs?error=Failed to load current owner's database") + .into_response() + } + }; + let new_dbs = match state.get_user_dbs(form.new_owner_user_id) { + Ok(dbs) => dbs, + Err(_) => { + return Redirect::to("/admin/slugs?error=Failed to load new owner's database") + .into_response() + } + }; + + { + let old_conn = old_dbs.content.lock().unwrap(); + let new_conn = new_dbs.content.lock().unwrap(); + + if target_type == "url" { + let url_opt = match crate::db::content::get_url_by_code(&old_conn, &form.slug) { + Ok(u) => u, + Err(e) => { + return Redirect::to(&format!( + "/admin/slugs?error=Failed to retrieve old URL: {}", + e + )) + .into_response() + } + }; + + if let Some(url) = url_opt { + // Check quota + let users_conn = state.users_db.lock().unwrap(); + let quota_opt = + crate::db::users::get_user_quotas(&users_conn, form.new_owner_user_id) + .unwrap_or(None); + if let Some(quota) = quota_opt { + if quota.current_urls >= quota.max_urls { + return Redirect::to( + "/admin/slugs?error=New owner has exceeded URL quota limit", + ) + .into_response(); + } + } + + // Copy + let new_url = match crate::db::content::create_url_extended( + &new_conn, + &url.code, + &url.destination, + url.title.as_deref(), + url.description.as_deref(), + &url.tags, + url.expires_at.as_deref(), + url.password_hash.as_deref(), + url.max_access_count, + ) { + Ok(u) => u, + Err(e) => { + return Redirect::to(&format!( + "/admin/slugs?error=Failed to copy URL record: {}", + e + )) + .into_response() + } + }; + new_target_id = new_url.id; + + // Delete old + let _ = crate::db::content::delete_url(&old_conn, &url.id); + } + } else if target_type == "page" { + let page_opt = match crate::db::content::get_landing_page_by_code(&old_conn, &form.slug) + { + Ok(p) => p, + Err(e) => { + return Redirect::to(&format!( + "/admin/slugs?error=Failed to retrieve old page: {}", + e + )) + .into_response() + } + }; + + if let Some(page) = page_opt { + // Check quota + let users_conn = state.users_db.lock().unwrap(); + let quota_opt = + crate::db::users::get_user_quotas(&users_conn, form.new_owner_user_id) + .unwrap_or(None); + if let Some(quota) = quota_opt { + if quota.current_landings >= quota.max_landings { + return Redirect::to( + "/admin/slugs?error=New owner has exceeded landing page quota limit", + ) + .into_response(); + } + } + + // Copy + let new_page = match crate::db::content::create_landing_page( + &new_conn, + &page.code, + &page.slug, + &page.title, + &page.html_content, + &page.state, + ) { + Ok(p) => p, + Err(e) => { + return Redirect::to(&format!( + "/admin/slugs?error=Failed to copy page record: {}", + e + )) + .into_response() + } + }; + new_target_id = new_page.id; + + // Delete old + let _ = crate::db::content::delete_landing_page(&old_conn, &page.id); + } + } + } + + { + let conn = state.system_db.lock().unwrap(); + let now = Utc::now().to_rfc3339(); + + let _ = conn.execute( + "UPDATE global_slugs SET owner_user_id = ?1, target_id = ?2, updated_at = ?3 WHERE slug = ?4;", + rusqlite::params![form.new_owner_user_id, new_target_id, now, form.slug], + ); + + let _ = conn.execute( + "INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username) + VALUES (?1, ?2, ?3, 'transferred', ?4, ?5);", + rusqlite::params![form.slug, old_owner, form.new_owner_user_id, now, user.username], + ); + + let _ = crate::db::audit_events::write_audit_event( + &conn, + &user.username, + "SLUG_TRANSFER", + "slug", + &form.slug, + Some(&format!( + "Transferred from {} to {}", + old_owner, form.new_owner_user_id + )), + ); + } + + Redirect::to(&format!( + "/admin/slugs?success=Slug /{} successfully transferred to user {}", + form.slug, form.new_owner_user_id + )) + .into_response() +} + +#[derive(Deserialize)] +pub struct AdminSlugStatusForm { + pub slug: String, + pub status: String, + pub csrf_token: String, +} + +// POST /admin/slugs/status +pub async fn slugs_status_post( + State(state): State, + jar: CookieJar, + Form(form): Form, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + if !verify_csrf(&session_id, &form.csrf_token) { + return Redirect::to("/admin/slugs?error=Invalid CSRF token").into_response(); + } + + let status = form.status.trim().to_lowercase(); + if !["active", "flagged", "disabled"].contains(&status.as_str()) { + return Redirect::to("/admin/slugs?error=Invalid status").into_response(); + } + + { + let conn = state.system_db.lock().unwrap(); + let now = Utc::now().to_rfc3339(); + + let _ = conn.execute( + "UPDATE global_slugs SET status = ?1, updated_at = ?2 WHERE slug = ?3;", + rusqlite::params![status, now, form.slug], + ); + + let _ = crate::db::audit_events::write_audit_event( + &conn, + &user.username, + "SLUG_STATUS_UPDATE", + "slug", + &form.slug, + Some(&format!("Status set to {}", status)), + ); + } + + Redirect::to(&format!( + "/admin/slugs?success=Slug /{} status updated to {}", + form.slug, status + )) + .into_response() +} + +#[derive(Deserialize)] +pub struct AdminSlugDeleteForm { + pub slug: String, + pub csrf_token: String, +} + +// POST /admin/slugs/delete +pub async fn slugs_delete_post( + State(state): State, + jar: CookieJar, + Form(form): Form, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + if !verify_csrf(&session_id, &form.csrf_token) { + return Redirect::to("/admin/slugs?error=Invalid CSRF token").into_response(); + } + + { + let conn = state.system_db.lock().unwrap(); + let now = Utc::now().to_rfc3339(); + + let old_owner_user_id: Option = conn + .query_row( + "SELECT owner_user_id FROM global_slugs WHERE slug = ?1;", + [&form.slug], + |row| row.get(0), + ) + .optional() + .unwrap_or(None); + + let _ = conn.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&form.slug]); + + let _ = conn.execute( + "INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username) + VALUES (?1, ?2, NULL, 'deleted', ?3, ?4);", + rusqlite::params![form.slug, old_owner_user_id, now, user.username], + ); + + let _ = crate::db::audit_events::write_audit_event( + &conn, + &user.username, + "SLUG_RELEASE", + "slug", + &form.slug, + Some("Slug released/deleted from global index"), + ); + } + + Redirect::to(&format!( + "/admin/slugs?success=Slug /{} released successfully", + form.slug + )) + .into_response() +} + +// GET /admin/sessions +pub async fn sessions_get( + State(state): State, + jar: CookieJar, + Query(params): Query>, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let sessions = { + let conn = state.users_db.lock().unwrap(); + let mut stmt = conn.prepare("SELECT id, user_id, expires_at, created_at FROM sessions ORDER BY created_at DESC;").unwrap(); + let rows = stmt + .query_map([], |row| { + Ok(crate::models::UserSession { + id: row.get(0)?, + user_id: row.get(1)?, + expires_at: row.get(2)?, + created_at: row.get(3)?, + }) + }) + .unwrap(); + rows.filter_map(|r| r.ok()).collect() + }; + + let csrf_token = generate_csrf_token(&session_id); + + let template = crate::templates::SessionsTemplate { + admin_username: user.username, + sessions, + csrf_token, + success: params.get("success").cloned(), + error: params.get("error").cloned(), + }; + + template.into_response() +} + +// POST /admin/sessions/revoke/:id +pub async fn sessions_revoke_post( + State(state): State, + jar: CookieJar, + Path(id): Path, + Form(form): Form>, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let form_csrf = form.get("csrf_token").cloned().unwrap_or_default(); + if !verify_csrf(&session_id, &form_csrf) { + return Redirect::to("/admin/sessions?error=Invalid CSRF token").into_response(); + } + + { + let conn = state.users_db.lock().unwrap(); + let _ = conn.execute("DELETE FROM sessions WHERE id = ?1;", [&id]); + } + + { + let conn_sys = state.system_db.lock().unwrap(); + let _ = crate::db::audit_events::write_audit_event( + &conn_sys, + &user.username, + "SESSION_REVOKED", + "session", + &id, + None, + ); + } + + Redirect::to("/admin/sessions?success=Session revoked").into_response() +} + +// POST /admin/sessions/revoke-all +pub async fn sessions_revoke_all_post( + State(state): State, + jar: CookieJar, + Form(form): Form>, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let form_csrf = form.get("csrf_token").cloned().unwrap_or_default(); + if !verify_csrf(&session_id, &form_csrf) { + return Redirect::to("/admin/sessions?error=Invalid CSRF token").into_response(); + } + + { + let conn = state.users_db.lock().unwrap(); + let _ = conn.execute("DELETE FROM sessions;", []); + } + + { + let conn_sys = state.system_db.lock().unwrap(); + let _ = crate::db::audit_events::write_audit_event( + &conn_sys, + &user.username, + "SESSIONS_ALL_REVOKED", + "session", + "all", + None, + ); + } + + Redirect::to("/admin/sessions?success=All active sessions revoked").into_response() +} + +// GET /admin/quotas +pub async fn quotas_get( + State(state): State, + jar: CookieJar, + Query(params): Query>, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let quotas = { + let conn = state.users_db.lock().unwrap(); + let mut stmt = conn.prepare("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 ORDER BY user_id ASC;").unwrap(); + let rows = stmt + .query_map([], |row| { + Ok(crate::models::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)?, + }) + }) + .unwrap(); + rows.filter_map(|r| r.ok()).collect() + }; + + let csrf_token = generate_csrf_token(&session_id); + + let template = crate::templates::QuotasTemplate { + admin_username: user.username, + quotas, + csrf_token, + success: params.get("success").cloned(), + error: params.get("error").cloned(), + }; + + template.into_response() +} + +// POST /admin/quotas +pub async fn quotas_post( + State(state): State, + jar: CookieJar, + Form(form): Form>, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let form_csrf = form.get("csrf_token").cloned().unwrap_or_default(); + if !verify_csrf(&session_id, &form_csrf) { + return Redirect::to("/admin/quotas?error=Invalid CSRF token").into_response(); + } + + let action = form.get("action").cloned().unwrap_or_default(); + let users_conn = state.users_db.lock().unwrap(); + + if action == "reconcile_all" { + let user_ids: Vec = { + let mut stmt = users_conn.prepare("SELECT id FROM users;").unwrap(); + let rows = stmt.query_map([], |row| row.get(0)).unwrap(); + rows.filter_map(|r| r.ok()).collect() + }; + + for uid in user_ids { + if let Ok(user_dbs) = state.get_user_dbs(uid) { + let user_content_conn = user_dbs.content.lock().unwrap(); + let _ = + crate::db::users::reconcile_user_quotas(&users_conn, uid, &user_content_conn); + } + } + + let system_conn = state.system_db.lock().unwrap(); + let _ = crate::db::audit_events::write_audit_event( + &system_conn, + &user.username, + "QUOTAS_RECONCILE_ALL", + "quota", + "all", + None, + ); + + Redirect::to("/admin/quotas?success=All user quotas reconciled successfully") + .into_response() + } else if action == "reconcile" { + let uid_str = form.get("user_id").cloned().unwrap_or_default(); + let uid = uid_str.parse::().unwrap_or(0); + if let Ok(user_dbs) = state.get_user_dbs(uid) { + let user_content_conn = user_dbs.content.lock().unwrap(); + let _ = crate::db::users::reconcile_user_quotas(&users_conn, uid, &user_content_conn); + + let system_conn = state.system_db.lock().unwrap(); + let _ = crate::db::audit_events::write_audit_event( + &system_conn, + &user.username, + "QUOTAS_RECONCILE", + "quota", + &uid.to_string(), + None, + ); + Redirect::to(&format!("/admin/users/{}?success=Quotas reconciled", uid)).into_response() + } else { + Redirect::to("/admin/quotas?error=User databases not found").into_response() + } + } else { + Redirect::to("/admin/quotas?error=Invalid action").into_response() + } +} + +// GET /admin/health +pub async fn health_get( + State(state): State, + jar: CookieJar, + Query(params): Query>, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let mut db_reports = vec![]; + if let Ok(r) = + crate::db::sqlite::collect_health_report(&state.admin_db.lock().unwrap(), "admin") + { + db_reports.push(r); + } + if let Ok(r) = + crate::db::sqlite::collect_health_report(&state.system_db.lock().unwrap(), "system") + { + db_reports.push(r); + } + if let Ok(r) = + crate::db::sqlite::collect_health_report(&state.users_db.lock().unwrap(), "users") + { + db_reports.push(r); + } + + let system_db_path = state.config.data_dir.join("admin").join("system.db"); + let users_db_path = state.config.data_dir.join("admin").join("users.db"); + let admin_db_path = state.config.data_dir.join("admin").join("admin.db"); + + let system_db_size = format_size( + std::fs::metadata(&system_db_path) + .map(|m| m.len()) + .unwrap_or(0), + ); + let users_db_size = format_size( + std::fs::metadata(&users_db_path) + .map(|m| m.len()) + .unwrap_or(0), + ); + let admin_db_size = format_size( + std::fs::metadata(&admin_db_path) + .map(|m| m.len()) + .unwrap_or(0), + ); + + let users_dir = state.config.data_dir.join("users"); + let tenants_db_size = format_size(get_dir_size(&users_dir).unwrap_or(0)); + let total_data_size = format_size(get_dir_size(&state.config.data_dir).unwrap_or(0)); + + let job_history = { + let conn = state.system_db.lock().unwrap(); + let mut stmt = conn.prepare("SELECT id, job_name, status, started_at, finished_at, error_message FROM job_history ORDER BY started_at DESC LIMIT 20;").unwrap(); + let rows = stmt + .query_map([], |row| { + Ok(JobHistoryRow { + id: row.get(0)?, + job_name: row.get(1)?, + status: row.get(2)?, + started_at: row.get(3)?, + finished_at: row.get(4)?, + error_message: row.get(5)?, + }) + }) + .unwrap(); + rows.filter_map(|r| r.ok()).collect() + }; + + let health_checks = { + let conn = state.system_db.lock().unwrap(); + let mut stmt = conn.prepare("SELECT id, object_type, object_id, checked_at, status_code, error_message, is_healthy FROM health_checks ORDER BY checked_at DESC LIMIT 20;").unwrap(); + let rows = stmt + .query_map([], |row| { + Ok(HealthCheckRow { + id: row.get(0)?, + object_type: row.get(1)?, + object_id: row.get(2)?, + checked_at: row.get(3)?, + status_code: row.get(4)?, + error_message: row.get(5)?, + is_healthy: row.get(6)?, + }) + }) + .unwrap(); + rows.filter_map(|r| r.ok()).collect() + }; + + let csrf_token = generate_csrf_token(&session_id); + + let template = crate::templates::HealthTemplate { + admin_username: user.username, + db_reports, + total_data_size, + system_db_size, + users_db_size, + admin_db_size, + tenants_db_size, + job_history, + health_checks, + csrf_token, + success: params.get("success").cloned(), + error: params.get("error").cloned(), + }; + + template.into_response() +} + +// GET /admin/backups +pub async fn backups_get( + State(state): State, + jar: CookieJar, + Query(params): Query>, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let mut files = vec![]; + if let Ok(dir_entries) = std::fs::read_dir(&state.config.backup_dir) { + for entry in dir_entries.flatten() { + let path = entry.path(); + if path.is_file() { + if let Some(filename) = path + .file_name() + .and_then(|n| n.to_str()) + .map(|s| s.to_string()) + { + if filename.ends_with(".tar.gz") { + let meta = entry.metadata().unwrap(); + let size_str = format_size(meta.len()); + let created_str = meta + .created() + .ok() + .map(|c| { + let datetime: chrono::DateTime = c.into(); + datetime.format("%Y-%m-%d %H:%M:%S").to_string() + }) + .unwrap_or_else(|| "-".to_string()); + files.push(BackupFileRow { + filename, + size_str, + created_str, + }); + } + } + } + } + } + files.sort_by(|a, b| b.filename.cmp(&a.filename)); + + let history = { + let conn = state.system_db.lock().unwrap(); + let mut stmt = conn.prepare("SELECT id, backup_path, status, created_at, size_bytes, error_message FROM backup_history ORDER BY created_at DESC LIMIT 30;").unwrap(); + let rows = stmt + .query_map([], |row| { + Ok(BackupHistoryRow { + id: row.get(0)?, + backup_path: row.get(1)?, + status: row.get(2)?, + created_at: row.get(3)?, + size_bytes: row.get(4)?, + error_message: row.get(5)?, + }) + }) + .unwrap(); + rows.filter_map(|r| r.ok()).collect() + }; + + let csrf_token = generate_csrf_token(&session_id); + + let template = crate::templates::BackupsTemplate { + admin_username: user.username, + files, + history, + csrf_token, + success: params.get("success").cloned(), + error: params.get("error").cloned(), + }; + + template.into_response() +} + +// POST /admin/backups/create +pub async fn backups_create_post( + State(state): State, + jar: CookieJar, + Form(form): Form>, +) -> Response { + let (_user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let form_csrf = form.get("csrf_token").cloned().unwrap_or_default(); + if !verify_csrf(&session_id, &form_csrf) { + return Redirect::to("/admin/backups?error=Invalid CSRF token").into_response(); + } + + match crate::jobs::backup::perform_backup(&state.db, &state.config).await { + Ok(path) => { + let filename = std::path::Path::new(&path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("backup.tar.gz"); + Redirect::to(&format!( + "/admin/backups?success=Backup created successfully: {}", + filename + )) + .into_response() + } + Err(e) => Redirect::to(&format!( + "/admin/backups?error=Failed to generate backup: {}", + e + )) + .into_response(), + } +} + +// GET /admin/backups/download/:filename +pub async fn backups_download_get( + State(state): State, + jar: CookieJar, + Path(filename): Path, +) -> Response { + if require_auth(&state, &jar).await.is_err() { + return StatusCode::UNAUTHORIZED.into_response(); + } + + if filename.contains('/') || filename.contains('\\') || filename.contains("..") { + return StatusCode::BAD_REQUEST.into_response(); + } + + let backup_path = state.config.backup_dir.join(&filename); + if !backup_path.exists() { + return StatusCode::NOT_FOUND.into_response(); + } + + match std::fs::read(&backup_path) { + Ok(bytes) => { + let body = axum::body::Body::from(bytes); + Response::builder() + .header("content-type", "application/octet-stream") + .header( + "content-disposition", + format!("attachment; filename=\"{}\"", filename), + ) + .body(body) + .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()) + } + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} + +// POST /admin/backups/delete/:filename +pub async fn backups_delete_post( + State(state): State, + jar: CookieJar, + Path(filename): Path, + Form(form): Form>, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let form_csrf = form.get("csrf_token").cloned().unwrap_or_default(); + if !verify_csrf(&session_id, &form_csrf) { + return Redirect::to("/admin/backups?error=Invalid CSRF token").into_response(); + } + + if filename.contains('/') || filename.contains('\\') || filename.contains("..") { + return Redirect::to("/admin/backups?error=Invalid filename").into_response(); + } + + let backup_path = state.config.backup_dir.join(&filename); + if !backup_path.exists() { + return Redirect::to("/admin/backups?error=Backup file not found").into_response(); + } + + match std::fs::remove_file(&backup_path) { + Ok(_) => { + let system_conn = state.system_db.lock().unwrap(); + let _ = crate::db::audit_events::write_audit_event( + &system_conn, + &user.username, + "BACKUP_DELETE", + "backup", + &filename, + None, + ); + Redirect::to("/admin/backups?success=Backup archive deleted").into_response() + } + Err(e) => Redirect::to(&format!( + "/admin/backups?error=Failed to delete backup file: {}", + e + )) + .into_response(), + } +} + +// POST /admin/backups/restore +pub async fn backups_restore_post( + State(state): State, + jar: CookieJar, + mut multipart: axum::extract::Multipart, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let mut backup_bytes = vec![]; + let mut confirm_text = String::new(); + let mut csrf_token = String::new(); + + while let Ok(Some(field)) = multipart.next_field().await { + let name = field.name().unwrap_or_default().to_string(); + if name == "backup_file" { + if let Ok(bytes) = field.bytes().await { + backup_bytes = bytes.to_vec(); + } + } else if name == "confirm_text" { + if let Ok(text) = field.text().await { + confirm_text = text.trim().to_string(); + } + } else if name == "csrf_token" { + if let Ok(text) = field.text().await { + csrf_token = text.trim().to_string(); + } + } + } + + if !verify_csrf(&session_id, &csrf_token) { + return Redirect::to("/admin/backups?error=Invalid CSRF token").into_response(); + } + + if confirm_text != "RESTORE" { + return Redirect::to( + "/admin/backups?error=Confirmation text mismatch. Please type RESTORE.", + ) + .into_response(); + } + + if backup_bytes.is_empty() { + return Redirect::to("/admin/backups?error=Backup file is empty or missing.") + .into_response(); + } + + let temp_file_path = state.config.data_dir.join("temp-restore-upload.tar.gz"); + if let Err(e) = std::fs::write(&temp_file_path, &backup_bytes) { + return Redirect::to(&format!( + "/admin/backups?error=Failed to save uploaded file: {}", + e + )) + .into_response(); + } + + match crate::cli::restore::run( + temp_file_path.to_string_lossy().to_string(), + None, + state.config.clone(), + ) + .await + { + Ok(_) => { + let _ = std::fs::remove_file(&temp_file_path); + let conn = state.users_db.lock().unwrap(); + let _ = conn.execute("DELETE FROM sessions;", []); + + let system_conn = state.system_db.lock().unwrap(); + let _ = crate::db::audit_events::write_audit_event( + &system_conn, + &user.username, + "RESTORE_EXECUTION", + "backup", + "upload", + None, + ); + Redirect::to("/admin/login?success=Restore successful. Please log in again.") + .into_response() + } + Err(e) => { + let _ = std::fs::remove_file(&temp_file_path); + Redirect::to(&format!("/admin/backups?error=Restore failed: {}", e)).into_response() + } + } +} + +// GET /api-tokens +pub async fn api_tokens_get( + State(state): State, + jar: CookieJar, + Query(params): Query>, +) -> Response { + let (user, session_id) = match require_user_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let tokens = { + let conn = state.users_db.lock().unwrap(); + let mut stmt = conn + .prepare( + "SELECT id, user_id, token_hash, created_at FROM api_tokens WHERE user_id = ?1;", + ) + .unwrap(); + let rows = stmt + .query_map([user.id], |row| { + Ok(crate::models::UserApiToken { + id: row.get(0)?, + user_id: row.get(1)?, + token_hash: row.get(2)?, + created_at: row.get(3)?, + }) + }) + .unwrap(); + rows.filter_map(|r| r.ok()).collect() + }; + + let csrf_token = generate_csrf_token(&session_id); + + let template = crate::templates::ApiTokensTemplate { + admin_username: user.username.clone(), + username: user.username, + tokens, + new_token: params.get("new_token").cloned(), + csrf_token, + success: params.get("success").cloned(), + error: params.get("error").cloned(), + }; + + template.into_response() +} + +// POST /api-tokens/create +pub async fn api_tokens_create_post( + State(state): State, + jar: CookieJar, + Form(form): Form>, +) -> Response { + let (user, session_id) = match require_user_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let form_csrf = form.get("csrf_token").cloned().unwrap_or_default(); + if !verify_csrf(&session_id, &form_csrf) { + return Redirect::to("/api-tokens?error=Invalid CSRF token").into_response(); + } + + use sha2::Digest; + let raw_token = format!("key_{}", generate_token(32)); + let mut hasher = sha2::Sha256::new(); + hasher.update(raw_token.as_bytes()); + let hashed_token = hex::encode(hasher.finalize()); + + { + let conn = state.users_db.lock().unwrap(); + let now = Utc::now().to_rfc3339(); + let _ = conn.execute( + "INSERT INTO api_tokens (user_id, token_hash, created_at) VALUES (?1, ?2, ?3);", + rusqlite::params![user.id, hashed_token, now], + ); + } + + { + let conn_sys = state.system_db.lock().unwrap(); + let _ = crate::db::audit_events::write_audit_event( + &conn_sys, + &user.username, + "API_TOKEN_CREATED", + "api_token", + "new", + None, + ); + } + + Redirect::to(&format!( + "/api-tokens?new_token={}&success=Token generated successfully", + raw_token + )) + .into_response() +} + +// POST /api-tokens/revoke/:id +pub async fn api_tokens_revoke_post( + State(state): State, + jar: CookieJar, + Path(token_id): Path, + Form(form): Form>, +) -> Response { + let (user, session_id) = match require_user_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let form_csrf = form.get("csrf_token").cloned().unwrap_or_default(); + if !verify_csrf(&session_id, &form_csrf) { + return Redirect::to("/api-tokens?error=Invalid CSRF token").into_response(); + } + + { + let conn = state.users_db.lock().unwrap(); + let _ = conn.execute( + "DELETE FROM api_tokens WHERE id = ?1 AND user_id = ?2;", + [token_id, user.id], + ); + } + + { + let conn_sys = state.system_db.lock().unwrap(); + let _ = crate::db::audit_events::write_audit_event( + &conn_sys, + &user.username, + "API_TOKEN_REVOKED", + "api_token", + &token_id.to_string(), + None, + ); + } + + Redirect::to("/api-tokens?success=API token revoked").into_response() +} + +// GET /analytics +pub async fn user_analytics_get( + State(state): State, + jar: CookieJar, + Query(params): Query>, +) -> Response { + let (user, session_id) = match require_user_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let user_dbs = match state.get_user_dbs(user.id) { + Ok(dbs) => dbs, + Err(_) => return Redirect::to("/user/dashboard?error=Database error").into_response(), + }; + + let conn = user_dbs.analytics.lock().unwrap(); + + let total_clicks = conn + .query_row("SELECT COUNT(*) FROM visits;", [], |row| { + row.get::<_, i64>(0) + }) + .unwrap_or(0); + let unique_visitors = conn + .query_row( + "SELECT COUNT(DISTINCT ip_address) FROM visits;", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap_or(0); + let direct_clicks = conn + .query_row( + "SELECT COUNT(*) FROM visits WHERE referer = '' OR referer = 'direct';", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap_or(0); + let referred_clicks = total_clicks - direct_clicks; + + let referrers_chart = "
No referrer channels logged yet
".to_string(); + let browsers_chart = "
No browser traffic logged yet
".to_string(); + + let visits = { + let mut stmt = conn.prepare("SELECT id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code FROM visits ORDER BY timestamp DESC LIMIT 50;").unwrap(); + let rows = stmt + .query_map([], |row| { + Ok(crate::models::VisitRecord { + id: row.get(0)?, + target_type: row.get(1)?, + target_id: row.get(2)?, + timestamp: row.get(3)?, + ip_address: row.get(4)?, + user_agent: row.get(5)?, + referer: row.get(6)?, + accept_language: row.get(7)?, + country: row.get(8)?, + status_code: row.get(9)?, + owner_user_id: Some(user.id), + }) + }) + .unwrap(); + rows.filter_map(|r| r.ok()).collect() + }; + + let csrf_token = generate_csrf_token(&session_id); + + let template = crate::templates::UserAnalyticsTemplate { + admin_username: user.username.clone(), + username: user.username, + total_clicks, + unique_visitors, + direct_clicks, + referred_clicks, + referrers_chart, + browsers_chart, + visits, + csrf_token, + success: params.get("success").cloned(), + error: params.get("error").cloned(), + }; + + template.into_response() +} + +pub async fn user_url_analytics_get( + State(state): State, + jar: CookieJar, + Path(id): Path, +) -> Response { + let (user, _session_id) = match require_user_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let user_dbs = match state.get_user_dbs(user.id) { + Ok(dbs) => dbs, + Err(_) => return Redirect::to("/user/urls?error=Database error").into_response(), + }; + + let url = { + let conn = user_dbs.content.lock().unwrap(); + match crate::db::content::get_url_by_id(&conn, &id) { + Ok(Some(u)) => u, + Ok(None) => return Redirect::to("/user/urls?error=Link not found").into_response(), + Err(_) => return Redirect::to("/user/urls?error=Database error").into_response(), + } + }; + + let visits = { + let conn = user_dbs.analytics.lock().unwrap(); + let mut stmt = conn + .prepare( + "SELECT id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code + FROM visits WHERE target_type = 'url' AND target_id = ?1 ORDER BY timestamp DESC;" + ) + .unwrap(); + let rows = stmt + .query_map([&url.id], |row| { + Ok(crate::models::VisitRecord { + id: row.get(0)?, + target_type: row.get(1)?, + target_id: row.get(2)?, + timestamp: row.get(3)?, + ip_address: row.get(4)?, + user_agent: row.get(5)?, + referer: row.get(6)?, + accept_language: row.get(7)?, + country: row.get(8)?, + status_code: row.get(9)?, + owner_user_id: Some(user.id), + }) + }) + .unwrap(); + rows.filter_map(|r| r.ok()) + .enumerate() + .map(|(idx, r)| { + let (browser, _, _) = parse_ua(&r.user_agent); + let referrer = clean_referrer(&r.referer); + crate::templates::VisitorLogEntry { + sr: idx + 1, + timestamp: r.timestamp, + ip_address: r.ip_address, + country: if r.country.is_empty() { + "Unknown".to_string() + } else { + r.country + }, + referrer, + browser, + user_agent: r.user_agent, + utm_source: "-".to_string(), + utm_campaign: "-".to_string(), + } + }) + .collect::>() + }; + + let template = crate::templates::UserUrlAnalyticsTemplate { + admin_username: user.username.clone(), + username: user.username, + url_code: url.code, + destination: url.destination, + visits, + }; + + template.into_response() +} + +pub async fn user_page_analytics_get( + State(state): State, + jar: CookieJar, + Path(id): Path, +) -> Response { + let (user, _session_id) = match require_user_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let user_dbs = match state.get_user_dbs(user.id) { + Ok(dbs) => dbs, + Err(_) => return Redirect::to("/user/pages?error=Database error").into_response(), + }; + + let page = { + let conn = user_dbs.content.lock().unwrap(); + match crate::db::content::get_landing_page_by_id(&conn, &id) { + Ok(Some(p)) => p, + Ok(None) => { + return Redirect::to("/user/pages?error=Landing page not found").into_response() + } + Err(_) => return Redirect::to("/user/pages?error=Database error").into_response(), + } + }; + + let visits = { + let conn = user_dbs.analytics.lock().unwrap(); + let mut stmt = conn + .prepare( + "SELECT id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code + FROM visits WHERE target_type = 'page' AND target_id = ?1 ORDER BY timestamp DESC;" + ) + .unwrap(); + let rows = stmt + .query_map([&page.id], |row| { + Ok(crate::models::VisitRecord { + id: row.get(0)?, + target_type: row.get(1)?, + target_id: row.get(2)?, + timestamp: row.get(3)?, + ip_address: row.get(4)?, + user_agent: row.get(5)?, + referer: row.get(6)?, + accept_language: row.get(7)?, + country: row.get(8)?, + status_code: row.get(9)?, + owner_user_id: Some(user.id), + }) + }) + .unwrap(); + rows.filter_map(|r| r.ok()) + .enumerate() + .map(|(idx, r)| { + let (browser, _, _) = parse_ua(&r.user_agent); + let referrer = clean_referrer(&r.referer); + crate::templates::VisitorLogEntry { + sr: idx + 1, + timestamp: r.timestamp, + ip_address: r.ip_address, + country: if r.country.is_empty() { + "Unknown".to_string() + } else { + r.country + }, + referrer, + browser, + user_agent: r.user_agent, + utm_source: "-".to_string(), + utm_campaign: "-".to_string(), + } + }) + .collect::>() + }; + + let template = crate::templates::UserPageAnalyticsTemplate { + admin_username: user.username.clone(), + username: user.username, + page_code: page.code, + title: page.title, + visits, + }; + + template.into_response() +} diff --git a/src/web/api.rs b/src/web/api.rs index 5e8de9b..be9ef99 100644 --- a/src/web/api.rs +++ b/src/web/api.rs @@ -167,7 +167,7 @@ pub async fn api_create_url( let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok()); let _ = write_audit_log( &state.admin_db.lock().unwrap(), - &user.0.username, + user.0.username(), "URL_CREATION", Some("url"), Some(&url.id), @@ -180,7 +180,7 @@ pub async fn api_create_url( let system_conn = state.system_db.lock().unwrap(); let _ = crate::db::audit_events::write_audit_event( &system_conn, - &user.0.username, + user.0.username(), "URL_CREATION", "url", &url.id, @@ -314,7 +314,7 @@ pub async fn api_update_url( let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok()); let _ = write_audit_log( &state.admin_db.lock().unwrap(), - &user.0.username, + user.0.username(), "URL_UPDATE", Some("url"), Some(&uuid), @@ -327,7 +327,7 @@ pub async fn api_update_url( let system_conn = state.system_db.lock().unwrap(); let _ = crate::db::audit_events::write_audit_event( &system_conn, - &user.0.username, + user.0.username(), "URL_UPDATE", "url", &uuid, @@ -369,7 +369,7 @@ pub async fn api_delete_url( let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok()); let _ = write_audit_log( &state.admin_db.lock().unwrap(), - &user.0.username, + user.0.username(), "URL_DELETION", Some("url"), Some(&uuid), @@ -382,7 +382,7 @@ pub async fn api_delete_url( let system_conn = state.system_db.lock().unwrap(); let _ = crate::db::audit_events::write_audit_event( &system_conn, - &user.0.username, + user.0.username(), "URL_DELETION", "url", &uuid, @@ -448,7 +448,7 @@ pub async fn api_create_page( let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok()); let _ = write_audit_log( &state.admin_db.lock().unwrap(), - &user.0.username, + user.0.username(), "PAGE_CREATION", Some("page"), Some(&page.id), @@ -549,7 +549,7 @@ pub async fn api_update_page( let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok()); let _ = write_audit_log( &state.admin_db.lock().unwrap(), - &user.0.username, + user.0.username(), "PAGE_UPDATE", Some("page"), Some(&uuid), @@ -590,7 +590,7 @@ pub async fn api_delete_page( let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok()); let _ = write_audit_log( &state.admin_db.lock().unwrap(), - &user.0.username, + user.0.username(), "PAGE_DELETION", Some("page"), Some(&uuid), @@ -889,7 +889,7 @@ pub async fn api_set_preview( let system_conn = state.system_db.lock().unwrap(); let _ = crate::db::audit_events::write_audit_event( &system_conn, - &user.0.username, + user.0.username(), "SET_PREVIEW", "url", &uuid, @@ -976,7 +976,7 @@ pub async fn api_delete_preview( let system_conn = state.system_db.lock().unwrap(); let _ = crate::db::audit_events::write_audit_event( &system_conn, - &user.0.username, + user.0.username(), "DELETE_PREVIEW", "url", &uuid, @@ -1051,7 +1051,7 @@ pub async fn api_set_password( let system_conn = state.system_db.lock().unwrap(); let _ = crate::db::audit_events::write_audit_event( &system_conn, - &user.0.username, + user.0.username(), "SET_PASSWORD", "url", &uuid, @@ -1109,7 +1109,7 @@ pub async fn api_remove_password( let system_conn = state.system_db.lock().unwrap(); let _ = crate::db::audit_events::write_audit_event( &system_conn, - &user.0.username, + user.0.username(), "REMOVE_PASSWORD", "url", &uuid, @@ -1177,7 +1177,7 @@ pub async fn api_create_qr( let system_conn = state.system_db.lock().unwrap(); let _ = crate::db::audit_events::write_audit_event( &system_conn, - &user.0.username, + user.0.username(), "CREATE_QR", "qr_code", &payload.url_id, diff --git a/src/web/bulk.rs b/src/web/bulk.rs index b2d721f..45404a0 100644 --- a/src/web/bulk.rs +++ b/src/web/bulk.rs @@ -120,7 +120,7 @@ pub async fn api_bulk_qr( let system_conn = state.db.system.lock().unwrap(); let _ = crate::db::audit_events::write_audit_event( &system_conn, - &user.0.username, + user.0.username(), "BULK_QR_EXPORT", "bulk", "qr", @@ -271,7 +271,7 @@ pub async fn api_bulk_url( let system_conn = state.db.system.lock().unwrap(); let _ = crate::db::audit_events::write_audit_event( &system_conn, - &user.0.username, + user.0.username(), "BULK_URL_CREATION", "bulk", "url", diff --git a/src/web/mod.rs b/src/web/mod.rs index fcf6fd3..3cf05f2 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -2,6 +2,7 @@ pub mod admin; pub mod api; pub mod bulk; pub mod middleware; +pub mod multi_user; pub mod pages; pub mod password_gate; pub mod qr; diff --git a/src/web/multi_user.rs b/src/web/multi_user.rs new file mode 100644 index 0000000..9a80527 --- /dev/null +++ b/src/web/multi_user.rs @@ -0,0 +1,991 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Json, Response}, +}; +use chrono::Utc; +use rusqlite::OptionalExtension; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::auth::ApiUser; +use crate::models::ApiActor; +use crate::state::AppState; + +#[derive(Serialize, Deserialize)] +pub struct CreateUserRequest { + pub username: String, + pub password: String, + pub account_type: Option, + pub metadata: Option, +} + +#[derive(Serialize)] +pub struct UserResponse { + pub id: i64, + pub username: String, + pub status: String, + pub account_type: String, + pub created_at: String, + pub metadata: Option, +} + +#[derive(Serialize, Deserialize)] +pub struct UpdateUserStatusRequest { + pub status: String, +} + +#[derive(Serialize, Deserialize)] +pub struct UpdateUserQuotasRequest { + pub max_urls: i64, + pub max_landings: i64, + pub max_api_tokens: i64, + pub max_storage_mb: i64, +} + +#[derive(Serialize, Deserialize)] +pub struct ResetPasswordRequest { + pub password: String, +} + +#[derive(Serialize, Deserialize)] +pub struct TransferSlugRequest { + pub slug: String, + pub new_owner_user_id: i64, +} + +#[derive(Serialize, Deserialize)] +pub struct ModerateSlugRequest { + pub slug: String, + pub action: String, // 'flagged', 'disabled', 'active' + pub severity: String, // 'low', 'medium', 'high', 'critical' + pub reason: String, +} + +#[derive(Serialize)] +pub struct ModerationEventResponse { + pub id: String, + pub timestamp: String, + pub admin_username: String, + pub target_user_id: i64, + pub target_username: Option, + pub resource_type: String, + pub resource_identifier: String, + pub action: String, + pub severity: String, + pub reason: String, +} + +#[derive(Serialize, Deserialize)] +pub struct ChangeOwnPasswordRequest { + pub old_password: String, + pub new_password: String, +} + +#[derive(Serialize, Deserialize)] +pub struct CreateApiTokenRequest { + // No request body needed, token is generated securely +} + +#[derive(Serialize)] +pub struct CreateApiTokenResponse { + pub id: i64, + pub token: String, // Cleartext token returned once + pub created_at: String, +} + +// Helper: Ensure the request actor is an Admin +#[allow(clippy::result_large_err)] +fn require_admin_role(user: &ApiUser) -> Result<&crate::models::User, Response> { + match &user.0 { + ApiActor::Admin(admin) => Ok(admin), + _ => Err((StatusCode::FORBIDDEN, "Admin privileges required").into_response()), + } +} + +// --- Admin: User CRUD Endpoints --- + +// GET /api/v1/admin/users +pub async fn admin_list_users(State(state): State, user: ApiUser) -> Response { + if let Err(err_resp) = require_admin_role(&user) { + return err_resp; + } + + let conn = state.users_db.lock().unwrap(); + match crate::db::users::list_users(&conn) { + Ok(users) => { + let resp: Vec = users + .into_iter() + .map(|u| UserResponse { + id: u.id, + username: u.username, + status: u.status, + account_type: u.account_type, + created_at: u.created_at, + metadata: u.metadata, + }) + .collect(); + Json(resp).into_response() + } + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +// POST /api/v1/admin/users +pub async fn admin_create_user( + State(state): State, + user: ApiUser, + Json(payload): Json, +) -> Response { + let admin = match require_admin_role(&user) { + Ok(a) => a, + Err(err_resp) => return err_resp, + }; + + // Username validation: minimum 3 chars, alphanumeric, hyphen, underscore + let username = payload.username.trim().to_lowercase(); + if username.len() < 3 { + return ( + StatusCode::BAD_REQUEST, + "Username must be at least 3 characters", + ) + .into_response(); + } + if !username + .chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_') + { + return ( + StatusCode::BAD_REQUEST, + "Username must contain only alphanumeric characters, hyphens, or underscores", + ) + .into_response(); + } + + // Hash password + let hash = match crate::auth::password::hash_password(&payload.password) { + Ok(h) => h, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Hashing error: {}", e), + ) + .into_response() + } + }; + + let conn = state.users_db.lock().unwrap(); + let account_type = payload.account_type.as_deref().unwrap_or("standard"); + match crate::db::users::create_user( + &conn, + &username, + &hash, + account_type, + payload.metadata.as_deref(), + ) { + Ok(new_user) => { + // Write system audit event + { + let system_conn = state.system_db.lock().unwrap(); + let _ = crate::db::audit_events::write_audit_event( + &system_conn, + &admin.username, + "USER_CREATION", + "user", + &new_user.id.to_string(), + Some(&format!("Username: {}", new_user.username)), + ); + } + + Json(UserResponse { + id: new_user.id, + username: new_user.username, + status: new_user.status, + account_type: new_user.account_type, + created_at: new_user.created_at, + metadata: new_user.metadata, + }) + .into_response() + } + Err(rusqlite::Error::SqliteFailure(err, _)) + if err.code == rusqlite::ErrorCode::ConstraintViolation => + { + (StatusCode::CONFLICT, "Username already exists").into_response() + } + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +// PUT /api/v1/admin/users/:id/status +pub async fn admin_update_user_status( + State(state): State, + user: ApiUser, + Path(target_id): Path, + Json(payload): Json, +) -> Response { + let admin = match require_admin_role(&user) { + Ok(a) => a, + Err(err_resp) => return err_resp, + }; + + let status = payload.status.trim().to_lowercase(); + if !["active", "disabled", "suspended", "pending", "deleted"].contains(&status.as_str()) { + return (StatusCode::BAD_REQUEST, "Invalid user status").into_response(); + } + + let conn = state.users_db.lock().unwrap(); + match crate::db::users::update_user_status(&conn, target_id, &status) { + Ok(_) => { + let system_conn = state.system_db.lock().unwrap(); + let _ = crate::db::audit_events::write_audit_event( + &system_conn, + &admin.username, + "USER_STATUS_UPDATE", + "user", + &target_id.to_string(), + Some(&format!("New Status: {}", status)), + ); + StatusCode::OK.into_response() + } + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +// PUT /api/v1/admin/users/:id/quotas +pub async fn admin_update_user_quotas( + State(state): State, + user: ApiUser, + Path(target_id): Path, + Json(payload): Json, +) -> Response { + let admin = match require_admin_role(&user) { + Ok(a) => a, + Err(err_resp) => return err_resp, + }; + + let conn = state.users_db.lock().unwrap(); + match crate::db::users::update_user_quotas( + &conn, + target_id, + payload.max_urls, + payload.max_landings, + payload.max_api_tokens, + payload.max_storage_mb, + ) { + Ok(_) => { + let system_conn = state.system_db.lock().unwrap(); + let _ = crate::db::audit_events::write_audit_event( + &system_conn, + &admin.username, + "USER_QUOTA_UPDATE", + "user", + &target_id.to_string(), + Some(&format!( + "max_urls: {}, max_landings: {}, max_api_tokens: {}, max_storage_mb: {}", + payload.max_urls, + payload.max_landings, + payload.max_api_tokens, + payload.max_storage_mb + )), + ); + StatusCode::OK.into_response() + } + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +// POST /api/v1/admin/users/:id/password +pub async fn admin_reset_user_password( + State(state): State, + user: ApiUser, + Path(target_id): Path, + Json(payload): Json, +) -> Response { + let admin = match require_admin_role(&user) { + Ok(a) => a, + Err(err_resp) => return err_resp, + }; + + let hash = match crate::auth::password::hash_password(&payload.password) { + Ok(h) => h, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Hashing error: {}", e), + ) + .into_response() + } + }; + + let conn = state.users_db.lock().unwrap(); + match crate::db::users::reset_user_password(&conn, target_id, &hash) { + Ok(_) => { + let system_conn = state.system_db.lock().unwrap(); + let _ = crate::db::audit_events::write_audit_event( + &system_conn, + &admin.username, + "USER_PASSWORD_RESET", + "user", + &target_id.to_string(), + None, + ); + StatusCode::OK.into_response() + } + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +pub fn delete_user_resources( + state: &AppState, + target_id: i64, + admin_username: &str, + force: bool, +) -> Result<(), String> { + if target_id == 1 && !force { + return Err("Deleting legacy_admin system account requires force flag".to_string()); + } + + let user_details = { + let conn = state.users_db.lock().unwrap(); + match crate::db::users::get_user_by_id(&conn, target_id) { + Ok(Some(u)) => u, + Ok(None) => return Err("User not found".to_string()), + Err(e) => return Err(e.to_string()), + } + }; + + // 1. Transactional clean up on system.db (deleting their global slug mappings) + { + let mut system_conn = state.system_db.lock().unwrap(); + let tx = system_conn.transaction().map_err(|e| e.to_string())?; + + let slugs: Vec = { + let mut stmt = tx + .prepare("SELECT slug FROM global_slugs WHERE owner_user_id = ?1;") + .map_err(|e| e.to_string())?; + let rows = stmt + .query_map([target_id], |row| row.get(0)) + .map_err(|e| e.to_string())?; + rows.filter_map(|r| r.ok()).collect() + }; + + let now = Utc::now().to_rfc3339(); + for slug in slugs { + let _ = tx.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&slug]); + let _ = tx.execute( + "INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username) + VALUES (?1, ?2, NULL, 'deleted', ?3, ?4);", + rusqlite::params![slug, target_id, now, admin_username], + ); + } + + tx.commit() + .map_err(|e| format!("Failed to release slugs: {}", e))?; + } + + let user_dir = state + .config + .data_dir + .join("users") + .join(target_id.to_string()); + if user_dir.exists() { + let _ = std::fs::remove_dir_all(&user_dir); + } + + let conn = state.users_db.lock().unwrap(); + crate::db::users::delete_user(&conn, target_id).map_err(|e| e.to_string())?; + + let system_conn = state.system_db.lock().unwrap(); + let _ = crate::db::audit_events::write_audit_event( + &system_conn, + admin_username, + "USER_DELETION", + "user", + &target_id.to_string(), + Some(&format!("Username: {}", user_details.username)), + ); + + Ok(()) +} + +// DELETE /api/v1/admin/users/:id +pub async fn admin_delete_user( + State(state): State, + user: ApiUser, + Path(target_id): Path, + axum::extract::Query(params): axum::extract::Query>, +) -> Response { + let admin = match require_admin_role(&user) { + Ok(a) => a, + Err(err_resp) => return err_resp, + }; + + let force = params.get("force").map(|v| v == "true").unwrap_or(false); + match delete_user_resources(&state, target_id, &admin.username, force) { + Ok(_) => StatusCode::OK.into_response(), + Err(err) if err == "User not found" => StatusCode::NOT_FOUND.into_response(), + Err(err) if err == "Deleting legacy_admin system account requires force flag" => { + (StatusCode::BAD_REQUEST, err).into_response() + } + Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err).into_response(), + } +} + +// --- Admin: Slug Transfer --- + +// POST /api/v1/admin/transfers +pub async fn admin_transfer_slug( + State(state): State, + user: ApiUser, + Json(payload): Json, +) -> Response { + let admin = match require_admin_role(&user) { + Ok(a) => a, + Err(err_resp) => return err_resp, + }; + + // 1. Check if the slug exists and get details + let (old_owner_user_id, target_type, _target_id) = { + let system_conn = state.system_db.lock().unwrap(); + let mut stmt = match system_conn.prepare( + "SELECT owner_user_id, target_type, target_id FROM global_slugs WHERE slug = ?1;", + ) { + Ok(s) => s, + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; + let row_opt = stmt + .query_row([&payload.slug], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + }) + .optional(); + + match row_opt { + Ok(Some(r)) => r, + Ok(None) => return (StatusCode::NOT_FOUND, "Slug not found").into_response(), + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } + }; + + if old_owner_user_id == payload.new_owner_user_id { + return ( + StatusCode::BAD_REQUEST, + "New owner must be different from the current owner", + ) + .into_response(); + } + + // 2. Fetch destination databases + let old_dbs = match state.get_user_dbs(old_owner_user_id) { + Ok(dbs) => dbs, + Err(_) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to load current owner's database", + ) + .into_response() + } + }; + let new_dbs = match state.get_user_dbs(payload.new_owner_user_id) { + Ok(dbs) => dbs, + Err(_) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to load new owner's database", + ) + .into_response() + } + }; + + // 3. Perform transfer: copy record from old owner's content.db to new owner's content.db + let mut new_target_id = String::new(); + let transfer_success = { + let old_conn = old_dbs.content.lock().unwrap(); + let new_conn = new_dbs.content.lock().unwrap(); + + if target_type == "url" { + // Get URL record + let url_opt = match crate::db::content::get_url_by_code(&old_conn, &payload.slug) { + Ok(u) => u, + Err(e) => { + return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response() + } + }; + + if let Some(url) = url_opt { + // Check new owner quotas + let new_users_conn = state.users_db.lock().unwrap(); + let quota_opt = + crate::db::users::get_user_quotas(&new_users_conn, payload.new_owner_user_id) + .unwrap_or(None); + if let Some(quota) = quota_opt { + if quota.current_urls >= quota.max_urls { + return ( + StatusCode::BAD_REQUEST, + "New owner has exceeded URL quota limit", + ) + .into_response(); + } + } + + // Insert into new owner database + let ins_res = crate::db::content::create_url_extended( + &new_conn, + &url.code, + &url.destination, + url.title.as_deref(), + url.description.as_deref(), + &url.tags, + url.expires_at.as_deref(), + url.password_hash.as_deref(), + url.max_access_count, + ); + + match ins_res { + Ok(new_url) => { + new_target_id = new_url.id; + // Delete from old owner database + let _ = crate::db::content::delete_url(&old_conn, &url.id); + true + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to copy URL to new owner: {}", e), + ) + .into_response(); + } + } + } else { + false + } + } else if target_type == "page" { + // Get page record + let page_opt = + match crate::db::content::get_landing_page_by_code(&old_conn, &payload.slug) { + Ok(p) => p, + Err(e) => { + return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response() + } + }; + + if let Some(page) = page_opt { + // Check new owner quotas + let new_users_conn = state.users_db.lock().unwrap(); + let quota_opt = + crate::db::users::get_user_quotas(&new_users_conn, payload.new_owner_user_id) + .unwrap_or(None); + if let Some(quota) = quota_opt { + if quota.current_landings >= quota.max_landings { + return ( + StatusCode::BAD_REQUEST, + "New owner has exceeded landing page quota limit", + ) + .into_response(); + } + } + + // Insert into new owner database + let ins_res = crate::db::content::create_landing_page( + &new_conn, + &page.code, + &page.slug, + &page.title, + &page.html_content, + &page.state, + ); + + match ins_res { + Ok(new_page) => { + new_target_id = new_page.id; + // Delete from old owner database + let _ = crate::db::content::delete_landing_page(&old_conn, &page.id); + true + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to copy Page to new owner: {}", e), + ) + .into_response(); + } + } + } else { + false + } + } else { + false + } + }; + + if !transfer_success { + return (StatusCode::NOT_FOUND, "Content not found in owner database").into_response(); + } + + // 4. Update system global_slugs, slug_history and adjust quotas + { + let system_conn = state.system_db.lock().unwrap(); + let now = Utc::now().to_rfc3339(); + + let _ = system_conn.execute( + "UPDATE global_slugs SET owner_user_id = ?1, target_id = ?2, updated_at = ?3 WHERE slug = ?4;", + rusqlite::params![payload.new_owner_user_id, new_target_id, now, payload.slug], + ); + + let _ = system_conn.execute( + "INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username) + VALUES (?1, ?2, ?3, 'transferred', ?4, ?5);", + rusqlite::params![payload.slug, old_owner_user_id, payload.new_owner_user_id, now, admin.username], + ); + + // Adjust quotas + let users_conn = state.users_db.lock().unwrap(); + let field = if target_type == "url" { + "urls" + } else { + "landings" + }; + let _ = crate::db::users::decrement_quota_counter(&users_conn, old_owner_user_id, field); + let _ = crate::db::users::increment_quota_counter( + &users_conn, + payload.new_owner_user_id, + field, + ); + + let _ = crate::db::audit_events::write_audit_event( + &system_conn, + &admin.username, + "SLUG_TRANSFER", + "slug", + &payload.slug, + Some(&format!( + "From owner {} to owner {}", + old_owner_user_id, payload.new_owner_user_id + )), + ); + } + + StatusCode::OK.into_response() +} + +// --- Admin: Content Moderation --- + +// POST /api/v1/admin/moderation +pub async fn admin_moderate_slug( + State(state): State, + user: ApiUser, + Json(payload): Json, +) -> Response { + let admin = match require_admin_role(&user) { + Ok(a) => a, + Err(err_resp) => return err_resp, + }; + + let action = payload.action.trim().to_lowercase(); + if !["flagged", "disabled", "active"].contains(&action.as_str()) { + return (StatusCode::BAD_REQUEST, "Invalid moderation action").into_response(); + } + + // 1. Verify slug and get owner user ID + let (owner_user_id, target_type) = { + let system_conn = state.system_db.lock().unwrap(); + let row_opt: Option<(i64, String)> = system_conn + .query_row( + "SELECT owner_user_id, target_type FROM global_slugs WHERE slug = ?1;", + [&payload.slug], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .unwrap_or(None); + + match row_opt { + Some(r) => r, + None => return (StatusCode::NOT_FOUND, "Slug not found").into_response(), + } + }; + + // Resolve owner username for log snapshot + let owner_username = { + let users_conn = state.users_db.lock().unwrap(); + crate::db::users::get_user_by_id(&users_conn, owner_user_id) + .unwrap_or(None) + .map(|u| u.username) + }; + + // 2. Perform moderation update in global_slugs + { + let system_conn = state.system_db.lock().unwrap(); + let now = Utc::now().to_rfc3339(); + + let _ = system_conn.execute( + "UPDATE global_slugs SET status = ?1, updated_at = ?2 WHERE slug = ?3;", + rusqlite::params![action, now, payload.slug], + ); + + // Record moderation event + let event_id = Uuid::new_v4().to_string(); + let _ = system_conn.execute( + "INSERT INTO moderation_events (id, timestamp, admin_username, target_user_id, target_username, resource_type, resource_identifier, action, severity, reason) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10);", + rusqlite::params![ + event_id, + now, + admin.username, + owner_user_id, + owner_username, + target_type, + payload.slug, + action, + payload.severity, + payload.reason + ], + ); + + let _ = crate::db::audit_events::write_audit_event( + &system_conn, + &admin.username, + "CONTENT_MODERATION", + "slug", + &payload.slug, + Some(&format!("Action: {}, Reason: {}", action, payload.reason)), + ); + } + + StatusCode::OK.into_response() +} + +// GET /api/v1/admin/moderation/events +pub async fn admin_list_moderation_events( + State(state): State, + user: ApiUser, +) -> Response { + if let Err(err_resp) = require_admin_role(&user) { + return err_resp; + } + + let system_conn = state.system_db.lock().unwrap(); + let mut stmt = match system_conn.prepare( + "SELECT id, timestamp, admin_username, target_user_id, target_username, resource_type, resource_identifier, action, severity, reason + FROM moderation_events ORDER BY timestamp DESC;" + ) { + Ok(s) => s, + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; + + let rows = stmt.query_map([], |row| { + Ok(ModerationEventResponse { + id: row.get(0)?, + timestamp: row.get(1)?, + admin_username: row.get(2)?, + target_user_id: row.get(3)?, + target_username: row.get(4)?, + resource_type: row.get(5)?, + resource_identifier: row.get(6)?, + action: row.get(7)?, + severity: row.get(8)?, + reason: row.get(9)?, + }) + }); + + match rows { + Ok(mapped) => { + let events: Vec = mapped.filter_map(|r| r.ok()).collect(); + Json(events).into_response() + } + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +// --- User: Dashboard, profile settings, API tokens --- + +// GET /api/v1/user/profile +pub async fn user_get_profile(State(state): State, user: ApiUser) -> Response { + let tenant_user = match user.0 { + ApiActor::User(u) => u, + ApiActor::Admin(_) => { + return ( + StatusCode::BAD_REQUEST, + "Profile endpoints are for tenant users only", + ) + .into_response(); + } + }; + + let users_conn = state.users_db.lock().unwrap(); + let quotas = crate::db::users::get_user_quotas(&users_conn, tenant_user.id).unwrap_or(None); + + Json(serde_json::json!({ + "id": tenant_user.id, + "username": tenant_user.username, + "status": tenant_user.status, + "account_type": tenant_user.account_type, + "created_at": tenant_user.created_at, + "metadata": tenant_user.metadata, + "quotas": quotas, + })) + .into_response() +} + +// POST /api/v1/user/password +pub async fn user_change_password( + State(state): State, + user: ApiUser, + Json(payload): Json, +) -> Response { + let tenant_user = match user.0 { + ApiActor::User(u) => u, + ApiActor::Admin(_) => { + return ( + StatusCode::BAD_REQUEST, + "Change password is for tenant users only", + ) + .into_response(); + } + }; + + // Verify old password + if !crate::auth::password::verify_password(&payload.old_password, &tenant_user.password_hash) { + return (StatusCode::UNAUTHORIZED, "Invalid current password").into_response(); + } + + // Hash new password + let hash = match crate::auth::password::hash_password(&payload.new_password) { + Ok(h) => h, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Hashing error: {}", e), + ) + .into_response() + } + }; + + let users_conn = state.users_db.lock().unwrap(); + match crate::db::users::reset_user_password(&users_conn, tenant_user.id, &hash) { + Ok(_) => { + let system_conn = state.system_db.lock().unwrap(); + let _ = crate::db::audit_events::write_audit_event( + &system_conn, + &tenant_user.username, + "PASSWORD_CHANGE", + "user", + &tenant_user.id.to_string(), + None, + ); + StatusCode::OK.into_response() + } + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +// GET /api/v1/user/api-tokens +pub async fn user_list_api_tokens(State(state): State, user: ApiUser) -> Response { + let tenant_user = match user.0 { + ApiActor::User(u) => u, + ApiActor::Admin(_) => { + return ( + StatusCode::BAD_REQUEST, + "API tokens are for tenant users only", + ) + .into_response(); + } + }; + + let users_conn = state.users_db.lock().unwrap(); + match crate::db::users::list_user_api_tokens(&users_conn, tenant_user.id) { + Ok(tokens) => Json(tokens).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +// POST /api/v1/user/api-tokens +pub async fn user_create_api_token(State(state): State, user: ApiUser) -> Response { + let tenant_user = match user.0 { + ApiActor::User(u) => u, + ApiActor::Admin(_) => { + return ( + StatusCode::BAD_REQUEST, + "API tokens are for tenant users only", + ) + .into_response(); + } + }; + + // 1. Quota check + let users_conn = state.users_db.lock().unwrap(); + let quotas = crate::db::users::get_user_quotas(&users_conn, tenant_user.id).unwrap_or(None); + if let Some(quota) = quotas { + if quota.current_api_tokens >= quota.max_api_tokens { + return (StatusCode::BAD_REQUEST, "API tokens quota limit exceeded").into_response(); + } + } + + // 2. Generate secure token + let token_secret = format!("bzo_{}", crate::auth::session::generate_token(16)); // bzo_ followed by 32 hex chars + + // Hash token using SHA-256 for storing + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(token_secret.as_bytes()); + let token_hash = hex::encode(hasher.finalize()); + + match crate::db::users::create_user_api_token(&users_conn, tenant_user.id, &token_hash) { + Ok(api_token) => { + let system_conn = state.system_db.lock().unwrap(); + let _ = crate::db::audit_events::write_audit_event( + &system_conn, + &tenant_user.username, + "API_TOKEN_CREATION", + "api_token", + &api_token.id.to_string(), + None, + ); + + Json(CreateApiTokenResponse { + id: api_token.id, + token: token_secret, // Return cleartext once + created_at: api_token.created_at, + }) + .into_response() + } + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +// DELETE /api/v1/user/api-tokens/:id +pub async fn user_delete_api_token( + State(state): State, + user: ApiUser, + Path(token_id): Path, +) -> Response { + let tenant_user = match user.0 { + ApiActor::User(u) => u, + ApiActor::Admin(_) => { + return ( + StatusCode::BAD_REQUEST, + "API tokens are for tenant users only", + ) + .into_response(); + } + }; + + let users_conn = state.users_db.lock().unwrap(); + match crate::db::users::delete_user_api_token(&users_conn, token_id, tenant_user.id) { + Ok(_) => { + let system_conn = state.system_db.lock().unwrap(); + let _ = crate::db::audit_events::write_audit_event( + &system_conn, + &tenant_user.username, + "API_TOKEN_DELETION", + "api_token", + &token_id.to_string(), + None, + ); + StatusCode::OK.into_response() + } + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} diff --git a/src/web/pages.rs b/src/web/pages.rs index 6750c08..65d5745 100644 --- a/src/web/pages.rs +++ b/src/web/pages.rs @@ -4,12 +4,12 @@ use axum::{ response::{Html, IntoResponse, Response}, }; use chrono::Utc; +use rusqlite::OptionalExtension; use std::net::SocketAddr; use uuid::Uuid; use crate::analytics::get_client_country; use crate::models::VisitRecord; -use crate::services::landing_pages::get_landing_page_by_code; use crate::state::AppState; use crate::utils::get_client_ip; @@ -25,11 +25,58 @@ pub async fn resolve_page( return (StatusCode::NOT_FOUND, "Not Found").into_response(); } - let page_opt = match get_landing_page_by_code(&state.db, &code) { - Ok(page) => page, + // 1. Query global slug namespace in system.db + let slug_info = { + let system_conn = state.system_db.lock().unwrap(); + let mut stmt = match system_conn.prepare( + "SELECT owner_user_id, target_type, target_id, status FROM global_slugs WHERE slug = ?1;" + ) { + Ok(s) => s, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), + }; + stmt.query_row(rusqlite::params![code], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + )) + }) + .optional() + }; + + let (owner_user_id, _target_type, _target_id, slug_status) = match slug_info { + Ok(Some(info)) => info, + Ok(None) => { + // Fallback to legacy_admin's DB (user_id = 1) if not found in global_slugs + (1, "page".to_string(), "".to_string(), "active".to_string()) + } Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), }; + // If slug status is disabled, flagged, or soft_deleted, we return 410 Gone + if slug_status != "active" { + return ( + StatusCode::GONE, + "This content has been disabled or moderated", + ) + .into_response(); + } + + // 2. Get user specific database connections + let user_dbs = match state.get_user_dbs(owner_user_id) { + Ok(dbs) => dbs, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), + }; + + let page_opt = { + let conn = user_dbs.content.lock().unwrap(); + match crate::db::content::get_landing_page_by_code(&conn, &code) { + Ok(page) => page, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), + } + }; + match page_opt { Some(page) => { // Check state @@ -67,6 +114,7 @@ pub async fn resolve_page( accept_language, country, status_code: 200, + owner_user_id: Some(owner_user_id), }; state.analytics_queue.push(record); diff --git a/src/web/qr.rs b/src/web/qr.rs index f21824b..e80faf1 100644 --- a/src/web/qr.rs +++ b/src/web/qr.rs @@ -1,5 +1,4 @@ use crate::services::qr::{generate_qr_png, generate_qr_svg}; -use crate::services::shortener::get_url_by_code; use crate::state::AppState; use crate::utils::get_client_ip; use axum::{ @@ -11,6 +10,7 @@ use std::net::SocketAddr; use serde_json::json; +// GET /api/qr/:file (e.g. /api/qr/abcdef.png or /api/qr/abcdef.svg or JSON stats /api/qr/abcdef) // GET /api/qr/:file (e.g. /api/qr/abcdef.png or /api/qr/abcdef.svg or JSON stats /api/qr/abcdef) pub async fn qr_handler( State(state): State, @@ -24,9 +24,10 @@ pub async fn qr_handler( let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok()); let authenticated = if let Some(auth) = auth_header { - let conn = state.admin_db.lock().unwrap(); + let admin_conn = state.admin_db.lock().unwrap(); + let users_conn = state.users_db.lock().unwrap(); matches!( - crate::auth::session::authenticate_api_key(&conn, auth), + crate::auth::session::authenticate_api_key(&admin_conn, &users_conn, auth), Ok(Some(_user)) ) } else { @@ -37,23 +38,63 @@ pub async fn qr_handler( return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(); } - let url_opt = match get_url_by_code(&state.db, &file) { - Ok(u) => u, + // We need to look up owner_user_id and status from global_slugs + let (owner_user_id, slug_status) = { + let system_conn = state.system_db.lock().unwrap(); + let mut stmt = match system_conn + .prepare("SELECT owner_user_id, status FROM global_slugs WHERE slug = ?1;") + { + Ok(s) => s, + Err(_) => { + return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response() + } + }; + use rusqlite::OptionalExtension; + match stmt + .query_row(rusqlite::params![&file], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) + }) + .optional() + { + Ok(Some((uid, status))) => (uid, status), + Ok(None) => (1, "active".to_string()), // fallback to admin + Err(_) => { + return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response() + } + } + }; + + if slug_status != "active" { + return (StatusCode::NOT_FOUND, "URL not found").into_response(); + } + + let user_dbs = match state.get_user_dbs(owner_user_id) { + Ok(dbs) => dbs, Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), }; + let url_opt = { + let conn = user_dbs.content.lock().unwrap(); + match crate::db::content::get_url_by_code(&conn, &file) { + Ok(u) => u, + Err(_) => { + return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response() + } + } + }; + let url = match url_opt { Some(u) => u, None => return (StatusCode::NOT_FOUND, "URL not found").into_response(), }; let qr_scans = { - let conn = state.analytics_db.lock().unwrap(); + let conn = user_dbs.analytics.lock().unwrap(); crate::db::qr::get_qr_scan_count(&conn, &url.id).unwrap_or(0) }; let direct_clicks = { - let conn = state.analytics_db.lock().unwrap(); + let conn = user_dbs.analytics.lock().unwrap(); conn.query_row( "SELECT COUNT(*) FROM visits WHERE target_type = 'url' AND target_id = ?1;", rusqlite::params![url.id], @@ -76,11 +117,45 @@ pub async fn qr_handler( return (StatusCode::NOT_FOUND, "Not Found").into_response(); } - let url_opt = match get_url_by_code(&state.db, code) { - Ok(u) => u, + // We need to look up owner_user_id and status from global_slugs + let (owner_user_id, slug_status) = { + let system_conn = state.system_db.lock().unwrap(); + let mut stmt = match system_conn + .prepare("SELECT owner_user_id, status FROM global_slugs WHERE slug = ?1;") + { + Ok(s) => s, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), + }; + use rusqlite::OptionalExtension; + match stmt + .query_row(rusqlite::params![code], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) + }) + .optional() + { + Ok(Some((uid, status))) => (uid, status), + Ok(None) => (1, "active".to_string()), // fallback to admin + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), + } + }; + + if slug_status != "active" { + return (StatusCode::NOT_FOUND, "Url not found").into_response(); + } + + let user_dbs = match state.get_user_dbs(owner_user_id) { + Ok(dbs) => dbs, Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), }; + let url_opt = { + let conn = user_dbs.content.lock().unwrap(); + match crate::db::content::get_url_by_code(&conn, code) { + Ok(u) => u, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), + } + }; + let url = match url_opt { Some(u) => u, None => return (StatusCode::NOT_FOUND, "Url not found").into_response(), @@ -144,7 +219,7 @@ pub async fn qr_handler( .map(|s| s.to_string()); { - let analytics_conn = state.db.analytics.lock().unwrap(); + let analytics_conn = user_dbs.analytics.lock().unwrap(); let _ = crate::db::qr::log_qr_access( &analytics_conn, &url.id, diff --git a/src/web/redirect.rs b/src/web/redirect.rs index dc5076c..3fb1c4f 100644 --- a/src/web/redirect.rs +++ b/src/web/redirect.rs @@ -5,12 +5,12 @@ use axum::{ }; use axum_extra::extract::CookieJar; use chrono::Utc; +use rusqlite::OptionalExtension; use std::net::SocketAddr; use uuid::Uuid; use crate::analytics::get_client_country; use crate::models::VisitRecord; -use crate::services::shortener::get_url_by_code; use crate::state::AppState; use crate::templates::PreviewTemplate; use crate::utils::get_client_ip; @@ -29,17 +29,64 @@ pub async fn resolve_redirect( return (StatusCode::NOT_FOUND, "Not Found").into_response(); } - let url_opt = match get_url_by_code(&state.db, &code) { - Ok(url) => url, + // 1. Query global slug namespace in system.db + let slug_info = { + let system_conn = state.system_db.lock().unwrap(); + let mut stmt = match system_conn.prepare( + "SELECT owner_user_id, target_type, target_id, status FROM global_slugs WHERE slug = ?1;" + ) { + Ok(s) => s, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), + }; + stmt.query_row(rusqlite::params![code], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + )) + }) + .optional() + }; + + let (owner_user_id, _target_type, _target_id, slug_status) = match slug_info { + Ok(Some(info)) => info, + Ok(None) => { + // Fallback to legacy_admin's DB (user_id = 1) if not found in global_slugs + (1, "url".to_string(), "".to_string(), "active".to_string()) + } Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), }; + // If slug status is disabled, flagged, or soft_deleted, we return 410 Gone + if slug_status != "active" { + return ( + StatusCode::GONE, + "This content has been disabled or moderated", + ) + .into_response(); + } + + // 2. Get user specific database connections + let user_dbs = match state.get_user_dbs(owner_user_id) { + Ok(dbs) => dbs, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), + }; + + let url_opt = { + let conn = user_dbs.content.lock().unwrap(); + match crate::db::content::get_url_by_code(&conn, &code) { + Ok(url) => url, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), + } + }; + let url = match url_opt { Some(u) => u, None => return (StatusCode::NOT_FOUND, "Short code not found").into_response(), }; - // 1. Expiration check + // 3. Expiration check if url.expired { return (StatusCode::GONE, "This link has expired").into_response(); } @@ -49,7 +96,7 @@ pub async fn resolve_redirect( if expires_at.with_timezone(&Utc) < Utc::now() { // Mark as expired in DB asynchronously/immediately { - let conn = state.db.content.lock().unwrap(); + let conn = user_dbs.content.lock().unwrap(); let _ = conn.execute( "UPDATE urls SET expired = 1 WHERE id = ?1;", [url.id.clone()], @@ -60,7 +107,7 @@ pub async fn resolve_redirect( } } - // 2. Access limit check + // 4. Access limit check if url.is_access_exhausted() { return ( StatusCode::GONE, @@ -69,7 +116,7 @@ pub async fn resolve_redirect( .into_response(); } - // 3. Password protection check + // 5. Password protection check if url.is_password_protected() { let cookie_name = format!("bzod_gate_{}", code); let authorized = jar @@ -82,14 +129,14 @@ pub async fn resolve_redirect( } } - // 4. Increment access count & retrieve preview config + // 6. Increment access count & retrieve preview config let _new_access_count = { - let conn = state.db.content.lock().unwrap(); + let conn = user_dbs.content.lock().unwrap(); crate::db::content::increment_access_count(&conn, &url.id).unwrap_or(url.access_count + 1) }; let preview_opt = { - let conn = state.db.content.lock().unwrap(); + let conn = user_dbs.content.lock().unwrap(); crate::db::preview::get_preview(&conn, &url.id).unwrap_or(None) }; @@ -123,12 +170,13 @@ pub async fn resolve_redirect( accept_language, country, status_code: if preview_opt.is_some() { 200 } else { 302 }, + owner_user_id: Some(owner_user_id), }; // Push to memory queue (non-blocking) state.analytics_queue.push(record); - // 5. Render Preview or Redirect + // 7. Render Preview or Redirect if let Some(preview) = preview_opt { PreviewTemplate { code, diff --git a/src/web/routes.rs b/src/web/routes.rs index 38881ed..7c406b2 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -1,7 +1,7 @@ use crate::state::AppState; -use crate::web::{admin, api, bulk, pages, password_gate, qr, redirect, system}; +use crate::web::{admin, api, bulk, multi_user, pages, password_gate, qr, redirect, system}; use axum::{ - routing::{get, post}, + routing::{delete, get, post, put}, Router, }; @@ -22,6 +22,46 @@ pub fn create_router(state: AppState) -> Router { // --- System Health & Diagnostics --- .route("/status", get(system::status_endpoint)) .route("/metrics", get(system::metrics_endpoint)) + // --- Public User Login --- + .route( + "/login", + get(admin::public_login_get).post(admin::public_login_post), + ) + .route("/logout", get(admin::public_logout)) + .route("/user/dashboard", get(admin::user_dashboard_get)) + .route("/user/urls", get(admin::user_urls_get)) + .route("/user/urls/create", post(admin::user_urls_create)) + .route("/user/urls/delete/:id", post(admin::user_urls_delete)) + .route("/user/audit", get(admin::user_audit_get)) + .route("/user/status", get(admin::user_status_get)) + .route("/user/pages", get(admin::user_pages_get)) + .route("/user/pages/create", post(admin::user_pages_create)) + .route("/user/pages/delete/:id", post(admin::user_pages_delete)) + .route("/user/settings", get(admin::user_settings_get)) + .route( + "/user/settings/password", + post(admin::user_change_password_post), + ) + .route("/user/settings/backup", get(admin::user_download_backup)) + .route( + "/user/settings/restore", + post(admin::user_restore_backup_post), + ) + .route("/analytics", get(admin::user_analytics_get)) + .route( + "/user/analytics/url/:id", + get(admin::user_url_analytics_get), + ) + .route( + "/user/analytics/page/:id", + get(admin::user_page_analytics_get), + ) + .route("/api-tokens", get(admin::api_tokens_get)) + .route("/api-tokens/create", post(admin::api_tokens_create_post)) + .route( + "/api-tokens/revoke/:id", + post(admin::api_tokens_revoke_post), + ) // --- Admin UI Login/Logout --- .route("/admin", get(admin::admin_index)) .route( @@ -57,6 +97,24 @@ pub fn create_router(state: AppState) -> Router { get(admin::page_analytics_json_export), ) .route("/admin/settings", get(admin::settings_get)) + .route("/admin/users", get(admin::users_get)) + .route("/admin/users/new", get(admin::users_new_get)) + .route("/admin/users/:id", get(admin::user_detail_get)) + .route( + "/admin/users/:id/edit", + get(admin::user_edit_get).post(admin::user_edit_post), + ) + .route("/admin/users/create", post(admin::users_create_post)) + .route( + "/admin/users/status/:id", + post(admin::users_update_status_post), + ) + .route("/admin/users/type/:id", post(admin::users_update_type_post)) + .route( + "/admin/users/password/:id", + post(admin::users_reset_password_post), + ) + .route("/admin/users/delete/:id", post(admin::users_delete_post)) .route( "/admin/settings/password", post(admin::change_password_post), @@ -79,6 +137,39 @@ pub fn create_router(state: AppState) -> Router { ) .route("/admin/audit", get(admin::audit_get)) .route("/admin/status", get(admin::status_get)) + .route( + "/admin/moderation", + get(admin::moderation_get).post(admin::moderation_post), + ) + .route("/admin/slugs", get(admin::slugs_get)) + .route("/admin/slugs/transfer", post(admin::slugs_transfer_post)) + .route("/admin/slugs/status", post(admin::slugs_status_post)) + .route("/admin/slugs/delete", post(admin::slugs_delete_post)) + .route("/admin/sessions", get(admin::sessions_get)) + .route( + "/admin/sessions/revoke/:id", + post(admin::sessions_revoke_post), + ) + .route( + "/admin/sessions/revoke-all", + post(admin::sessions_revoke_all_post), + ) + .route( + "/admin/quotas", + get(admin::quotas_get).post(admin::quotas_post), + ) + .route("/admin/health", get(admin::health_get)) + .route("/admin/backups", get(admin::backups_get)) + .route("/admin/backups/create", post(admin::backups_create_post)) + .route( + "/admin/backups/download/:filename", + get(admin::backups_download_get), + ) + .route( + "/admin/backups/delete/:filename", + post(admin::backups_delete_post), + ) + .route("/admin/backups/restore", post(admin::backups_restore_post)) // --- REST API v1 JSON Endpoints --- .route( "/api/v1/urls", @@ -124,6 +215,53 @@ pub fn create_router(state: AppState) -> Router { "/api/v1/urls/:uuid/password", post(api::api_set_password).delete(api::api_remove_password), ) + // --- Multi-User REST API v1 Admin Endpoints --- + .route( + "/api/v1/admin/users", + get(multi_user::admin_list_users).post(multi_user::admin_create_user), + ) + .route( + "/api/v1/admin/users/:id/status", + put(multi_user::admin_update_user_status), + ) + .route( + "/api/v1/admin/users/:id/quotas", + put(multi_user::admin_update_user_quotas), + ) + .route( + "/api/v1/admin/users/:id/password", + post(multi_user::admin_reset_user_password), + ) + .route( + "/api/v1/admin/users/:id", + delete(multi_user::admin_delete_user), + ) + .route( + "/api/v1/admin/transfers", + post(multi_user::admin_transfer_slug), + ) + .route( + "/api/v1/admin/moderation", + post(multi_user::admin_moderate_slug), + ) + .route( + "/api/v1/admin/moderation/events", + get(multi_user::admin_list_moderation_events), + ) + // --- Multi-User REST API v1 Tenant User Endpoints --- + .route("/api/v1/user/profile", get(multi_user::user_get_profile)) + .route( + "/api/v1/user/password", + post(multi_user::user_change_password), + ) + .route( + "/api/v1/user/api-tokens", + get(multi_user::user_list_api_tokens).post(multi_user::user_create_api_token), + ) + .route( + "/api/v1/user/api-tokens/:id", + delete(multi_user::user_delete_api_token), + ) // --- Static Asset Stub --- .route( "/static/style.css", diff --git a/src/web/system.rs b/src/web/system.rs index 0e20eee..ba41624 100644 --- a/src/web/system.rs +++ b/src/web/system.rs @@ -6,7 +6,7 @@ use axum::{ use axum_extra::extract::CookieJar; use serde::Serialize; -use crate::auth::{authenticate_api_key, authenticate_session}; +use crate::auth::{authenticate_admin_session, authenticate_api_key}; use crate::db::admin::get_user_count; use crate::state::AppState; use crate::utils::{get_db_file_info, get_memory_usage}; @@ -15,15 +15,16 @@ use crate::utils::{get_db_file_info, get_memory_usage}; fn authenticate_request(state: &AppState, jar: &CookieJar, headers: &HeaderMap) -> bool { // 1. Try Authorization header if let Some(auth_header) = headers.get("Authorization").and_then(|h| h.to_str().ok()) { - let conn = state.admin_db.lock().unwrap(); - if let Ok(Some(_)) = authenticate_api_key(&conn, auth_header) { + let admin_conn = state.admin_db.lock().unwrap(); + let users_conn = state.users_db.lock().unwrap(); + if let Ok(Some(_)) = authenticate_api_key(&admin_conn, &users_conn, auth_header) { return true; } } // 2. Try cookie session - let conn = state.admin_db.lock().unwrap(); - if let Ok(Some(_)) = authenticate_session(&conn, jar) { + let conn = state.users_db.lock().unwrap(); + if let Ok(Some(_)) = authenticate_admin_session(&conn, jar) { return true; } diff --git a/templates/api_tokens.html b/templates/api_tokens.html new file mode 100644 index 0000000..b7a6a13 --- /dev/null +++ b/templates/api_tokens.html @@ -0,0 +1,82 @@ +{% extends "user_layout.html" %} + +{% block title %}My API Tokens - BZOD{% endblock %} + +{% block active_tokens %}active{% endblock %} + +{% block header_title %}API Tokens & Automation{% endblock %} + +{% block content %} +{% if let Some(msg) = success %} +
+ {{ msg }} +
+{% endif %} +{% if let Some(err) = error %} +
+ {{ err }} +
+{% endif %} + +
+ +
+

Generate API Token

+

+ API tokens allow you to automate link shortening and landing page creations. Generated tokens are hashed immediately; you will only be shown the raw token once. +

+ + {% if let Some(raw_token) = new_token %} +
+ Raw Token (Copy now!) + {{ raw_token }} +
+ {% endif %} + +
+ + +
+
+ + +
+
+

Active API Tokens

+
+
+ + + + + + + + + + {% if tokens.is_empty() %} + + + + {% else %} + {% for t in tokens %} + + + + + + {% endfor %} + {% endif %} + +
Token IDCreated AtAction
+ No active API tokens generated yet. +
{{ t.id }}{{ t.created_at }} +
+ + +
+
+
+
+
+{% endblock %} diff --git a/templates/backups.html b/templates/backups.html new file mode 100644 index 0000000..1bbf9e6 --- /dev/null +++ b/templates/backups.html @@ -0,0 +1,144 @@ +{% extends "layout.html" %} + +{% block title %}Backup Management - BZOD{% endblock %} + +{% block active_settings %}active{% endblock %} + +{% block header_title %}Database Backups Console{% endblock %} + +{% block header_actions %} +
+ + +
+{% endblock %} + +{% block content %} +{% if let Some(msg) = success %} +
+ {{ msg }} +
+{% endif %} +{% if let Some(err) = error %} +
+ {{ err }} +
+{% endif %} + +
+ +
+
+

Available Backup Archives

+
+
+ + + + + + + + + + + {% if files.is_empty() %} + + + + {% else %} + {% for f in files %} + + + + + + + {% endfor %} + {% endif %} + +
Archive FileFile SizeCreatedActions
+ No backup archive files found in backups folder. +
+ {{ f.filename }} + {{ f.size_str }}{{ f.created_str }} +
+ Download + +
+ + +
+
+
+
+
+ + +
+

Restore Platform Databases

+

+ To restore the system databases, select a `.tar.gz` backup archive file. Warning: This will overwrite all active user accounts, quotas, links, and analytics data! +

+ +
+ + +
+ + +
+ +
+ + +
+ + +
+
+
+ + +
+
+

Backup Audit History

+
+
+ + + + + + + + + + + + {% if history.is_empty() %} + + + + {% else %} + {% for h in history %} + + + + + + + + {% endfor %} + {% endif %} + +
TimestampBackup File PathStatusSizeError Message
+ No backup execution logs found. +
{{ h.created_at[0..19].replace("T", " ") }}{{ h.backup_path }} + + {{ h.status }} + + {{ h.size_bytes }} B{{ h.error_message.as_deref().unwrap_or("-") }}
+
+
+{% endblock %} diff --git a/templates/health.html b/templates/health.html new file mode 100644 index 0000000..6415736 --- /dev/null +++ b/templates/health.html @@ -0,0 +1,171 @@ +{% extends "layout.html" %} + +{% block title %}System Health & Diagnostics - BZOD{% endblock %} + +{% block active_status %}active{% endblock %} + +{% block header_title %}System Health Dashboard{% endblock %} + +{% block content %} +
+ +
+
+

+ + Database Structural Health +

+
+
+ + + + + + + + + + + + {% for db in db_reports %} + + + + + + + + {% endfor %} + +
Database FileSchema Ver.Journal ModeFK StateIntegrity Check
{{ db.database }}.dbv{{ db.schema_version }}{{ db.journal_mode }}{% if db.foreign_keys_enabled %}ON{% else %}OFF{% endif %} + {% if db.integrity_ok %} + Passed + {% else %} + Corrupt/Failed + {% endif %} +
+
+
+ + +
+
+

+ + Storage Utilization +

+
+
+
+ Total Data Directory Size: + {{ total_data_size }} +
+
+ System Database size (`system.db`): + {{ system_db_size }} +
+
+ Users/Quotas Database size (`users.db`): + {{ users_db_size }} +
+
+ Administration database (`admin.db`): + {{ admin_db_size }} +
+
+ Standard Tenants Databases (`/users/*`): + {{ tenants_db_size }} +
+
+
+
+ +
+ +
+
+

+ + Maintenance & Reconcile Jobs +

+
+
+ + + + + + + + + + + {% if job_history.is_empty() %} + + + + {% else %} + {% for job in job_history %} + + + + + + + {% endfor %} + {% endif %} + +
Job NameLast Run StatusStarted AtError Msg
No job history logs.
{{ job.job_name }} + + {{ job.status }} + + {{ job.started_at[0..19].replace("T", " ") }} + {{ job.error_message.as_deref().unwrap_or("-") }} +
+
+
+ + +
+
+

+ + Diagnostics Checks (URLs/Pages) +

+
+
+ + + + + + + + + + + {% if health_checks.is_empty() %} + + + + {% else %} + {% for check in health_checks %} + + + + + + + {% endfor %} + {% endif %} + +
ResourceStatusCodeChecked At
No resource diagnostics logs.
{{ check.object_type }}:{{ check.object_id }} + + {% if check.is_healthy == 1 %}healthy{% else %}unhealthy{% endif %} + + {{ check.status_code.unwrap_or(0) }}{{ check.checked_at[0..16].replace("T", " ") }}
+
+
+
+{% endblock %} diff --git a/templates/layout.html b/templates/layout.html index 97842a3..18ec9d8 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -378,6 +378,7 @@ + {% block sidebar_footer %} + {% endblock %} diff --git a/templates/login.html b/templates/login.html index 380c899..5ccd8ee 100644 --- a/templates/login.html +++ b/templates/login.html @@ -3,7 +3,7 @@ - Login - BZOD Platform + {{ title }} - BZOD Platform