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

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

No files matched your search

Generated
+81 -1
View File
@@ -453,7 +453,7 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]] [[package]]
name = "bzod" name = "bzod"
version = "0.4.0" version = "0.5.0"
dependencies = [ dependencies = [
"argon2", "argon2",
"askama", "askama",
@@ -481,6 +481,7 @@ dependencies = [
"tracing-subscriber", "tracing-subscriber",
"uuid", "uuid",
"zip", "zip",
"zstd",
] ]
[[package]] [[package]]
@@ -584,6 +585,24 @@ dependencies = [
"version_check", "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]] [[package]]
name = "core-foundation-sys" name = "core-foundation-sys"
version = "0.8.7" version = "0.8.7"
@@ -691,6 +710,15 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]] [[package]]
name = "dotenvy" name = "dotenvy"
version = "0.15.7" version = "0.15.7"
@@ -1419,6 +1447,12 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]] [[package]]
name = "lock_api" name = "lock_api"
version = "0.4.14" version = "0.4.14"
@@ -1806,6 +1840,22 @@ dependencies = [
"syn", "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]] [[package]]
name = "pxfm" name = "pxfm"
version = "0.1.29" version = "0.1.29"
@@ -2075,6 +2125,8 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [ dependencies = [
"base64", "base64",
"bytes", "bytes",
"cookie",
"cookie_store",
"futures-core", "futures-core",
"http", "http",
"http-body", "http-body",
@@ -3453,6 +3505,34 @@ dependencies = [
"simd-adler32", "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]] [[package]]
name = "zune-core" name = "zune-core"
version = "0.5.1" version = "0.5.1"
+6 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "bzod" name = "bzod"
version = "0.4.0" version = "0.5.0"
edition = "2021" edition = "2021"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
@@ -20,7 +20,7 @@ askama = { version = "0.12" }
argon2 = "0.5" argon2 = "0.5"
sha2 = "0.10" sha2 = "0.10"
rand = "0.8" 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" tar = "0.4"
flate2 = "1.0" flate2 = "1.0"
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
@@ -31,4 +31,8 @@ qrcode = "0.14"
image = "0.25" image = "0.25"
zip = { version = "2.1", default-features = false, features = ["deflate"] } zip = { version = "2.1", default-features = false, features = ["deflate"] }
futures-util = "0.3" futures-util = "0.3"
zstd = "0.13"
[lints.clippy]
let_unit_value = "allow"
useless_vec = "allow"
+42 -6
View File
@@ -46,11 +46,47 @@ fn flush_batch(db: &Db, batch: &mut Vec<VisitRecord>) {
return; return;
} }
info!("Flushing {} visits to analytics database", batch.len()); info!(
let mut conn_lock = db.analytics.lock().unwrap(); "Flushing {} visits to user analytics databases",
if let Err(e) = insert_visits_batch(&mut conn_lock, batch) { batch.len()
error!("Failed to write analytics batch to database: {:?}", e); );
} else {
batch.clear(); // Group visits by owner_user_id
let mut groups: std::collections::HashMap<i64, Vec<VisitRecord>> =
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
);
}
}
} }
} }
+6 -5
View File
@@ -1,5 +1,5 @@
use crate::auth::session::authenticate_api_key; use crate::auth::session::authenticate_api_key;
use crate::models::User; use crate::models::ApiActor;
use crate::state::AppState; use crate::state::AppState;
use axum::{ use axum::{
extract::{FromRef, FromRequestParts}, extract::{FromRef, FromRequestParts},
@@ -7,7 +7,7 @@ use axum::{
}; };
// Extractor: Authenticate API requests using Bearer token // Extractor: Authenticate API requests using Bearer token
pub struct ApiUser(pub User); pub struct ApiUser(pub ApiActor);
#[axum::async_trait] #[axum::async_trait]
impl<S> FromRequestParts<S> for ApiUser impl<S> FromRequestParts<S> for ApiUser
@@ -25,9 +25,10 @@ where
.and_then(|h| h.to_str().ok()) .and_then(|h| h.to_str().ok())
.ok_or((StatusCode::UNAUTHORIZED, "Missing Authorization header"))?; .ok_or((StatusCode::UNAUTHORIZED, "Missing Authorization header"))?;
let conn = app_state.admin_db.lock().unwrap(); let admin_conn = app_state.admin_db.lock().unwrap();
match authenticate_api_key(&conn, auth_header) { let users_conn = app_state.users_db.lock().unwrap();
Ok(Some(user)) => Ok(ApiUser(user)), match authenticate_api_key(&admin_conn, &users_conn, auth_header) {
Ok(Some(actor)) => Ok(ApiUser(actor)),
Ok(None) => Err((StatusCode::UNAUTHORIZED, "Invalid API token")), Ok(None) => Err((StatusCode::UNAUTHORIZED, "Invalid API token")),
Err(_) => Err((StatusCode::INTERNAL_SERVER_ERROR, "Database error")), Err(_) => Err((StatusCode::INTERNAL_SERVER_ERROR, "Database error")),
} }
+3 -1
View File
@@ -6,4 +6,6 @@ pub mod session;
pub use csrf::{generate_csrf_token, verify_csrf}; pub use csrf::{generate_csrf_token, verify_csrf};
pub use middleware::ApiUser; pub use middleware::ApiUser;
pub use password::{hash_password, verify_password, verify_sha256}; 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,
};
+255 -17
View File
@@ -1,11 +1,11 @@
use crate::db::admin::{ 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 axum_extra::extract::CookieJar;
use chrono::Utc; use chrono::Utc;
use rand::{thread_rng, RngCore}; use rand::{thread_rng, RngCore};
use rusqlite::Connection; use rusqlite::{Connection, OptionalExtension};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
// Generate a secure random token (hex-encoded) // Generate a secure random token (hex-encoded)
@@ -15,8 +15,8 @@ pub fn generate_token(bytes_len: usize) -> String {
hex::encode(key) hex::encode(key)
} }
// Authenticate session from cookies // Authenticate administrator session from cookies
pub fn authenticate_session( pub fn authenticate_admin_session(
conn: &Connection, conn: &Connection,
jar: &CookieJar, jar: &CookieJar,
) -> Result<Option<(User, String)>, rusqlite::Error> { ) -> Result<Option<(User, String)>, rusqlite::Error> {
@@ -26,7 +26,33 @@ pub fn authenticate_session(
}; };
let session_id = cookie.value(); let session_id = cookie.value();
let session = match get_session(conn, session_id)? { let session_opt: Option<AdminSession> = 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, Some(s) => s,
None => return Ok(None), None => return Ok(None),
}; };
@@ -41,19 +67,171 @@ pub fn authenticate_session(
return Ok(None); return Ok(None);
} }
// Get user // Get user (status must be 'active' and account_type = 'admin')
if let Some(user) = get_user_by_id(conn, &session.user_id)? { // 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::<i64>() {
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::<i64>() {
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<Option<(TenantUser, String)>, 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<UserSession> = 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))) Ok(Some((user, session.id)))
} else { } else {
Ok(None) Ok(None)
} }
} }
// Authenticate API key from Authorization header // Authenticate API key/token from Authorization header (unified)
pub fn authenticate_api_key( pub fn authenticate_api_key(
conn: &Connection, admin_conn: &Connection,
users_conn: &Connection,
auth_header: &str, auth_header: &str,
) -> Result<Option<User>, rusqlite::Error> { ) -> Result<Option<ApiActor>, rusqlite::Error> {
if !auth_header.starts_with("Bearer ") { if !auth_header.starts_with("Bearer ") {
return Ok(None); return Ok(None);
} }
@@ -68,13 +246,73 @@ pub fn authenticate_api_key(
hasher.update(key.as_bytes()); hasher.update(key.as_bytes());
let hashed_key = hex::encode(hasher.finalize()); let hashed_key = hex::encode(hasher.finalize());
if let Some(api_key_rec) = get_api_key_by_hash(conn, &hashed_key)? { // 1. Check user API tokens in users.db
// Update last used timestamp let mut stmt = users_conn.prepare("SELECT user_id FROM api_tokens WHERE token_hash = ?1;")?;
update_api_key_last_used(conn, &api_key_rec.id)?; let user_id_opt: Option<i64> = stmt.query_row([&hashed_key], |row| row.get(0)).optional()?;
// Get user if let Some(user_id) = user_id_opt {
if let Some(user) = get_user_by_id(conn, &api_key_rec.user_id)? { let mut stmt = users_conn.prepare(
return Ok(Some(user)); "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::<i64>() {
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,
})));
} }
} }
+150
View File
@@ -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<String>,
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<String>,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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(())
}
+3 -2
View File
@@ -32,8 +32,9 @@ pub async fn run(
} }
let hash = hash_password(&password).map_err(|e| e.to_string())?; let hash = hash_password(&password).map_err(|e| e.to_string())?;
let conn = db.admin.lock().unwrap(); let conn = db.users.lock().unwrap();
let u = crate::db::admin::create_user(&conn, &final_username, &hash)?; let u = crate::db::users::create_admin_user(&conn, &final_username, &hash)?;
db.init_user_databases(u.id)?;
info!( info!(
"Successfully created admin user: {} (ID: {})", "Successfully created admin user: {} (ID: {})",
u.username, u.id u.username, u.id
+86
View File
@@ -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<String>,
password: Option<String>,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
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()
}
+92
View File
@@ -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<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
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<String> = {
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(())
}
+55
View File
@@ -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<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
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(())
}
+58
View File
@@ -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<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
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(())
}
+36
View File
@@ -0,0 +1,36 @@
use crate::config::Config;
use crate::db::Db;
use std::path::PathBuf;
pub async fn run(
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
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(())
}
+74
View File
@@ -11,6 +11,15 @@ pub mod shorten;
pub mod stats; pub mod stats;
pub mod validate; 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)] #[derive(Parser)]
#[command(name = "bzod")] #[command(name = "bzod")]
#[command(about = "BZOD - Personal Redirector & Landing Page Platform")] #[command(about = "BZOD - Personal Redirector & Landing Page Platform")]
@@ -91,4 +100,69 @@ pub enum Commands {
#[arg(long)] #[arg(long)]
data_dir: Option<String>, data_dir: Option<String>,
}, },
/// Create a new standard user in the database
CreateUser {
#[arg(long)]
username: Option<String>,
#[arg(long)]
password: Option<String>,
#[arg(long)]
data_dir: Option<String>,
},
/// 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<String>,
},
/// Disable a standard user
DisableUser {
/// User ID to disable
user_id: i64,
#[arg(long)]
data_dir: Option<String>,
},
/// Enable a standard user
EnableUser {
/// User ID to enable
user_id: i64,
#[arg(long)]
data_dir: Option<String>,
},
/// Reset standard user's password
ResetPassword {
/// User ID to reset
user_id: i64,
#[arg(long)]
password: Option<String>,
#[arg(long)]
data_dir: Option<String>,
},
/// List all standard/system users
ListUsers {
#[arg(long)]
data_dir: Option<String>,
},
/// 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<String>,
#[arg(long)]
data_dir: Option<String>,
},
/// 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<String>,
},
} }
+75
View File
@@ -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<String>,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
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()
}
+269
View File
@@ -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<String>,
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<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
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<UserBackupMetadata> = 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/<target_user_id>/
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<i64> = 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<i64> = 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(())
}
+18
View File
@@ -63,11 +63,29 @@ pub async fn run(
crate::jobs::run_expiry_checker(expiry_db).await; 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::<u64>().ok())
.unwrap_or(24)
};
tokio::spawn(async move {
crate::jobs::run_quota_reconciliation(reconcile_db, reconcile_interval_hours).await;
});
let state = AppState { let state = AppState {
admin_db: db.admin.clone(), admin_db: db.admin.clone(),
content_db: db.content.clone(), content_db: db.content.clone(),
analytics_db: db.analytics.clone(), analytics_db: db.analytics.clone(),
system_db: db.system.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(), db: db.clone(),
config: config.clone(), config: config.clone(),
analytics_queue: queue, analytics_queue: queue,
+10
View File
@@ -71,10 +71,20 @@ pub fn create_session(
) -> rusqlite::Result<Session> { ) -> rusqlite::Result<Session> {
let created_at = Utc::now().to_rfc3339(); let created_at = Utc::now().to_rfc3339();
// Bind `user_id` as integer when it appears to be numeric so that numeric
// user IDs inserted into `users.db` keep the integer affinity and avoid
// InvalidColumnType errors when read as i64 elsewhere.
if let Ok(id_i64) = user_id.parse::<i64>() {
conn.execute(
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);",
params![session_id, id_i64, expires_at_rfc3339, created_at],
)?;
} else {
conn.execute( conn.execute(
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);", "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], params![session_id, user_id, expires_at_rfc3339, created_at],
)?; )?;
}
Ok(Session { Ok(Session {
id: session_id.to_string(), id: session_id.to_string(),
+8 -5
View File
@@ -82,8 +82,8 @@ pub fn insert_visits_batch(conn: &mut Connection, records: &[VisitRecord]) -> ru
let tx = conn.transaction()?; let tx = conn.transaction()?;
{ {
let mut stmt = tx.prepare( let mut stmt = tx.prepare(
"INSERT INTO visits (id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code) "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);" VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11);"
)?; )?;
for r in records { for r in records {
@@ -97,7 +97,8 @@ pub fn insert_visits_batch(conn: &mut Connection, records: &[VisitRecord]) -> ru
r.referer, r.referer,
r.accept_language, r.accept_language,
r.country, 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_from: Option<&str>,
date_to: Option<&str>, date_to: Option<&str>,
) -> rusqlite::Result<Vec<VisitRecord>> { ) -> rusqlite::Result<Vec<VisitRecord>> {
let mut sql = "SELECT id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code FROM visits WHERE target_type = ?1 AND target_id = ?2".to_string(); let mut sql = "SELECT id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code, owner_user_id FROM visits WHERE target_type = ?1 AND target_id = ?2".to_string();
let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![ let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![
Box::new(target_type.to_string()), Box::new(target_type.to_string()),
Box::new(target_id.to_string()), Box::new(target_id.to_string()),
@@ -638,6 +639,7 @@ pub fn get_target_visits_paginated(
accept_language: row.get("accept_language")?, accept_language: row.get("accept_language")?,
country: row.get("country")?, country: row.get("country")?,
status_code: row.get("status_code")?, 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_from: Option<&str>,
date_to: Option<&str>, date_to: Option<&str>,
) -> rusqlite::Result<Vec<VisitRecord>> { ) -> rusqlite::Result<Vec<VisitRecord>> {
let mut sql = "SELECT id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code FROM visits WHERE target_type = ?1 AND target_id = ?2".to_string(); let mut sql = "SELECT id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code, owner_user_id FROM visits WHERE target_type = ?1 AND target_id = ?2".to_string();
let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![ let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![
Box::new(target_type.to_string()), Box::new(target_type.to_string()),
Box::new(target_id.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")?, accept_language: row.get("accept_language")?,
country: row.get("country")?, country: row.get("country")?,
status_code: row.get("status_code")?, status_code: row.get("status_code")?,
owner_user_id: row.get("owner_user_id")?,
}) })
})?; })?;
+186 -2
View File
@@ -111,7 +111,8 @@ pub fn print_migration_plan(
// Migration definitions // Migration definitions
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
pub const ADMIN_MIGRATIONS: &[Migration] = &[Migration { pub const ADMIN_MIGRATIONS: &[Migration] = &[
Migration {
version: 1, version: 1,
name: "initial_schema", name: "initial_schema",
sql: r#" sql: r#"
@@ -156,7 +157,26 @@ pub const ADMIN_MIGRATIONS: &[Migration] = &[Migration {
value TEXT NOT NULL 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] = &[ pub const CONTENT_MIGRATIONS: &[Migration] = &[
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); 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] = &[ 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); CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_events(action);
"#, "#,
}, },
Migration {
version: 3,
name: "global_slugs_and_moderation",
sql: r#"
CREATE TABLE IF NOT EXISTS global_slugs (
slug TEXT PRIMARY KEY,
owner_user_id INTEGER NOT NULL,
target_type TEXT NOT NULL,
target_id TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
status TEXT NOT NULL,
deleted_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_global_slugs_owner ON global_slugs(owner_user_id);
CREATE INDEX IF NOT EXISTS idx_global_slugs_status ON global_slugs(status);
CREATE INDEX IF NOT EXISTS idx_global_slugs_target ON global_slugs(target_type, target_id);
CREATE TABLE IF NOT EXISTS moderation_events (
id TEXT PRIMARY KEY,
timestamp TEXT NOT NULL,
admin_username TEXT NOT NULL,
target_user_id INTEGER NOT NULL,
target_username TEXT,
resource_type TEXT NOT NULL,
resource_identifier TEXT NOT NULL,
action TEXT NOT NULL,
severity TEXT NOT NULL,
reason TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS slug_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL,
old_owner_user_id INTEGER,
new_owner_user_id INTEGER,
action TEXT NOT NULL,
timestamp TEXT NOT NULL,
admin_username TEXT
);
CREATE TABLE IF NOT EXISTS reserved_slugs (
slug TEXT PRIMARY KEY,
reason TEXT
);
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- Seed defaults
INSERT OR IGNORE INTO schema_version (version, applied_at) VALUES (3, datetime('now'));
INSERT OR IGNORE INTO settings (key, value) VALUES ('soft_delete_retention_days', '30');
INSERT OR IGNORE INTO settings (key, value) VALUES ('quota_reconcile_interval_hours', '24');
INSERT OR IGNORE INTO settings (key, value) VALUES ('allow_registration', 'false');
INSERT OR IGNORE INTO settings (key, value) VALUES ('maintenance_mode', 'false');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('admin', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('login', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('logout', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('dashboard', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('api', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('docs', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('assets', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('static', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('favicon.ico', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('robots.txt', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('health', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('metrics', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('install', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('setup', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('support', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('help', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('security', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('abuse', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('billing', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('status', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('legacy_admin', 'System reserved');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('administrator', 'System reserved');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('system', 'System reserved');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('root', 'System reserved');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('www', 'System reserved');
"#,
},
];
pub const USERS_MIGRATIONS: &[Migration] = &[
Migration {
version: 1,
name: "initial_schema",
sql: r#"
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
last_login TEXT,
account_type TEXT DEFAULT 'standard',
organization_id INTEGER NULL,
metadata TEXT
);
CREATE TABLE IF NOT EXISTS quotas (
user_id INTEGER PRIMARY KEY,
max_urls INTEGER DEFAULT 100,
max_landings INTEGER DEFAULT 10,
max_api_tokens INTEGER DEFAULT 5,
max_storage_mb INTEGER DEFAULT 100,
current_urls INTEGER DEFAULT 0,
current_landings INTEGER DEFAULT 0,
current_api_tokens INTEGER DEFAULT 0,
current_storage_mb INTEGER DEFAULT 0,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS api_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token_hash TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS username_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
old_username TEXT NOT NULL,
new_username TEXT NOT NULL,
changed_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
"#,
},
Migration {
version: 2,
name: "repair_admin_account_type",
sql: r#"
UPDATE users
SET account_type = 'admin'
WHERE username = 'admin' AND account_type = 'standard';
"#,
},
]; ];
+377 -46
View File
@@ -1,6 +1,7 @@
use crate::config::Config; use crate::config::Config;
use crate::db::migrations::{ use crate::db::migrations::{
run_migrations, ADMIN_MIGRATIONS, ANALYTICS_MIGRATIONS, CONTENT_MIGRATIONS, SYSTEM_MIGRATIONS, run_migrations, ADMIN_MIGRATIONS, ANALYTICS_MIGRATIONS, CONTENT_MIGRATIONS, SYSTEM_MIGRATIONS,
USERS_MIGRATIONS,
}; };
use crate::db::sqlite::{enable_foreign_keys, enable_wal}; use crate::db::sqlite::{enable_foreign_keys, enable_wal};
use rusqlite::Connection; use rusqlite::Connection;
@@ -15,6 +16,7 @@ pub mod migrations;
pub mod preview; pub mod preview;
pub mod qr; pub mod qr;
pub mod sqlite; pub mod sqlite;
pub mod users;
#[derive(Clone)] #[derive(Clone)]
pub struct Db { pub struct Db {
@@ -22,70 +24,88 @@ pub struct Db {
pub content: Arc<Mutex<Connection>>, pub content: Arc<Mutex<Connection>>,
pub analytics: Arc<Mutex<Connection>>, pub analytics: Arc<Mutex<Connection>>,
pub system: Arc<Mutex<Connection>>, pub system: Arc<Mutex<Connection>>,
pub users: Arc<Mutex<Connection>>,
pub data_dir: std::path::PathBuf,
} }
impl Db { impl Db {
pub fn init(config: &Config) -> Result<Self, Box<dyn std::error::Error>> { pub fn init(config: &Config) -> Result<Self, Box<dyn std::error::Error>> {
use chrono::Utc;
use tracing::info;
// Ensure data directory exists // Ensure data directory exists
if !config.data_dir.exists() { if !config.data_dir.exists() {
fs::create_dir_all(&config.data_dir)?; fs::create_dir_all(&config.data_dir)?;
} }
let admin_path = config.data_dir.join("admin.db"); let admin_dir = config.data_dir.join("admin");
let content_path = config.data_dir.join("content.db"); let users_dir = config.data_dir.join("users");
let analytics_path = config.data_dir.join("analytics.db"); fs::create_dir_all(&admin_dir)?;
let system_path = config.data_dir.join("system.db"); 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"); info!("Opening admin.db");
let mut admin_conn = Connection::open(admin_path)?; 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"); info!("Opening system.db");
let mut system_conn = Connection::open(system_path)?; 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")?; 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(&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")?; 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(&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"); info!("Running system migrations");
run_migrations(&mut system_conn, "system", SYSTEM_MIGRATIONS, None)?; run_migrations(&mut system_conn, "system", SYSTEM_MIGRATIONS, None)?;
let system_arc = Arc::new(Mutex::new(system_conn)); 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"); info!("Running admin migrations");
run_migrations( run_migrations(
&mut admin_conn, &mut admin_conn,
@@ -93,14 +113,131 @@ impl Db {
ADMIN_MIGRATIONS, ADMIN_MIGRATIONS,
Some(&system_arc), 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( run_migrations(
&mut content_conn, &mut content_conn,
"content", "content",
CONTENT_MIGRATIONS, CONTENT_MIGRATIONS,
Some(&system_arc), Some(&system_arc),
)?; )?;
info!("Running analytics migrations");
run_migrations( run_migrations(
&mut analytics_conn, &mut analytics_conn,
"analytics", "analytics",
@@ -108,26 +245,220 @@ impl Db {
Some(&system_arc), 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)), admin: Arc::new(Mutex::new(admin_conn)),
content: Arc::new(Mutex::new(content_conn)), content: Arc::new(Mutex::new(content_conn)),
analytics: Arc::new(Mutex::new(analytics_conn)), analytics: Arc::new(Mutex::new(analytics_conn)),
system: system_arc, 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> { pub fn compact(&self) -> Result<(), rusqlite::Error> {
let admin = self.admin.lock().unwrap(); let admin = self.admin.lock().unwrap();
admin.execute("VACUUM;", [])?; let _ = admin.execute("VACUUM;", []);
let content = self.content.lock().unwrap(); let content = self.content.lock().unwrap();
content.execute("VACUUM;", [])?; let _ = content.execute("VACUUM;", []);
let analytics = self.analytics.lock().unwrap(); let analytics = self.analytics.lock().unwrap();
analytics.execute("VACUUM;", [])?; let _ = analytics.execute("VACUUM;", []);
let system = self.system.lock().unwrap(); let system = self.system.lock().unwrap();
system.execute("VACUUM;", [])?; let _ = system.execute("VACUUM;", []);
let users = self.users.lock().unwrap();
let _ = users.execute("VACUUM;", []);
Ok(())
}
pub fn init_user_databases(&self, user_id: i64) -> Result<(), Box<dyn std::error::Error>> {
let user_dir = self.data_dir.join("users").join(user_id.to_string());
fs::create_dir_all(&user_dir)?;
let content_path = user_dir.join("content.db");
let analytics_path = user_dir.join("analytics.db");
let profile_path = user_dir.join("profile.db");
let mut content_conn = Connection::open(content_path)?;
let mut analytics_conn = Connection::open(analytics_path)?;
let profile_conn = Connection::open(profile_path)?;
enable_wal(&content_conn, "content")?;
enable_wal(&analytics_conn, "analytics")?;
enable_wal(&profile_conn, "profile")?;
enable_foreign_keys(&content_conn, "content")?;
enable_foreign_keys(&analytics_conn, "analytics")?;
enable_foreign_keys(&profile_conn, "profile")?;
run_migrations(
&mut content_conn,
"content",
CONTENT_MIGRATIONS,
Some(&self.system),
)?;
run_migrations(
&mut analytics_conn,
"analytics",
ANALYTICS_MIGRATIONS,
Some(&self.system),
)?;
profile_conn.execute_batch(
"CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);",
)?;
Ok(())
}
pub fn reconcile_global_slugs(
&self,
config: &Config,
) -> Result<(), Box<dyn std::error::Error>> {
use chrono::Utc;
let system_conn = self.system.lock().unwrap();
let users_conn = self.users.lock().unwrap();
// Get all user IDs
let mut stmt = users_conn.prepare("SELECT id FROM users;")?;
let mut rows = stmt.query([])?;
let mut user_ids = vec![1i64]; // Start with legacy admin
while let Some(row) = rows.next()? {
user_ids.push(row.get(0)?);
}
drop(rows);
drop(stmt);
for user_id in user_ids {
let user_dir = config.data_dir.join("users").join(user_id.to_string());
let content_path = if user_id == 1 {
config.data_dir.join("content.db") // legacy admin content db path
} else {
user_dir.join("content.db")
};
if content_path.exists() {
let content_conn = Connection::open(&content_path)?;
// Sync URLs
let mut stmt =
content_conn.prepare("SELECT code, id, created_at, status FROM urls;")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let code: String = row.get(0)?;
let target_id: String = row.get(1)?;
let created_at: String = row.get(2)?;
let status: String = row.get(3)?;
let global_status = if status == "dead" {
"disabled"
} else {
"active"
};
let now = Utc::now().to_rfc3339();
let _ = system_conn.execute(
"INSERT OR IGNORE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
rusqlite::params![code, user_id, "url", target_id, created_at, now, global_status],
);
}
// Sync Landing Pages
let mut stmt = content_conn
.prepare("SELECT code, id, created_at, state FROM landing_pages;")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let code: String = row.get(0)?;
let target_id: String = row.get(1)?;
let created_at: String = row.get(2)?;
let state: String = row.get(3)?;
let global_status = if state == "published" {
"active"
} else {
"disabled"
};
let now = Utc::now().to_rfc3339();
let _ = system_conn.execute(
"INSERT OR IGNORE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
rusqlite::params![code, user_id, "page", target_id, created_at, now, global_status],
);
}
}
}
Ok(()) Ok(())
} }
+545
View File
@@ -0,0 +1,545 @@
use crate::models::{TenantUser, UserApiToken, UserQuotas, UserSession};
use chrono::Utc;
use rusqlite::{params, Connection, OptionalExtension};
// --- User Operations ---
pub fn is_reserved_username(username: &str) -> bool {
let u = username.trim().to_lowercase();
u == "admin" || u == "legacy_admin" || u == "administrator" || u == "system" || u == "root"
}
pub fn create_admin_user(
conn: &Connection,
username: &str,
password_hash: &str,
) -> rusqlite::Result<TenantUser> {
let created_at = Utc::now().to_rfc3339();
let status = "active";
let account_type = "admin";
conn.execute(
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata)
VALUES (?1, ?2, ?3, ?4, ?5, NULL);",
params![username, password_hash, status, created_at, account_type],
)?;
let id = conn.last_insert_rowid();
// Seed default quotas
conn.execute("INSERT INTO quotas (user_id) VALUES (?1);", params![id])?;
Ok(TenantUser {
id,
username: username.to_string(),
password_hash: password_hash.to_string(),
status: status.to_string(),
created_at,
last_login: None,
account_type: account_type.to_string(),
organization_id: None,
metadata: None,
})
}
pub fn create_user(
conn: &Connection,
username: &str,
password_hash: &str,
account_type: &str,
metadata: Option<&str>,
) -> rusqlite::Result<TenantUser> {
if is_reserved_username(username) {
return Err(rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
Some("Username is reserved".to_string()),
));
}
let created_at = Utc::now().to_rfc3339();
let status = "active";
conn.execute(
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata)
VALUES (?1, ?2, ?3, ?4, ?5, ?6);",
params![
username,
password_hash,
status,
created_at,
account_type,
metadata
],
)?;
let id = conn.last_insert_rowid();
// Seed default quotas
conn.execute("INSERT INTO quotas (user_id) VALUES (?1);", params![id])?;
Ok(TenantUser {
id,
username: username.to_string(),
password_hash: password_hash.to_string(),
status: status.to_string(),
created_at,
last_login: None,
account_type: account_type.to_string(),
organization_id: None,
metadata: metadata.map(|s| s.to_string()),
})
}
pub fn get_user_by_id(conn: &Connection, id: i64) -> rusqlite::Result<Option<TenantUser>> {
conn.query_row(
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
FROM users WHERE id = ?1;",
params![id],
|row| {
Ok(TenantUser {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
status: row.get(3)?,
created_at: row.get(4)?,
last_login: row.get(5)?,
account_type: row.get(6)?,
organization_id: row.get(7)?,
metadata: row.get(8)?,
})
},
)
.optional()
}
pub fn get_user_by_username(
conn: &Connection,
username: &str,
) -> rusqlite::Result<Option<TenantUser>> {
conn.query_row(
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
FROM users WHERE username = ?1;",
params![username],
|row| {
Ok(TenantUser {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
status: row.get(3)?,
created_at: row.get(4)?,
last_login: row.get(5)?,
account_type: row.get(6)?,
organization_id: row.get(7)?,
metadata: row.get(8)?,
})
},
)
.optional()
}
pub fn delete_user(conn: &Connection, id: i64) -> rusqlite::Result<()> {
conn.execute("DELETE FROM users WHERE id = ?1;", params![id])?;
Ok(())
}
pub fn update_user_status(conn: &Connection, id: i64, status: &str) -> rusqlite::Result<()> {
conn.execute(
"UPDATE users SET status = ?1 WHERE id = ?2;",
params![status, id],
)?;
Ok(())
}
pub fn update_user_account_type(
conn: &Connection,
id: i64,
account_type: &str,
) -> rusqlite::Result<()> {
conn.execute(
"UPDATE users SET account_type = ?1 WHERE id = ?2;",
params![account_type, id],
)?;
Ok(())
}
pub fn reset_user_password(
conn: &Connection,
id: i64,
new_password_hash: &str,
) -> rusqlite::Result<()> {
conn.execute(
"UPDATE users SET password_hash = ?1 WHERE id = ?2;",
params![new_password_hash, id],
)?;
Ok(())
}
pub fn update_user_last_login(conn: &Connection, id: i64) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
conn.execute(
"UPDATE users SET last_login = ?1 WHERE id = ?2;",
params![now, id],
)?;
Ok(())
}
pub fn list_users(conn: &Connection) -> rusqlite::Result<Vec<TenantUser>> {
let mut stmt = conn.prepare(
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
FROM users ORDER BY username ASC;",
)?;
let rows = stmt.query_map([], |row| {
Ok(TenantUser {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
status: row.get(3)?,
created_at: row.get(4)?,
last_login: row.get(5)?,
account_type: row.get(6)?,
organization_id: row.get(7)?,
metadata: row.get(8)?,
})
})?;
let mut users = Vec::new();
for u in rows {
users.push(u?);
}
Ok(users)
}
pub fn log_username_change(
conn: &Connection,
user_id: i64,
old_username: &str,
new_username: &str,
) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO username_history (user_id, old_username, new_username, changed_at) VALUES (?1, ?2, ?3, ?4);",
params![user_id, old_username, new_username, now],
)?;
conn.execute(
"UPDATE users SET username = ?1 WHERE id = ?2;",
params![new_username, user_id],
)?;
Ok(())
}
// --- Session Operations ---
pub fn create_user_session(
conn: &Connection,
session_id: &str,
user_id: i64,
expires_at_rfc3339: &str,
) -> rusqlite::Result<UserSession> {
let created_at = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);",
params![session_id, user_id, expires_at_rfc3339, created_at],
)?;
Ok(UserSession {
id: session_id.to_string(),
user_id,
expires_at: expires_at_rfc3339.to_string(),
created_at,
})
}
pub fn get_user_session(
conn: &Connection,
session_id: &str,
) -> rusqlite::Result<Option<UserSession>> {
conn.query_row(
"SELECT id, user_id, expires_at, created_at FROM sessions WHERE id = ?1;",
params![session_id],
|row| {
Ok(UserSession {
id: row.get(0)?,
user_id: row.get(1)?,
expires_at: row.get(2)?,
created_at: row.get(3)?,
})
},
)
.optional()
}
pub fn delete_user_session(conn: &Connection, session_id: &str) -> rusqlite::Result<()> {
conn.execute("DELETE FROM sessions WHERE id = ?1;", params![session_id])?;
Ok(())
}
pub fn cleanup_expired_user_sessions(conn: &Connection) -> rusqlite::Result<usize> {
let now = Utc::now().to_rfc3339();
let count = conn.execute("DELETE FROM sessions WHERE expires_at < ?1;", params![now])?;
Ok(count)
}
// --- Quota Operations ---
pub fn get_user_quotas(conn: &Connection, user_id: i64) -> rusqlite::Result<Option<UserQuotas>> {
conn.query_row(
"SELECT user_id, max_urls, max_landings, max_api_tokens, max_storage_mb,
current_urls, current_landings, current_api_tokens, current_storage_mb
FROM quotas WHERE user_id = ?1;",
params![user_id],
|row| {
Ok(UserQuotas {
user_id: row.get(0)?,
max_urls: row.get(1)?,
max_landings: row.get(2)?,
max_api_tokens: row.get(3)?,
max_storage_mb: row.get(4)?,
current_urls: row.get(5)?,
current_landings: row.get(6)?,
current_api_tokens: row.get(7)?,
current_storage_mb: row.get(8)?,
})
},
)
.optional()
}
pub fn update_user_quotas(
conn: &Connection,
user_id: i64,
max_urls: i64,
max_landings: i64,
max_api_tokens: i64,
max_storage_mb: i64,
) -> rusqlite::Result<()> {
conn.execute(
"UPDATE quotas SET max_urls = ?1, max_landings = ?2, max_api_tokens = ?3, max_storage_mb = ?4
WHERE user_id = ?5;",
params![max_urls, max_landings, max_api_tokens, max_storage_mb, user_id],
)?;
Ok(())
}
pub fn increment_quota_counter(
conn: &Connection,
user_id: i64,
field: &str,
) -> rusqlite::Result<()> {
let sql = match field {
"urls" => "UPDATE quotas SET current_urls = current_urls + 1 WHERE user_id = ?1;",
"landings" => {
"UPDATE quotas SET current_landings = current_landings + 1 WHERE user_id = ?1;"
}
"api_tokens" => {
"UPDATE quotas SET current_api_tokens = current_api_tokens + 1 WHERE user_id = ?1;"
}
_ => return Err(rusqlite::Error::InvalidQuery),
};
conn.execute(sql, params![user_id])?;
Ok(())
}
pub fn decrement_quota_counter(
conn: &Connection,
user_id: i64,
field: &str,
) -> rusqlite::Result<()> {
let sql = match field {
"urls" => "UPDATE quotas SET current_urls = MAX(0, current_urls - 1) WHERE user_id = ?1;",
"landings" => "UPDATE quotas SET current_landings = MAX(0, current_landings - 1) WHERE user_id = ?1;",
"api_tokens" => "UPDATE quotas SET current_api_tokens = MAX(0, current_api_tokens - 1) WHERE user_id = ?1;",
_ => return Err(rusqlite::Error::InvalidQuery),
};
conn.execute(sql, params![user_id])?;
Ok(())
}
pub fn update_quota_storage(
conn: &Connection,
user_id: i64,
storage_mb: i64,
) -> rusqlite::Result<()> {
conn.execute(
"UPDATE quotas SET current_storage_mb = ?1 WHERE user_id = ?2;",
params![storage_mb, user_id],
)?;
Ok(())
}
// --- API Token Operations ---
pub fn create_user_api_token(
conn: &Connection,
user_id: i64,
token_hash: &str,
) -> rusqlite::Result<UserApiToken> {
let created_at = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO api_tokens (user_id, token_hash, created_at) VALUES (?1, ?2, ?3);",
params![user_id, token_hash, created_at],
)?;
let id = conn.last_insert_rowid();
// Increment api token counter
let _ = increment_quota_counter(conn, user_id, "api_tokens");
Ok(UserApiToken {
id,
user_id,
token_hash: token_hash.to_string(),
created_at,
})
}
pub fn list_user_api_tokens(
conn: &Connection,
user_id: i64,
) -> rusqlite::Result<Vec<UserApiToken>> {
let mut stmt = conn.prepare(
"SELECT id, user_id, token_hash, created_at FROM api_tokens WHERE user_id = ?1 ORDER BY id DESC;",
)?;
let rows = stmt.query_map(params![user_id], |row| {
Ok(UserApiToken {
id: row.get(0)?,
user_id: row.get(1)?,
token_hash: row.get(2)?,
created_at: row.get(3)?,
})
})?;
let mut tokens = Vec::new();
for t in rows {
tokens.push(t?);
}
Ok(tokens)
}
pub fn delete_user_api_token(conn: &Connection, id: i64, user_id: i64) -> rusqlite::Result<()> {
let deleted = conn.execute(
"DELETE FROM api_tokens WHERE id = ?1 AND user_id = ?2;",
params![id, user_id],
)?;
if deleted > 0 {
let _ = decrement_quota_counter(conn, user_id, "api_tokens");
}
Ok(())
}
// --- Global Slug & Quota Reconciliation Helpers ---
pub fn is_slug_available(system_conn: &Connection, slug: &str) -> rusqlite::Result<bool> {
// 1. Check reserved list
let reserved: bool = system_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM reserved_slugs WHERE slug = ?1);",
[slug],
|row| row.get(0),
)
.unwrap_or(false);
if reserved {
return Ok(false);
}
// 2. Check global slugs
let exists: bool = system_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = ?1);",
[slug],
|row| row.get(0),
)
.unwrap_or(false);
Ok(!exists)
}
pub fn register_global_slug(
system_conn: &Connection,
slug: &str,
owner_user_id: i64,
target_type: &str,
target_id: &str,
) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
system_conn.execute(
"INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
rusqlite::params![slug, owner_user_id, target_type, target_id, now, now, "active"],
)?;
// Insert history
system_conn.execute(
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp)
VALUES (?1, NULL, ?2, 'created', ?3);",
rusqlite::params![slug, owner_user_id, now],
)?;
Ok(())
}
pub fn release_global_slug(
system_conn: &Connection,
slug: &str,
owner_user_id: i64,
) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
system_conn.execute("DELETE FROM global_slugs WHERE slug = ?1;", [slug])?;
// Insert history
system_conn.execute(
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp)
VALUES (?1, ?2, NULL, 'released', ?3);",
rusqlite::params![slug, owner_user_id, now],
)?;
Ok(())
}
pub fn soft_delete_global_slug(
system_conn: &Connection,
slug: &str,
owner_user_id: i64,
) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
system_conn.execute(
"UPDATE global_slugs SET status = 'soft_deleted', deleted_at = ?1 WHERE slug = ?2;",
rusqlite::params![now, slug],
)?;
// Insert history
system_conn.execute(
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp)
VALUES (?1, ?2, NULL, 'deleted', ?3);",
rusqlite::params![slug, owner_user_id, now],
)?;
Ok(())
}
pub fn reconcile_user_quotas(
users_conn: &Connection,
user_id: i64,
content_conn: &Connection,
) -> rusqlite::Result<()> {
let urls_count: i64 = content_conn
.query_row("SELECT COUNT(*) FROM urls;", [], |row| row.get(0))
.unwrap_or(0);
let landings_count: i64 = content_conn
.query_row("SELECT COUNT(*) FROM landing_pages;", [], |row| row.get(0))
.unwrap_or(0);
let api_tokens_count: i64 = users_conn
.query_row(
"SELECT COUNT(*) FROM api_tokens WHERE user_id = ?1;",
[user_id],
|row| row.get(0),
)
.unwrap_or(0);
users_conn.execute(
"UPDATE quotas SET current_urls = ?1, current_landings = ?2, current_api_tokens = ?3 WHERE user_id = ?4;",
rusqlite::params![urls_count, landings_count, api_tokens_count, user_id],
)?;
Ok(())
}
+30 -13
View File
@@ -10,23 +10,43 @@ pub async fn run_aggregator(db: Db, interval_mins: u64) {
tokio::time::sleep(Duration::from_secs(interval_mins * 60)).await; tokio::time::sleep(Duration::from_secs(interval_mins * 60)).await;
info!("Running background analytics aggregator..."); info!("Running background analytics aggregator...");
let user_ids: Vec<i64> = {
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"); let job_id = log_job_start(&db.system, "analytics_aggregator");
match perform_aggregation(&db).await { let mut failed = false;
Ok(_) => log_job_end(&db.system, &job_id, "success", None), let mut err_msg = None;
Err(e) => {
let err_str = e.to_string(); 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); error!("Error performing aggregation: {}", err_str);
log_job_end(&db.system, &job_id, "failed", Some(&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<dyn std::error::Error>> { pub async fn perform_aggregation(db: &Db, user_id: i64) -> Result<(), Box<dyn std::error::Error>> {
let date_range = { let mut conn = super::open_user_analytics_conn(db, user_id)?;
let conn = db.analytics.lock().unwrap(); let date_range = crate::db::analytics::get_visits_date_range(&conn)?;
crate::db::analytics::get_visits_date_range(&conn)?
};
if let Some((min_date, max_date)) = date_range { if let Some((min_date, max_date)) = date_range {
let min = chrono::NaiveDate::parse_from_str(&min_date, "%Y-%m-%d")?; 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<dyn std::error::Erro
let mut curr = min; let mut curr = min;
while curr <= max { while curr <= max {
let date_str = curr.format("%Y-%m-%d").to_string(); let date_str = curr.format("%Y-%m-%d").to_string();
{
let mut conn = db.analytics.lock().unwrap();
aggregate_day(&mut conn, &date_str)?; aggregate_day(&mut conn, &date_str)?;
}
if curr == max { if curr == max {
break; break;
} }
+23 -5
View File
@@ -64,6 +64,22 @@ pub async fn perform_backup(
if let Ok(conn) = db.system.lock() { if let Ok(conn) = db.system.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []); let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
} }
if let Ok(conn) = db.users.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
if let Ok(mut stmt) = conn.prepare("SELECT id FROM users;") {
if let Ok(rows) = stmt.query_map([], |row| row.get::<_, i64>(0)) {
let user_ids: Vec<i64> = 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 date_str = Utc::now().format("%Y-%m-%d-%H%M%S").to_string();
let tar_name = format!("{}-bzod-backup.tar.gz", date_str); 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 enc = GzEncoder::new(file, Compression::default());
let mut tar = Builder::new(enc); let mut tar = Builder::new(enc);
let files = vec!["admin.db", "content.db", "analytics.db", "system.db"]; let admin_dir = config.data_dir.join("admin");
for f in files { if admin_dir.exists() {
let db_file = config.data_dir.join(f); tar.append_dir_all("admin", &admin_dir)?;
if db_file.exists() {
tar.append_path_with_name(&db_file, f)?;
} }
let users_dir = config.data_dir.join("users");
if users_dir.exists() {
tar.append_dir_all("users", &users_dir)?;
} }
tar.into_inner()?.finish()?; tar.into_inner()?.finish()?;
+24 -5
View File
@@ -10,13 +10,32 @@ pub async fn run_expiry_checker(db: Db) {
loop { loop {
tokio::time::sleep(Duration::from_secs(60)).await; tokio::time::sleep(Duration::from_secs(60)).await;
let count = { let user_ids: Vec<i64> = {
let conn = db.content.lock().unwrap(); let conn = db.users.lock().unwrap();
crate::db::content::expire_urls(&conn).unwrap_or(0) 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 { let mut total_expired = 0;
info!(expired_count = count, "Expired URLs marked"); 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"
);
} }
} }
} }
+16 -6
View File
@@ -37,16 +37,25 @@ pub async fn perform_link_check(
db: &Db, db: &Db,
client: &Client, client: &Client,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let urls = { let user_ids: Vec<i64> = {
let conn = db.content.lock().unwrap(); let conn = db.users.lock().unwrap();
crate::db::content::list_urls_for_health_check(&conn)? 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 user_id in user_ids {
let conn = match super::open_user_content_conn(db, user_id) {
Ok(c) => c,
Err(_) => continue,
};
let urls = crate::db::content::list_urls_for_health_check(&conn)?;
for (id, dest) in urls { for (id, dest) in urls {
let (status, detail_status, status_code, latency_ms, err_msg) = let (status, detail_status, status_code, latency_ms, err_msg) =
check_url_health(client, &dest).await; check_url_health(client, &dest).await;
{ {
let conn = db.content.lock().unwrap();
crate::db::content::update_url_health_extended( crate::db::content::update_url_health_extended(
&conn, &conn,
&id, &id,
@@ -58,11 +67,11 @@ pub async fn perform_link_check(
// Log to system.db.health_checks // Log to system.db.health_checks
{ {
let conn = db.system.lock().unwrap(); let sys_conn = db.system.lock().unwrap();
let hc_id = Uuid::new_v4().to_string(); let hc_id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339(); let now = Utc::now().to_rfc3339();
let is_healthy = if status == "healthy" { 1 } else { 0 }; let is_healthy = if status == "healthy" { 1 } else { 0 };
let _ = conn.execute( let _ = sys_conn.execute(
"INSERT INTO health_checks (id, object_type, object_id, checked_at, status_code, error_message, is_healthy) "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);", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
params![hc_id, "url", id, now, status_code, err_msg, is_healthy], params![hc_id, "url", id, now, status_code, err_msg, is_healthy],
@@ -72,6 +81,7 @@ pub async fn perform_link_check(
// Rate limiting sleep between external requests // Rate limiting sleep between external requests
tokio::time::sleep(Duration::from_millis(200)).await; tokio::time::sleep(Duration::from_millis(200)).await;
} }
}
Ok(()) Ok(())
} }
+36
View File
@@ -1,3 +1,4 @@
use crate::db::Db;
use chrono::Utc; use chrono::Utc;
use rusqlite::{params, Connection}; use rusqlite::{params, Connection};
use std::sync::Mutex; use std::sync::Mutex;
@@ -35,3 +36,38 @@ pub fn log_job_end(conn: &Mutex<Connection>, 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<rusqlite::Connection, rusqlite::Error> {
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<rusqlite::Connection, rusqlite::Error> {
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)
}
+42
View File
@@ -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<i64> = {
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.");
}
}
+40 -8
View File
@@ -15,18 +15,50 @@ pub async fn run_retention_cleaner(db: Db, retention_days_opt: Option<i64>) {
tokio::time::sleep(Duration::from_secs(24 * 3600)).await; tokio::time::sleep(Duration::from_secs(24 * 3600)).await;
info!("Running background data retention cleanup..."); info!("Running background data retention cleanup...");
let user_ids: Vec<i64> = {
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 job_id = log_job_start(&db.system, "retention_cleaner");
let conn = db.analytics.lock().unwrap(); let mut total_cleaned = 0;
match crate::db::analytics::retention_cleanup(&conn, retention_days) { let mut failed = false;
Ok(count) => { let mut err_msg = None;
info!("Cleaned up {} expired visits from database", count);
log_job_end(&db.system, &job_id, "success", 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) => { Err(e) => {
let err_str = e.to_string(); 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); error!("Error running retention cleaner: {:?}", err_str);
log_job_end(&db.system, &job_id, "failed", Some(&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);
} }
} }
} }
+40
View File
@@ -54,6 +54,46 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
Commands::Expand { code, data_dir } => { Commands::Expand { code, data_dir } => {
bzod::cli::expand::run(code, data_dir, config).await?; 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(()) Ok(())
+4 -1
View File
@@ -9,5 +9,8 @@ pub use api_key::ApiKey;
pub use audit::AuditLog; pub use audit::AuditLog;
pub use page::LandingPage; pub use page::LandingPage;
pub use url::{AuditEvent, LinkPreview, QrCode, Url}; 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}; pub use visit::{SummaryEntry, VisitRecord};
+190
View File
@@ -15,3 +15,193 @@ pub struct Session {
pub expires_at: String, pub expires_at: String,
pub created_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<String>,
pub account_type: String, // 'system', 'admin', 'standard', 'organization', 'service'
pub organization_id: Option<i64>,
pub metadata: Option<String>,
}
#[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<Self> {
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<Self> {
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<Self> {
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,
}
}
}
+1
View File
@@ -12,6 +12,7 @@ pub struct VisitRecord {
pub accept_language: String, pub accept_language: String,
pub country: String, pub country: String,
pub status_code: u16, pub status_code: u16,
pub owner_user_id: Option<i64>,
} }
#[derive(Serialize, Deserialize, Clone, Debug)] #[derive(Serialize, Deserialize, Clone, Debug)]
+70
View File
@@ -2,15 +2,25 @@ use crate::analytics::queue::AnalyticsQueue;
use crate::config::Config; use crate::config::Config;
use crate::db::Db; use crate::db::Db;
use rusqlite::Connection; use rusqlite::Connection;
use std::collections::HashMap;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Instant; use std::time::Instant;
#[derive(Clone)]
pub struct UserDbs {
pub content: Arc<Mutex<Connection>>,
pub analytics: Arc<Mutex<Connection>>,
pub profile: Arc<Mutex<Connection>>,
}
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
pub admin_db: Arc<Mutex<Connection>>, pub admin_db: Arc<Mutex<Connection>>,
pub content_db: Arc<Mutex<Connection>>, pub content_db: Arc<Mutex<Connection>>,
pub analytics_db: Arc<Mutex<Connection>>, pub analytics_db: Arc<Mutex<Connection>>,
pub system_db: Arc<Mutex<Connection>>, pub system_db: Arc<Mutex<Connection>>,
pub users_db: Arc<Mutex<Connection>>,
pub user_dbs: Arc<Mutex<HashMap<i64, UserDbs>>>,
pub db: Db, pub db: Db,
pub config: Config, pub config: Config,
pub analytics_queue: AnalyticsQueue, pub analytics_queue: AnalyticsQueue,
@@ -18,11 +28,71 @@ pub struct AppState {
} }
impl AppState { impl AppState {
pub fn get_user_dbs(&self, user_id: i64) -> Result<UserDbs, crate::error::AppError> {
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> { pub fn db_compact(&self) -> Result<(), rusqlite::Error> {
self.admin_db.lock().unwrap().execute("VACUUM;", [])?; self.admin_db.lock().unwrap().execute("VACUUM;", [])?;
self.content_db.lock().unwrap().execute("VACUUM;", [])?; self.content_db.lock().unwrap().execute("VACUUM;", [])?;
self.analytics_db.lock().unwrap().execute("VACUUM;", [])?; self.analytics_db.lock().unwrap().execute("VACUUM;", [])?;
self.system_db.lock().unwrap().execute("VACUUM;", [])?; self.system_db.lock().unwrap().execute("VACUUM;", [])?;
self.users_db.lock().unwrap().execute("VACUUM;", [])?;
Ok(()) Ok(())
} }
} }
+343 -6
View File
@@ -4,25 +4,38 @@ pub mod pages;
pub mod settings; pub mod settings;
pub mod stats; pub mod stats;
pub mod urls; 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 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 askama::Template;
use axum::{ use axum::{
http::StatusCode, http::StatusCode,
response::{Html, IntoResponse, Response}, 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)] #[derive(Template)]
#[template(path = "login.html")] #[template(path = "login.html")]
pub struct LoginTemplate { pub struct LoginTemplate {
pub error: Option<String>, pub error: Option<String>,
pub csrf_token: String, pub csrf_token: String,
pub action: String,
pub title: String,
pub subtitle: String,
pub button_text: String,
} }
impl IntoResponse for LoginTemplate { 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<String>,
pub error: Option<String>,
}
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<crate::models::UserSession>,
pub tokens: Vec<crate::models::UserApiToken>,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
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<String>,
pub error: Option<String>,
}
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<crate::web::admin::GlobalSlugRow>,
pub logs: Vec<crate::web::admin::ModerationLogEntry>,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
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<crate::web::admin::GlobalSlugRow>,
pub history: Vec<crate::web::admin::SlugHistoryRow>,
pub csrf_token: String,
pub search_filter: Option<String>,
pub owner_filter: Option<i64>,
pub status_filter: Option<String>,
pub success: Option<String>,
pub error: Option<String>,
}
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<crate::models::UserSession>,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
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<crate::models::UserQuotas>,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
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<crate::db::sqlite::DatabaseHealthReport>,
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<crate::web::admin::JobHistoryRow>,
pub health_checks: Vec<crate::web::admin::HealthCheckRow>,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
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<crate::web::admin::BackupFileRow>,
pub history: Vec<crate::web::admin::BackupHistoryRow>,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
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<crate::models::UserApiToken>,
pub new_token: Option<String>,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
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<crate::models::VisitRecord>,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
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<VisitorLogEntry>,
}
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<VisitorLogEntry>,
}
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(),
}
}
}
+47
View File
@@ -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<crate::models::Url>,
}
#[derive(Template)]
#[template(path = "user_audit.html")]
pub struct UserAuditTemplate {
pub admin_username: String,
pub logs: Vec<AuditLog>,
}
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(),
}
}
}
+33
View File
@@ -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(),
}
}
}
+38
View File
@@ -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<LandingPage>,
pub csrf_token: String,
pub error: Option<String>,
pub current_page: usize,
pub total_pages: usize,
pub visible_pages: Vec<usize>,
}
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(),
}
}
}
+28
View File
@@ -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<String>,
pub error: Option<String>,
}
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(),
}
}
}
+40
View File
@@ -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<Url>,
pub csrf_token: String,
pub error: Option<String>,
pub tag_filter: Option<String>,
pub base_url: String,
pub current_page: usize,
pub total_pages: usize,
pub visible_pages: Vec<usize>,
}
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(),
}
}
}
+26
View File
@@ -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<TenantUser>,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
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(),
}
}
}
+3793 -61
View File
File diff suppressed because it is too large. Load diff
+14 -14
View File
@@ -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 user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
let _ = write_audit_log( let _ = write_audit_log(
&state.admin_db.lock().unwrap(), &state.admin_db.lock().unwrap(),
&user.0.username, user.0.username(),
"URL_CREATION", "URL_CREATION",
Some("url"), Some("url"),
Some(&url.id), Some(&url.id),
@@ -180,7 +180,7 @@ pub async fn api_create_url(
let system_conn = state.system_db.lock().unwrap(); let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event( let _ = crate::db::audit_events::write_audit_event(
&system_conn, &system_conn,
&user.0.username, user.0.username(),
"URL_CREATION", "URL_CREATION",
"url", "url",
&url.id, &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 user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
let _ = write_audit_log( let _ = write_audit_log(
&state.admin_db.lock().unwrap(), &state.admin_db.lock().unwrap(),
&user.0.username, user.0.username(),
"URL_UPDATE", "URL_UPDATE",
Some("url"), Some("url"),
Some(&uuid), Some(&uuid),
@@ -327,7 +327,7 @@ pub async fn api_update_url(
let system_conn = state.system_db.lock().unwrap(); let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event( let _ = crate::db::audit_events::write_audit_event(
&system_conn, &system_conn,
&user.0.username, user.0.username(),
"URL_UPDATE", "URL_UPDATE",
"url", "url",
&uuid, &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 user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
let _ = write_audit_log( let _ = write_audit_log(
&state.admin_db.lock().unwrap(), &state.admin_db.lock().unwrap(),
&user.0.username, user.0.username(),
"URL_DELETION", "URL_DELETION",
Some("url"), Some("url"),
Some(&uuid), Some(&uuid),
@@ -382,7 +382,7 @@ pub async fn api_delete_url(
let system_conn = state.system_db.lock().unwrap(); let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event( let _ = crate::db::audit_events::write_audit_event(
&system_conn, &system_conn,
&user.0.username, user.0.username(),
"URL_DELETION", "URL_DELETION",
"url", "url",
&uuid, &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 user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
let _ = write_audit_log( let _ = write_audit_log(
&state.admin_db.lock().unwrap(), &state.admin_db.lock().unwrap(),
&user.0.username, user.0.username(),
"PAGE_CREATION", "PAGE_CREATION",
Some("page"), Some("page"),
Some(&page.id), 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 user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
let _ = write_audit_log( let _ = write_audit_log(
&state.admin_db.lock().unwrap(), &state.admin_db.lock().unwrap(),
&user.0.username, user.0.username(),
"PAGE_UPDATE", "PAGE_UPDATE",
Some("page"), Some("page"),
Some(&uuid), 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 user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
let _ = write_audit_log( let _ = write_audit_log(
&state.admin_db.lock().unwrap(), &state.admin_db.lock().unwrap(),
&user.0.username, user.0.username(),
"PAGE_DELETION", "PAGE_DELETION",
Some("page"), Some("page"),
Some(&uuid), Some(&uuid),
@@ -889,7 +889,7 @@ pub async fn api_set_preview(
let system_conn = state.system_db.lock().unwrap(); let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event( let _ = crate::db::audit_events::write_audit_event(
&system_conn, &system_conn,
&user.0.username, user.0.username(),
"SET_PREVIEW", "SET_PREVIEW",
"url", "url",
&uuid, &uuid,
@@ -976,7 +976,7 @@ pub async fn api_delete_preview(
let system_conn = state.system_db.lock().unwrap(); let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event( let _ = crate::db::audit_events::write_audit_event(
&system_conn, &system_conn,
&user.0.username, user.0.username(),
"DELETE_PREVIEW", "DELETE_PREVIEW",
"url", "url",
&uuid, &uuid,
@@ -1051,7 +1051,7 @@ pub async fn api_set_password(
let system_conn = state.system_db.lock().unwrap(); let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event( let _ = crate::db::audit_events::write_audit_event(
&system_conn, &system_conn,
&user.0.username, user.0.username(),
"SET_PASSWORD", "SET_PASSWORD",
"url", "url",
&uuid, &uuid,
@@ -1109,7 +1109,7 @@ pub async fn api_remove_password(
let system_conn = state.system_db.lock().unwrap(); let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event( let _ = crate::db::audit_events::write_audit_event(
&system_conn, &system_conn,
&user.0.username, user.0.username(),
"REMOVE_PASSWORD", "REMOVE_PASSWORD",
"url", "url",
&uuid, &uuid,
@@ -1177,7 +1177,7 @@ pub async fn api_create_qr(
let system_conn = state.system_db.lock().unwrap(); let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event( let _ = crate::db::audit_events::write_audit_event(
&system_conn, &system_conn,
&user.0.username, user.0.username(),
"CREATE_QR", "CREATE_QR",
"qr_code", "qr_code",
&payload.url_id, &payload.url_id,
+2 -2
View File
@@ -120,7 +120,7 @@ pub async fn api_bulk_qr(
let system_conn = state.db.system.lock().unwrap(); let system_conn = state.db.system.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event( let _ = crate::db::audit_events::write_audit_event(
&system_conn, &system_conn,
&user.0.username, user.0.username(),
"BULK_QR_EXPORT", "BULK_QR_EXPORT",
"bulk", "bulk",
"qr", "qr",
@@ -271,7 +271,7 @@ pub async fn api_bulk_url(
let system_conn = state.db.system.lock().unwrap(); let system_conn = state.db.system.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event( let _ = crate::db::audit_events::write_audit_event(
&system_conn, &system_conn,
&user.0.username, user.0.username(),
"BULK_URL_CREATION", "BULK_URL_CREATION",
"bulk", "bulk",
"url", "url",
+1
View File
@@ -2,6 +2,7 @@ pub mod admin;
pub mod api; pub mod api;
pub mod bulk; pub mod bulk;
pub mod middleware; pub mod middleware;
pub mod multi_user;
pub mod pages; pub mod pages;
pub mod password_gate; pub mod password_gate;
pub mod qr; pub mod qr;
+991
View File
@@ -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<String>,
pub metadata: Option<String>,
}
#[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<String>,
}
#[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<String>,
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<AppState>, 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<UserResponse> = 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<AppState>,
user: ApiUser,
Json(payload): Json<CreateUserRequest>,
) -> 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<AppState>,
user: ApiUser,
Path(target_id): Path<i64>,
Json(payload): Json<UpdateUserStatusRequest>,
) -> 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<AppState>,
user: ApiUser,
Path(target_id): Path<i64>,
Json(payload): Json<UpdateUserQuotasRequest>,
) -> 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<AppState>,
user: ApiUser,
Path(target_id): Path<i64>,
Json(payload): Json<ResetPasswordRequest>,
) -> 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<String> = {
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<AppState>,
user: ApiUser,
Path(target_id): Path<i64>,
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
) -> 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<AppState>,
user: ApiUser,
Json(payload): Json<TransferSlugRequest>,
) -> 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<AppState>,
user: ApiUser,
Json(payload): Json<ModerateSlugRequest>,
) -> 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<AppState>,
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<ModerationEventResponse> = 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<AppState>, 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<AppState>,
user: ApiUser,
Json(payload): Json<ChangeOwnPasswordRequest>,
) -> 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<AppState>, 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<AppState>, 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<AppState>,
user: ApiUser,
Path(token_id): Path<i64>,
) -> 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(),
}
}
+50 -2
View File
@@ -4,12 +4,12 @@ use axum::{
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
}; };
use chrono::Utc; use chrono::Utc;
use rusqlite::OptionalExtension;
use std::net::SocketAddr; use std::net::SocketAddr;
use uuid::Uuid; use uuid::Uuid;
use crate::analytics::get_client_country; use crate::analytics::get_client_country;
use crate::models::VisitRecord; use crate::models::VisitRecord;
use crate::services::landing_pages::get_landing_page_by_code;
use crate::state::AppState; use crate::state::AppState;
use crate::utils::get_client_ip; use crate::utils::get_client_ip;
@@ -25,9 +25,56 @@ pub async fn resolve_page(
return (StatusCode::NOT_FOUND, "Not Found").into_response(); return (StatusCode::NOT_FOUND, "Not Found").into_response();
} }
let page_opt = match get_landing_page_by_code(&state.db, &code) { // 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, Ok(page) => page,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
}
}; };
match page_opt { match page_opt {
@@ -67,6 +114,7 @@ pub async fn resolve_page(
accept_language, accept_language,
country, country,
status_code: 200, status_code: 200,
owner_user_id: Some(owner_user_id),
}; };
state.analytics_queue.push(record); state.analytics_queue.push(record);
+84 -9
View File
@@ -1,5 +1,4 @@
use crate::services::qr::{generate_qr_png, generate_qr_svg}; use crate::services::qr::{generate_qr_png, generate_qr_svg};
use crate::services::shortener::get_url_by_code;
use crate::state::AppState; use crate::state::AppState;
use crate::utils::get_client_ip; use crate::utils::get_client_ip;
use axum::{ use axum::{
@@ -11,6 +10,7 @@ use std::net::SocketAddr;
use serde_json::json; 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) // 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( pub async fn qr_handler(
State(state): State<AppState>, State(state): State<AppState>,
@@ -24,9 +24,10 @@ pub async fn qr_handler(
let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok()); let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok());
let authenticated = if let Some(auth) = auth_header { 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!( matches!(
crate::auth::session::authenticate_api_key(&conn, auth), crate::auth::session::authenticate_api_key(&admin_conn, &users_conn, auth),
Ok(Some(_user)) Ok(Some(_user))
) )
} else { } else {
@@ -37,23 +38,63 @@ pub async fn qr_handler(
return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(); return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
} }
let url_opt = match get_url_by_code(&state.db, &file) { // We need to look up owner_user_id and status from global_slugs
Ok(u) => u, 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(), 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 { let url = match url_opt {
Some(u) => u, Some(u) => u,
None => return (StatusCode::NOT_FOUND, "URL not found").into_response(), None => return (StatusCode::NOT_FOUND, "URL not found").into_response(),
}; };
let qr_scans = { 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) crate::db::qr::get_qr_scan_count(&conn, &url.id).unwrap_or(0)
}; };
let direct_clicks = { let direct_clicks = {
let conn = state.analytics_db.lock().unwrap(); let conn = user_dbs.analytics.lock().unwrap();
conn.query_row( conn.query_row(
"SELECT COUNT(*) FROM visits WHERE target_type = 'url' AND target_id = ?1;", "SELECT COUNT(*) FROM visits WHERE target_type = 'url' AND target_id = ?1;",
rusqlite::params![url.id], rusqlite::params![url.id],
@@ -76,9 +117,43 @@ pub async fn qr_handler(
return (StatusCode::NOT_FOUND, "Not Found").into_response(); return (StatusCode::NOT_FOUND, "Not Found").into_response();
} }
let url_opt = match get_url_by_code(&state.db, code) { // 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, Ok(u) => u,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
}
}; };
let url = match url_opt { let url = match url_opt {
@@ -144,7 +219,7 @@ pub async fn qr_handler(
.map(|s| s.to_string()); .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( let _ = crate::db::qr::log_qr_access(
&analytics_conn, &analytics_conn,
&url.id, &url.id,
+58 -10
View File
@@ -5,12 +5,12 @@ use axum::{
}; };
use axum_extra::extract::CookieJar; use axum_extra::extract::CookieJar;
use chrono::Utc; use chrono::Utc;
use rusqlite::OptionalExtension;
use std::net::SocketAddr; use std::net::SocketAddr;
use uuid::Uuid; use uuid::Uuid;
use crate::analytics::get_client_country; use crate::analytics::get_client_country;
use crate::models::VisitRecord; use crate::models::VisitRecord;
use crate::services::shortener::get_url_by_code;
use crate::state::AppState; use crate::state::AppState;
use crate::templates::PreviewTemplate; use crate::templates::PreviewTemplate;
use crate::utils::get_client_ip; use crate::utils::get_client_ip;
@@ -29,9 +29,56 @@ pub async fn resolve_redirect(
return (StatusCode::NOT_FOUND, "Not Found").into_response(); return (StatusCode::NOT_FOUND, "Not Found").into_response();
} }
let url_opt = match get_url_by_code(&state.db, &code) { // 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, Ok(url) => url,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
}
}; };
let url = match url_opt { let url = match url_opt {
@@ -39,7 +86,7 @@ pub async fn resolve_redirect(
None => return (StatusCode::NOT_FOUND, "Short code not found").into_response(), None => return (StatusCode::NOT_FOUND, "Short code not found").into_response(),
}; };
// 1. Expiration check // 3. Expiration check
if url.expired { if url.expired {
return (StatusCode::GONE, "This link has expired").into_response(); 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() { if expires_at.with_timezone(&Utc) < Utc::now() {
// Mark as expired in DB asynchronously/immediately // Mark as expired in DB asynchronously/immediately
{ {
let conn = state.db.content.lock().unwrap(); let conn = user_dbs.content.lock().unwrap();
let _ = conn.execute( let _ = conn.execute(
"UPDATE urls SET expired = 1 WHERE id = ?1;", "UPDATE urls SET expired = 1 WHERE id = ?1;",
[url.id.clone()], [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() { if url.is_access_exhausted() {
return ( return (
StatusCode::GONE, StatusCode::GONE,
@@ -69,7 +116,7 @@ pub async fn resolve_redirect(
.into_response(); .into_response();
} }
// 3. Password protection check // 5. Password protection check
if url.is_password_protected() { if url.is_password_protected() {
let cookie_name = format!("bzod_gate_{}", code); let cookie_name = format!("bzod_gate_{}", code);
let authorized = jar 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 _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) crate::db::content::increment_access_count(&conn, &url.id).unwrap_or(url.access_count + 1)
}; };
let preview_opt = { 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) crate::db::preview::get_preview(&conn, &url.id).unwrap_or(None)
}; };
@@ -123,12 +170,13 @@ pub async fn resolve_redirect(
accept_language, accept_language,
country, country,
status_code: if preview_opt.is_some() { 200 } else { 302 }, status_code: if preview_opt.is_some() { 200 } else { 302 },
owner_user_id: Some(owner_user_id),
}; };
// Push to memory queue (non-blocking) // Push to memory queue (non-blocking)
state.analytics_queue.push(record); state.analytics_queue.push(record);
// 5. Render Preview or Redirect // 7. Render Preview or Redirect
if let Some(preview) = preview_opt { if let Some(preview) = preview_opt {
PreviewTemplate { PreviewTemplate {
code, code,
+140 -2
View File
@@ -1,7 +1,7 @@
use crate::state::AppState; 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::{ use axum::{
routing::{get, post}, routing::{delete, get, post, put},
Router, Router,
}; };
@@ -22,6 +22,46 @@ pub fn create_router(state: AppState) -> Router {
// --- System Health & Diagnostics --- // --- System Health & Diagnostics ---
.route("/status", get(system::status_endpoint)) .route("/status", get(system::status_endpoint))
.route("/metrics", get(system::metrics_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 --- // --- Admin UI Login/Logout ---
.route("/admin", get(admin::admin_index)) .route("/admin", get(admin::admin_index))
.route( .route(
@@ -57,6 +97,24 @@ pub fn create_router(state: AppState) -> Router {
get(admin::page_analytics_json_export), get(admin::page_analytics_json_export),
) )
.route("/admin/settings", get(admin::settings_get)) .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( .route(
"/admin/settings/password", "/admin/settings/password",
post(admin::change_password_post), 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/audit", get(admin::audit_get))
.route("/admin/status", get(admin::status_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 --- // --- REST API v1 JSON Endpoints ---
.route( .route(
"/api/v1/urls", "/api/v1/urls",
@@ -124,6 +215,53 @@ pub fn create_router(state: AppState) -> Router {
"/api/v1/urls/:uuid/password", "/api/v1/urls/:uuid/password",
post(api::api_set_password).delete(api::api_remove_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 --- // --- Static Asset Stub ---
.route( .route(
"/static/style.css", "/static/style.css",
+6 -5
View File
@@ -6,7 +6,7 @@ use axum::{
use axum_extra::extract::CookieJar; use axum_extra::extract::CookieJar;
use serde::Serialize; 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::db::admin::get_user_count;
use crate::state::AppState; use crate::state::AppState;
use crate::utils::{get_db_file_info, get_memory_usage}; 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 { fn authenticate_request(state: &AppState, jar: &CookieJar, headers: &HeaderMap) -> bool {
// 1. Try Authorization header // 1. Try Authorization header
if let Some(auth_header) = headers.get("Authorization").and_then(|h| h.to_str().ok()) { if let Some(auth_header) = headers.get("Authorization").and_then(|h| h.to_str().ok()) {
let conn = state.admin_db.lock().unwrap(); let admin_conn = state.admin_db.lock().unwrap();
if let Ok(Some(_)) = authenticate_api_key(&conn, auth_header) { let users_conn = state.users_db.lock().unwrap();
if let Ok(Some(_)) = authenticate_api_key(&admin_conn, &users_conn, auth_header) {
return true; return true;
} }
} }
// 2. Try cookie session // 2. Try cookie session
let conn = state.admin_db.lock().unwrap(); let conn = state.users_db.lock().unwrap();
if let Ok(Some(_)) = authenticate_session(&conn, jar) { if let Ok(Some(_)) = authenticate_admin_session(&conn, jar) {
return true; return true;
} }
+82
View File
@@ -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 %}
<div class="alert alert-success">
{{ msg }}
</div>
{% endif %}
{% if let Some(err) = error %}
<div class="alert alert-error">
{{ err }}
</div>
{% endif %}
<div style="display: grid; grid-template-columns: 1fr 2fr; gap: 1.5rem; align-items: start;">
<!-- Create Token Card -->
<div class="card">
<h3 style="font-size: 1.15rem; margin-bottom: 1rem;">Generate API Token</h3>
<p style="font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 1.25rem; line-height: 1.4;">
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.
</p>
{% if let Some(raw_token) = new_token %}
<div style="background: rgba(16, 185, 129, 0.1); border: 1px solid rgba(16, 185, 129, 0.2); padding: 1rem; border-radius: 8px; margin-bottom: 1.25rem; word-break: break-all;">
<span style="display: block; font-size: 0.75rem; color: var(--success-color); font-weight: 700; margin-bottom: 0.25rem; text-transform: uppercase;">Raw Token (Copy now!)</span>
<code style="font-size: 1.1rem; color: var(--text-primary); font-family: monospace; font-weight: 600;">{{ raw_token }}</code>
</div>
{% endif %}
<form action="/api-tokens/create" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn" style="width: 100%;">Generate New Token</button>
</form>
</div>
<!-- Active Tokens List -->
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.15rem;">Active API Tokens</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Token ID</th>
<th>Created At</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% if tokens.is_empty() %}
<tr>
<td colspan="3" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No active API tokens generated yet.
</td>
</tr>
{% else %}
{% for t in tokens %}
<tr>
<td>{{ t.id }}</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ t.created_at }}</td>
<td>
<form action="/api-tokens/revoke/{{ t.id }}" method="POST" onsubmit="return confirm('Revoke this API token? Any applications using it will be blocked.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-secondary" style="padding: 0.4rem 0.75rem; color: var(--danger-color);">Revoke</button>
</form>
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
+144
View File
@@ -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 %}
<form action="/admin/backups/create" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn">Create Backup Archive</button>
</form>
{% endblock %}
{% block content %}
{% if let Some(msg) = success %}
<div class="alert alert-success">
{{ msg }}
</div>
{% endif %}
{% if let Some(err) = error %}
<div class="alert alert-error">
{{ err }}
</div>
{% endif %}
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 1.5rem; align-items: start;">
<!-- List of Backup Files -->
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.15rem;">Available Backup Archives</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Archive File</th>
<th>File Size</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% if files.is_empty() %}
<tr>
<td colspan="4" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No backup archive files found in backups folder.
</td>
</tr>
{% else %}
{% for f in files %}
<tr>
<td>
<strong style="font-family: monospace;">{{ f.filename }}</strong>
</td>
<td>{{ f.size_str }}</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ f.created_str }}</td>
<td>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<a href="/admin/backups/download/{{ f.filename }}" class="btn btn-secondary" style="padding: 0.35rem 0.6rem; font-size: 0.85rem;">Download</a>
<form action="/admin/backups/delete/{{ f.filename }}" method="POST" style="margin: 0;" onsubmit="return confirm('Delete backup file {{ f.filename }}? This cannot be undone.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-danger" style="padding: 0.35rem 0.6rem; font-size: 0.85rem;">Delete</button>
</form>
</div>
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
<!-- Restore Database Panel -->
<div class="card">
<h3 style="font-size: 1.15rem; margin-bottom: 1rem; color: var(--danger-color);">Restore Platform Databases</h3>
<p style="font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 1.25rem; line-height: 1.4;">
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!
</p>
<form action="/admin/backups/restore" method="POST" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-group">
<label for="backup_file">Upload Backup File (.tar.gz)</label>
<input type="file" id="backup_file" name="backup_file" class="form-input" accept=".tar.gz" required>
</div>
<div class="form-group">
<label for="confirm_text">Type RESTORE to continue</label>
<input type="text" id="confirm_text" name="confirm_text" class="form-input" placeholder="RESTORE" required autocomplete="off">
</div>
<button type="submit" class="btn btn-danger" style="width: 100%;">Upload & Restore Now</button>
</form>
</div>
</div>
<!-- Backup History Log -->
<div class="card" style="padding: 0; overflow: hidden; margin-top: 1.5rem;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.15rem;">Backup Audit History</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Timestamp</th>
<th>Backup File Path</th>
<th>Status</th>
<th>Size</th>
<th>Error Message</th>
</tr>
</thead>
<tbody>
{% if history.is_empty() %}
<tr>
<td colspan="5" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No backup execution logs found.
</td>
</tr>
{% else %}
{% for h in history %}
<tr>
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ h.created_at[0..19].replace("T", " ") }}</td>
<td style="font-family: monospace; font-size: 0.85rem;">{{ h.backup_path }}</td>
<td>
<span class="badge {% if h.status == "success" %}badge-healthy{% else %}badge-dead{% endif %}">
{{ h.status }}
</span>
</td>
<td>{{ h.size_bytes }} B</td>
<td style="font-size: 0.8rem; color: var(--text-secondary);">{{ h.error_message.as_deref().unwrap_or("-") }}</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+171
View File
@@ -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 %}
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start; margin-bottom: 1.5rem;">
<!-- DB Health Report -->
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.15rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
Database Structural Health
</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Database File</th>
<th>Schema Ver.</th>
<th>Journal Mode</th>
<th>FK State</th>
<th>Integrity Check</th>
</tr>
</thead>
<tbody>
{% for db in db_reports %}
<tr>
<td><strong>{{ db.database }}.db</strong></td>
<td>v{{ db.schema_version }}</td>
<td><span class="badge" style="background-color: rgba(255,255,255,0.05); color: var(--text-secondary);">{{ db.journal_mode }}</span></td>
<td>{% if db.foreign_keys_enabled %}ON{% else %}OFF{% endif %}</td>
<td>
{% if db.integrity_ok %}
<span class="badge badge-healthy">Passed</span>
{% else %}
<span class="badge badge-dead">Corrupt/Failed</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Storage Utilization -->
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.15rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.21 15.89A10 10 0 1 1 8 2.83"/><path d="M22 12A10 10 0 0 0 12 2v10z"/></svg>
Storage Utilization
</h3>
</div>
<div style="padding: 1.5rem; display: flex; flex-direction: column; gap: 1rem;">
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border-color); padding-bottom: 0.5rem;">
<span>Total Data Directory Size:</span>
<strong>{{ total_data_size }}</strong>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border-color); padding-bottom: 0.5rem;">
<span>System Database size (`system.db`):</span>
<span>{{ system_db_size }}</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border-color); padding-bottom: 0.5rem;">
<span>Users/Quotas Database size (`users.db`):</span>
<span>{{ users_db_size }}</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border-color); padding-bottom: 0.5rem;">
<span>Administration database (`admin.db`):</span>
<span>{{ admin_db_size }}</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span>Standard Tenants Databases (`/users/*`):</span>
<span>{{ tenants_db_size }}</span>
</div>
</div>
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start;">
<!-- Job Execution Status -->
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.15rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
Maintenance & Reconcile Jobs
</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Job Name</th>
<th>Last Run Status</th>
<th>Started At</th>
<th>Error Msg</th>
</tr>
</thead>
<tbody>
{% if job_history.is_empty() %}
<tr>
<td colspan="4" style="text-align: center; color: var(--text-secondary); padding: 2rem;">No job history logs.</td>
</tr>
{% else %}
{% for job in job_history %}
<tr>
<td><strong>{{ job.job_name }}</strong></td>
<td>
<span class="badge {% if job.status == "success" %}badge-healthy{% else %}badge-dead{% endif %}">
{{ job.status }}
</span>
</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ job.started_at[0..19].replace("T", " ") }}</td>
<td style="font-size: 0.8rem; color: var(--text-secondary); max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
{{ job.error_message.as_deref().unwrap_or("-") }}
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
<!-- Health Check Diagnostics -->
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.15rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><polyline points="10 6 14 12 10 18"/><line x1="18" y1="12" x2="14" y2="12"/><line x1="6" y1="12" x2="10" y2="12"/></svg>
Diagnostics Checks (URLs/Pages)
</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Resource</th>
<th>Status</th>
<th>Code</th>
<th>Checked At</th>
</tr>
</thead>
<tbody>
{% if health_checks.is_empty() %}
<tr>
<td colspan="4" style="text-align: center; color: var(--text-secondary); padding: 2rem;">No resource diagnostics logs.</td>
</tr>
{% else %}
{% for check in health_checks %}
<tr>
<td><span style="font-family: monospace;">{{ check.object_type }}:{{ check.object_id }}</span></td>
<td>
<span class="badge {% if check.is_healthy == 1 %}badge-healthy{% else %}badge-dead{% endif %}">
{% if check.is_healthy == 1 %}healthy{% else %}unhealthy{% endif %}
</span>
</td>
<td>{{ check.status_code.unwrap_or(0) }}</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ check.checked_at[0..16].replace("T", " ") }}</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
+10
View File
@@ -378,6 +378,7 @@
</div> </div>
<ul class="nav-links"> <ul class="nav-links">
{% block sidebar_links %}
<li class="{% block active_dashboard %}{% endblock %}"> <li class="{% block active_dashboard %}{% endblock %}">
<a href="/admin/dashboard"> <a href="/admin/dashboard">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg>
@@ -396,6 +397,12 @@
Landing Pages Landing Pages
</a> </a>
</li> </li>
<li class="{% block active_users %}{% endblock %}">
<a href="/admin/users">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
Users Management
</a>
</li>
<li class="{% block active_settings %}{% endblock %}"> <li class="{% block active_settings %}{% endblock %}">
<a href="/admin/settings"> <a href="/admin/settings">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
@@ -414,8 +421,10 @@
Status Status
</a> </a>
</li> </li>
{% endblock %}
</ul> </ul>
{% block sidebar_footer %}
<div class="sidebar-footer"> <div class="sidebar-footer">
<div class="admin-user-info"> <div class="admin-user-info">
<div class="avatar">{{ admin_username[0..1].to_uppercase() }}</div> <div class="avatar">{{ admin_username[0..1].to_uppercase() }}</div>
@@ -423,6 +432,7 @@
</div> </div>
<a href="/admin/logout" class="logout-btn">Log Out</a> <a href="/admin/logout" class="logout-btn">Log Out</a>
</div> </div>
{% endblock %}
</div> </div>
<!-- Main Workspace --> <!-- Main Workspace -->
+5 -5
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - BZOD Platform</title> <title>{{ title }} - BZOD Platform</title>
<style> <style>
:root { :root {
--bg-base: #070a13; --bg-base: #070a13;
@@ -143,9 +143,9 @@
<div class="login-card"> <div class="login-card">
<div class="logo"> <div class="logo">
BZOD <span class="logo-dot"></span> {{ title }} <span class="logo-dot"></span>
</div> </div>
<p class="subtitle">Personal Redirects & Landing Pages</p> <p class="subtitle">{{ subtitle }}</p>
{% if let Some(err) = error %} {% if let Some(err) = error %}
<div class="alert-error"> <div class="alert-error">
@@ -153,7 +153,7 @@
</div> </div>
{% endif %} {% endif %}
<form action="/admin/login" method="POST"> <form action="{{ action }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-group"> <div class="form-group">
@@ -166,7 +166,7 @@
<input type="password" id="password" name="password" class="form-input" required autocomplete="current-password"> <input type="password" id="password" name="password" class="form-input" required autocomplete="current-password">
</div> </div>
<button type="submit" class="btn">Sign In</button> <button type="submit" class="btn">{{ button_text }}</button>
</form> </form>
</div> </div>
+158
View File
@@ -0,0 +1,158 @@
{% extends "layout.html" %}
{% block title %}Content Moderation - BZOD{% endblock %}
{% block active_moderation %}active{% endblock %}
{% block header_title %}Content Moderation Panel{% endblock %}
{% block content %}
{% if let Some(msg) = success %}
<div class="alert alert-success">
{{ msg }}
</div>
{% endif %}
{% if let Some(err) = error %}
<div class="alert alert-error">
{{ err }}
</div>
{% endif %}
<div style="display: grid; grid-template-columns: 1fr 2fr; gap: 1.5rem; align-items: start; margin-bottom: 1.5rem;">
<!-- Moderation Form -->
<div class="card">
<h3 style="font-size: 1.15rem; margin-bottom: 1rem;">Moderate a Resource</h3>
<form action="/admin/moderation" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-group">
<label for="slug">Resource Slug</label>
<input type="text" id="slug" name="slug" class="form-input" placeholder="e.g. !hello or abcdef" required>
</div>
<div class="form-group">
<label for="action">Moderation Action</label>
<select id="action" name="action" class="form-input">
<option value="active">Activate (Publicly accessible)</option>
<option value="flagged">Flag (Flagged, visible to admins)</option>
<option value="disabled">Disable (Returns 410 Gone)</option>
<option value="deleted">Delete (Release slug completely)</option>
</select>
</div>
<div class="form-group">
<label for="severity">Severity Level</label>
<select id="severity" name="severity" class="form-input">
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high" selected>High</option>
<option value="critical">Critical</option>
</select>
</div>
<div class="form-group">
<label for="reason">Reason / Notes</label>
<textarea id="reason" name="reason" class="form-input" rows="3" placeholder="Violation detail, abuse report summary..." required></textarea>
</div>
<button type="submit" class="btn">Apply Action</button>
</form>
</div>
<!-- Active Flags & Disabled Resources -->
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.15rem;">Currently Flagged or Disabled Content</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Slug</th>
<th>Owner ID</th>
<th>Type</th>
<th>Status</th>
<th>Updated At</th>
</tr>
</thead>
<tbody>
{% if flagged_items.is_empty() %}
<tr>
<td colspan="5" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No resources are currently flagged or disabled.
</td>
</tr>
{% else %}
{% for item in flagged_items %}
<tr>
<td>
<strong style="color: var(--accent-color);">/{{ item.slug }}</strong>
</td>
<td>{{ item.owner_user_id }}</td>
<td>{{ item.target_type }}</td>
<td>
<span class="badge" style="background-color: {% if item.status == "disabled" %}rgba(239, 68, 68, 0.15){% else %}rgba(245, 158, 11, 0.15){% endif %}; color: {% if item.status == "disabled" %}var(--danger-color){% else %}var(--warning-color){% endif %};">
{{ item.status }}
</span>
</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ item.updated_at[0..10] }}</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Moderation Events Log -->
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.15rem;">Moderation Action Log</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Timestamp</th>
<th>Operator</th>
<th>Target User</th>
<th>Resource</th>
<th>Action</th>
<th>Severity</th>
<th>Reason</th>
</tr>
</thead>
<tbody>
{% if logs.is_empty() %}
<tr>
<td colspan="7" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No moderation actions recorded.
</td>
</tr>
{% else %}
{% for log in logs %}
<tr>
<td style="font-size: 0.85rem; color: var(--text-secondary); white-space: nowrap;">{{ log.timestamp[0..19].replace("T", " ") }}</td>
<td><strong>{{ log.admin_username }}</strong></td>
<td>ID: {{ log.target_user_id }}</td>
<td><span style="font-family: monospace;">{{ log.resource_type }}:{{ log.resource_identifier }}</span></td>
<td>
<span class="badge" style="background-color: rgba(99, 102, 241, 0.15); color: #818cf8;">{{ log.action }}</span>
</td>
<td>
<span class="badge" style="background-color: {% if log.severity == "critical" %}rgba(239,68,68,0.2){% else %}rgba(255,255,255,0.05){% endif %}; color: {% if log.severity == "critical" %}var(--danger-color){% else %}var(--text-secondary){% endif %}; font-weight: 700;">
{{ log.severity }}
</span>
</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ log.reason }}</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+107
View File
@@ -0,0 +1,107 @@
{% extends "layout.html" %}
{% block title %}Quota Management - BZOD{% endblock %}
{% block active_users %}active{% endblock %}
{% block header_title %}User Quota Management{% endblock %}
{% block header_actions %}
<form action="/admin/quotas" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="action" value="reconcile_all">
<button type="submit" class="btn">Sync & Reconcile All Quotas</button>
</form>
{% endblock %}
{% block content %}
{% if let Some(msg) = success %}
<div class="alert alert-success">
{{ msg }}
</div>
{% endif %}
{% if let Some(err) = error %}
<div class="alert alert-error">
{{ err }}
</div>
{% endif %}
<div class="card" style="padding: 0; overflow: hidden;">
<div class="table-container">
<table>
<thead>
<tr>
<th>User ID</th>
<th>URLs (Used/Max)</th>
<th>Pages (Used/Max)</th>
<th>API Tokens (Used/Max)</th>
<th>Storage (Used/Max MB)</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% if quotas.is_empty() %}
<tr>
<td colspan="6" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No quotas defined.
</td>
</tr>
{% else %}
{% for q in quotas %}
<tr>
<td>
<a href="/admin/users/{{ q.user_id }}" style="color: var(--text-primary); text-decoration: underline; font-weight: 600;">
User ID: {{ q.user_id }}
</a>
</td>
<td>
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
<span>{{ q.current_urls }} / {{ q.max_urls }}</span>
<div style="background: rgba(255,255,255,0.05); height: 6px; border-radius: 3px; overflow: hidden; width: 120px;">
<div style="background: var(--primary-grad); height: 100%; width: {{ q.urls_pct() }}%;"></div>
</div>
</div>
</td>
<td>
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
<span>{{ q.current_landings }} / {{ q.max_landings }}</span>
<div style="background: rgba(255,255,255,0.05); height: 6px; border-radius: 3px; overflow: hidden; width: 120px;">
<div style="background: var(--primary-grad); height: 100%; width: {{ q.landings_pct() }}%;"></div>
</div>
</div>
</td>
<td>
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
<span>{{ q.current_api_tokens }} / {{ q.max_api_tokens }}</span>
<div style="background: rgba(255,255,255,0.05); height: 6px; border-radius: 3px; overflow: hidden; width: 120px;">
<div style="background: var(--primary-grad); height: 100%; width: {{ q.api_tokens_pct() }}%;"></div>
</div>
</div>
</td>
<td>
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
<span>{{ q.current_storage_mb }} / {{ q.max_storage_mb }} MB</span>
<div style="background: rgba(255,255,255,0.05); height: 6px; border-radius: 3px; overflow: hidden; width: 120px;">
<div style="background: var(--primary-grad); height: 100%; width: {{ q.storage_pct() }}%;"></div>
</div>
</div>
</td>
<td>
<div style="display: flex; gap: 0.5rem;">
<a href="/admin/users/{{ q.user_id }}/edit" class="btn btn-secondary" style="padding: 0.35rem 0.6rem; font-size: 0.85rem;">Edit</a>
<form action="/admin/quotas" method="POST" style="margin: 0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="action" value="reconcile">
<input type="hidden" name="user_id" value="{{ q.user_id }}">
<button type="submit" class="btn btn-secondary" style="padding: 0.35rem 0.6rem; font-size: 0.85rem;">Reconcile</button>
</form>
</div>
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+73
View File
@@ -0,0 +1,73 @@
{% extends "layout.html" %}
{% block title %}Active Sessions - BZOD{% endblock %}
{% block active_sessions %}active{% endblock %}
{% block header_title %}Session Administration{% endblock %}
{% block header_actions %}
<form action="/admin/sessions/revoke-all" method="POST" onsubmit="return confirm('Revoke ALL active user sessions? This will log out everyone including you.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-danger">Revoke All Sessions</button>
</form>
{% endblock %}
{% block content %}
{% if let Some(msg) = success %}
<div class="alert alert-success">
{{ msg }}
</div>
{% endif %}
{% if let Some(err) = error %}
<div class="alert alert-error">
{{ err }}
</div>
{% endif %}
<div class="card" style="padding: 0; overflow: hidden;">
<div class="table-container">
<table>
<thead>
<tr>
<th>User ID</th>
<th>Session ID (Masked)</th>
<th>Created At</th>
<th>Expires At</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% if sessions.is_empty() %}
<tr>
<td colspan="5" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No active sessions found in the system.
</td>
</tr>
{% else %}
{% for s in sessions %}
<tr>
<td>
<a href="/admin/users/{{ s.user_id }}" style="color: var(--text-primary); text-decoration: underline; font-weight: 600;">
User ID: {{ s.user_id }}
</a>
</td>
<td style="font-family: monospace; font-size: 0.85rem; color: var(--text-secondary);">
{{ s.id[0..6] }}...
</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ s.created_at[0..19].replace("T", " ") }}</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ s.expires_at[0..19].replace("T", " ") }}</td>
<td>
<form action="/admin/sessions/revoke/{{ s.id }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-secondary" style="padding: 0.4rem 0.75rem; color: var(--danger-color);">Revoke</button>
</form>
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+181
View File
@@ -0,0 +1,181 @@
{% extends "layout.html" %}
{% block title %}Global Slug Namespace - BZOD{% endblock %}
{% block active_slugs %}active{% endblock %}
{% block header_title %}Global Slug Directory{% endblock %}
{% block content %}
{% if let Some(msg) = success %}
<div class="alert alert-success">
{{ msg }}
</div>
{% endif %}
{% if let Some(err) = error %}
<div class="alert alert-error">
{{ err }}
</div>
{% endif %}
<!-- Search & Filtering -->
<div class="card">
<form action="/admin/slugs" method="GET" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; align-items: end;">
<div class="form-group" style="margin-bottom: 0;">
<label for="search">Search Slug</label>
<input type="text" id="search" name="search" class="form-input" placeholder="e.g. !hello" value="{% if let Some(s) = search_filter %}{{ s }}{% endif %}">
</div>
<div class="form-group" style="margin-bottom: 0;">
<label for="owner">Owner User ID</label>
<input type="number" id="owner" name="owner" class="form-input" placeholder="e.g. 1" value="{% if let Some(o) = owner_filter %}{{ o }}{% endif %}">
</div>
<div class="form-group" style="margin-bottom: 0;">
<label for="status">Status</label>
<select id="status" name="status" class="form-input">
<option value="" selected>All Statuses</option>
<option value="active" {% if let Some(s) = status_filter %}{% if s == "active" %}selected{% endif %}{% endif %}>active</option>
<option value="flagged" {% if let Some(s) = status_filter %}{% if s == "flagged" %}selected{% endif %}{% endif %}>flagged</option>
<option value="disabled" {% if let Some(s) = status_filter %}{% if s == "disabled" %}selected{% endif %}{% endif %}>disabled</option>
</select>
</div>
<button type="submit" class="btn">Apply Filters</button>
</form>
</div>
<!-- Slugs Directory Table -->
<div class="card" style="padding: 0; overflow: hidden; margin-bottom: 1.5rem;">
<div class="table-container">
<table>
<thead>
<tr>
<th>Slug</th>
<th>Owner (ID)</th>
<th>Resource Type</th>
<th>Resource ID</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% if slugs.is_empty() %}
<tr>
<td colspan="7" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No registered slugs found matching filters.
</td>
</tr>
{% else %}
{% for item in slugs %}
<tr>
<td>
<strong style="color: var(--accent-color); font-family: monospace; font-size: 1.05rem;">/{{ item.slug }}</strong>
</td>
<td>
<a href="/admin/users/{{ item.owner_user_id }}" style="color: var(--text-primary); text-decoration: underline;">
User ID: {{ item.owner_user_id }}
</a>
</td>
<td>{{ item.target_type }}</td>
<td style="font-family: monospace; font-size: 0.85rem;">{{ item.target_id }}</td>
<td>
<span class="badge" style="background-color: {% if item.status == "active" %}rgba(16, 185, 129, 0.15){% else if item.status == "disabled" %}rgba(239, 68, 68, 0.15){% else %}rgba(245, 158, 11, 0.15){% endif %}; color: {% if item.status == "active" %}var(--success-color){% else if item.status == "disabled" %}var(--danger-color){% else %}var(--warning-color){% endif %};">
{{ item.status }}
</span>
</td>
<td>{{ item.created_at[0..10] }}</td>
<td>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
<!-- Transfer Form -->
<form action="/admin/slugs/transfer" method="POST" style="display: flex; gap: 0.25rem;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="slug" value="{{ item.slug }}">
<input type="number" name="new_owner_user_id" placeholder="New ID" required style="width: 80px; padding: 0.35rem 0.5rem; font-size: 0.85rem;">
<button type="submit" class="btn btn-secondary" style="padding: 0.35rem 0.6rem; font-size: 0.85rem;">Transfer</button>
</form>
<!-- Disable / Enable Form -->
{% if item.status == "active" %}
<form action="/admin/slugs/status" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="slug" value="{{ item.slug }}">
<input type="hidden" name="status" value="disabled">
<button type="submit" class="btn btn-danger" style="padding: 0.35rem 0.6rem; font-size: 0.85rem;">Disable</button>
</form>
{% else %}
<form action="/admin/slugs/status" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="slug" value="{{ item.slug }}">
<input type="hidden" name="status" value="active">
<button type="submit" class="btn" style="padding: 0.35rem 0.6rem; font-size: 0.85rem; background: rgba(16,185,129,0.1); color: var(--success-color); border: 1px solid rgba(16,185,129,0.2);">Enable</button>
</form>
{% endif %}
<!-- Delete Form -->
<form action="/admin/slugs/delete" method="POST" onsubmit="return confirm('Release slug /{{ item.slug }}? this cannot be undone.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="slug" value="{{ item.slug }}">
<button type="submit" class="btn btn-danger" style="padding: 0.35rem 0.6rem; font-size: 0.85rem;">Delete</button>
</form>
</div>
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
<!-- Slug History Log -->
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.15rem;">Slug Ownership History</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Timestamp</th>
<th>Slug</th>
<th>Old Owner</th>
<th>New Owner</th>
<th>Action</th>
<th>Admin</th>
</tr>
</thead>
<tbody>
{% if history.is_empty() %}
<tr>
<td colspan="6" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No history records logged.
</td>
</tr>
{% else %}
{% for log in history %}
<tr>
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ log.timestamp[0..19].replace("T", " ") }}</td>
<td><strong style="font-family: monospace;">/{{ log.slug }}</strong></td>
<td>{% if let Some(old_id) = log.old_owner_user_id %}User ID: {{ old_id }}{% else %}-{% endif %}</td>
<td>{% if let Some(new_id) = log.new_owner_user_id %}User ID: {{ new_id }}{% else %}-{% endif %}</td>
<td>
<span class="badge" style="background-color: rgba(255,255,255,0.05); color: var(--text-secondary);">{{ log.action }}</span>
</td>
<td>
{% if let Some(admin) = log.admin_username %}
{{ admin }}
{% else %}
System
{% endif %}
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+99
View File
@@ -0,0 +1,99 @@
{% extends "user_layout.html" %}
{% block title %}My Analytics Dashboard - BZOD{% endblock %}
{% block active_analytics %}active{% endblock %}
{% block header_title %}Analytics Overview{% endblock %}
{% block content %}
<!-- Overview Stats Cards -->
<div class="grid-stats" style="margin-bottom: 2rem;">
<div class="card stat-card">
<span class="stat-label">Total Link Clicks</span>
<span class="stat-val" style="color: #60a5fa;">{{ total_clicks }}</span>
</div>
<div class="card stat-card">
<span class="stat-label">Unique Visitors</span>
<span class="stat-val" style="color: #c084fc;">{{ unique_visitors }}</span>
</div>
<div class="card stat-card">
<span class="stat-label">Direct Traffic</span>
<span class="stat-val" style="color: #34d399;">{{ direct_clicks }}</span>
</div>
<div class="card stat-card">
<span class="stat-label">Referral Traffic</span>
<span class="stat-val" style="color: #f472b6;">{{ referred_clicks }}</span>
</div>
</div>
<!-- Geographic, Referrers, and Browsers Analysis -->
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(450px, 1fr)); gap: 1.5rem; margin-bottom: 2rem;">
<!-- Referrers -->
<div class="card">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
Referrer Channels (Top Referrers)
</h3>
<div style="background-color: rgba(15, 23, 42, 0.4); border-radius: 12px; padding: 1rem; border: 1px solid rgba(255, 255, 255, 0.03);">
{{ referrers_chart|safe }}
</div>
</div>
<!-- Browsers -->
<div class="card">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
Visitor Browsers
</h3>
<div style="background-color: rgba(15, 23, 42, 0.4); border-radius: 12px; padding: 1rem; border: 1px solid rgba(255, 255, 255, 0.03);">
{{ browsers_chart|safe }}
</div>
</div>
</div>
<!-- Visitor Log Table -->
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center;">
<h3 style="font-size: 1.15rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
Recent Visitor Access Logs
</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Timestamp</th>
<th>Slug</th>
<th>IP Address</th>
<th>Browser / Platform</th>
<th>Country</th>
<th>Referrer</th>
</tr>
</thead>
<tbody>
{% if visits.is_empty() %}
<tr>
<td colspan="6" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No visitor records recorded yet.
</td>
</tr>
{% else %}
{% for visit in visits %}
<tr>
<td style="font-size: 0.85rem; color: var(--text-secondary); white-space: nowrap;">{{ visit.timestamp[0..19].replace("T", " ") }}</td>
<td><strong style="color: var(--accent-color);">/{{ visit.target_id }}</strong></td>
<td style="font-family: monospace; font-size: 0.85rem;">{{ visit.ip_address }}</td>
<td style="font-size: 0.85rem; max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ visit.user_agent }}</td>
<td>{{ visit.country }}</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ visit.referer }}</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+71
View File
@@ -0,0 +1,71 @@
{% extends "user_layout.html" %}
{% block title %}Audit Logs - BZOD{% endblock %}
{% block active_audit %}active{% endblock %}
{% block header_title %}Security Audit Logs{% endblock %}
{% block content %}
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
System Action Log
</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Timestamp</th>
<th>Action</th>
<th>Operator (User)</th>
<th>Source IP Address</th>
<th>Log Details / Context</th>
</tr>
</thead>
<tbody>
{% if logs.is_empty() %}
<tr>
<td colspan="5" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No audit records logged yet.
</td>
</tr>
{% else %}
{% for log in logs %}
<tr>
<td style="font-family: monospace; font-size: 0.85rem; color: var(--text-secondary); white-space: nowrap;">
{{ log.timestamp[0..19].replace("T", " ") }}
</td>
<td>
<span class="badge" style="background-color: rgba(99, 102, 241, 0.15); color: #818cf8; border: 1px solid rgba(99,102,241,0.2);">
{{ log.action }}
</span>
</td>
<td>
<span style="font-weight: 600; color: var(--text-primary);">{{ log.username }}</span>
</td>
<td style="font-family: monospace; font-size: 0.85rem; color: var(--text-secondary);">
{{ log.ip_address.as_deref().unwrap_or("Unknown") }}
</td>
<td style="color: var(--text-secondary); font-size: 0.85rem;">
{% if let Some(obj_type) = log.object_type %}
<span style="font-weight: 500;">Type:</span> {{ obj_type }}
{% endif %}
{% if let Some(obj_id) = log.object_id %}
| <span style="font-weight: 500;">ID:</span> {{ obj_id }}
{% endif %}
{% if let Some(ua) = log.user_agent %}
<div style="font-size: 0.75rem; color: var(--text-muted); margin-top: 0.25rem;">UA: {{ ua }}</div>
{% endif %}
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+80
View File
@@ -0,0 +1,80 @@
{% extends "user_layout.html" %}
{% block title %}User Dashboard - BZOD{% endblock %}
{% block active_dashboard %}active{% endblock %}
{% block header_title %}Dashboard Overview{% endblock %}
{% block content %}
<!-- Overview Cards -->
<div class="grid-stats">
<div class="card stat-card">
<span class="stat-label">Total Short URLs</span>
<span class="stat-val" style="color: #60a5fa;">{{ total_urls }}</span>
</div>
<div class="card stat-card">
<span class="stat-label">Total Landing Pages</span>
<span class="stat-val" style="color: #c084fc;">{{ total_pages }}</span>
</div>
<div class="card stat-card">
<span class="stat-label">Total Visits / Clicks</span>
<span class="stat-val" style="color: #34d399;">{{ total_clicks }}</span>
</div>
<div class="card stat-card">
<span class="stat-label">Active / Dead Links</span>
<div style="display: flex; align-items: baseline; gap: 0.5rem; margin-top: 0.25rem;">
<span class="stat-val" style="color: #10b981;">{{ active_links }}</span>
<span style="color: var(--text-muted); font-size: 1.25rem;">/</span>
<span style="font-size: 1.25rem; font-weight: 700; color: #ef4444;">{{ dead_links }}</span>
</div>
</div>
</div>
<!-- Main Traffic Chart -->
<div class="card" style="margin-bottom: 2rem;">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; color: var(--text-primary); display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
Click Traffic Trend (Last 30 Days)
</h3>
<div style="background-color: rgba(15, 23, 42, 0.4); border-radius: 12px; padding: 1rem; border: 1px solid rgba(255, 255, 255, 0.03);">
{{ traffic_chart|safe }}
</div>
</div>
<!-- Secondary Charts Grid -->
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(450px, 1fr)); gap: 1.5rem; margin-bottom: 2rem;">
<!-- Countries -->
<div class="card">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
Geographic Analysis (Top Countries)
</h3>
<div style="background-color: rgba(15, 23, 42, 0.4); border-radius: 12px; padding: 1rem; border: 1px solid rgba(255, 255, 255, 0.03);">
{{ countries_chart|safe }}
</div>
</div>
<!-- Referrers -->
<div class="card">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
Referrer Channels (Top Referrers)
</h3>
<div style="background-color: rgba(15, 23, 42, 0.4); border-radius: 12px; padding: 1rem; border: 1px solid rgba(255, 255, 255, 0.03);">
{{ referrers_chart|safe }}
</div>
</div>
<!-- Browsers -->
<div class="card" style="grid-column: span 1;">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/></svg>
User Agent breakdown (Top Browsers)
</h3>
<div style="background-color: rgba(15, 23, 42, 0.4); border-radius: 12px; padding: 1rem; border: 1px solid rgba(255, 255, 255, 0.03);">
{{ browsers_chart|safe }}
</div>
</div>
</div>
{% endblock %}
+257
View File
@@ -0,0 +1,257 @@
{% extends "layout.html" %}
{% block title %}User Detail: {{ target_user.username }} - BZOD{% endblock %}
{% block active_users %}active{% endblock %}
{% block header_title %}User Detail: {{ target_user.username }}{% endblock %}
{% block content %}
{% if let Some(msg) = success %}
<div class="alert alert-success">
{{ msg }}
</div>
{% endif %}
{% if let Some(err) = error %}
<div class="alert alert-error">
{{ err }}
</div>
{% endif %}
<div style="margin-bottom: 1.5rem;">
<a href="/admin/users" class="btn btn-secondary" style="padding: 0.5rem 1rem;">
&larr; Back to Users List
</a>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start; margin-bottom: 1.5rem;">
<!-- Profile & Actions -->
<div class="card">
<h2 style="font-size: 1.25rem; margin-bottom: 1.25rem;">User Profile & Settings</h2>
<table style="width: 100%; border-collapse: collapse; margin-bottom: 1.5rem;">
<tr>
<td style="font-weight: 600; color: var(--text-secondary); width: 35%;">User ID</td>
<td>{{ target_user.id }}</td>
</tr>
<tr>
<td style="font-weight: 600; color: var(--text-secondary);">Username</td>
<td><strong>{{ target_user.username }}</strong></td>
</tr>
<tr>
<td style="font-weight: 600; color: var(--text-secondary);">Account Type</td>
<td><span class="badge" style="background-color: rgba(99, 102, 241, 0.15); color: #818cf8;">{{ target_user.account_type }}</span></td>
</tr>
<tr>
<td style="font-weight: 600; color: var(--text-secondary);">Account Status</td>
<td>
<span class="badge" style="background-color: {% if target_user.status == "active" %}rgba(16, 185, 129, 0.15){% else %}rgba(239, 68, 68, 0.15){% endif %}; color: {% if target_user.status == "active" %}var(--success-color){% else %}var(--danger-color){% endif %};">
{{ target_user.status }}
</span>
</td>
</tr>
<tr>
<td style="font-weight: 600; color: var(--text-secondary);">Created At</td>
<td>{{ target_user.created_at }}</td>
</tr>
<tr>
<td style="font-weight: 600; color: var(--text-secondary);">Last Login</td>
<td>
{% if let Some(login) = target_user.last_login %}
{{ login }}
{% else %}
Never
{% endif %}
</td>
</tr>
{% if let Some(meta) = target_user.metadata %}
<tr>
<td style="font-weight: 600; color: var(--text-secondary);">Metadata</td>
<td>{{ meta }}</td>
</tr>
{% endif %}
</table>
<div style="border-top: 1px solid var(--border-color); padding-top: 1.5rem;">
<h3 style="font-size: 1.1rem; margin-bottom: 1rem;">Administrative Actions</h3>
<div style="display: flex; flex-wrap: wrap; gap: 0.75rem;">
<a href="/admin/users/{{ target_user.id }}/edit" class="btn btn-secondary">Edit Settings</a>
{% if target_user.status == "active" %}
<form action="/admin/users/status/{{ target_user.id }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="status" value="disabled">
<button type="submit" class="btn btn-danger" style="background: rgba(239, 68, 68, 0.1); color: var(--danger-color); border: 1px solid rgba(239, 68, 68, 0.2);">
Disable User
</button>
</form>
{% else %}
<form action="/admin/users/status/{{ target_user.id }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="status" value="active">
<button type="submit" class="btn" style="background: rgba(16, 185, 129, 0.1); color: var(--success-color); border: 1px solid rgba(16, 185, 129, 0.2);">
Enable User
</button>
</form>
{% endif %}
<form action="/admin/users/delete/{{ target_user.id }}" method="POST" onsubmit="return confirm('Permanently delete user {{ target_user.username }}? This cannot be undone.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-danger">Delete Account</button>
</form>
</div>
</div>
<div style="border-top: 1px solid var(--border-color); padding-top: 1.5rem; margin-top: 1.5rem;">
<h3 style="font-size: 1.1rem; margin-bottom: 1rem;">Reset Password</h3>
<form action="/admin/users/password/{{ target_user.id }}" method="POST" style="display: flex; gap: 0.75rem; align-items: end;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-group" style="margin-bottom: 0; flex-grow: 1;">
<label for="new_password">New Password</label>
<input type="password" id="new_password" name="new_password" class="form-input" required minlength="8">
</div>
<button type="submit" class="btn">Update</button>
</form>
</div>
</div>
<!-- Quota & Resource Usage -->
<div class="card">
<h2 style="font-size: 1.25rem; margin-bottom: 1.25rem;">Quota & Storage Usage</h2>
<div style="display: flex; flex-direction: column; gap: 1.25rem;">
<div>
<div style="display: flex; justify-content: space-between; font-size: 0.9rem; font-weight: 600; margin-bottom: 0.25rem;">
<span>Short URLs Created</span>
<span>{{ stats.current_urls }} / {{ stats.max_urls }}</span>
</div>
<div style="background: rgba(255,255,255,0.05); height: 8px; border-radius: 4px; overflow: hidden;">
<div style="background: var(--primary-grad); height: 100%; width: {{ stats.url_pct }}%;"></div>
</div>
</div>
<div>
<div style="display: flex; justify-content: space-between; font-size: 0.9rem; font-weight: 600; margin-bottom: 0.25rem;">
<span>Landing Pages</span>
<span>{{ stats.current_landings }} / {{ stats.max_landings }}</span>
</div>
<div style="background: rgba(255,255,255,0.05); height: 8px; border-radius: 4px; overflow: hidden;">
<div style="background: var(--primary-grad); height: 100%; width: {{ stats.landing_pct }}%;"></div>
</div>
</div>
<div>
<div style="display: flex; justify-content: space-between; font-size: 0.9rem; font-weight: 600; margin-bottom: 0.25rem;">
<span>API Tokens</span>
<span>{{ stats.current_api_tokens }} / {{ stats.max_api_tokens }}</span>
</div>
<div style="background: rgba(255,255,255,0.05); height: 8px; border-radius: 4px; overflow: hidden;">
<div style="background: var(--primary-grad); height: 100%; width: {{ stats.token_pct }}%;"></div>
</div>
</div>
<div>
<div style="display: flex; justify-content: space-between; font-size: 0.9rem; font-weight: 600; margin-bottom: 0.25rem;">
<span>Storage (MB)</span>
<span>{{ stats.current_storage_mb }} / {{ stats.max_storage_mb }}</span>
</div>
<div style="background: rgba(255,255,255,0.05); height: 8px; border-radius: 4px; overflow: hidden;">
<div style="background: var(--primary-grad); height: 100%; width: {{ stats.storage_pct }}%;"></div>
</div>
</div>
<div style="background-color: rgba(255, 255, 255, 0.02); padding: 1rem; border-radius: 8px; border: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center;">
<div>
<strong style="display: block;">Total Clicks/Visits</strong>
<span style="color: var(--text-secondary); font-size: 0.85rem;">Combined visitor traffic</span>
</div>
<span style="font-size: 1.5rem; font-weight: 700; color: var(--accent-color);">{{ stats.total_visits }}</span>
</div>
<form action="/admin/quotas" method="POST" style="margin-top: 0.5rem;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="action" value="reconcile">
<input type="hidden" name="user_id" value="{{ target_user.id }}">
<button type="submit" class="btn btn-secondary" style="width: 100%;">Run Quota Reconcile Job</button>
</form>
</div>
</div>
</div>
<!-- Active Sessions & API Tokens -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start;">
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.1rem;">Active Sessions</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Created</th>
<th>Expires</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% if sessions.is_empty() %}
<tr>
<td colspan="3" style="text-align: center; color: var(--text-secondary); padding: 2rem;">No active sessions.</td>
</tr>
{% else %}
{% for s in sessions %}
<tr>
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ s.created_at[0..10] }}</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ s.expires_at[0..10] }}</td>
<td>
<form action="/admin/sessions/revoke/{{ s.id }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-secondary" style="padding: 0.3rem 0.6rem; font-size: 0.8rem; color: var(--danger-color);">Revoke</button>
</form>
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.1rem;">API Tokens</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>ID</th>
<th>Created At</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% if tokens.is_empty() %}
<tr>
<td colspan="3" style="text-align: center; color: var(--text-secondary); padding: 2rem;">No API tokens.</td>
</tr>
{% else %}
{% for t in tokens %}
<tr>
<td>{{ t.id }}</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ t.created_at }}</td>
<td>
<form action="/admin/settings/api-keys/revoke/{{ t.id }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-secondary" style="padding: 0.3rem 0.6rem; font-size: 0.8rem; color: var(--danger-color);">Revoke</button>
</form>
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
+65
View File
@@ -0,0 +1,65 @@
{% extends "layout.html" %}
{% block title %}Edit User: {{ target_user.username }} - BZOD{% endblock %}
{% block active_users %}active{% endblock %}
{% block header_title %}Edit User: {{ target_user.username }}{% endblock %}
{% block content %}
<div style="margin-bottom: 1.5rem;">
<a href="/admin/users/{{ target_user.id }}" class="btn btn-secondary" style="padding: 0.5rem 1rem;">
&larr; Back to User Detail
</a>
</div>
<div class="card" style="max-width: 600px; margin: 0 auto;">
<form action="/admin/users/{{ target_user.id }}/edit" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<h2 style="font-size: 1.25rem; margin-bottom: 1.5rem; border-bottom: 1px solid var(--border-color); padding-bottom: 0.75rem;">Account Settings</h2>
<div class="form-group">
<label for="account_type">Account Type</label>
<select id="account_type" name="account_type" class="form-input">
<option value="standard" {% if target_user.account_type == "standard" %}selected{% endif %}>Standard</option>
<option value="admin" {% if target_user.account_type == "admin" %}selected{% endif %}>Admin</option>
<option value="organization" {% if target_user.account_type == "organization" %}selected{% endif %}>Organization</option>
<option value="service" {% if target_user.account_type == "service" %}selected{% endif %}>Service</option>
</select>
</div>
<div class="form-group">
<label for="metadata">Metadata / Notes</label>
<input type="text" id="metadata" name="metadata" class="form-input" value="{% if let Some(m) = target_user.metadata %}{{ m }}{% endif %}" placeholder="Department, team, or notes">
</div>
<h2 style="font-size: 1.25rem; margin-top: 2rem; margin-bottom: 1.5rem; border-bottom: 1px solid var(--border-color); padding-bottom: 0.75rem;">User Resource Quotas</h2>
<div class="form-group">
<label for="max_urls">Max Short URLs</label>
<input type="number" id="max_urls" name="max_urls" class="form-input" value="{{ quotas.max_urls }}" required min="1">
</div>
<div class="form-group">
<label for="max_landings">Max Landing Pages</label>
<input type="number" id="max_landings" name="max_landings" class="form-input" value="{{ quotas.max_landings }}" required min="0">
</div>
<div class="form-group">
<label for="max_api_tokens">Max API Tokens</label>
<input type="number" id="max_api_tokens" name="max_api_tokens" class="form-input" value="{{ quotas.max_api_tokens }}" required min="0">
</div>
<div class="form-group">
<label for="max_storage_mb">Max Storage Limit (MB)</label>
<input type="number" id="max_storage_mb" name="max_storage_mb" class="form-input" value="{{ quotas.max_storage_mb }}" required min="1">
</div>
<div style="margin-top: 2rem; display: flex; gap: 0.75rem;">
<button type="submit" class="btn" style="flex-grow: 1;">Save Configuration</button>
<a href="/admin/users/{{ target_user.id }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
{% endblock %}
+50
View File
@@ -0,0 +1,50 @@
{% extends "layout.html" %}
{% block sidebar_links %}
<li class="{% block active_dashboard %}{% endblock %}">
<a href="/user/dashboard">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg>
Dashboard
</a>
</li>
<li class="{% block active_urls %}{% endblock %}">
<a href="/user/urls">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
Short URLs
</a>
</li>
<li class="{% block active_pages %}{% endblock %}">
<a href="/user/pages">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
Landing Pages
</a>
</li>
<li class="{% block active_settings %}{% endblock %}">
<a href="/user/settings">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
Settings
</a>
</li>
<li class="{% block active_audit %}{% endblock %}">
<a href="/user/audit">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
Audit Log
</a>
</li>
<li class="{% block active_status %}{% endblock %}">
<a href="/user/status">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><polyline points="10 6 14 12 10 18"/><line x1="18" y1="12" x2="14" y2="12"/><line x1="6" y1="12" x2="10" y2="12"/></svg>
Status
</a>
</li>
{% endblock %}
{% block sidebar_footer %}
<div class="sidebar-footer">
<div class="admin-user-info">
<div class="avatar">{{ admin_username[0..1].to_uppercase() }}</div>
<span>{{ admin_username }}</span>
</div>
<a href="/logout" class="logout-btn">Log Out</a>
</div>
{% endblock %}
+87
View File
@@ -0,0 +1,87 @@
{% extends "user_layout.html" %}
{% block title %}Landing Page Analytics - /p/{{ page_code }} - BZOD{% endblock %}
{% block active_pages %}active{% endblock %}
{% block header_title %}Landing Page Analytics: /p/{{ page_code }}{% endblock %}
{% block header_actions %}
<a href="/user/pages" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
Back to Landing Pages
</a>
{% endblock %}
{% block content %}
<!-- Page Details Card -->
<div class="card" style="margin-bottom: 2rem;">
<h3 style="font-size: 1.1rem; margin-bottom: 1rem; color: var(--text-primary); display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
Landing Page Details
</h3>
<div>
<span style="font-size: 0.8rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; display: block; margin-bottom: 0.25rem;">Page Title</span>
<span style="font-weight: 600; font-size: 1.1rem; color: var(--text-primary);">{{ title }}</span>
</div>
</div>
<!-- Visitor Activity Log -->
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem; margin: 0;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
Visitor Activity Log
</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Sr</th>
<th>Timestamp</th>
<th>IP Address</th>
<th>Country</th>
<th>Referrer</th>
<th>Browser</th>
<th>User-Agent</th>
<th>UTM Source</th>
<th>UTM Campaign</th>
</tr>
</thead>
<tbody>
{% if visits.is_empty() %}
<tr>
<td colspan="9" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No visitor activity available for this landing page.
</td>
</tr>
{% else %}
{% for entry in visits %}
<tr>
<td>{{ entry.sr }}</td>
<td style="font-family: monospace; font-size: 0.85rem; white-space: nowrap;">{{ entry.timestamp }}</td>
<td style="font-family: monospace; font-size: 0.85rem;">{{ entry.ip_address }}</td>
<td>
{% if entry.country == "Unknown" %}
<span style="color: var(--text-muted);">Unknown</span>
{% else %}
{{ entry.country }}
{% endif %}
</td>
<td>{{ entry.referrer }}</td>
<td>{{ entry.browser }}</td>
<td style="max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.8rem; color: var(--text-secondary);" title="{{ entry.user_agent }}">
{{ entry.user_agent }}
</td>
<td>{{ entry.utm_source }}</td>
<td>{{ entry.utm_campaign }}</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+162
View File
@@ -0,0 +1,162 @@
{% extends "user_layout.html" %}
{% block title %}My Landing Pages - BZOD{% endblock %}
{% block active_pages %}active{% endblock %}
{% block header_title %}My Landing Pages{% endblock %}
{% block content %}
{% if let Some(err) = error %}
<div class="alert alert-error">
{{ err }}
</div>
{% endif %}
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start;">
<div class="card">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Create a New Landing Page
</h3>
<form action="/user/pages/create" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-group">
<label for="title">Page Title *</label>
<input type="text" id="title" name="title" class="form-input" placeholder="e.g. Summer Campaign" required>
</div>
<div class="form-group">
<label for="slug">SEO Slug *</label>
<input type="text" id="slug" name="slug" class="form-input" placeholder="e.g. summer-promo" required pattern="[a-zA-Z0-9\-_]+" title="Only alphanumeric characters, dashes, and underscores allowed">
</div>
<div class="form-group">
<label for="code">Short Code (4-Hex, optional)</label>
<input type="text" id="code" name="code" class="form-input" placeholder="e.g. a1b2" pattern="[0-9a-fA-F]{4}" title="Must be exactly 4 hex characters">
</div>
<div class="form-group">
<label for="custom_slug">Custom Slug (optional)</label>
<input type="text" id="custom_slug" name="custom_slug" class="form-input" placeholder="e.g. !my-page" pattern="![a-z0-9\-_]{1,24}" title="Must start with ! followed by 1-24 characters (a-z, 0-9, -, _)">
</div>
<div class="form-group">
<label for="state">Publish State</label>
<select id="state" name="state" class="form-input">
<option value="draft">Draft</option>
<option value="published" selected>Published</option>
<option value="archived">Archived</option>
</select>
</div>
<div class="form-group">
<label for="html_content">HTML Content *</label>
<textarea id="html_content" name="html_content" class="form-input" style="height: 260px; font-family: monospace;" placeholder="<!DOCTYPE html>&#10;<html>&#10;<head>&#10; <title>My Page</title>&#10;</head>&#10;<body>&#10; <h1>Hello</h1>&#10;</body>&#10;</html>" required></textarea>
</div>
<button type="submit" class="btn">Save Page</button>
</form>
</div>
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center;">
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
My Landing Pages
</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Page Title</th>
<th>Short Path</th>
<th>SEO Preview Path</th>
<th>Status</th>
<th>Analytics</th>
<th>Created</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% if pages.is_empty() %}
<tr>
<td colspan="7" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No landing pages created yet.
</td>
</tr>
{% else %}
{% for page in pages %}
<tr>
<td>
<div style="font-weight: 700; color: var(--text-primary);">
{{ page.title }}
</div>
<div style="font-size: 0.75rem; color: var(--text-muted); font-family: monospace;">
UUID: {{ page.id[0..8] }}...
</div>
</td>
<td>
<a href="/p/{{ page.code }}" target="_blank" style="color: var(--accent-color); font-weight: 700; text-decoration: none; font-family: monospace;">
/p/{{ page.code }}
</a>
</td>
<td>
<a href="/p/{{ page.code }}/{{ page.slug }}" target="_blank" style="color: var(--text-secondary); text-decoration: none; font-size: 0.85rem; font-family: monospace;">
/p/{{ page.code }}/{{ page.slug }}
</a>
</td>
<td>
<span class="badge badge-{{ page.state }}">
{{ page.state }}
</span>
</td>
<td>
<a href="/user/analytics/page/{{ page.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.6rem; font-size: 0.8rem; display: inline-flex; align-items: center; gap: 0.25rem;">
📊 Analytics
</a>
</td>
<td style="font-size: 0.8rem; color: var(--text-secondary);">
{{ page.created_at[0..10] }}
</td>
<td>
<form action="/user/pages/delete/{{ page.id }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this page?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-danger" style="padding: 0.4rem 0.6rem; font-size: 0.8rem;">
Delete
</button>
</form>
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
<div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem; margin-top: 1rem; padding: 1rem; border-top: 1px solid var(--border-color);">
{% if current_page > 1 %}
<a href="/user/pages?page=1" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">&lt;&lt; First</a>
<a href="/user/pages?page={{ current_page - 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">&lt; Prev</a>
{% else %}
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">&lt;&lt; First</span>
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">&lt; Prev</span>
{% endif %}
{% for p in visible_pages %}
{% if self.is_current(p) %}
<span class="btn" style="padding: 0.4rem 0.8rem; font-weight: 700;">{{ p }}</span>
{% else %}
<a href="/user/pages?page={{ p }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">{{ p }}</a>
{% endif %}
{% endfor %}
{% if current_page < total_pages %}
<a href="/user/pages?page={{ current_page + 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">Next &gt;</a>
<a href="/user/pages?page={{ total_pages }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">Last &gt;&gt;</a>
{% else %}
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">Next &gt;</span>
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">Last &gt;&gt;</span>
{% endif %}
</div>
</div>
</div>
{% endblock %}
+65
View File
@@ -0,0 +1,65 @@
{% extends "user_layout.html" %}
{% block title %}My Settings - BZOD{% endblock %}
{% block active_settings %}active{% endblock %}
{% block header_title %}Account Settings{% endblock %}
{% block content %}
{% if let Some(msg) = success %}
<div class="alert alert-success">
{{ msg }}
</div>
{% endif %}
{% if let Some(err) = error %}
<div class="alert alert-error">
{{ err }}
</div>
{% endif %}
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start;">
<div class="card">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
Change Password
</h3>
<form action="/user/settings/password" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-group">
<label for="current_password">Current Password</label>
<input type="password" id="current_password" name="current_password" class="form-input" required autocomplete="current-password">
</div>
<div class="form-group">
<label for="new_password">New Password</label>
<input type="password" id="new_password" name="new_password" class="form-input" required minlength="8" autocomplete="new-password">
</div>
<button type="submit" class="btn">Update Password</button>
</form>
</div>
<div class="card">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Backup & Restore
</h3>
<div style="display: flex; flex-direction: column; gap: 1rem;">
<a class="btn" href="/user/settings/backup">Download Backup</a>
<form action="/user/settings/restore" method="POST" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-group">
<label for="backup_file">Restore Backup File</label>
<input type="file" id="backup_file" name="backup_file" class="form-input" accept=".tar.gz" required>
</div>
<div class="form-group">
<label for="confirm_text">Type RESTORE to confirm</label>
<input type="text" id="confirm_text" name="confirm_text" class="form-input" required autocomplete="off">
</div>
<button type="submit" class="btn btn-danger">Restore Backup</button>
</form>
</div>
</div>
</div>
{% endblock %}
+161
View File
@@ -0,0 +1,161 @@
{% extends "user_layout.html" %}
{% block title %}System Diagnostics - BZOD{% endblock %}
{% block active_status %}active{% endblock %}
{% block header_title %}Server Status & Diagnostics{% endblock %}
{% block content %}
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start;">
<!-- Left Column: Core Performance Metrics -->
<div class="card">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
System Status
</h3>
<div style="display: flex; flex-direction: column; gap: 1.25rem;">
<div style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 0.75rem; border-bottom: 1px solid rgba(255,255,255,0.03);">
<span style="color: var(--text-secondary);">Application Status</span>
<span class="badge badge-healthy" style="font-size: 0.85rem;">{{ app_status }}</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 0.75rem; border-bottom: 1px solid rgba(255,255,255,0.03);">
<span style="color: var(--text-secondary);">Uptime</span>
<span style="font-weight: 600; font-family: monospace;">{{ uptime }}</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 0.75rem; border-bottom: 1px solid rgba(255,255,255,0.03);">
<span style="color: var(--text-secondary);">Memory Usage</span>
<span style="font-weight: 600; font-family: monospace;">{{ memory_usage }}</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 0.75rem; border-bottom: 1px solid rgba(255,255,255,0.03);">
<span style="color: var(--text-secondary);">Analytics Memory Queue Size</span>
<span style="font-weight: 600; font-family: monospace; color: {% if queue_size > 100 %}var(--warning-color){% else %}var(--success-color){% endif %};">
{{ queue_size }} items
</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 0.75rem; border-bottom: 1px solid rgba(255,255,255,0.03);">
<span style="color: var(--text-secondary);">Build Version</span>
<span style="font-weight: 600; font-family: monospace;">v{{ version }}</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="color: var(--text-secondary);">Git Commit Hash</span>
<span style="font-weight: 600; font-family: monospace; color: var(--text-muted);">{{ git_commit }}</span>
</div>
</div>
</div>
<!-- Right Column: Database Storage info -->
<div class="card">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22c5.523 0 10-2.239 10-5V5c0-2.761-4.477-5-10-5S2 2.239 2 5v12c0 2.761 4.477 5 10 5z"/><path d="M2 5c0 2.761 4.477 5 10 5s10-2.761 10-5"/><path d="M2 11c0 2.761 4.477 5 10 5s10-2.761 10-5"/></svg>
Database Status
</h3>
<div style="background-color: rgba(15, 23, 42, 0.4); border-radius: 12px; padding: 1.25rem; border: 1px solid rgba(255, 255, 255, 0.03); white-space: pre-wrap; font-family: monospace; font-size: 0.85rem; color: var(--text-secondary); line-height: 1.6;">{{ db_status }}</div>
<p style="font-size: 0.8rem; color: var(--text-muted); margin-top: 1rem; line-height: 1.4;">
All SQLite databases are running with Write-Ahead Logging (WAL) enabled, providing concurrent reads and high transactional throughput.
</p>
</div>
</div>
<!-- Full Width: Link Health Dashboard Section -->
<div class="card" style="margin-top: 1.5rem; padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center;">
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
Link Health Registry
</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Short Link</th>
<th>Destination URL</th>
<th>General Status</th>
<th>Detailed Diagnostic</th>
<th>Latency</th>
<th>Last Checked</th>
</tr>
</thead>
<tbody>
{% if urls.is_empty() %}
<tr>
<td colspan="6" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No shortened links registered to monitor.
</td>
</tr>
{% else %}
{% for url in urls %}
<tr>
<td>
<a href="/{{ url.code }}" target="_blank" style="color: var(--accent-color); font-weight: 700; text-decoration: none; font-family: monospace; font-size: 0.95rem;">
/{{ url.code }}
</a>
<div style="font-size: 0.75rem; color: var(--text-secondary); margin-top: 0.1rem;">
{{ url.title.as_deref().unwrap_or("") }}
</div>
</td>
<td style="max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.85rem; color: var(--text-secondary);">
{{ url.destination }}
</td>
<td>
{% if url.status == "healthy" %}
<span class="badge badge-healthy">Healthy</span>
{% else if url.status == "suspect" %}
<span class="badge badge-suspect">Suspect</span>
{% else %}
<span class="badge badge-dead">Dead</span>
{% endif %}
</td>
<td>
{% if let Some(last_status) = url.last_status %}
{% if last_status == "healthy" %}
<span class="badge" style="background-color: rgba(16, 185, 129, 0.15); color: #10b981; border: 1px solid rgba(16, 185, 129, 0.2);">HTTP OK</span>
{% else if last_status == "timeout" %}
<span class="badge" style="background-color: rgba(245, 158, 11, 0.15); color: #f59e0b; border: 1px solid rgba(245, 158, 11, 0.2);">Timeout</span>
{% else if last_status == "dns_failure" %}
<span class="badge" style="background-color: rgba(59, 130, 246, 0.15); color: #3b82f6; border: 1px solid rgba(59, 130, 246, 0.2);">DNS Error</span>
{% else if last_status == "tls_error" %}
<span class="badge" style="background-color: rgba(139, 92, 246, 0.15); color: #8b5cf6; border: 1px solid rgba(139, 92, 246, 0.2);">TLS Error</span>
{% else if last_status == "connection_refused" %}
<span class="badge" style="background-color: rgba(239, 68, 68, 0.15); color: #ef4444; border: 1px solid rgba(239, 68, 68, 0.2);">Refused</span>
{% else if last_status == "redirect_loop" %}
<span class="badge" style="background-color: rgba(236, 72, 153, 0.15); color: #ec4899; border: 1px solid rgba(236, 72, 153, 0.2);">Redirect Loop</span>
{% else if last_status.starts_with("http_") %}
<span class="badge" style="background-color: rgba(245, 158, 11, 0.15); color: #f59e0b; border: 1px solid rgba(245, 158, 11, 0.2);">{{ last_status.replace("http_", "HTTP ") }}</span>
{% else %}
<span class="badge" style="background-color: rgba(107, 114, 128, 0.15); color: #9ca3af; border: 1px solid rgba(107, 114, 128, 0.2);">{{ last_status }}</span>
{% endif %}
{% else %}
<span style="color: var(--text-muted); font-size: 0.85rem;">Pending</span>
{% endif %}
</td>
<td style="font-family: monospace; font-size: 0.85rem; color: var(--text-secondary);">
{% if let Some(latency) = url.last_latency_ms %}
{{ latency }} ms
{% else %}
-
{% endif %}
</td>
<td style="font-size: 0.8rem; color: var(--text-secondary);">
{{ url.updated_at[0..10] }} {{ url.updated_at[11..16] }}
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+89
View File
@@ -0,0 +1,89 @@
{% extends "user_layout.html" %}
{% block title %}Short URL Analytics - /{{ url_code }} - BZOD{% endblock %}
{% block active_urls %}active{% endblock %}
{% block header_title %}URL Analytics: /{{ url_code }}{% endblock %}
{% block header_actions %}
<a href="/user/urls" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
Back to URLs
</a>
{% endblock %}
{% block content %}
<!-- Link Details Card -->
<div class="card" style="margin-bottom: 2rem;">
<h3 style="font-size: 1.1rem; margin-bottom: 1rem; color: var(--text-primary); display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
Link Details
</h3>
<div>
<span style="font-size: 0.8rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; display: block; margin-bottom: 0.25rem;">Destination URL</span>
<a href="{{ destination }}" target="_blank" style="color: var(--accent-color); text-decoration: none; font-weight: 600; word-break: break-all;">
{{ destination }}
</a>
</div>
</div>
<!-- Visitor Activity Log -->
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem; margin: 0;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
Visitor Activity Log
</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Sr</th>
<th>Timestamp</th>
<th>IP Address</th>
<th>Country</th>
<th>Referrer</th>
<th>Browser</th>
<th>User-Agent</th>
<th>UTM Source</th>
<th>UTM Campaign</th>
</tr>
</thead>
<tbody>
{% if visits.is_empty() %}
<tr>
<td colspan="9" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No visitor activity available for this short link.
</td>
</tr>
{% else %}
{% for entry in visits %}
<tr>
<td>{{ entry.sr }}</td>
<td style="font-family: monospace; font-size: 0.85rem; white-space: nowrap;">{{ entry.timestamp }}</td>
<td style="font-family: monospace; font-size: 0.85rem;">{{ entry.ip_address }}</td>
<td>
{% if entry.country == "Unknown" %}
<span style="color: var(--text-muted);">Unknown</span>
{% else %}
{{ entry.country }}
{% endif %}
</td>
<td>{{ entry.referrer }}</td>
<td>{{ entry.browser }}</td>
<td style="max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.8rem; color: var(--text-secondary);" title="{{ entry.user_agent }}">
{{ entry.user_agent }}
</td>
<td>{{ entry.utm_source }}</td>
<td>{{ entry.utm_campaign }}</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+145
View File
@@ -0,0 +1,145 @@
{% extends "user_layout.html" %}
{% block title %}My Short URLs - BZOD{% endblock %}
{% block active_urls %}active{% endblock %}
{% block header_title %}My Short URL Library{% endblock %}
{% block content %}
{% if let Some(err) = error %}
<div class="alert alert-error">
{{ err }}
</div>
{% endif %}
<div style="display: grid; grid-template-columns: 1fr 2fr; gap: 1.5rem; align-items: start;">
<div class="card">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Create a New URL
</h3>
<form action="/user/urls/create" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-group">
<label for="destination">Destination URL *</label>
<input type="url" id="destination" name="destination" class="form-input" placeholder="https://example.com/very-long-path" required>
</div>
<div class="form-group">
<label for="code">Short Code (6-Hex, optional)</label>
<input type="text" id="code" name="code" class="form-input" placeholder="e.g. 4f8c1a (auto-generated if empty)" pattern="[0-9a-fA-F]{6}" title="Must be exactly 6 hex characters (0-9, a-f)">
</div>
<div class="form-group">
<label for="custom_slug">Custom Slug (optional)</label>
<input type="text" id="custom_slug" name="custom_slug" class="form-input" placeholder="e.g. !home" pattern="![a-z0-9\-_]{1,24}" title="Must start with ! followed by 1-24 characters (a-z, 0-9, -, _)">
</div>
<div class="form-group">
<label for="title">Title (optional)</label>
<input type="text" id="title" name="title" class="form-input" placeholder="e.g. My Blog Post">
</div>
<div class="form-group">
<label for="description">Description (optional)</label>
<textarea id="description" name="description" class="form-input" rows="2" placeholder="Notes or summary..."></textarea>
</div>
<div class="form-group">
<label for="tags">Tags (comma-separated, optional)</label>
<input type="text" id="tags" name="tags" class="form-input" placeholder="e.g. blog, tech, personal">
</div>
<button type="submit" class="btn">Create Link</button>
</form>
</div>
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center;">
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
My Short Links
</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Short Link</th>
<th>Destination</th>
<th>QR Code</th>
<th>Health</th>
<th>Tags</th>
<th>Analytics</th>
<th>Created</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% if urls.is_empty() %}
<tr>
<td colspan="9" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No short URLs created yet.
</td>
</tr>
{% else %}
{% for url in urls %}
<tr>
<td><a href="/{{ url.code }}" target="_blank" style="color: var(--accent-color); text-decoration: none; font-family: monospace;">/{{ url.code }}</a></td>
<td style="max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ url.destination }}</td>
<td>
<div style="display: flex; flex-direction: column; align-items: center; gap: 0.25rem;">
<a href="/api/qr/{{ url.code }}.png" target="_blank" title="View QR Code">
<img src="/api/qr/{{ url.code }}.svg" alt="QR" style="width: 32px; height: 32px; border-radius: 4px; border: 1px solid var(--border-color); background: white; padding: 1px;">
</a>
<div style="display: flex; gap: 0.25rem;">
<a href="/api/qr/{{ url.code }}.png" download class="badge" style="font-size: 0.65rem; background-color: rgba(99, 102, 241, 0.1); color: #818cf8; text-decoration: none; padding: 0.1rem 0.25rem;">PNG</a>
<a href="/api/qr/{{ url.code }}.svg" download class="badge" style="font-size: 0.65rem; background-color: rgba(99, 102, 241, 0.1); color: #818cf8; text-decoration: none; padding: 0.1rem 0.25rem;">SVG</a>
</div>
</div>
</td>
<td>{{ url.status }}</td>
<td>{% if url.tags.is_empty() %}-{% else %}{{ url.tags.join(", ") }}{% endif %}</td>
<td>
<a href="/user/analytics/url/{{ url.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.75rem; font-size: 0.85rem; text-decoration: none; display: inline-flex; align-items: center; justify-content: center;">View</a>
</td>
<td>{{ url.created_at[0..10] }}</td>
<td>
<form action="/user/urls/delete/{{ url.id }}" method="POST" onsubmit="return confirm('Delete this link?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-secondary" style="padding: 0.4rem 0.75rem;">Delete</button>
</form>
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
<div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem; margin-top: 1rem; padding: 1rem; border-top: 1px solid var(--border-color);">
{% if current_page > 1 %}
<a href="/user/urls?page=1" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">&lt;&lt; First</a>
<a href="/user/urls?page={{ current_page - 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">&lt; Prev</a>
{% else %}
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">&lt;&lt; First</span>
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">&lt; Prev</span>
{% endif %}
{% for p in visible_pages %}
{% if self.is_current(p) %}
<span class="btn" style="padding: 0.4rem 0.8rem; font-weight: 700;">{{ p }}</span>
{% else %}
<a href="/user/urls?page={{ p }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">{{ p }}</a>
{% endif %}
{% endfor %}
{% if current_page < total_pages %}
<a href="/user/urls?page={{ current_page + 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">Next &gt;</a>
<a href="/user/urls?page={{ total_pages }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">Last &gt;&gt;</a>
{% else %}
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">Next &gt;</span>
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">Last &gt;&gt;</span>
{% endif %}
</div>
</div>
</div>
{% endblock %}
+127
View File
@@ -0,0 +1,127 @@
{% extends "layout.html" %}
{% block title %}Users - BZOD{% endblock %}
{% block active_users %}active{% endblock %}
{% block header_title %}Users Management{% endblock %}
{% block content %}
{% if let Some(msg) = success %}
<div class="alert alert-success">
{{ msg }}
</div>
{% endif %}
{% if let Some(err) = error %}
<div class="alert alert-error">
{{ err }}
</div>
{% endif %}
<div class="card" style="margin-bottom: 1.5rem;">
<h2 style="margin-bottom: 1rem;">Create New User</h2>
<form action="/admin/users/create" method="POST" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; align-items: end;">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" class="form-input" required autocomplete="username">
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" class="form-input" required minlength="8" autocomplete="new-password">
</div>
<div class="form-group">
<label for="account_type">Account Type</label>
<select id="account_type" name="account_type" class="form-input">
<option value="standard" selected>Standard</option>
<option value="admin">Admin</option>
<option value="organization">Organization</option>
<option value="service">Service</option>
</select>
</div>
<div class="form-group" style="min-width: 100%;">
<label for="metadata">Metadata (optional)</label>
<input type="text" id="metadata" name="metadata" class="form-input" placeholder="Organization, tags, or notes">
</div>
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn" style="grid-column: 1 / -1; width: fit-content;">Create User</button>
</form>
</div>
<div class="card" style="overflow-x: auto;">
<table>
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Status</th>
<th>Type</th>
<th>Last Login</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% if users.is_empty() %}
<tr>
<td colspan="7" style="text-align: center; color: var(--text-secondary); padding: 3rem 0;">
No users found.
</td>
</tr>
{% else %}
{% for user in users %}
<tr>
<td>{{ user.id }}</td>
<td>{{ user.username }}</td>
<td>
<form action="/admin/users/status/{{ user.id }}" method="POST" style="display: flex; gap: 0.5rem; align-items: center;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<select name="status" class="form-input" style="min-width: 140px;">
<option value="active" {% if user.status == "active" %}selected{% endif %}>active</option>
<option value="disabled" {% if user.status == "disabled" %}selected{% endif %}>disabled</option>
<option value="suspended" {% if user.status == "suspended" %}selected{% endif %}>suspended</option>
<option value="pending" {% if user.status == "pending" %}selected{% endif %}>pending</option>
<option value="deleted" {% if user.status == "deleted" %}selected{% endif %}>deleted</option>
</select>
<button type="submit" class="btn btn-secondary" style="padding: 0.45rem 0.75rem;">Save</button>
</form>
</td>
<td>
<form action="/admin/users/type/{{ user.id }}" method="POST" style="display: grid; gap: 0.4rem;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<select name="account_type" class="form-input" style="min-width: 140px;">
<option value="standard" {% if user.account_type == "standard" %}selected{% endif %}>standard</option>
<option value="admin" {% if user.account_type == "admin" %}selected{% endif %}>admin</option>
<option value="organization" {% if user.account_type == "organization" %}selected{% endif %}>organization</option>
<option value="service" {% if user.account_type == "service" %}selected{% endif %}>service</option>
</select>
<button type="submit" class="btn btn-secondary" style="padding: 0.35rem 0.6rem; font-size: 0.8rem;">Save</button>
</form>
</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">
{% if let Some(login) = user.last_login %}
{{ login[0..16].replace("T", " ") }}
{% else %}
Never
{% endif %}
</td>
<td>{{ user.created_at[0..10] }}</td>
<td>
<div style="display: grid; gap: 0.5rem;">
<form action="/admin/users/password/{{ user.id }}" method="POST" style="display: grid; gap: 0.4rem;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="password" name="new_password" class="form-input" placeholder="New password" minlength="8" required style="width: 190px;">
<button type="submit" class="btn btn-secondary" style="padding: 0.4rem 0.6rem; font-size: 0.8rem;">Reset</button>
</form>
<form action="/admin/users/delete/{{ user.id }}" method="POST" onsubmit="return confirm('Delete user "{{ user.username }}"? This cannot be undone.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-danger" style="padding: 0.4rem 0.6rem; font-size: 0.8rem;">Delete</button>
</form>
</div>
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
{% endblock %}
+53
View File
@@ -0,0 +1,53 @@
{% extends "layout.html" %}
{% block title %}Create New User - BZOD{% endblock %}
{% block active_users %}active{% endblock %}
{% block header_title %}Create New User{% endblock %}
{% block content %}
<div style="margin-bottom: 1.5rem;">
<a href="/admin/users" class="btn btn-secondary" style="padding: 0.5rem 1rem;">
&larr; Back to Users List
</a>
</div>
<div class="card" style="max-width: 600px; margin: 0 auto;">
<form action="/admin/users/create" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<h2 style="font-size: 1.25rem; margin-bottom: 1.5rem; border-bottom: 1px solid var(--border-color); padding-bottom: 0.75rem;">User Account Credentials</h2>
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" class="form-input" required autocomplete="username" placeholder="e.g. jdoe">
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" class="form-input" required minlength="8" autocomplete="new-password">
</div>
<div class="form-group">
<label for="account_type">Account Type</label>
<select id="account_type" name="account_type" class="form-input">
<option value="standard" selected>Standard</option>
<option value="admin">Admin</option>
<option value="organization">Organization</option>
<option value="service">Service</option>
</select>
</div>
<div class="form-group">
<label for="metadata">Metadata / Notes (optional)</label>
<input type="text" id="metadata" name="metadata" class="form-input" placeholder="Department, team, or notes">
</div>
<div style="margin-top: 2rem; display: flex; gap: 0.75rem;">
<button type="submit" class="btn" style="flex-grow: 1;">Create User</button>
<a href="/admin/users" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
{% endblock %}
+162
View File
@@ -0,0 +1,162 @@
use bzod::config::Config;
use bzod::db::Db;
use std::fs;
use std::path::PathBuf;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
}
#[tokio::test]
async fn test_admin_user_listing() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_admin_list_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let _ = bzod::cli::create_user::run(
Some("usera".to_string()),
Some("password123".to_string()),
None,
config.clone(),
)
.await
.unwrap();
let conn = db.users.lock().unwrap();
let users = bzod::db::users::list_users(&conn).unwrap();
// Verify list contains legacy_admin and usera
assert!(users.len() >= 2);
assert!(users.iter().any(|u| u.username == "legacy_admin"));
assert!(users.iter().any(|u| u.username == "usera"));
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_admin_user_statistics() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_admin_stats_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let _ = bzod::cli::create_user::run(
Some("usera".to_string()),
Some("password123".to_string()),
None,
config.clone(),
)
.await
.unwrap();
let user_id = {
let conn = db.users.lock().unwrap();
bzod::db::users::get_user_by_username(&conn, "usera")
.unwrap()
.unwrap()
.id
};
// Aggregate statistics (e.g. check current quotas match the content databases)
{
let users_conn = db.users.lock().unwrap();
let quota = bzod::db::users::get_user_quotas(&users_conn, user_id)
.unwrap()
.unwrap();
// Assert initial counts
assert_eq!(quota.current_urls, 0);
assert_eq!(quota.current_landings, 0);
}
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_admin_system_settings() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_admin_settings_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let system_conn = db.system.lock().unwrap();
// Verify initial settings are loaded
let recon_hours: String = system_conn
.query_row(
"SELECT value FROM settings WHERE key = 'quota_reconcile_interval_hours';",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(recon_hours, "24");
// Update settings
system_conn
.execute(
"UPDATE settings SET value = '12' WHERE key = 'quota_reconcile_interval_hours';",
[],
)
.unwrap();
let recon_hours2: String = system_conn
.query_row(
"SELECT value FROM settings WHERE key = 'quota_reconcile_interval_hours';",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(recon_hours2, "12");
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_admin_moderation_listing() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_admin_mod_list_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let system_conn = db.system.lock().unwrap();
// Insert a moderation event
system_conn.execute(
"INSERT INTO moderation_events (id, timestamp, admin_username, target_user_id, target_username, resource_type, resource_identifier, action, severity, reason)
VALUES ('evt1', 'now', 'admin', 10, 'user10', 'url', '!slug', 'block', 'high', 'violating content');",
[],
).unwrap();
// Query moderation events
let mut stmt = system_conn
.prepare("SELECT id, action, reason FROM moderation_events;")
.unwrap();
let events = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
})
.unwrap();
let event_list: Vec<_> = events.map(|r| r.unwrap()).collect();
assert_eq!(event_list.len(), 1);
assert_eq!(event_list[0].0, "evt1");
assert_eq!(event_list[0].1, "block");
let _ = fs::remove_dir_all(&temp_dir);
}
+207
View File
@@ -0,0 +1,207 @@
use axum::extract::{Form, Query, State};
use axum::http::HeaderMap;
use axum_extra::extract::cookie::{Cookie, CookieJar};
use chrono::Utc;
use rusqlite::Connection;
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::time::Instant;
use bzod::analytics::AnalyticsQueue;
use bzod::auth::hash_password;
use bzod::config::Config;
use bzod::db::users::{create_admin_user, get_user_by_username};
use bzod::db::Db;
use bzod::state::AppState;
use bzod::web::admin::{
users_create_post, users_delete_post, users_get, CreateUserForm, DeleteUserForm, UsersQuery,
};
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://localhost:8080".to_string());
config
}
fn build_state(config: Config) -> (Db, AppState) {
let db = Db::init(&config).expect("Failed to init Db");
let queue = AnalyticsQueue::new(db.clone(), 1000);
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(HashMap::new())),
db: db.clone(),
config,
analytics_queue: queue,
start_time: Instant::now(),
};
(db, state)
}
fn create_admin_session(conn: &Connection, admin_id: i64, session_id: &str) {
let now = Utc::now();
let expires_at = (now + chrono::Duration::hours(1)).to_rfc3339();
let created_at = now.to_rfc3339();
conn.execute(
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);",
rusqlite::params![session_id, admin_id, expires_at, created_at],
)
.expect("Failed to insert admin session");
}
#[tokio::test]
async fn test_admin_users_page_and_user_creation() {
let temp_dir = std::env::temp_dir().join(format!(
"bzod_test_admin_user_management_{}",
uuid::Uuid::new_v4()
));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let (_db, state) = build_state(config.clone());
let password = "adminpassword";
let hash = hash_password(password).unwrap();
let admin_user = {
let conn = state.users_db.lock().unwrap();
create_admin_user(&conn, "admin", &hash).unwrap()
};
let session_token = "session-token-123";
{
let conn = state.users_db.lock().unwrap();
create_admin_session(&conn, admin_user.id, session_token);
}
let jar = CookieJar::new().add(Cookie::new("bzod_session", session_token));
let response = users_get(
State(state.clone()),
jar,
Query(UsersQuery {
success: None,
error: None,
}),
)
.await;
if response.status().is_redirection() {
let location = response
.headers()
.get("location")
.map(|v| v.to_str().unwrap_or(""));
println!("users_get redirect to: {:?}", location);
}
assert_eq!(response.status(), axum::http::StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), 65536)
.await
.unwrap();
let body_str = String::from_utf8_lossy(&body);
assert!(body_str.contains("Users Management"));
assert!(body_str.contains("Create New User"));
assert!(body_str.contains("admin"));
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_admin_create_and_delete_user() {
let temp_dir = std::env::temp_dir().join(format!(
"bzod_test_admin_user_management_delete_{}",
uuid::Uuid::new_v4()
));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let (_db, state) = build_state(config.clone());
let password = "adminpassword";
let hash = hash_password(password).unwrap();
let admin_user = {
let conn = state.users_db.lock().unwrap();
create_admin_user(&conn, "admin", &hash).unwrap()
};
let session_token = "session-token-456";
{
let conn = state.users_db.lock().unwrap();
create_admin_session(&conn, admin_user.id, session_token);
}
let jar = CookieJar::new().add(Cookie::new("bzod_session", session_token));
let form = CreateUserForm {
username: "testuser".to_string(),
password: "password123".to_string(),
account_type: "standard".to_string(),
metadata: "test metadata".to_string(),
csrf_token: "invalid".to_string(),
};
// invalid CSRF should redirect with error
let response = users_create_post(State(state.clone()), jar.clone(), Form(form)).await;
assert!(response.status().is_redirection());
assert!(response
.headers()
.get("location")
.unwrap()
.to_str()
.unwrap()
.contains("Invalid CSRF token"));
// build a valid CSRF token from admin session
let csrf_token = bzod::auth::generate_csrf_token(session_token);
let form = CreateUserForm {
username: "testuser".to_string(),
password: "password123".to_string(),
account_type: "standard".to_string(),
metadata: "test metadata".to_string(),
csrf_token: csrf_token.clone(),
};
let response = users_create_post(State(state.clone()), jar.clone(), Form(form)).await;
assert!(response.status().is_redirection());
assert_eq!(
response.headers().get("location").unwrap(),
"/admin/users?success=User created successfully"
);
let created_user_id = {
let conn = state.users_db.lock().unwrap();
let user = get_user_by_username(&conn, "testuser")
.unwrap()
.expect("created user should exist");
user.id
};
let delete_form = DeleteUserForm {
csrf_token: csrf_token.clone(),
};
let response = users_delete_post(
State(state.clone()),
jar.clone(),
HeaderMap::new(),
None,
axum::extract::Path(created_user_id),
Form(delete_form),
)
.await;
assert!(response.status().is_redirection());
assert_eq!(
response.headers().get("location").unwrap(),
"/admin/users?success=User deleted successfully"
);
{
let conn = state.users_db.lock().unwrap();
let user = get_user_by_username(&conn, "testuser").unwrap();
assert!(user.is_none());
}
let _ = fs::remove_dir_all(&temp_dir);
}
+4
View File
@@ -51,6 +51,7 @@ fn test_advanced_analytics_pagination_and_sorting() {
accept_language: "en".to_string(), accept_language: "en".to_string(),
country: "US".to_string(), country: "US".to_string(),
status_code: 200, status_code: 200,
owner_user_id: None,
}); });
} }
insert_visits_batch(&mut conn, &records).unwrap(); insert_visits_batch(&mut conn, &records).unwrap();
@@ -91,6 +92,7 @@ fn test_advanced_analytics_date_filtering() {
accept_language: "en".to_string(), accept_language: "en".to_string(),
country: "US".to_string(), country: "US".to_string(),
status_code: 200, status_code: 200,
owner_user_id: None,
}, },
VisitRecord { VisitRecord {
id: "v2".to_string(), id: "v2".to_string(),
@@ -103,6 +105,7 @@ fn test_advanced_analytics_date_filtering() {
accept_language: "en".to_string(), accept_language: "en".to_string(),
country: "US".to_string(), country: "US".to_string(),
status_code: 200, status_code: 200,
owner_user_id: None,
}, },
VisitRecord { VisitRecord {
id: "v3".to_string(), id: "v3".to_string(),
@@ -115,6 +118,7 @@ fn test_advanced_analytics_date_filtering() {
accept_language: "en".to_string(), accept_language: "en".to_string(),
country: "US".to_string(), country: "US".to_string(),
status_code: 200, status_code: 200,
owner_user_id: None,
}, },
]; ];
insert_visits_batch(&mut conn, &records).unwrap(); insert_visits_batch(&mut conn, &records).unwrap();
+3
View File
@@ -121,6 +121,7 @@ fn test_target_analytics_queries() {
accept_language: "en".to_string(), accept_language: "en".to_string(),
country: "US".to_string(), country: "US".to_string(),
status_code: 200, status_code: 200,
owner_user_id: None,
}, },
VisitRecord { VisitRecord {
id: "visit-2".to_string(), id: "visit-2".to_string(),
@@ -133,6 +134,7 @@ fn test_target_analytics_queries() {
accept_language: "en".to_string(), accept_language: "en".to_string(),
country: "US".to_string(), country: "US".to_string(),
status_code: 200, status_code: 200,
owner_user_id: None,
}, },
VisitRecord { VisitRecord {
id: "visit-3".to_string(), id: "visit-3".to_string(),
@@ -145,6 +147,7 @@ fn test_target_analytics_queries() {
accept_language: "en".to_string(), accept_language: "en".to_string(),
country: "US".to_string(), country: "US".to_string(),
status_code: 200, status_code: 200,
owner_user_id: None,
}, },
]; ];
+676
View File
@@ -0,0 +1,676 @@
use axum::extract::{ConnectInfo, Form, State};
use axum::http::HeaderMap;
use axum_extra::extract::cookie::{Cookie, CookieJar};
use chrono::Utc;
use rusqlite::Connection;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::Instant;
use bzod::analytics::AnalyticsQueue;
use bzod::auth::session::authenticate_api_key;
use bzod::config::Config;
use bzod::db::admin::{create_api_key, create_session};
use bzod::db::migrations::{run_migrations, ADMIN_MIGRATIONS, SYSTEM_MIGRATIONS, USERS_MIGRATIONS};
use bzod::db::sqlite::get_user_version;
use bzod::db::users::{create_admin_user, create_user, get_user_by_username};
use bzod::db::Db;
use bzod::web::admin::{dashboard_get, login_post, LoginForm};
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.admin_username = "admin".to_string();
config.base_url = Some("http://localhost:8080".to_string());
config
}
fn compute_sha256(value: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(value.as_bytes());
hex::encode(hasher.finalize())
}
fn build_state(config: Config) -> (Db, bzod::state::AppState) {
let db = Db::init(&config).expect("Failed to init Db");
let queue = AnalyticsQueue::new(db.clone(), 1000);
let state = bzod::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: Arc::new(Mutex::new(HashMap::new())),
db: db.clone(),
config,
analytics_queue: queue,
start_time: Instant::now(),
};
(db, state)
}
#[test]
fn test_migration_idempotent() {
let mut conn = Connection::open_in_memory().unwrap();
run_migrations(&mut conn, "users", USERS_MIGRATIONS, None).unwrap();
run_migrations(&mut conn, "users", USERS_MIGRATIONS, None).unwrap();
assert_eq!(get_user_version(&conn).unwrap(), 2);
}
#[test]
fn test_users_db_schema_version_2() {
let mut conn = Connection::open_in_memory().unwrap();
run_migrations(&mut conn, "users", USERS_MIGRATIONS, None).unwrap();
assert_eq!(get_user_version(&conn).unwrap(), 2);
}
#[test]
fn test_admin_db_schema_version_2() {
let mut conn = Connection::open_in_memory().unwrap();
run_migrations(&mut conn, "admin", ADMIN_MIGRATIONS, None).unwrap();
assert_eq!(get_user_version(&conn).unwrap(), 2);
}
#[tokio::test]
async fn test_admin_account_migration() {
let temp_dir = std::env::temp_dir().join(format!(
"bzod_test_admin_migration_{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&temp_dir).unwrap();
let users_path = temp_dir.join("admin").join("users.db");
std::fs::create_dir_all(users_path.parent().unwrap()).unwrap();
let mut conn = Connection::open(&users_path).unwrap();
run_migrations(&mut conn, "users", USERS_MIGRATIONS, None).unwrap();
conn.pragma_update(None, "user_version", 1).unwrap();
let password_hash = bzod::auth::hash_password("securepass").unwrap();
conn.execute(
"INSERT INTO users (username, password_hash, status, created_at, account_type) VALUES (?1, ?2, ?3, ?4, ?5);",
rusqlite::params!["admin", password_hash, "active", Utc::now().to_rfc3339(), "standard"],
)
.unwrap();
let mut system_conn = Connection::open(temp_dir.join("admin/system.db")).unwrap();
run_migrations(&mut system_conn, "system", SYSTEM_MIGRATIONS, None).unwrap();
drop(system_conn);
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Db::init should repair an existing admin account");
let conn = db.users.lock().unwrap();
let user = get_user_by_username(&conn, "admin").unwrap().unwrap();
println!("admin account_type = {}", user.account_type);
println!("admin status = {}", user.status);
let schema_version: i64 = conn
.query_row("PRAGMA user_version;", [], |row| row.get(0))
.unwrap();
println!("users db version = {}", schema_version);
assert_eq!(user.account_type, "admin");
assert_eq!(user.status, "active");
let system_conn = db.system.lock().unwrap();
let count: i64 = system_conn
.query_row(
"SELECT COUNT(*) FROM audit_events WHERE action = 'migration_repair' AND actor = 'admin' AND object_type = 'users' AND object_id = 'admin';",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(count, 1);
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_admin_login() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_admin_login_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let (_db, state) = build_state(config);
let password = "adminpassword";
let hash = bzod::auth::hash_password(password).unwrap();
{
let conn = state.users_db.lock().unwrap();
create_admin_user(&conn, "admin", &hash).unwrap();
}
let csrf_token = compute_sha256("csrf-token");
let jar = CookieJar::new().add(Cookie::new("bzod_temp_csrf", csrf_token.clone()));
let mut headers = HeaderMap::new();
headers.insert("user-agent", "test-agent".parse().unwrap());
let connect_info = Some(ConnectInfo(SocketAddr::new(
IpAddr::V4(Ipv4Addr::LOCALHOST),
8080,
)));
let form = LoginForm {
username: "admin".to_string(),
password: password.to_string(),
csrf_token,
};
let response = login_post(
State(state.clone()),
jar.clone(),
headers,
connect_info,
Form(form),
)
.await;
assert!(response.status().is_redirection());
assert_eq!(
response.headers().get("location").unwrap(),
"/admin/dashboard"
);
let conn = state.users_db.lock().unwrap();
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM sessions;", [], |row| row.get(0))
.unwrap();
assert_eq!(count, 1);
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_disabled_user_login_rejected() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_disabled_login_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let (_db, state) = build_state(config);
let password = "disabledpass";
let hash = bzod::auth::hash_password(password).unwrap();
{
let conn = state.users_db.lock().unwrap();
conn.execute(
"INSERT INTO users (username, password_hash, status, created_at, account_type) VALUES (?1, ?2, ?3, ?4, ?5);",
rusqlite::params!["disabled_user", hash, "disabled", Utc::now().to_rfc3339(), "system"],
)
.unwrap();
}
let csrf_token = compute_sha256("csrf-token");
let jar = CookieJar::new().add(Cookie::new("bzod_temp_csrf", csrf_token.clone()));
let mut headers = HeaderMap::new();
headers.insert("user-agent", "test-agent".parse().unwrap());
let connect_info = Some(ConnectInfo(SocketAddr::new(
IpAddr::V4(Ipv4Addr::LOCALHOST),
8080,
)));
let form = LoginForm {
username: "disabled_user".to_string(),
password: password.to_string(),
csrf_token,
};
let response = login_post(
State(state.clone()),
jar.clone(),
headers,
connect_info,
Form(form),
)
.await;
assert!(response.status().is_redirection());
assert!(response
.headers()
.get("location")
.unwrap()
.to_str()
.unwrap()
.contains("/admin/login"));
let conn = state.users_db.lock().unwrap();
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM sessions;", [], |row| row.get(0))
.unwrap();
assert_eq!(count, 0);
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_session_created_after_login() {
let temp_dir = std::env::temp_dir().join(format!(
"bzod_test_session_created_{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let (_db, state) = build_state(config);
let password = "adminpass2";
let hash = bzod::auth::hash_password(password).unwrap();
{
let conn = state.users_db.lock().unwrap();
create_admin_user(&conn, "admin", &hash).unwrap();
}
let csrf_token = compute_sha256("csrf-token");
let jar = CookieJar::new().add(Cookie::new("bzod_temp_csrf", csrf_token.clone()));
let mut headers = HeaderMap::new();
headers.insert("user-agent", "test-agent".parse().unwrap());
let connect_info = Some(ConnectInfo(SocketAddr::new(
IpAddr::V4(Ipv4Addr::LOCALHOST),
8080,
)));
let form = LoginForm {
username: "admin".to_string(),
password: password.to_string(),
csrf_token,
};
let response = login_post(
State(state.clone()),
jar.clone(),
headers,
connect_info,
Form(form),
)
.await;
assert!(response.status().is_redirection());
let conn = state.users_db.lock().unwrap();
let row_count: i64 = conn
.query_row("SELECT COUNT(*) FROM sessions;", [], |row| row.get(0))
.unwrap();
assert_eq!(row_count, 1);
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_admin_dashboard_access() {
let temp_dir = std::env::temp_dir().join(format!(
"bzod_test_dashboard_access_{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let (_db, state) = build_state(config);
let password = "adminpass3";
let hash = bzod::auth::hash_password(password).unwrap();
let user_id = {
let conn = state.users_db.lock().unwrap();
let user = create_admin_user(&conn, "admin", &hash).unwrap();
user.id
};
let session_token = "session_access_token";
{
let conn = state.users_db.lock().unwrap();
create_session(
&conn,
session_token,
&user_id.to_string(),
&(Utc::now() + chrono::Duration::hours(1)).to_rfc3339(),
)
.unwrap();
}
let jar = CookieJar::new().add(Cookie::new("bzod_session", session_token));
let response = dashboard_get(State(state.clone()), jar).await;
assert!(response.status().is_success());
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_standard_user_cannot_access_admin_panel() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_standard_block_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let (_db, state) = build_state(config);
let password = "userpass";
let hash = bzod::auth::hash_password(password).unwrap();
let user_id = {
let conn = state.users_db.lock().unwrap();
create_user(&conn, "regular_user", &hash, "standard", None)
.unwrap()
.id
};
let session_token = "standard_session_token";
{
let conn = state.users_db.lock().unwrap();
create_session(
&conn,
session_token,
&user_id.to_string(),
&Utc::now().to_rfc3339(),
)
.unwrap();
}
let jar = CookieJar::new().add(Cookie::new("bzod_session", session_token));
let response = dashboard_get(State(state.clone()), jar).await;
assert!(response.status().is_redirection());
assert!(response
.headers()
.get("location")
.unwrap()
.to_str()
.unwrap()
.contains("/admin/login"));
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_bootstrap_only_when_no_admin_exists() {
let temp_dir = std::env::temp_dir().join(format!(
"bzod_test_bootstrap_block_{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&temp_dir).unwrap();
let mut config = create_temp_config(temp_dir.clone());
config.bootstrap_password_sha256 = compute_sha256("bootstrap-secret");
let (_db, state) = build_state(config.clone());
let mut headers = HeaderMap::new();
headers.insert("user-agent", "test-agent".parse().unwrap());
let connect_info = Some(ConnectInfo(SocketAddr::new(
IpAddr::V4(Ipv4Addr::LOCALHOST),
8080,
)));
// Create an existing active admin, which should block bootstrap mode because the user count and admin count are already populated.
let _existing_admin = {
let conn = state.users_db.lock().unwrap();
let user = create_admin_user(
&conn,
"admin",
&bzod::auth::hash_password("admin-password").unwrap(),
)
.unwrap();
create_session(
&conn,
"existing_admin_session",
&user.id.to_string(),
&(Utc::now() + chrono::Duration::hours(1)).to_rfc3339(),
)
.unwrap();
user.id
};
let csrf_token = compute_sha256("csrf-token");
let jar = CookieJar::new().add(Cookie::new("bzod_temp_csrf", csrf_token.clone()));
let form = LoginForm {
username: "admin".to_string(),
password: "bootstrap-secret".to_string(),
csrf_token,
};
let response = login_post(
State(state.clone()),
jar.clone(),
headers,
connect_info,
Form(form),
)
.await;
assert!(response.status().is_redirection());
assert!(response
.headers()
.get("location")
.unwrap()
.to_str()
.unwrap()
.contains("/admin/login"));
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_system_account_cannot_access_admin_panel() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_system_block_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let (_db, state) = build_state(config);
let password = "systempass";
let hash = bzod::auth::hash_password(password).unwrap();
let user_id = {
let conn = state.users_db.lock().unwrap();
conn.execute(
"INSERT INTO users (username, password_hash, status, created_at, account_type) VALUES (?1, ?2, ?3, ?4, ?5);",
rusqlite::params!["system_user", hash, "active", Utc::now().to_rfc3339(), "system"],
)
.unwrap();
conn.last_insert_rowid()
};
let session_token = "system_session_token";
{
let conn = state.users_db.lock().unwrap();
create_session(
&conn,
session_token,
&user_id.to_string(),
&(Utc::now() + chrono::Duration::hours(1)).to_rfc3339(),
)
.unwrap();
}
let jar = CookieJar::new().add(Cookie::new("bzod_session", session_token));
let response = dashboard_get(State(state.clone()), jar).await;
assert!(response.status().is_redirection());
assert!(response
.headers()
.get("location")
.unwrap()
.to_str()
.unwrap()
.contains("/admin/login"));
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_reserved_username_rejected() {
let temp_dir = std::env::temp_dir().join(format!(
"bzod_test_reserved_username_{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let (_db, state) = build_state(config);
let password_hash = bzod::auth::hash_password("test").unwrap();
let create_result = create_user(
&state.users_db.lock().unwrap(),
"admin",
&password_hash,
"standard",
None,
);
assert!(create_result.is_err());
let admin_result = create_admin_user(&state.users_db.lock().unwrap(), "admin", &password_hash);
assert!(admin_result.is_ok());
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_existing_installation_can_login_after_upgrade() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_upgrade_login_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let admin_dir = temp_dir.join("admin");
std::fs::create_dir_all(&admin_dir).unwrap();
let users_db_path = admin_dir.join("users.db");
let mut users_conn = Connection::open(&users_db_path).unwrap();
run_migrations(&mut users_conn, "users", USERS_MIGRATIONS, None).unwrap();
users_conn.pragma_update(None, "user_version", 1).unwrap();
let password = "upgradetest";
let password_hash = bzod::auth::hash_password(password).unwrap();
users_conn
.execute(
"INSERT INTO users (username, password_hash, status, created_at, account_type) VALUES (?1, ?2, ?3, ?4, ?5);",
rusqlite::params!["admin", password_hash, "active", Utc::now().to_rfc3339(), "standard"],
)
.unwrap();
let _ = Db::init(&config).expect("Db::init should repair legacy install");
let (_db, state) = build_state(config.clone());
{
let conn = state.users_db.lock().unwrap();
let user_count: i64 = conn
.query_row("SELECT COUNT(*) FROM users;", [], |row| row.get(0))
.unwrap();
let admin_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM users WHERE account_type = 'admin';",
[],
|row| row.get(0),
)
.unwrap();
let active_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM users WHERE status = 'active';",
[],
|row| row.get(0),
)
.unwrap();
println!(
"upgrade login counts users={}, admin={}, active={}",
user_count, admin_count, active_count
);
let mut stmt = conn
.prepare("SELECT username, status, account_type FROM users;")
.unwrap();
let mut rows = stmt.query([]).unwrap();
while let Some(row) = rows.next().unwrap() {
let username: String = row.get(0).unwrap();
let status: String = row.get(1).unwrap();
let account_type: String = row.get(2).unwrap();
println!("user row: {} {} {}", username, status, account_type);
}
}
let csrf_token = compute_sha256("csrf-token");
let jar = CookieJar::new().add(Cookie::new("bzod_temp_csrf", csrf_token.clone()));
let mut headers = HeaderMap::new();
headers.insert("user-agent", "test-agent".parse().unwrap());
let connect_info = Some(ConnectInfo(SocketAddr::new(
IpAddr::V4(Ipv4Addr::LOCALHOST),
8080,
)));
let form = LoginForm {
username: "admin".to_string(),
password: password.to_string(),
csrf_token,
};
let response = login_post(
State(state.clone()),
jar.clone(),
headers,
connect_info,
Form(form),
)
.await;
assert!(response.status().is_redirection());
assert_eq!(
response.headers().get("location").unwrap(),
"/admin/dashboard"
);
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_expired_session_cannot_access_dashboard() {
let temp_dir = std::env::temp_dir().join(format!(
"bzod_test_expired_session_{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let (_db, state) = build_state(config);
let password = "adminpass4";
let hash = bzod::auth::hash_password(password).unwrap();
let user_id = {
let conn = state.users_db.lock().unwrap();
create_admin_user(&conn, "admin", &hash).unwrap().id
};
let session_token = "expired_session_token";
{
let conn = state.users_db.lock().unwrap();
create_session(
&conn,
session_token,
&user_id.to_string(),
&(Utc::now() - chrono::Duration::hours(1)).to_rfc3339(),
)
.unwrap();
}
let jar = CookieJar::new().add(Cookie::new("bzod_session", session_token));
let response = dashboard_get(State(state.clone()), jar).await;
assert!(response.status().is_redirection());
assert!(response
.headers()
.get("location")
.unwrap()
.to_str()
.unwrap()
.contains("/admin/login"));
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_api_key_rejected_for_non_admin_user() {
let temp_dir = std::env::temp_dir().join(format!(
"bzod_test_api_key_non_admin_{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let (db, _state) = build_state(config);
let password = "standardpass";
let hash = bzod::auth::hash_password(password).unwrap();
let user_id = {
let conn = db.users.lock().unwrap();
create_user(&conn, "standard_user", &hash, "standard", None)
.unwrap()
.id
};
let key_secret = "nonadmin-api-key";
let mut hasher = Sha256::new();
hasher.update(key_secret.as_bytes());
let hashed_key = hex::encode(hasher.finalize());
{
let conn = db.admin.lock().unwrap();
create_api_key(&conn, &user_id.to_string(), "test-key", &hashed_key).unwrap();
}
let auth_header = format!("Bearer {}", key_secret);
let admin_conn = db.admin.lock().unwrap();
let users_conn = db.users.lock().unwrap();
let auth_res = authenticate_api_key(&admin_conn, &users_conn, &auth_header).unwrap();
assert!(auth_res.is_none());
let _ = std::fs::remove_dir_all(&temp_dir);
}
+138
View File
@@ -0,0 +1,138 @@
use axum_extra::extract::cookie::Cookie;
use axum_extra::extract::CookieJar;
use bzod::config::Config;
use bzod::db::Db;
use sha2::{Digest, Sha256};
use std::fs;
use std::path::PathBuf;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
}
#[tokio::test]
async fn test_session_creation_and_expiry() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_auth_sess_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let _ = bzod::cli::create_user::run(
Some("testuser".to_string()),
Some("password123".to_string()),
None,
config.clone(),
)
.await
.unwrap();
let user_id = {
let conn = db.users.lock().unwrap();
bzod::db::users::get_user_by_username(&conn, "testuser")
.unwrap()
.unwrap()
.id
};
// 1. Session created in future must authenticate successfully
{
let conn = db.users.lock().unwrap();
let expires_at = (chrono::Utc::now() + chrono::Duration::seconds(3600)).to_rfc3339();
bzod::db::users::create_user_session(&conn, "valid_session_123", user_id, &expires_at)
.unwrap();
let jar = CookieJar::new().add(Cookie::new("bzod_user_session", "valid_session_123"));
let auth_res = bzod::auth::session::authenticate_user_session(&conn, &jar).unwrap();
assert!(auth_res.is_some());
assert_eq!(auth_res.unwrap().0.id, user_id);
}
// 2. Session created in past (expired) must be rejected
{
let conn = db.users.lock().unwrap();
let expires_at = (chrono::Utc::now() - chrono::Duration::seconds(3600)).to_rfc3339();
bzod::db::users::create_user_session(&conn, "expired_session_123", user_id, &expires_at)
.unwrap();
let jar = CookieJar::new().add(Cookie::new("bzod_user_session", "expired_session_123"));
let auth_res = bzod::auth::session::authenticate_user_session(&conn, &jar).unwrap();
assert!(auth_res.is_none());
}
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_api_token_creation_and_revocation() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_auth_token_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let _ = bzod::cli::create_user::run(
Some("testuser".to_string()),
Some("password123".to_string()),
None,
config.clone(),
)
.await
.unwrap();
let user_id = {
let conn = db.users.lock().unwrap();
bzod::db::users::get_user_by_username(&conn, "testuser")
.unwrap()
.unwrap()
.id
};
let raw_token = "bzo_testtoken1234567890abcdef";
let mut hasher = Sha256::new();
hasher.update(raw_token.as_bytes());
let token_hash = hex::encode(hasher.finalize());
// Create API Token
let token_rec = {
let conn = db.users.lock().unwrap();
bzod::db::users::create_user_api_token(&conn, user_id, &token_hash).unwrap()
};
// Authenticate with valid token
{
let conn = db.users.lock().unwrap();
let auth_header = format!("Bearer {}", raw_token);
let actor = bzod::auth::session::authenticate_api_key(
&db.admin.lock().unwrap(),
&conn,
&auth_header,
)
.unwrap();
assert!(actor.is_some());
}
// Revoke API Token
{
let conn = db.users.lock().unwrap();
bzod::db::users::delete_user_api_token(&conn, token_rec.id, user_id).unwrap();
// Verify revoked token is rejected
let auth_header = format!("Bearer {}", raw_token);
let actor = bzod::auth::session::authenticate_api_key(
&db.admin.lock().unwrap(),
&conn,
&auth_header,
)
.unwrap();
assert!(actor.is_none());
}
let _ = fs::remove_dir_all(&temp_dir);
}
+319
View File
@@ -0,0 +1,319 @@
use bzod::config::Config;
use bzod::db::Db;
use std::fs;
use std::fs::File;
use std::path::PathBuf;
use tar::Archive;
use zstd::Decoder;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
}
#[derive(serde::Serialize, serde::Deserialize)]
struct UserBackupMetadata {
id: i64,
username: String,
password_hash: String,
status: String,
created_at: String,
account_type: String,
metadata: Option<String>,
quotas: UserBackupQuotas,
}
#[derive(serde::Serialize, serde::Deserialize)]
struct UserBackupQuotas {
max_urls: i64,
max_landings: i64,
max_api_tokens: i64,
max_storage_mb: i64,
}
#[tokio::test]
async fn test_backup_metadata_integrity() {
let temp_dir = std::env::temp_dir().join(format!(
"bzod_test_backup_integrity_{}",
uuid::Uuid::new_v4()
));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let _db = Db::init(&config).expect("Failed to init Db");
let _ = bzod::cli::create_user::run(
Some("testuser".to_string()),
Some("password123".to_string()),
None,
config.clone(),
)
.await
.unwrap();
let backup_file = temp_dir.join("testuser-backup.tar.zst");
bzod::cli::backup_user::run(
"testuser".to_string(),
Some(backup_file.to_string_lossy().to_string()),
None,
config.clone(),
)
.await
.unwrap();
// Verify archive contains metadata.json and it has valid fields
let f = File::open(&backup_file).unwrap();
let zst_dec = Decoder::new(f).unwrap();
let mut archive = Archive::new(zst_dec);
let mut found_metadata = false;
for entry_res in archive.entries().unwrap() {
let mut entry = entry_res.unwrap();
let path = entry.path().unwrap();
let file_name = path.file_name().unwrap().to_str().unwrap();
if file_name == "metadata.json" {
found_metadata = true;
let meta: UserBackupMetadata = serde_json::from_reader(&mut entry).unwrap();
assert_eq!(meta.username, "testuser");
assert_eq!(meta.quotas.max_urls, 100);
break;
}
}
assert!(found_metadata);
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_backup_restore_roundtrip() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_backup_rt_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let _ = bzod::cli::create_user::run(
Some("testuser".to_string()),
Some("password123".to_string()),
None,
config.clone(),
)
.await
.unwrap();
let user_id = {
let conn = db.users.lock().unwrap();
bzod::db::users::get_user_by_username(&conn, "testuser")
.unwrap()
.unwrap()
.id
};
// Add a link for user
{
let user_content_conn = bzod::jobs::open_user_content_conn(&db, user_id).unwrap();
bzod::db::content::create_url_extended(
&user_content_conn,
"!rt-slug",
"https://example.com/rt",
None,
None,
&vec![],
None,
None,
None,
)
.unwrap();
// Increment quota counter
let users_conn = db.users.lock().unwrap();
bzod::db::users::increment_quota_counter(&users_conn, user_id, "urls").unwrap();
// Register global slug
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(&system_conn, "!rt-slug", user_id, "url", "rt-id")
.unwrap();
}
// Backup user
let backup_file = temp_dir.join("testuser-backup.tar.zst");
bzod::cli::backup_user::run(
"testuser".to_string(),
Some(backup_file.to_string_lossy().to_string()),
None,
config.clone(),
)
.await
.unwrap();
// Delete user
bzod::cli::delete_user::run(user_id, false, None, config.clone())
.await
.unwrap();
// Restore user
bzod::cli::restore_user::run(
backup_file.to_string_lossy().to_string(),
None,
config.clone(),
)
.await
.unwrap();
// Verify restored user
{
let conn = db.users.lock().unwrap();
let user = bzod::db::users::get_user_by_username(&conn, "testuser")
.unwrap()
.unwrap();
assert_eq!(user.status, "active");
let quota = bzod::db::users::get_user_quotas(&conn, user.id)
.unwrap()
.unwrap();
assert_eq!(quota.current_urls, 1);
// Verify content restored
let user_content_conn = bzod::jobs::open_user_content_conn(&db, user.id).unwrap();
let url = bzod::db::content::get_url_by_code(&user_content_conn, "!rt-slug")
.unwrap()
.unwrap();
assert_eq!(url.destination, "https://example.com/rt");
}
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_restore_slug_collision_rejection() {
let temp_dir = std::env::temp_dir().join(format!(
"bzod_test_restore_collision_{}",
uuid::Uuid::new_v4()
));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
// Create User A
let _ = bzod::cli::create_user::run(
Some("usera".to_string()),
Some("password123".to_string()),
None,
config.clone(),
)
.await
.unwrap();
let id_a = {
let conn = db.users.lock().unwrap();
bzod::db::users::get_user_by_username(&conn, "usera")
.unwrap()
.unwrap()
.id
};
// Add a link for User A
{
let conn_a = bzod::jobs::open_user_content_conn(&db, id_a).unwrap();
bzod::db::content::create_url_extended(
&conn_a,
"!collision-slug",
"https://usera.com",
None,
None,
&vec![],
None,
None,
None,
)
.unwrap();
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(&system_conn, "!collision-slug", id_a, "url", "a-id")
.unwrap();
}
// Backup User A
let backup_file = temp_dir.join("usera-backup.tar.zst");
bzod::cli::backup_user::run(
"usera".to_string(),
Some(backup_file.to_string_lossy().to_string()),
None,
config.clone(),
)
.await
.unwrap();
// Delete User A from the database so we can try restoring them
bzod::cli::delete_user::run(id_a, false, None, config.clone())
.await
.unwrap();
// Create User B
let _ = bzod::cli::create_user::run(
Some("userb".to_string()),
Some("password123".to_string()),
None,
config.clone(),
)
.await
.unwrap();
let id_b = {
let conn = db.users.lock().unwrap();
bzod::db::users::get_user_by_username(&conn, "userb")
.unwrap()
.unwrap()
.id
};
// Add colliding slug for User B
{
let conn_b = bzod::jobs::open_user_content_conn(&db, id_b).unwrap();
bzod::db::content::create_url_extended(
&conn_b,
"!collision-slug",
"https://userb.com",
None,
None,
&vec![],
None,
None,
None,
)
.unwrap();
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(&system_conn, "!collision-slug", id_b, "url", "b-id")
.unwrap();
}
// Attempt to restore User A from backup
bzod::cli::restore_user::run(
backup_file.to_string_lossy().to_string(),
None,
config.clone(),
)
.await
.unwrap();
// Verify that User B still owns the slug in global_slugs and User A's slug registration was skipped/rejected
{
let system_conn = db.system.lock().unwrap();
let owner_id: i64 = system_conn
.query_row(
"SELECT owner_user_id FROM global_slugs WHERE slug = '!collision-slug';",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(owner_id, id_b);
}
let _ = fs::remove_dir_all(&temp_dir);
}
+625
View File
@@ -0,0 +1,625 @@
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::time::Instant;
use tokio::net::TcpListener;
use bzod::analytics::AnalyticsQueue;
use bzod::config::Config;
use bzod::db::Db;
use bzod::state::AppState;
use bzod::web::create_router;
fn compute_sha256(value: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(value.as_bytes());
hex::encode(hasher.finalize())
}
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.admin_username = "admin".to_string();
config.base_url = Some("http://localhost:8080".to_string());
config.cookie_secure = false;
config.bootstrap_password_sha256 = compute_sha256("bootstrap-secret");
config
}
fn extract_csrf_token(html: &str) -> Option<String> {
let marker = "name=\"csrf_token\" value=\"";
if let Some(pos) = html.find(marker) {
let start = pos + marker.len();
if let Some(end) = html[start..].find('"') {
return Some(html[start..start + end].to_string());
}
}
None
}
async fn start_test_server(
temp_dir: PathBuf,
) -> (
reqwest::Client,
String,
AppState,
tokio::task::JoinHandle<()>,
) {
let config = create_temp_config(temp_dir);
let db = Db::init(&config).expect("Failed to init Db");
let queue = AnalyticsQueue::new(db.clone(), 10);
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(HashMap::new())),
db: db.clone(),
config,
analytics_queue: queue,
start_time: Instant::now(),
};
let router = create_router(state.clone());
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let url = format!("http://{}", addr);
let handle = tokio::spawn(async move {
axum::serve(listener, router).await.unwrap();
});
let client = reqwest::Client::builder()
.cookie_store(true)
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap();
(client, url, state, handle)
}
#[tokio::test]
async fn test_scenario_a_user_create_login_shorten_visit_analytics() {
let temp_dir = std::env::temp_dir().join(format!("bzod_biz_test_a_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let (client, base_url, state, _server_handle) = start_test_server(temp_dir.clone()).await;
// 1. Login admin
let login_page_url = format!("{}/admin/login", base_url);
let res = client.get(&login_page_url).send().await.unwrap();
let csrf_token = extract_csrf_token(&res.text().await.unwrap()).unwrap();
let mut params = HashMap::new();
params.insert("username", "admin");
params.insert("password", "bootstrap-secret");
params.insert("csrf_token", &csrf_token);
client
.post(&login_page_url)
.form(&params)
.send()
.await
.unwrap();
// 2. Create user standard_a
let users_page_url = format!("{}/admin/users", base_url);
let res = client.get(&users_page_url).send().await.unwrap();
let csrf_token = extract_csrf_token(&res.text().await.unwrap()).unwrap();
let mut good_params = HashMap::new();
good_params.insert("username", "standard_a");
good_params.insert("password", "password123");
good_params.insert("account_type", "standard");
good_params.insert("metadata", "scenario a user");
good_params.insert("csrf_token", &csrf_token);
client
.post(format!("{}/admin/users/create", base_url))
.form(&good_params)
.send()
.await
.unwrap();
// Logout admin
client
.get(format!("{}/admin/logout", base_url))
.send()
.await
.unwrap();
// 3. Login standard_a
let public_login_page_url = format!("{}/login", base_url);
let res = client.get(&public_login_page_url).send().await.unwrap();
let csrf_token = extract_csrf_token(&res.text().await.unwrap()).unwrap();
let mut user_login_params = HashMap::new();
user_login_params.insert("username", "standard_a");
user_login_params.insert("password", "password123");
user_login_params.insert("csrf_token", &csrf_token);
client
.post(&public_login_page_url)
.form(&user_login_params)
.send()
.await
.unwrap();
// 4. Create a shortened URL
let user_urls_url = format!("{}/user/urls", base_url);
let res = client.get(&user_urls_url).send().await.unwrap();
let csrf_token = extract_csrf_token(&res.text().await.unwrap()).unwrap();
let mut create_url_params = HashMap::new();
create_url_params.insert("destination", "https://google.com");
create_url_params.insert("code", "");
create_url_params.insert("custom_slug", "!mygoogle");
create_url_params.insert("title", "Google");
create_url_params.insert("description", "Search Engine");
create_url_params.insert("tags", "search,google");
create_url_params.insert("csrf_token", &csrf_token);
create_url_params.insert("expires_at", "");
create_url_params.insert("password", "");
create_url_params.insert("max_access_count", "");
create_url_params.insert("utm_source", "");
create_url_params.insert("utm_medium", "");
create_url_params.insert("utm_campaign", "");
client
.post(format!("{}/user/urls/create", base_url))
.form(&create_url_params)
.send()
.await
.unwrap();
// 5. Perform redirection visit to the custom slug
let redir_url = format!("{}/!mygoogle", base_url);
let res = client.get(&redir_url).send().await.unwrap();
// It should redirect to google.com
assert_eq!(res.status(), reqwest::StatusCode::TEMPORARY_REDIRECT);
assert_eq!(
res.headers().get("location").unwrap().to_str().unwrap(),
"https://google.com"
);
// Flush the queue manually or wait a moment for the queue to write to database
tokio::time::sleep(tokio::time::Duration::from_millis(2500)).await;
// 6. Verify visit is logged in analytics
let url_uuid = {
let conn = state.system_db.lock().unwrap();
conn.query_row(
"SELECT target_id FROM global_slugs WHERE slug = '!mygoogle';",
[],
|row| row.get::<_, String>(0),
)
.unwrap()
};
let res = client
.get(format!("{}/analytics", base_url))
.send()
.await
.unwrap();
let html = res.text().await.unwrap();
assert!(html.contains(&url_uuid));
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_scenario_b_user_disable_session_invalidation() {
let temp_dir = std::env::temp_dir().join(format!("bzod_biz_test_b_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let (client, base_url, state, _server_handle) = start_test_server(temp_dir.clone()).await;
// 1. Login admin
let login_page_url = format!("{}/admin/login", base_url);
let res = client.get(&login_page_url).send().await.unwrap();
let csrf_token = extract_csrf_token(&res.text().await.unwrap()).unwrap();
let mut params = HashMap::new();
params.insert("username", "admin");
params.insert("password", "bootstrap-secret");
params.insert("csrf_token", &csrf_token);
client
.post(&login_page_url)
.form(&params)
.send()
.await
.unwrap();
// 2. Create user standard_b
let users_page_url = format!("{}/admin/users", base_url);
let res = client.get(&users_page_url).send().await.unwrap();
let csrf_token = extract_csrf_token(&res.text().await.unwrap()).unwrap();
let mut good_params = HashMap::new();
good_params.insert("username", "standard_b");
good_params.insert("password", "password123");
good_params.insert("account_type", "standard");
good_params.insert("metadata", "scenario b user");
good_params.insert("csrf_token", &csrf_token);
client
.post(format!("{}/admin/users/create", base_url))
.form(&good_params)
.send()
.await
.unwrap();
let target_user_id = {
let conn = state.users_db.lock().unwrap();
conn.query_row(
"SELECT id FROM users WHERE username = 'standard_b';",
[],
|row| row.get::<_, i64>(0),
)
.unwrap()
};
// Logout admin
client
.get(format!("{}/admin/logout", base_url))
.send()
.await
.unwrap();
// 3. Login standard_b
let public_login_page_url = format!("{}/login", base_url);
let res = client.get(&public_login_page_url).send().await.unwrap();
let csrf_token = extract_csrf_token(&res.text().await.unwrap()).unwrap();
let mut user_login_params = HashMap::new();
user_login_params.insert("username", "standard_b");
user_login_params.insert("password", "password123");
user_login_params.insert("csrf_token", &csrf_token);
client
.post(&public_login_page_url)
.form(&user_login_params)
.send()
.await
.unwrap();
// Verify standard_b can access dashboard
let res = client
.get(format!("{}/user/dashboard", base_url))
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::OK);
// 4. Create another client for admin to disable standard_b
let admin_client = reqwest::Client::builder()
.cookie_store(true)
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap();
// Login admin
let res = admin_client.get(&login_page_url).send().await.unwrap();
let csrf_token = extract_csrf_token(&res.text().await.unwrap()).unwrap();
let mut admin_params = HashMap::new();
admin_params.insert("username", "admin");
admin_params.insert("password", "bootstrap-secret");
admin_params.insert("csrf_token", &csrf_token);
admin_client
.post(&login_page_url)
.form(&admin_params)
.send()
.await
.unwrap();
// GET /admin/users to get token
let res = admin_client
.get(format!("{}/admin/users", base_url))
.send()
.await
.unwrap();
let csrf_token = extract_csrf_token(&res.text().await.unwrap()).unwrap();
// Disable standard_b
let mut disable_params = HashMap::new();
disable_params.insert("status", "disabled");
disable_params.insert("csrf_token", &csrf_token);
let disable_url = format!("{}/admin/users/status/{}", base_url, target_user_id);
admin_client
.post(&disable_url)
.form(&disable_params)
.send()
.await
.unwrap();
// 5. Access user dashboard with standard_b's client (should be rejected/redirected to login)
let res = client
.get(format!("{}/user/dashboard", base_url))
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER);
assert_eq!(
res.headers().get("location").unwrap().to_str().unwrap(),
"/login"
);
// Attempting login again should fail
let res = client.get(&public_login_page_url).send().await.unwrap();
let csrf_token = extract_csrf_token(&res.text().await.unwrap()).unwrap();
let mut login_again = HashMap::new();
login_again.insert("username", "standard_b");
login_again.insert("password", "password123");
login_again.insert("csrf_token", &csrf_token);
let res = client
.post(&public_login_page_url)
.form(&login_again)
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER);
assert!(res
.headers()
.get("location")
.unwrap()
.to_str()
.unwrap()
.contains("error=Invalid"));
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_scenario_c_slug_transfer_workflow() {
let temp_dir = std::env::temp_dir().join(format!("bzod_biz_test_c_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let (client, base_url, state, _server_handle) = start_test_server(temp_dir.clone()).await;
// 1. Login admin
let login_page_url = format!("{}/admin/login", base_url);
let res = client.get(&login_page_url).send().await.unwrap();
let csrf_token = extract_csrf_token(&res.text().await.unwrap()).unwrap();
let mut params = HashMap::new();
params.insert("username", "admin");
params.insert("password", "bootstrap-secret");
params.insert("csrf_token", &csrf_token);
client
.post(&login_page_url)
.form(&params)
.send()
.await
.unwrap();
// 2. Create standard_c1 and standard_c2
let res = client
.get(format!("{}/admin/users", base_url))
.send()
.await
.unwrap();
let csrf_token = extract_csrf_token(&res.text().await.unwrap()).unwrap();
let mut user1_params = HashMap::new();
user1_params.insert("username", "standard_c1");
user1_params.insert("password", "password123");
user1_params.insert("account_type", "standard");
user1_params.insert("metadata", "c1 user");
user1_params.insert("csrf_token", &csrf_token);
client
.post(format!("{}/admin/users/create", base_url))
.form(&user1_params)
.send()
.await
.unwrap();
let res = client
.get(format!("{}/admin/users", base_url))
.send()
.await
.unwrap();
let csrf_token = extract_csrf_token(&res.text().await.unwrap()).unwrap();
let mut user2_params = HashMap::new();
user2_params.insert("username", "standard_c2");
user2_params.insert("password", "password123");
user2_params.insert("account_type", "standard");
user2_params.insert("metadata", "c2 user");
user2_params.insert("csrf_token", &csrf_token);
client
.post(format!("{}/admin/users/create", base_url))
.form(&user2_params)
.send()
.await
.unwrap();
let user2_id = {
let conn = state.users_db.lock().unwrap();
conn.query_row(
"SELECT id FROM users WHERE username = 'standard_c2';",
[],
|row| row.get::<_, i64>(0),
)
.unwrap()
};
// Logout admin
client
.get(format!("{}/admin/logout", base_url))
.send()
.await
.unwrap();
// 3. Login as standard_c1 and create a slug
let public_login_page_url = format!("{}/login", base_url);
let res = client.get(&public_login_page_url).send().await.unwrap();
let csrf_token = extract_csrf_token(&res.text().await.unwrap()).unwrap();
let mut login_params = HashMap::new();
login_params.insert("username", "standard_c1");
login_params.insert("password", "password123");
login_params.insert("csrf_token", &csrf_token);
client
.post(&public_login_page_url)
.form(&login_params)
.send()
.await
.unwrap();
let res = client
.get(format!("{}/user/urls", base_url))
.send()
.await
.unwrap();
let csrf_token = extract_csrf_token(&res.text().await.unwrap()).unwrap();
let mut create_url_params = HashMap::new();
create_url_params.insert("destination", "https://yahoo.com");
create_url_params.insert("code", "");
create_url_params.insert("custom_slug", "!myyahoo");
create_url_params.insert("title", "Yahoo");
create_url_params.insert("description", "Portal");
create_url_params.insert("tags", "yahoo");
create_url_params.insert("csrf_token", &csrf_token);
create_url_params.insert("expires_at", "");
create_url_params.insert("password", "");
create_url_params.insert("max_access_count", "");
create_url_params.insert("utm_source", "");
create_url_params.insert("utm_medium", "");
create_url_params.insert("utm_campaign", "");
client
.post(format!("{}/user/urls/create", base_url))
.form(&create_url_params)
.send()
.await
.unwrap();
// Visit standard_c1's link
client
.get(format!("{}/!myyahoo", base_url))
.send()
.await
.unwrap();
tokio::time::sleep(tokio::time::Duration::from_millis(2500)).await;
// Verify visit recorded for standard_c1
let url_uuid = {
let conn = state.system_db.lock().unwrap();
conn.query_row(
"SELECT target_id FROM global_slugs WHERE slug = '!myyahoo';",
[],
|row| row.get::<_, String>(0),
)
.unwrap()
};
let res = client
.get(format!("{}/analytics", base_url))
.send()
.await
.unwrap();
assert!(res.text().await.unwrap().contains(&url_uuid));
// Logout standard_c1
client
.get(format!("{}/logout", base_url))
.send()
.await
.unwrap();
// 4. Log in admin to perform the slug transfer to standard_c2
client
.get(format!("{}/admin/logout", base_url))
.send()
.await
.unwrap();
let res = client.get(&login_page_url).send().await.unwrap();
let csrf_token = extract_csrf_token(&res.text().await.unwrap()).unwrap();
let mut admin_params = HashMap::new();
admin_params.insert("username", "admin");
admin_params.insert("password", "bootstrap-secret");
admin_params.insert("csrf_token", &csrf_token);
client
.post(&login_page_url)
.form(&admin_params)
.send()
.await
.unwrap();
// GET /admin/slugs to get CSRF token
let res = client
.get(format!("{}/admin/slugs", base_url))
.send()
.await
.unwrap();
let csrf_token = extract_csrf_token(&res.text().await.unwrap()).unwrap();
// Transfer slug
let user2_id_str = user2_id.to_string();
let mut transfer_params = HashMap::new();
transfer_params.insert("slug", "!myyahoo");
transfer_params.insert("new_owner_user_id", &user2_id_str);
transfer_params.insert("csrf_token", &csrf_token);
let res = client
.post(format!("{}/admin/slugs/transfer", base_url))
.form(&transfer_params)
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER);
// 5. Test redirection redirection still works
let res = client
.get(format!("{}/!myyahoo", base_url))
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::TEMPORARY_REDIRECT);
assert_eq!(
res.headers().get("location").unwrap().to_str().unwrap(),
"https://yahoo.com"
);
// Wait for the background worker to write the standard_c2 visit to the database
tokio::time::sleep(tokio::time::Duration::from_millis(2500)).await;
// 6. Verify that visitor logs / analytics for yahoo are now in standard_c2's domain
client
.get(format!("{}/admin/logout", base_url))
.send()
.await
.unwrap();
// Login as standard_c2
let res = client.get(&public_login_page_url).send().await.unwrap();
let csrf_token = extract_csrf_token(&res.text().await.unwrap()).unwrap();
let mut login_c2 = HashMap::new();
login_c2.insert("username", "standard_c2");
login_c2.insert("password", "password123");
login_c2.insert("csrf_token", &csrf_token);
client
.post(&public_login_page_url)
.form(&login_c2)
.send()
.await
.unwrap();
// standard_c2 analytics should display Yahoo!
let url_uuid = {
let conn = state.system_db.lock().unwrap();
conn.query_row(
"SELECT target_id FROM global_slugs WHERE slug = '!myyahoo';",
[],
|row| row.get::<_, String>(0),
)
.unwrap()
};
let res = client
.get(format!("{}/analytics", base_url))
.send()
.await
.unwrap();
assert!(res.text().await.unwrap().contains(&url_uuid));
let _ = fs::remove_dir_all(&temp_dir);
}
+71
View File
@@ -0,0 +1,71 @@
use bzod::config::Config;
use bzod::db::Db;
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Barrier;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
}
#[tokio::test]
async fn test_concurrent_slug_creation() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_concurrency_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let barrier = Arc::new(Barrier::new(2));
let db_path = temp_dir.join("admin/system.db");
let b1 = barrier.clone();
let path1 = db_path.clone();
let task1 = tokio::spawn(async move {
let conn = rusqlite::Connection::open(&path1).unwrap();
b1.wait().await;
bzod::db::users::register_global_slug(&conn, "!conc-slug", 10, "url", "url10")
});
let b2 = barrier.clone();
let path2 = db_path.clone();
let task2 = tokio::spawn(async move {
let conn = rusqlite::Connection::open(&path2).unwrap();
b2.wait().await;
bzod::db::users::register_global_slug(&conn, "!conc-slug", 20, "url", "url20")
});
let res1 = task1.await.unwrap();
let res2 = task2.await.unwrap();
// Exactly one should succeed, and one should fail (due to UNIQUE constraint)
match (res1, res2) {
(Ok(_), Err(_)) => {}
(Err(_), Ok(_)) => {}
(r1, r2) => panic!(
"Concurrent creation outcome invalid. Res1: {:?}, Res2: {:?}",
r1, r2
),
}
// Verify exactly 1 record exists in global_slugs
{
let system_conn = db.system.lock().unwrap();
let count: i64 = system_conn
.query_row(
"SELECT COUNT(*) FROM global_slugs WHERE slug = '!conc-slug';",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(count, 1);
}
let _ = fs::remove_dir_all(&temp_dir);
}
+92
View File
@@ -0,0 +1,92 @@
use bzod::config::Config;
use bzod::db::Db;
use std::fs;
use std::path::PathBuf;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
}
#[tokio::test]
async fn test_corrupted_backup_rejection() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_disaster_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
// Create a corrupted backup file (just random/invalid text bytes)
let corrupted_file = temp_dir.join("corrupted.tar.zst");
fs::write(
&corrupted_file,
b"this-is-not-a-valid-zstd-tar-archive-file",
)
.unwrap();
// Verify restore fails/rejects it
let res = bzod::cli::restore_user::run(
corrupted_file.to_string_lossy().to_string(),
None,
config.clone(),
)
.await;
assert!(res.is_err());
// Verify no user was created in the database
{
let conn = db.users.lock().unwrap();
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM users WHERE username = 'corrupted';",
[],
|row| row.get(0),
)
.unwrap_or(0);
assert_eq!(count, 0);
}
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_partial_restore_failure_rollback() {
let temp_dir = std::env::temp_dir().join(format!(
"bzod_test_partial_rollback_{}",
uuid::Uuid::new_v4()
));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
// Restore user from completely missing file path should fail
let res = bzod::cli::restore_user::run(
temp_dir
.join("completely-non-existent-file.tar.zst")
.to_string_lossy()
.to_string(),
None,
config.clone(),
)
.await;
// Verify it fails or runs cleanly without creating any users
assert!(res.is_ok() || res.is_err());
{
let conn = db.users.lock().unwrap();
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM users;", [], |row| row.get(0))
.unwrap();
// Only legacy_admin (ID 1) should exist
assert_eq!(count, 1);
}
let _ = fs::remove_dir_all(&temp_dir);
}
+210
View File
@@ -0,0 +1,210 @@
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::time::Instant;
use tokio::net::TcpListener;
use bzod::analytics::AnalyticsQueue;
use bzod::config::Config;
use bzod::db::Db;
use bzod::state::AppState;
use bzod::web::create_router;
fn compute_sha256(value: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(value.as_bytes());
hex::encode(hasher.finalize())
}
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.admin_username = "admin".to_string();
config.base_url = Some("http://localhost:8080".to_string());
config.cookie_secure = false; // Disable secure flag for testing over HTTP loopback
config.bootstrap_password_sha256 = compute_sha256("bootstrap-secret");
config
}
fn extract_csrf_token(html: &str) -> Option<String> {
let marker = "name=\"csrf_token\" value=\"";
if let Some(pos) = html.find(marker) {
let start = pos + marker.len();
if let Some(end) = html[start..].find('"') {
return Some(html[start..start + end].to_string());
}
}
None
}
// Start a test server on a random free port and return client + address
async fn start_test_server(
temp_dir: PathBuf,
) -> (reqwest::Client, String, tokio::task::JoinHandle<()>) {
let config = create_temp_config(temp_dir);
let db = Db::init(&config).expect("Failed to init Db");
let queue = AnalyticsQueue::new(db.clone(), 100);
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(HashMap::new())),
db: db.clone(),
config,
analytics_queue: queue,
start_time: Instant::now(),
};
let router = create_router(state);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let url = format!("http://{}", addr);
let handle = tokio::spawn(async move {
axum::serve(listener, router).await.unwrap();
});
let client = reqwest::Client::builder()
.cookie_store(true)
.redirect(reqwest::redirect::Policy::none()) // Let us manually inspect redirects
.build()
.unwrap();
(client, url, handle)
}
#[tokio::test]
async fn test_full_http_e2e_flow() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_e2e_http_test_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let (client, base_url, _server_handle) = start_test_server(temp_dir.clone()).await;
// 1. Fetch admin login page to get CSRF token
let login_page_url = format!("{}/admin/login", base_url);
let res = client.get(&login_page_url).send().await.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::OK);
let html = res.text().await.unwrap();
let csrf_token = extract_csrf_token(&html).expect("Failed to extract CSRF token");
// 2. Perform bootstrap login using BOOTSTRAP_PASSWORD_SHA256
let mut params = HashMap::new();
params.insert("username", "admin");
params.insert("password", "bootstrap-secret");
params.insert("csrf_token", &csrf_token);
let res = client
.post(&login_page_url)
.form(&params)
.send()
.await
.unwrap();
// After successful login, it should redirect to /admin/dashboard
assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER);
let redirect_url = res.headers().get("location").unwrap().to_str().unwrap();
assert_eq!(redirect_url, "/admin/dashboard");
// 3. Try to access /admin/users to make sure we are authorized
let users_page_url = format!("{}/admin/users", base_url);
let res = client.get(&users_page_url).send().await.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::OK);
let html = res.text().await.unwrap();
assert!(html.contains("Users Management"));
// Extract CSRF token from admin users page
let csrf_token =
extract_csrf_token(&html).expect("Failed to extract CSRF token from users page");
// 4. Test CSRF validation failure during user creation
let mut bad_params = HashMap::new();
bad_params.insert("username", "testuser");
bad_params.insert("password", "password123");
bad_params.insert("account_type", "standard");
bad_params.insert("metadata", "e2e standard user");
bad_params.insert("csrf_token", "invalid-csrf-token");
let create_user_url = format!("{}/admin/users/create", base_url);
let res = client
.post(&create_user_url)
.form(&bad_params)
.send()
.await
.unwrap();
// Should redirect back with Invalid CSRF token error
assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER);
let redirect_url = res.headers().get("location").unwrap().to_str().unwrap();
assert!(redirect_url.contains("Invalid CSRF token"));
// 5. Create a standard user successfully
let mut good_params = HashMap::new();
good_params.insert("username", "testuser");
good_params.insert("password", "password123");
good_params.insert("account_type", "standard");
good_params.insert("metadata", "e2e standard user");
good_params.insert("csrf_token", &csrf_token);
let res = client
.post(&create_user_url)
.form(&good_params)
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER);
let redirect_url = res.headers().get("location").unwrap().to_str().unwrap();
assert!(redirect_url.contains("success=User created successfully"));
// 6. Test RBAC: logout admin and login as standard user
let admin_logout_url = format!("{}/admin/logout", base_url);
let res = client.get(&admin_logout_url).send().await.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER);
// Get public login page and extract CSRF token
let public_login_page_url = format!("{}/login", base_url);
let res = client.get(&public_login_page_url).send().await.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::OK);
let html = res.text().await.unwrap();
let csrf_token = extract_csrf_token(&html).expect("Failed to extract public CSRF token");
// Login as standard user
let mut user_login_params = HashMap::new();
user_login_params.insert("username", "testuser");
user_login_params.insert("password", "password123");
user_login_params.insert("csrf_token", &csrf_token);
let res = client
.post(&public_login_page_url)
.form(&user_login_params)
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER);
let redirect_url = res.headers().get("location").unwrap().to_str().unwrap();
assert_eq!(redirect_url, "/user/dashboard");
// Access user dashboard (should succeed)
let user_dashboard_url = format!("{}/user/dashboard", base_url);
let res = client.get(&user_dashboard_url).send().await.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::OK);
// Try to access admin dashboard as standard user (RBAC check - should redirect to admin login)
let admin_dashboard_url = format!("{}/admin/dashboard", base_url);
let res = client.get(&admin_dashboard_url).send().await.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER);
let redirect_url = res.headers().get("location").unwrap().to_str().unwrap();
assert_eq!(redirect_url, "/admin/login");
let _ = fs::remove_dir_all(&temp_dir);
}
+129
View File
@@ -0,0 +1,129 @@
use bzod::config::Config;
use bzod::db::Db;
use std::fs;
use std::path::PathBuf;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
}
#[tokio::test]
async fn test_global_slug_index_consistency() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_integrity_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let _ = bzod::cli::create_user::run(
Some("testuser".to_string()),
Some("password123".to_string()),
None,
config.clone(),
)
.await
.unwrap();
let user_id = {
let conn = db.users.lock().unwrap();
bzod::db::users::get_user_by_username(&conn, "testuser")
.unwrap()
.unwrap()
.id
};
// Add a URL for the user
let url_id = {
let conn = bzod::jobs::open_user_content_conn(&db, user_id).unwrap();
let url = bzod::db::content::create_url_extended(
&conn,
"!integ-slug",
"https://google.com",
None,
None,
&vec![],
None,
None,
None,
)
.unwrap();
url.id
};
// Register globally
{
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(&system_conn, "!integ-slug", user_id, "url", &url_id)
.unwrap();
}
// Consistency Check:
// 1. Every active slug in user databases exists in global_slugs
{
let user_ids: Vec<i64> = {
let conn = db.users.lock().unwrap();
let mut stmt = conn.prepare("SELECT id FROM users;").unwrap();
let rows = stmt.query_map([], |row| row.get(0)).unwrap();
rows.filter_map(|r| r.ok()).collect()
};
let system_conn = db.system.lock().unwrap();
for id in user_ids {
let conn = bzod::jobs::open_user_content_conn(&db, id).unwrap();
let mut stmt = conn.prepare("SELECT code FROM urls;").unwrap();
let rows = stmt.query_map([], |row| row.get::<_, String>(0)).unwrap();
for code_res in rows {
let code = code_res.unwrap();
let exists: bool = system_conn.query_row(
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = ?1 AND owner_user_id = ?2);",
rusqlite::params![code, id],
|row| row.get(0),
).unwrap();
assert!(
exists,
"Active user slug '{}' missing from global_slugs",
code
);
}
}
}
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_slug_history_consistency() {
let temp_dir = std::env::temp_dir().join(format!(
"bzod_test_history_consistency_{}",
uuid::Uuid::new_v4()
));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let system_conn = db.system.lock().unwrap();
// Verify slug history table columns exist and accept transition entries
system_conn.execute(
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp)
VALUES ('!slug1', 1, 2, 'transferred', 'now');",
[],
).unwrap();
let count: i64 = system_conn
.query_row(
"SELECT COUNT(*) FROM slug_history WHERE slug = '!slug1' AND action = 'transferred';",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(count, 1);
let _ = fs::remove_dir_all(&temp_dir);
}
+114
View File
@@ -0,0 +1,114 @@
use bzod::config::Config;
use bzod::db::Db;
use std::fs;
use std::path::PathBuf;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
}
#[tokio::test]
async fn test_legacy_migration() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_migration_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
// Create legacy files at the root of temp_dir
let legacy_admin_path = temp_dir.join("admin.db");
let legacy_system_path = temp_dir.join("system.db");
let legacy_content_path = temp_dir.join("content.db");
let legacy_analytics_path = temp_dir.join("analytics.db");
// Apply old schemas/migrations
{
let mut admin_conn = rusqlite::Connection::open(&legacy_admin_path).unwrap();
bzod::db::migrations::run_migrations(
&mut admin_conn,
"admin",
bzod::db::migrations::ADMIN_MIGRATIONS,
None,
)
.unwrap();
// Insert admin user
let hash = bzod::auth::hash_password("admin_pass").unwrap();
bzod::db::admin::create_user(&admin_conn, "admin", &hash).unwrap();
let mut system_conn = rusqlite::Connection::open(&legacy_system_path).unwrap();
bzod::db::migrations::run_migrations(
&mut system_conn,
"system",
bzod::db::migrations::SYSTEM_MIGRATIONS,
None,
)
.unwrap();
let mut content_conn = rusqlite::Connection::open(&legacy_content_path).unwrap();
bzod::db::migrations::run_migrations(
&mut content_conn,
"content",
bzod::db::migrations::CONTENT_MIGRATIONS,
None,
)
.unwrap();
// Insert a link in legacy content
bzod::db::content::create_url_extended(
&content_conn,
"!legacy-slug",
"https://legacy.com",
None,
None,
&[],
None,
None,
None,
)
.unwrap();
let mut analytics_conn = rusqlite::Connection::open(&legacy_analytics_path).unwrap();
bzod::db::migrations::run_migrations(
&mut analytics_conn,
"analytics",
bzod::db::migrations::ANALYTICS_MIGRATIONS,
None,
)
.unwrap();
}
// Call Db::init which triggers the legacy migration
let db = Db::init(&config).expect("Failed to init Db and run legacy migration");
// Verify files moved to correct directories
assert!(!legacy_admin_path.exists());
assert!(!legacy_content_path.exists());
assert!(temp_dir.join("admin/admin.db").exists());
assert!(temp_dir.join("users/1/content.db").exists());
// Verify legacy_admin created as system and disabled
{
let conn = db.users.lock().unwrap();
let user = bzod::db::users::get_user_by_id(&conn, 1).unwrap().unwrap();
assert_eq!(user.username, "legacy_admin");
assert_eq!(user.status, "disabled");
assert_eq!(user.account_type, "system");
}
// Verify slug registered in global_slugs
{
let conn = db.system.lock().unwrap();
let exists: bool = conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = '!legacy-slug');",
[],
|row| row.get(0),
)
.unwrap();
assert!(exists);
}
let _ = fs::remove_dir_all(&temp_dir);
}
+106
View File
@@ -0,0 +1,106 @@
use bzod::config::Config;
use bzod::db::Db;
use chrono::Utc;
use std::fs;
use std::path::PathBuf;
use uuid::Uuid;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
}
#[tokio::test]
async fn test_content_flagging_and_disabling() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_mod_flag_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let system_conn = db.system.lock().unwrap();
// Register slug
bzod::db::users::register_global_slug(&system_conn, "!badslug", 10, "url", "url_abc").unwrap();
// Verify it is active initially
let status: String = system_conn
.query_row(
"SELECT status FROM global_slugs WHERE slug = ?1;",
["!badslug"],
|row| row.get(0),
)
.unwrap();
assert_eq!(status, "active");
// Moderate/disable slug
system_conn
.execute(
"UPDATE global_slugs SET status = 'disabled' WHERE slug = ?1;",
["!badslug"],
)
.unwrap();
// Verify it is disabled
let status2: String = system_conn
.query_row(
"SELECT status FROM global_slugs WHERE slug = ?1;",
["!badslug"],
|row| row.get(0),
)
.unwrap();
assert_eq!(status2, "disabled");
// Moderate/re-enable slug
system_conn
.execute(
"UPDATE global_slugs SET status = 'active' WHERE slug = ?1;",
["!badslug"],
)
.unwrap();
let status3: String = system_conn
.query_row(
"SELECT status FROM global_slugs WHERE slug = ?1;",
["!badslug"],
|row| row.get(0),
)
.unwrap();
assert_eq!(status3, "active");
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_moderation_event_logging() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_mod_logging_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let system_conn = db.system.lock().unwrap();
let event_id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
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", 10, "testuser", "slug", "!badslug", "block", "high", "abusive content"],
).unwrap();
// Verify logged
let count: i64 = system_conn.query_row(
"SELECT COUNT(*) FROM moderation_events WHERE target_user_id = 10 AND resource_identifier = '!badslug';",
[],
|row| row.get(0),
).unwrap();
assert_eq!(count, 1);
let _ = fs::remove_dir_all(&temp_dir);
}
+5 -4
View File
@@ -139,10 +139,11 @@ async fn test_perform_restore_and_validation() {
assert!(validation_res.is_ok()); assert!(validation_res.is_ok());
// Verify database files were extracted // Verify database files were extracted
assert!(restore_dir.join("admin.db").exists()); assert!(restore_dir.join("admin/admin.db").exists());
assert!(restore_dir.join("content.db").exists()); assert!(restore_dir.join("admin/users.db").exists());
assert!(restore_dir.join("analytics.db").exists()); assert!(restore_dir.join("admin/system.db").exists());
assert!(restore_dir.join("system.db").exists()); assert!(restore_dir.join("users/1/content.db").exists());
assert!(restore_dir.join("users/1/analytics.db").exists());
// Verify custom slug was preserved in the restored DB // Verify custom slug was preserved in the restored DB
let restore_config = create_temp_config(restore_dir.clone()); let restore_config = create_temp_config(restore_dir.clone());
+141
View File
@@ -0,0 +1,141 @@
use bzod::config::Config;
use bzod::db::Db;
use std::fs;
use std::path::PathBuf;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
}
#[tokio::test]
async fn test_quota_limit_enforcement() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_quota_limits_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let _ = bzod::cli::create_user::run(
Some("testuser".to_string()),
Some("password123".to_string()),
None,
config.clone(),
)
.await
.unwrap();
let user_id = {
let conn = db.users.lock().unwrap();
bzod::db::users::get_user_by_username(&conn, "testuser")
.unwrap()
.unwrap()
.id
};
// Set max_urls to 1
{
let conn = db.users.lock().unwrap();
conn.execute(
"UPDATE quotas SET max_urls = 1 WHERE user_id = ?1;",
[user_id],
)
.unwrap();
}
// Attempt to add URLs and manually increment count
{
let users_conn = db.users.lock().unwrap();
let quota = bzod::db::users::get_user_quotas(&users_conn, user_id)
.unwrap()
.unwrap();
assert_eq!(quota.max_urls, 1);
assert_eq!(quota.current_urls, 0);
// 1st Url: Success
bzod::db::users::increment_quota_counter(&users_conn, user_id, "urls").unwrap();
let quota1 = bzod::db::users::get_user_quotas(&users_conn, user_id)
.unwrap()
.unwrap();
assert_eq!(quota1.current_urls, 1);
// 2nd Url: Quota check fails
let quota_check = quota1.current_urls >= quota1.max_urls;
assert!(quota_check);
}
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_quota_reconcile_job() {
let temp_dir = std::env::temp_dir().join(format!(
"bzod_test_quota_reconcile_{}",
uuid::Uuid::new_v4()
));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let _ = bzod::cli::create_user::run(
Some("testuser".to_string()),
Some("password123".to_string()),
None,
config.clone(),
)
.await
.unwrap();
let user_id = {
let conn = db.users.lock().unwrap();
bzod::db::users::get_user_by_username(&conn, "testuser")
.unwrap()
.unwrap()
.id
};
// Introduce drift manually: make current_urls = 10, when actually 1 URL exists
{
let user_content_conn = bzod::jobs::open_user_content_conn(&db, user_id).unwrap();
bzod::db::content::create_url_extended(
&user_content_conn,
"!my-reconcile-slug",
"https://example.com",
None,
None,
&vec![],
None,
None,
None,
)
.unwrap();
let users_conn = db.users.lock().unwrap();
users_conn
.execute(
"UPDATE quotas SET current_urls = 10 WHERE user_id = ?1;",
[user_id],
)
.unwrap();
}
// Run reconciliation
{
let users_conn = db.users.lock().unwrap();
let user_content_conn = bzod::jobs::open_user_content_conn(&db, user_id).unwrap();
bzod::db::users::reconcile_user_quotas(&users_conn, user_id, &user_content_conn).unwrap();
// Verify drift is repaired
let quota = bzod::db::users::get_user_quotas(&users_conn, user_id)
.unwrap()
.unwrap();
assert_eq!(quota.current_urls, 1);
}
let _ = fs::remove_dir_all(&temp_dir);
}
+202
View File
@@ -0,0 +1,202 @@
use axum::extract::{Path, State};
use axum::http::{HeaderMap, StatusCode};
use axum_extra::extract::CookieJar;
use bzod::analytics::AnalyticsQueue;
use bzod::config::Config;
use bzod::db::Db;
use bzod::state::AppState;
use bzod::web::redirect::resolve_redirect;
use std::fs;
use std::path::PathBuf;
use std::time::Instant;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
}
#[tokio::test]
async fn test_global_slug_lookup_and_redirection() {
let temp_dir = std::env::temp_dir().join(format!("bzod_test_routing_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let queue = AnalyticsQueue::new(db.clone(), 1000);
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,
start_time: Instant::now(),
};
// Create a user and link
let _ = bzod::cli::create_user::run(
Some("testuser".to_string()),
Some("password123".to_string()),
None,
config.clone(),
)
.await
.unwrap();
let user_id = {
let conn = db.users.lock().unwrap();
bzod::db::users::get_user_by_username(&conn, "testuser")
.unwrap()
.unwrap()
.id
};
{
let conn = bzod::jobs::open_user_content_conn(&db, user_id).unwrap();
bzod::db::content::create_url_extended(
&conn,
"!my-routing-slug",
"https://example.com/target",
None,
None,
&vec![],
None,
None,
None,
)
.unwrap();
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(
&system_conn,
"!my-routing-slug",
user_id,
"url",
"xyz",
)
.unwrap();
}
// Call resolve_redirect directly
let response = resolve_redirect(
State(state.clone()),
CookieJar::new(),
Path("!my-routing-slug".to_string()),
HeaderMap::new(),
None,
)
.await;
// Verify it redirects (status 303 or 302/307/etc)
assert!(response.status().is_redirection());
assert_eq!(
response
.headers()
.get("location")
.unwrap()
.to_str()
.unwrap(),
"https://example.com/target"
);
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_disabled_slug_returns_410() {
let temp_dir = std::env::temp_dir().join(format!(
"bzod_test_routing_disabled_{}",
uuid::Uuid::new_v4()
));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let queue = AnalyticsQueue::new(db.clone(), 1000);
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,
start_time: Instant::now(),
};
let _ = bzod::cli::create_user::run(
Some("testuser".to_string()),
Some("password123".to_string()),
None,
config.clone(),
)
.await
.unwrap();
let user_id = {
let conn = db.users.lock().unwrap();
bzod::db::users::get_user_by_username(&conn, "testuser")
.unwrap()
.unwrap()
.id
};
{
let conn = bzod::jobs::open_user_content_conn(&db, user_id).unwrap();
bzod::db::content::create_url_extended(
&conn,
"!disabled-slug",
"https://example.com/target",
None,
None,
&vec![],
None,
None,
None,
)
.unwrap();
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(
&system_conn,
"!disabled-slug",
user_id,
"url",
"xyz",
)
.unwrap();
// Disable slug
system_conn
.execute(
"UPDATE global_slugs SET status = 'disabled' WHERE slug = '!disabled-slug';",
[],
)
.unwrap();
}
// Call resolve_redirect directly
let response = resolve_redirect(
State(state),
CookieJar::new(),
Path("!disabled-slug".to_string()),
HeaderMap::new(),
None,
)
.await;
// Verify it returns 410 Gone (StatusCode::GONE)
assert_eq!(response.status(), StatusCode::GONE);
let _ = fs::remove_dir_all(&temp_dir);
}
+18 -11
View File
@@ -4,11 +4,11 @@ use chrono::Utc;
use rusqlite::Connection; use rusqlite::Connection;
use bzod::auth::{ use bzod::auth::{
authenticate_api_key, authenticate_session, generate_csrf_token, hash_password, verify_csrf, authenticate_admin_session, authenticate_api_key, generate_csrf_token, hash_password,
verify_password, verify_sha256, verify_csrf, verify_password, verify_sha256,
}; };
use bzod::db::admin::{create_api_key, create_session, create_user, get_user_count}; use bzod::db::admin::{create_api_key, create_session, create_user, get_user_count};
use bzod::db::migrations::{run_migrations, ADMIN_MIGRATIONS}; use bzod::db::migrations::{run_migrations, ADMIN_MIGRATIONS, USERS_MIGRATIONS};
// Helper to set up an in-memory admin.db connection with migrations applied // Helper to set up an in-memory admin.db connection with migrations applied
fn setup_test_db() -> Connection { fn setup_test_db() -> Connection {
@@ -17,6 +17,12 @@ fn setup_test_db() -> Connection {
conn conn
} }
fn setup_users_db() -> Connection {
let mut conn = Connection::open_in_memory().unwrap();
run_migrations(&mut conn, "users", USERS_MIGRATIONS, None).unwrap();
conn
}
#[test] #[test]
fn test_csrf_tampering_prevention() { fn test_csrf_tampering_prevention() {
let session_id = "secret_session_id_123456"; let session_id = "secret_session_id_123456";
@@ -35,6 +41,7 @@ fn test_csrf_tampering_prevention() {
#[test] #[test]
fn test_api_key_sql_injection_resistance() { fn test_api_key_sql_injection_resistance() {
let conn = setup_test_db(); let conn = setup_test_db();
let users_conn = setup_users_db();
// Create an API key // Create an API key
let user_hash = hash_password("admin_pass").unwrap(); let user_hash = hash_password("admin_pass").unwrap();
@@ -50,26 +57,26 @@ fn test_api_key_sql_injection_resistance() {
// 1. Test valid key passes // 1. Test valid key passes
let valid_auth = format!("Bearer {}", key_secret); let valid_auth = format!("Bearer {}", key_secret);
let auth_res = authenticate_api_key(&conn, &valid_auth).unwrap(); let auth_res = authenticate_api_key(&conn, &users_conn, &valid_auth).unwrap();
assert!(auth_res.is_some()); assert!(auth_res.is_some());
assert_eq!(auth_res.unwrap().username, "admin"); assert_eq!(auth_res.unwrap().username(), "admin");
// 2. Test SQL Injection attempt in the header does not succeed or crash // 2. Test SQL Injection attempt in the header does not succeed or crash
let sql_inj_auth1 = "Bearer ' OR 1=1 --"; let sql_inj_auth1 = "Bearer ' OR 1=1 --";
let res = authenticate_api_key(&conn, sql_inj_auth1).unwrap(); let res = authenticate_api_key(&conn, &users_conn, sql_inj_auth1).unwrap();
assert!(res.is_none()); assert!(res.is_none());
let sql_inj_auth2 = "Bearer ' UNION SELECT id, username FROM users --"; let sql_inj_auth2 = "Bearer ' UNION SELECT id, username FROM users --";
let res = authenticate_api_key(&conn, sql_inj_auth2).unwrap(); let res = authenticate_api_key(&conn, &users_conn, sql_inj_auth2).unwrap();
assert!(res.is_none()); assert!(res.is_none());
// 3. Test malformed header // 3. Test malformed header
let malformed_auth = "Bearer"; let malformed_auth = "Bearer";
let res = authenticate_api_key(&conn, malformed_auth).unwrap(); let res = authenticate_api_key(&conn, &users_conn, malformed_auth).unwrap();
assert!(res.is_none()); assert!(res.is_none());
let wrong_scheme = "Basic admin:pass"; let wrong_scheme = "Basic admin:pass";
let res = authenticate_api_key(&conn, wrong_scheme).unwrap(); let res = authenticate_api_key(&conn, &users_conn, wrong_scheme).unwrap();
assert!(res.is_none()); assert!(res.is_none());
} }
@@ -86,7 +93,7 @@ fn test_expired_session_invalidation() {
create_session(&conn, session_id_future, &user.id, &future_expiry).unwrap(); create_session(&conn, session_id_future, &user.id, &future_expiry).unwrap();
let jar_future = CookieJar::new().add(Cookie::new("bzod_session", session_id_future)); let jar_future = CookieJar::new().add(Cookie::new("bzod_session", session_id_future));
let auth_future = authenticate_session(&conn, &jar_future).unwrap(); let auth_future = authenticate_admin_session(&conn, &jar_future).unwrap();
assert!(auth_future.is_some()); assert!(auth_future.is_some());
assert_eq!(auth_future.unwrap().0.id, user.id); assert_eq!(auth_future.unwrap().0.id, user.id);
@@ -96,7 +103,7 @@ fn test_expired_session_invalidation() {
create_session(&conn, session_id_past, &user.id, &past_expiry).unwrap(); create_session(&conn, session_id_past, &user.id, &past_expiry).unwrap();
let jar_past = CookieJar::new().add(Cookie::new("bzod_session", session_id_past)); let jar_past = CookieJar::new().add(Cookie::new("bzod_session", session_id_past));
let auth_past = authenticate_session(&conn, &jar_past).unwrap(); let auth_past = authenticate_admin_session(&conn, &jar_past).unwrap();
assert!(auth_past.is_none()); assert!(auth_past.is_none());
} }
+145
View File
@@ -0,0 +1,145 @@
use bzod::config::Config;
use bzod::db::Db;
use std::fs;
use std::path::PathBuf;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
}
#[tokio::test]
async fn test_global_slug_uniqueness() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_slug_uniq_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let system_conn = db.system.lock().unwrap();
// Register a slug
bzod::db::users::register_global_slug(&system_conn, "!myslug", 1, "url", "url1").unwrap();
// Verify it is not available
let avail = bzod::db::users::is_slug_available(&system_conn, "!myslug").unwrap();
assert!(!avail);
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_reserved_slug_rejection() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_reserved_slug_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let system_conn = db.system.lock().unwrap();
let reserved = vec![
"admin",
"login",
"logout",
"dashboard",
"api",
"docs",
"assets",
"static",
"favicon.ico",
"robots.txt",
"health",
"metrics",
"install",
"setup",
"support",
"help",
"security",
"abuse",
"billing",
"status",
"legacy_admin",
"administrator",
"system",
"root",
"www",
];
for slug in reserved {
let avail = bzod::db::users::is_slug_available(&system_conn, slug).unwrap();
assert!(!avail, "Reserved slug '{}' should not be available", slug);
}
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_slug_release_on_user_deletion() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_slug_release_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let _ = bzod::cli::create_user::run(
Some("testuser".to_string()),
Some("password123".to_string()),
None,
config.clone(),
)
.await
.unwrap();
let user_id = {
let conn = db.users.lock().unwrap();
bzod::db::users::get_user_by_username(&conn, "testuser")
.unwrap()
.unwrap()
.id
};
// Register slug for this user
{
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(
&system_conn,
"!user-slug",
user_id,
"url",
"url_xyz",
)
.unwrap();
let avail = bzod::db::users::is_slug_available(&system_conn, "!user-slug").unwrap();
assert!(!avail);
}
// Delete user
bzod::cli::delete_user::run(user_id, false, None, config.clone())
.await
.unwrap();
// Slug should now be released and available
{
let system_conn = db.system.lock().unwrap();
let avail = bzod::db::users::is_slug_available(&system_conn, "!user-slug").unwrap();
assert!(avail);
// Verify history populated
let count: i64 = system_conn
.query_row(
"SELECT COUNT(*) FROM slug_history WHERE slug = ?1 AND action = 'deleted';",
["!user-slug"],
|row| row.get(0),
)
.unwrap();
assert!(count > 0);
}
let _ = fs::remove_dir_all(&temp_dir);
}
+176
View File
@@ -0,0 +1,176 @@
use bzod::config::Config;
use bzod::db::Db;
use chrono::Utc;
use std::fs;
use std::path::PathBuf;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
}
#[tokio::test]
async fn test_slug_transfer() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_transfer_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
// Create User A
let _ = bzod::cli::create_user::run(
Some("usera".to_string()),
Some("password123".to_string()),
None,
config.clone(),
)
.await
.unwrap();
// Create User B
let _ = bzod::cli::create_user::run(
Some("userb".to_string()),
Some("password123".to_string()),
None,
config.clone(),
)
.await
.unwrap();
let (id_a, id_b) = {
let conn = db.users.lock().unwrap();
let a = bzod::db::users::get_user_by_username(&conn, "usera")
.unwrap()
.unwrap()
.id;
let b = bzod::db::users::get_user_by_username(&conn, "userb")
.unwrap()
.unwrap()
.id;
(a, b)
};
// User A creates a URL
let conn_a = bzod::jobs::open_user_content_conn(&db, id_a).unwrap();
let url = bzod::db::content::create_url_extended(
&conn_a,
"!trans-slug",
"https://google.com/transfer",
None,
None,
&vec![],
None,
None,
None,
)
.unwrap();
// Register globally
{
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(&system_conn, "!trans-slug", id_a, "url", &url.id)
.unwrap();
let users_conn = db.users.lock().unwrap();
bzod::db::users::increment_quota_counter(&users_conn, id_a, "urls").unwrap();
}
// Perform transfer to User B
{
let old_conn = bzod::jobs::open_user_content_conn(&db, id_a).unwrap();
let new_conn = bzod::jobs::open_user_content_conn(&db, id_b).unwrap();
// 1. Copy URL to new owner content.db
let url_to_copy = bzod::db::content::get_url_by_code(&old_conn, "!trans-slug")
.unwrap()
.unwrap();
bzod::db::content::create_url_extended(
&new_conn,
&url_to_copy.code,
&url_to_copy.destination,
url_to_copy.title.as_deref(),
url_to_copy.description.as_deref(),
&url_to_copy.tags,
url_to_copy.expires_at.as_deref(),
url_to_copy.password_hash.as_deref(),
url_to_copy.max_access_count,
)
.unwrap();
// 2. Delete from old owner
bzod::db::content::delete_url(&old_conn, &url_to_copy.id).unwrap();
// 3. Update global slugs and quotas
let system_conn = db.system.lock().unwrap();
let now = Utc::now().to_rfc3339();
system_conn
.execute(
"UPDATE global_slugs SET owner_user_id = ?1, updated_at = ?2 WHERE slug = ?3;",
rusqlite::params![id_b, now, "!trans-slug"],
)
.unwrap();
let users_conn = db.users.lock().unwrap();
bzod::db::users::decrement_quota_counter(&users_conn, id_a, "urls").unwrap();
bzod::db::users::increment_quota_counter(&users_conn, id_b, "urls").unwrap();
}
// Verify User A has no link, User B has it
{
let conn_a = bzod::jobs::open_user_content_conn(&db, id_a).unwrap();
let conn_b = bzod::jobs::open_user_content_conn(&db, id_b).unwrap();
assert!(bzod::db::content::get_url_by_code(&conn_a, "!trans-slug")
.unwrap()
.is_none());
assert!(bzod::db::content::get_url_by_code(&conn_b, "!trans-slug")
.unwrap()
.is_some());
// Verify quotas adjusted
let users_conn = db.users.lock().unwrap();
let q_a = bzod::db::users::get_user_quotas(&users_conn, id_a)
.unwrap()
.unwrap();
let q_b = bzod::db::users::get_user_quotas(&users_conn, id_b)
.unwrap()
.unwrap();
assert_eq!(q_a.current_urls, 0);
assert_eq!(q_b.current_urls, 1);
}
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_slug_transfer_history_logging() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_trans_history_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let system_conn = db.system.lock().unwrap();
let now = Utc::now().to_rfc3339();
// Log a slug transfer history entry
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!["!trans-slug", 10, 20, now, "admin"],
).unwrap();
// Verify it is logged
let count: i64 = system_conn.query_row(
"SELECT COUNT(*) FROM slug_history WHERE slug = '!trans-slug' AND old_owner_user_id = 10 AND new_owner_user_id = 20;",
[],
|row| row.get(0),
).unwrap();
assert_eq!(count, 1);
let _ = fs::remove_dir_all(&temp_dir);
}
+69
View File
@@ -0,0 +1,69 @@
use bzod::config::Config;
use bzod::db::Db;
use std::fs;
use std::path::PathBuf;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
}
#[tokio::test]
async fn test_soft_delete_reserves_slug() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_soft_del_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let system_conn = db.system.lock().unwrap();
// Register slug
bzod::db::users::register_global_slug(&system_conn, "!slug-to-delete", 10, "url", "url_123")
.unwrap();
// Soft delete slug
bzod::db::users::soft_delete_global_slug(&system_conn, "!slug-to-delete", 10).unwrap();
// Verify it is not available (keeps the slug reserved)
let avail = bzod::db::users::is_slug_available(&system_conn, "!slug-to-delete").unwrap();
assert!(!avail);
let status: String = system_conn
.query_row(
"SELECT status FROM global_slugs WHERE slug = ?1;",
["!slug-to-delete"],
|row| row.get(0),
)
.unwrap();
assert_eq!(status, "soft_deleted");
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_permanent_delete_releases_slug() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_perm_del_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let system_conn = db.system.lock().unwrap();
// Register slug
bzod::db::users::register_global_slug(&system_conn, "!slug-to-purge", 10, "url", "url_456")
.unwrap();
// Release global slug (permanent deletion)
bzod::db::users::release_global_slug(&system_conn, "!slug-to-purge", 10).unwrap();
// Verify slug is released (available for reuse)
let avail = bzod::db::users::is_slug_available(&system_conn, "!slug-to-purge").unwrap();
assert!(avail);
let _ = fs::remove_dir_all(&temp_dir);
}
+243
View File
@@ -0,0 +1,243 @@
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::time::{Duration, Instant};
use tokio::net::TcpListener;
use tokio::time::sleep;
use bzod::analytics::AnalyticsQueue;
use bzod::config::Config;
use bzod::db::Db;
use bzod::state::AppState;
use bzod::web::create_router;
fn compute_sha256(value: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(value.as_bytes());
hex::encode(hasher.finalize())
}
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.admin_username = "admin".to_string();
config.base_url = Some("http://localhost:8080".to_string());
config.cookie_secure = false;
config.bootstrap_password_sha256 = compute_sha256("bootstrap-secret");
config
}
fn extract_csrf_token(html: &str) -> Option<String> {
let marker = "name=\"csrf_token\" value=\"";
if let Some(pos) = html.find(marker) {
let start = pos + marker.len();
if let Some(end) = html[start..].find('"') {
return Some(html[start..start + end].to_string());
}
}
None
}
#[tokio::test]
async fn test_upgrade_from_v0_4_0() {
// Create a temporary directory for the legacy v0.4.0 data
let temp_dir = std::env::temp_dir().join(format!("bzod_upgrade_test_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let legacy_admin_path = temp_dir.join("admin.db");
let legacy_system_path = temp_dir.join("system.db");
let legacy_content_path = temp_dir.join("content.db");
let legacy_analytics_path = temp_dir.join("analytics.db");
// Initialize legacy databases with their respective version 1 schemas and seed data
{
// 1. admin.db
let mut admin_conn = rusqlite::Connection::open(&legacy_admin_path).unwrap();
bzod::db::migrations::run_migrations(
&mut admin_conn,
"admin",
bzod::db::migrations::ADMIN_MIGRATIONS,
None,
)
.unwrap();
// Seed legacy admin user
let legacy_admin_hash = bzod::auth::hash_password("legacy-admin-pass").unwrap();
bzod::db::admin::create_user(&admin_conn, "admin", &legacy_admin_hash).unwrap();
// 2. system.db
let mut system_conn = rusqlite::Connection::open(&legacy_system_path).unwrap();
bzod::db::migrations::run_migrations(
&mut system_conn,
"system",
bzod::db::migrations::SYSTEM_MIGRATIONS,
None,
)
.unwrap();
// 3. content.db
let mut content_conn = rusqlite::Connection::open(&legacy_content_path).unwrap();
bzod::db::migrations::run_migrations(
&mut content_conn,
"content",
bzod::db::migrations::CONTENT_MIGRATIONS,
None,
)
.unwrap();
// Seed legacy url
let url = bzod::db::content::create_url_extended(
&content_conn,
"!legacy-link",
"https://example.com/legacy-redirect-target",
Some("Legacy URL"),
Some("A link from v0.4.0"),
&[],
None,
None,
None,
)
.unwrap();
// 4. analytics.db
let mut analytics_conn = rusqlite::Connection::open(&legacy_analytics_path).unwrap();
bzod::db::migrations::run_migrations(
&mut analytics_conn,
"analytics",
bzod::db::migrations::ANALYTICS_MIGRATIONS,
None,
)
.unwrap();
// Seed legacy visit record matching the seeded legacy url id
let timestamp = chrono::Utc::now().to_rfc3339();
analytics_conn
.execute(
"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);",
rusqlite::params![
"legacy-visit-id-123",
"url",
url.id,
timestamp,
"1.1.1.1",
"LegacyBrowser/1.0",
"https://referrer-channel.com",
"en-US",
"US",
302,
1i64 // user ID 1
],
)
.unwrap();
}
// Now start the application setup which should trigger migrations/upgrade
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Db::init failed to upgrade v0.4.0 database directory");
// Verify file layout was reorganized
assert!(!legacy_admin_path.exists());
assert!(!legacy_content_path.exists());
assert!(!legacy_analytics_path.exists());
assert!(!legacy_system_path.exists());
assert!(temp_dir.join("admin/admin.db").exists());
assert!(temp_dir.join("admin/system.db").exists());
assert!(temp_dir.join("admin/users.db").exists());
assert!(temp_dir.join("users/1/content.db").exists());
assert!(temp_dir.join("users/1/analytics.db").exists());
// Spawn server
let queue = AnalyticsQueue::new(db.clone(), 10);
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(HashMap::new())),
db: db.clone(),
config,
analytics_queue: queue,
start_time: Instant::now(),
};
let router = create_router(state.clone());
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let base_url = format!("http://{}", addr);
let _server_handle = tokio::spawn(async move {
axum::serve(listener, router).await.unwrap();
});
let client = reqwest::Client::builder()
.cookie_store(true)
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap();
// 1. Admin login works (Bootstrap path because we migrated)
let login_url = format!("{}/admin/login", base_url);
let res = client.get(&login_url).send().await.unwrap();
let html = res.text().await.unwrap();
let csrf_token =
extract_csrf_token(&html).expect("Failed to extract CSRF token from login page");
let mut params = HashMap::new();
params.insert("username", "admin");
params.insert("password", "bootstrap-secret");
params.insert("csrf_token", &csrf_token);
let login_res = client.post(&login_url).form(&params).send().await.unwrap();
assert!(login_res.status().is_redirection());
assert_eq!(
login_res
.headers()
.get("location")
.unwrap()
.to_str()
.unwrap(),
"/admin/dashboard"
);
// 2. Existing links redirect correctly
let redirect_url = format!("{}/!legacy-link", base_url);
let redirect_res = client.get(&redirect_url).send().await.unwrap();
let res_headers = redirect_res.headers();
assert!(res_headers.get("location").is_some());
assert_eq!(
res_headers.get("location").unwrap().to_str().unwrap(),
"https://example.com/legacy-redirect-target"
);
// Sleep 3 seconds for the analytics queue to process and flush to database
sleep(Duration::from_millis(3000)).await;
// 3. Analytics preserved and new click recorded
let user_dbs = state.get_user_dbs(1).unwrap();
let analytics_conn = user_dbs.analytics.lock().unwrap();
// Check count of visits for the legacy link (should be 2: 1 legacy + 1 new redirect click)
let visit_count: i64 = analytics_conn
.query_row(
"SELECT COUNT(*) FROM visits WHERE target_type = 'url';",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(visit_count, 2);
// Verify the details of the legacy visit are preserved
let legacy_visit_exists: bool = analytics_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM visits WHERE id = 'legacy-visit-id-123' AND user_agent = 'LegacyBrowser/1.0' AND referer = 'https://referrer-channel.com');",
[],
|row| row.get(0),
)
.unwrap();
assert!(legacy_visit_exists);
// Clean up temporary folder
let _ = fs::remove_dir_all(&temp_dir);
}
+127
View File
@@ -0,0 +1,127 @@
use bzod::config::Config;
use bzod::db::Db;
use std::fs;
use std::path::PathBuf;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
}
#[tokio::test]
async fn test_user_deletion_cleanup() {
let temp_dir = std::env::temp_dir().join(format!(
"bzod_test_deletion_cleanup_{}",
uuid::Uuid::new_v4()
));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let _ = bzod::cli::create_user::run(
Some("testuser".to_string()),
Some("password123".to_string()),
None,
config.clone(),
)
.await
.unwrap();
let user_id = {
let conn = db.users.lock().unwrap();
bzod::db::users::get_user_by_username(&conn, "testuser")
.unwrap()
.unwrap()
.id
};
// Add session and API token
{
let conn = db.users.lock().unwrap();
let expires_at = (chrono::Utc::now() + chrono::Duration::seconds(3600)).to_rfc3339();
bzod::db::users::create_user_session(&conn, "sess_abc", user_id, &expires_at).unwrap();
bzod::db::users::create_user_api_token(&conn, user_id, "token_abc").unwrap();
}
// Delete user
bzod::cli::delete_user::run(user_id, false, None, config.clone())
.await
.unwrap();
// Verify directory is deleted
let user_dir = temp_dir.join("users").join(user_id.to_string());
assert!(!user_dir.exists());
// Verify sessions/tokens are cascadingly deleted
{
let conn = db.users.lock().unwrap();
let sess_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sessions WHERE user_id = ?1;",
[user_id],
|row| row.get(0),
)
.unwrap();
assert_eq!(sess_count, 0);
let token_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM api_tokens WHERE user_id = ?1;",
[user_id],
|row| row.get(0),
)
.unwrap();
assert_eq!(token_count, 0);
}
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_user_deletion_audit_snapshot() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_deletion_audit_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let _ = bzod::cli::create_user::run(
Some("testuser".to_string()),
Some("password123".to_string()),
None,
config.clone(),
)
.await
.unwrap();
let user_id = {
let conn = db.users.lock().unwrap();
bzod::db::users::get_user_by_username(&conn, "testuser")
.unwrap()
.unwrap()
.id
};
// Delete user
bzod::cli::delete_user::run(user_id, false, None, config.clone())
.await
.unwrap();
// Verify audit events and history entries are NOT deleted (retained)
{
let system_conn = db.system.lock().unwrap();
let audit_count: i64 = system_conn.query_row(
"SELECT COUNT(*) FROM audit_events WHERE action = 'USER_DELETION' AND object_id = ?1;",
[user_id.to_string()],
|row| row.get(0),
).unwrap();
assert!(audit_count > 0);
}
let _ = fs::remove_dir_all(&temp_dir);
}
+148
View File
@@ -0,0 +1,148 @@
use bzod::config::Config;
use bzod::db::Db;
use std::fs;
use std::path::PathBuf;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
}
#[tokio::test]
async fn test_user_database_isolation() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_isolation_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let _ = bzod::cli::create_user::run(
Some("usera".to_string()),
Some("password123".to_string()),
None,
config.clone(),
)
.await
.unwrap();
let _ = bzod::cli::create_user::run(
Some("userb".to_string()),
Some("password123".to_string()),
None,
config.clone(),
)
.await
.unwrap();
let (id_a, id_b) = {
let conn = db.users.lock().unwrap();
let a = bzod::db::users::get_user_by_username(&conn, "usera")
.unwrap()
.unwrap()
.id;
let b = bzod::db::users::get_user_by_username(&conn, "userb")
.unwrap()
.unwrap()
.id;
(a, b)
};
let dir_a = temp_dir.join("users").join(id_a.to_string());
let dir_b = temp_dir.join("users").join(id_b.to_string());
assert_ne!(dir_a, dir_b);
assert!(dir_a.join("content.db").exists());
assert!(dir_b.join("content.db").exists());
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_cross_user_content_access_denied() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_cross_access_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let _ = bzod::cli::create_user::run(
Some("usera".to_string()),
Some("password123".to_string()),
None,
config.clone(),
)
.await
.unwrap();
let _ = bzod::cli::create_user::run(
Some("userb".to_string()),
Some("password123".to_string()),
None,
config.clone(),
)
.await
.unwrap();
let (id_a, id_b) = {
let conn = db.users.lock().unwrap();
let a = bzod::db::users::get_user_by_username(&conn, "usera")
.unwrap()
.unwrap()
.id;
let b = bzod::db::users::get_user_by_username(&conn, "userb")
.unwrap()
.unwrap()
.id;
(a, b)
};
// User A adds a URL
let conn_a = bzod::jobs::open_user_content_conn(&db, id_a).unwrap();
let _ = bzod::db::content::create_url_extended(
&conn_a,
"!slug-a",
"https://google.com/usera",
None,
None,
&vec!["tag_usera".to_string()],
None,
None,
None,
)
.unwrap();
// User B adds a URL
let conn_b = bzod::jobs::open_user_content_conn(&db, id_b).unwrap();
let _ = bzod::db::content::create_url_extended(
&conn_b,
"!slug-b",
"https://google.com/userb",
None,
None,
&vec!["tag_userb".to_string()],
None,
None,
None,
)
.unwrap();
// Verify User B's DB has no trace of User A's content or tags
let url_opt = bzod::db::content::get_url_by_code(&conn_b, "!slug-a").unwrap();
assert!(url_opt.is_none());
let tag_exists: bool = conn_b
.query_row(
"SELECT EXISTS(SELECT 1 FROM tags WHERE name = 'tag_usera');",
[],
|row| row.get(0),
)
.unwrap();
assert!(!tag_exists);
let _ = fs::remove_dir_all(&temp_dir);
}
+214
View File
@@ -0,0 +1,214 @@
use bzod::config::Config;
use bzod::db::Db;
use std::fs;
use std::path::PathBuf;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
}
#[tokio::test]
async fn test_user_creation() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_creation_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let res = bzod::cli::create_user::run(
Some("testuser".to_string()),
Some("password123".to_string()),
None,
config.clone(),
)
.await;
assert!(res.is_ok());
// Verify user directories and files
let conn = db.users.lock().unwrap();
let user = bzod::db::users::get_user_by_username(&conn, "testuser")
.unwrap()
.unwrap();
assert_eq!(user.status, "active");
let user_dir = temp_dir.join("users").join(user.id.to_string());
assert!(user_dir.join("content.db").exists());
assert!(user_dir.join("analytics.db").exists());
assert!(user_dir.join("profile.db").exists());
// Verify quotas are seeded
let quota = bzod::db::users::get_user_quotas(&conn, user.id)
.unwrap()
.unwrap();
assert_eq!(quota.max_urls, 100);
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_user_disable_enable() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_disable_enable_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let _ = bzod::cli::create_user::run(
Some("testuser".to_string()),
Some("password123".to_string()),
None,
config.clone(),
)
.await
.unwrap();
let user_id = {
let conn = db.users.lock().unwrap();
bzod::db::users::get_user_by_username(&conn, "testuser")
.unwrap()
.unwrap()
.id
};
// Disable the user
bzod::cli::disable_user::run(user_id, None, config.clone())
.await
.unwrap();
{
let conn = db.users.lock().unwrap();
let user = bzod::db::users::get_user_by_id(&conn, user_id)
.unwrap()
.unwrap();
assert_eq!(user.status, "disabled");
// Verify authentication fails for disabled users
let api_key = "Bearer test_api_token";
let auth_res = bzod::auth::authenticate_api_key(&db.admin.lock().unwrap(), &conn, api_key);
assert!(auth_res.unwrap().is_none());
}
// Re-enable the user
bzod::cli::enable_user::run(user_id, None, config.clone())
.await
.unwrap();
{
let conn = db.users.lock().unwrap();
let user = bzod::db::users::get_user_by_id(&conn, user_id)
.unwrap()
.unwrap();
assert_eq!(user.status, "active");
}
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_password_reset() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_pwd_reset_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let _ = bzod::cli::create_user::run(
Some("testuser".to_string()),
Some("oldpassword".to_string()),
None,
config.clone(),
)
.await
.unwrap();
let user_id = {
let conn = db.users.lock().unwrap();
bzod::db::users::get_user_by_username(&conn, "testuser")
.unwrap()
.unwrap()
.id
};
// Reset password
bzod::cli::reset_password::run(
user_id,
Some("newpassword".to_string()),
None,
config.clone(),
)
.await
.unwrap();
{
let conn = db.users.lock().unwrap();
let user = bzod::db::users::get_user_by_id(&conn, user_id)
.unwrap()
.unwrap();
// Verify password hash changed and verifies correctly
assert!(bzod::auth::verify_password(
"newpassword",
&user.password_hash
));
assert!(!bzod::auth::verify_password(
"oldpassword",
&user.password_hash
));
}
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_user_status_transitions() {
let temp_dir = std::env::temp_dir().join(format!(
"bzod_test_status_transitions_{}",
uuid::Uuid::new_v4()
));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let _ = bzod::cli::create_user::run(
Some("testuser".to_string()),
Some("password123".to_string()),
None,
config.clone(),
)
.await
.unwrap();
let user_id = {
let conn = db.users.lock().unwrap();
bzod::db::users::get_user_by_username(&conn, "testuser")
.unwrap()
.unwrap()
.id
};
let conn = db.users.lock().unwrap();
// active -> disabled
bzod::db::users::update_user_status(&conn, user_id, "disabled").unwrap();
let u1 = bzod::db::users::get_user_by_id(&conn, user_id)
.unwrap()
.unwrap();
assert_eq!(u1.status, "disabled");
// disabled -> active
bzod::db::users::update_user_status(&conn, user_id, "active").unwrap();
let u2 = bzod::db::users::get_user_by_id(&conn, user_id)
.unwrap()
.unwrap();
assert_eq!(u2.status, "active");
let _ = fs::remove_dir_all(&temp_dir);
}
+104
View File
@@ -0,0 +1,104 @@
use bzod::config::Config;
use bzod::db::Db;
use std::fs;
use std::path::PathBuf;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
}
#[tokio::test]
async fn test_wal_recovery_after_backup() {
let temp_dir = std::env::temp_dir().join(format!("bzod_test_wal_rec_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let _ = bzod::cli::create_user::run(
Some("testuser".to_string()),
Some("password123".to_string()),
None,
config.clone(),
)
.await
.unwrap();
let user_id = {
let conn = db.users.lock().unwrap();
bzod::db::users::get_user_by_username(&conn, "testuser")
.unwrap()
.unwrap()
.id
};
// 1. Write some content
{
let conn = bzod::jobs::open_user_content_conn(&db, user_id).unwrap();
bzod::db::content::create_url_extended(
&conn,
"!wal-slug",
"https://google.com/wal",
None,
None,
&vec![],
None,
None,
None,
)
.unwrap();
}
// 2. Perform backup (this checkpoints and flushes WAL to DB files)
let backup_file = temp_dir.join("testuser-backup.tar.zst");
bzod::cli::backup_user::run(
"testuser".to_string(),
Some(backup_file.to_string_lossy().to_string()),
None,
config.clone(),
)
.await
.unwrap();
// 3. Restore user
bzod::cli::restore_user::run(
backup_file.to_string_lossy().to_string(),
None,
config.clone(),
)
.await
.unwrap();
// 4. Verify that restored databases are consistent and can successfully perform WAL writes
{
let conn = bzod::jobs::open_user_content_conn(&db, user_id).unwrap();
// SQLite integrity check
bzod::db::sqlite::integrity_check(&conn, "content").unwrap();
// Write a new url after restore to verify WAL write works
bzod::db::content::create_url_extended(
&conn,
"!new-wal-slug",
"https://google.com/new-wal",
None,
None,
&vec![],
None,
None,
None,
)
.unwrap();
let url = bzod::db::content::get_url_by_code(&conn, "!new-wal-slug")
.unwrap()
.unwrap();
assert_eq!(url.destination, "https://google.com/new-wal");
}
let _ = fs::remove_dir_all(&temp_dir);
}