BZOD v0.5.0 RC2: multi-user platform, dashboards, analytics, backups and validation
This commit is contained in:
1 parent
743502b183
commit
7dfb8c0f1b
100 files changed
+15588
-309
No files matched your search
Generated
+81
-1
@@ -453,7 +453,7 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "bzod"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"askama",
|
||||
@@ -481,6 +481,7 @@ dependencies = [
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
"zip",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -584,6 +585,24 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie_store"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
|
||||
dependencies = [
|
||||
"cookie",
|
||||
"document-features",
|
||||
"idna",
|
||||
"log",
|
||||
"publicsuffix",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"time",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
@@ -691,6 +710,15 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "document-features"
|
||||
version = "0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
|
||||
dependencies = [
|
||||
"litrs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dotenvy"
|
||||
version = "0.15.7"
|
||||
@@ -1419,6 +1447,12 @@ version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
@@ -1806,6 +1840,22 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psl-types"
|
||||
version = "2.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
|
||||
|
||||
[[package]]
|
||||
name = "publicsuffix"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
|
||||
dependencies = [
|
||||
"idna",
|
||||
"psl-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.29"
|
||||
@@ -2075,6 +2125,8 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"cookie_store",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
@@ -3453,6 +3505,34 @@ dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "0.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
|
||||
dependencies = [
|
||||
"zstd-safe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-safe"
|
||||
version = "7.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
|
||||
dependencies = [
|
||||
"zstd-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-sys"
|
||||
version = "2.0.16+zstd.1.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.1"
|
||||
|
||||
+6
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "bzod"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -20,7 +20,7 @@ askama = { version = "0.12" }
|
||||
argon2 = "0.5"
|
||||
sha2 = "0.10"
|
||||
rand = "0.8"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "cookies"] }
|
||||
tar = "0.4"
|
||||
flate2 = "1.0"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
@@ -31,4 +31,8 @@ qrcode = "0.14"
|
||||
image = "0.25"
|
||||
zip = { version = "2.1", default-features = false, features = ["deflate"] }
|
||||
futures-util = "0.3"
|
||||
zstd = "0.13"
|
||||
|
||||
[lints.clippy]
|
||||
let_unit_value = "allow"
|
||||
useless_vec = "allow"
|
||||
+42
-6
@@ -46,11 +46,47 @@ fn flush_batch(db: &Db, batch: &mut Vec<VisitRecord>) {
|
||||
return;
|
||||
}
|
||||
|
||||
info!("Flushing {} visits to analytics database", batch.len());
|
||||
let mut conn_lock = db.analytics.lock().unwrap();
|
||||
if let Err(e) = insert_visits_batch(&mut conn_lock, batch) {
|
||||
error!("Failed to write analytics batch to database: {:?}", e);
|
||||
} else {
|
||||
batch.clear();
|
||||
info!(
|
||||
"Flushing {} visits to user analytics databases",
|
||||
batch.len()
|
||||
);
|
||||
|
||||
// Group visits by owner_user_id
|
||||
let mut groups: std::collections::HashMap<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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::auth::session::authenticate_api_key;
|
||||
use crate::models::User;
|
||||
use crate::models::ApiActor;
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
extract::{FromRef, FromRequestParts},
|
||||
@@ -7,7 +7,7 @@ use axum::{
|
||||
};
|
||||
|
||||
// Extractor: Authenticate API requests using Bearer token
|
||||
pub struct ApiUser(pub User);
|
||||
pub struct ApiUser(pub ApiActor);
|
||||
|
||||
#[axum::async_trait]
|
||||
impl<S> FromRequestParts<S> for ApiUser
|
||||
@@ -25,9 +25,10 @@ where
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Missing Authorization header"))?;
|
||||
|
||||
let conn = app_state.admin_db.lock().unwrap();
|
||||
match authenticate_api_key(&conn, auth_header) {
|
||||
Ok(Some(user)) => Ok(ApiUser(user)),
|
||||
let admin_conn = app_state.admin_db.lock().unwrap();
|
||||
let users_conn = app_state.users_db.lock().unwrap();
|
||||
match authenticate_api_key(&admin_conn, &users_conn, auth_header) {
|
||||
Ok(Some(actor)) => Ok(ApiUser(actor)),
|
||||
Ok(None) => Err((StatusCode::UNAUTHORIZED, "Invalid API token")),
|
||||
Err(_) => Err((StatusCode::INTERNAL_SERVER_ERROR, "Database error")),
|
||||
}
|
||||
|
||||
+3
-1
@@ -6,4 +6,6 @@ pub mod session;
|
||||
pub use csrf::{generate_csrf_token, verify_csrf};
|
||||
pub use middleware::ApiUser;
|
||||
pub use password::{hash_password, verify_password, verify_sha256};
|
||||
pub use session::{authenticate_api_key, authenticate_session, generate_token};
|
||||
pub use session::{
|
||||
authenticate_admin_session, authenticate_api_key, authenticate_user_session, generate_token,
|
||||
};
|
||||
+255
-17
@@ -1,11 +1,11 @@
|
||||
use crate::db::admin::{
|
||||
get_api_key_by_hash, get_session, get_user_by_id, update_api_key_last_used,
|
||||
get_api_key_by_hash, get_user_by_id as get_admin_user_by_id, update_api_key_last_used,
|
||||
};
|
||||
use crate::models::User;
|
||||
use crate::models::{ApiActor, Session as AdminSession, TenantUser, User, UserSession};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use chrono::Utc;
|
||||
use rand::{thread_rng, RngCore};
|
||||
use rusqlite::Connection;
|
||||
use rusqlite::{Connection, OptionalExtension};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
// Generate a secure random token (hex-encoded)
|
||||
@@ -15,8 +15,8 @@ pub fn generate_token(bytes_len: usize) -> String {
|
||||
hex::encode(key)
|
||||
}
|
||||
|
||||
// Authenticate session from cookies
|
||||
pub fn authenticate_session(
|
||||
// Authenticate administrator session from cookies
|
||||
pub fn authenticate_admin_session(
|
||||
conn: &Connection,
|
||||
jar: &CookieJar,
|
||||
) -> Result<Option<(User, String)>, rusqlite::Error> {
|
||||
@@ -26,7 +26,33 @@ pub fn authenticate_session(
|
||||
};
|
||||
|
||||
let session_id = cookie.value();
|
||||
let session = match get_session(conn, session_id)? {
|
||||
let session_opt: Option<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,
|
||||
None => return Ok(None),
|
||||
};
|
||||
@@ -41,19 +67,171 @@ pub fn authenticate_session(
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Get user
|
||||
if let Some(user) = get_user_by_id(conn, &session.user_id)? {
|
||||
// Get user (status must be 'active' and account_type = 'admin')
|
||||
// If the session user_id looks numeric, bind as integer when querying users.db
|
||||
// Try the extended lookup but catch errors (e.g., missing columns in legacy admin DB)
|
||||
// Try the extended lookup; if it errors (legacy schema), perform a fallback lookup.
|
||||
let (user_opt, extended_failed) = match if let Ok(id_i64) = session.user_id.parse::<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)))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate API key from Authorization header
|
||||
// Authenticate API key/token from Authorization header (unified)
|
||||
pub fn authenticate_api_key(
|
||||
conn: &Connection,
|
||||
admin_conn: &Connection,
|
||||
users_conn: &Connection,
|
||||
auth_header: &str,
|
||||
) -> Result<Option<User>, rusqlite::Error> {
|
||||
) -> Result<Option<ApiActor>, rusqlite::Error> {
|
||||
if !auth_header.starts_with("Bearer ") {
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -68,13 +246,73 @@ pub fn authenticate_api_key(
|
||||
hasher.update(key.as_bytes());
|
||||
let hashed_key = hex::encode(hasher.finalize());
|
||||
|
||||
if let Some(api_key_rec) = get_api_key_by_hash(conn, &hashed_key)? {
|
||||
// Update last used timestamp
|
||||
update_api_key_last_used(conn, &api_key_rec.id)?;
|
||||
// 1. Check user API tokens in users.db
|
||||
let mut stmt = users_conn.prepare("SELECT user_id FROM api_tokens WHERE token_hash = ?1;")?;
|
||||
let user_id_opt: Option<i64> = stmt.query_row([&hashed_key], |row| row.get(0)).optional()?;
|
||||
|
||||
// Get user
|
||||
if let Some(user) = get_user_by_id(conn, &api_key_rec.user_id)? {
|
||||
return Ok(Some(user));
|
||||
if let Some(user_id) = user_id_opt {
|
||||
let mut stmt = users_conn.prepare(
|
||||
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
|
||||
FROM users WHERE id = ?1 AND status = 'active';"
|
||||
)?;
|
||||
let user_opt = stmt
|
||||
.query_row([user_id], |row| {
|
||||
Ok(TenantUser {
|
||||
id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
status: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
last_login: row.get(5)?,
|
||||
account_type: row.get(6)?,
|
||||
organization_id: row.get(7)?,
|
||||
metadata: row.get(8)?,
|
||||
})
|
||||
})
|
||||
.optional()?;
|
||||
|
||||
if let Some(user) = user_opt {
|
||||
return Ok(Some(ApiActor::User(user)));
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check admin system API keys in admin.db
|
||||
if let Some(api_key_rec) = get_api_key_by_hash(admin_conn, &hashed_key)? {
|
||||
// Update last used timestamp
|
||||
update_api_key_last_used(admin_conn, &api_key_rec.id)?;
|
||||
|
||||
// Get admin user from users.db (users_conn)
|
||||
// Try to interpret the api_key user_id as an integer referencing users.db
|
||||
if let Ok(user_id_i64) = api_key_rec.user_id.parse::<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,
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -32,8 +32,9 @@ pub async fn run(
|
||||
}
|
||||
|
||||
let hash = hash_password(&password).map_err(|e| e.to_string())?;
|
||||
let conn = db.admin.lock().unwrap();
|
||||
let u = crate::db::admin::create_user(&conn, &final_username, &hash)?;
|
||||
let conn = db.users.lock().unwrap();
|
||||
let u = crate::db::users::create_admin_user(&conn, &final_username, &hash)?;
|
||||
db.init_user_databases(u.id)?;
|
||||
info!(
|
||||
"Successfully created admin user: {} (ID: {})",
|
||||
u.username, u.id
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -11,6 +11,15 @@ pub mod shorten;
|
||||
pub mod stats;
|
||||
pub mod validate;
|
||||
|
||||
pub mod backup_user;
|
||||
pub mod create_user;
|
||||
pub mod delete_user;
|
||||
pub mod disable_user;
|
||||
pub mod enable_user;
|
||||
pub mod list_users;
|
||||
pub mod reset_password;
|
||||
pub mod restore_user;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "bzod")]
|
||||
#[command(about = "BZOD - Personal Redirector & Landing Page Platform")]
|
||||
@@ -91,4 +100,69 @@ pub enum Commands {
|
||||
#[arg(long)]
|
||||
data_dir: Option<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>,
|
||||
},
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -63,11 +63,29 @@ pub async fn run(
|
||||
crate::jobs::run_expiry_checker(expiry_db).await;
|
||||
});
|
||||
|
||||
let reconcile_db = db.clone();
|
||||
let reconcile_interval_hours = {
|
||||
let conn = db.system.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT value FROM settings WHERE key = 'quota_reconcile_interval_hours';",
|
||||
[],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.ok()
|
||||
.and_then(|val| val.parse::<u64>().ok())
|
||||
.unwrap_or(24)
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
crate::jobs::run_quota_reconciliation(reconcile_db, reconcile_interval_hours).await;
|
||||
});
|
||||
|
||||
let state = AppState {
|
||||
admin_db: db.admin.clone(),
|
||||
content_db: db.content.clone(),
|
||||
analytics_db: db.analytics.clone(),
|
||||
system_db: db.system.clone(),
|
||||
users_db: db.users.clone(),
|
||||
user_dbs: std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
|
||||
db: db.clone(),
|
||||
config: config.clone(),
|
||||
analytics_queue: queue,
|
||||
|
||||
+14
-4
@@ -71,10 +71,20 @@ pub fn create_session(
|
||||
) -> rusqlite::Result<Session> {
|
||||
let created_at = Utc::now().to_rfc3339();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);",
|
||||
params![session_id, user_id, expires_at_rfc3339, created_at],
|
||||
)?;
|
||||
// Bind `user_id` as integer when it appears to be numeric so that numeric
|
||||
// user IDs inserted into `users.db` keep the integer affinity and avoid
|
||||
// InvalidColumnType errors when read as i64 elsewhere.
|
||||
if let Ok(id_i64) = user_id.parse::<i64>() {
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);",
|
||||
params![session_id, id_i64, expires_at_rfc3339, created_at],
|
||||
)?;
|
||||
} else {
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);",
|
||||
params![session_id, user_id, expires_at_rfc3339, created_at],
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(Session {
|
||||
id: session_id.to_string(),
|
||||
|
||||
+8
-5
@@ -82,8 +82,8 @@ pub fn insert_visits_batch(conn: &mut Connection, records: &[VisitRecord]) -> ru
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
let mut stmt = tx.prepare(
|
||||
"INSERT INTO visits (id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10);"
|
||||
"INSERT INTO visits (id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code, owner_user_id)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11);"
|
||||
)?;
|
||||
|
||||
for r in records {
|
||||
@@ -97,7 +97,8 @@ pub fn insert_visits_batch(conn: &mut Connection, records: &[VisitRecord]) -> ru
|
||||
r.referer,
|
||||
r.accept_language,
|
||||
r.country,
|
||||
r.status_code
|
||||
r.status_code,
|
||||
r.owner_user_id
|
||||
])?;
|
||||
}
|
||||
}
|
||||
@@ -594,7 +595,7 @@ pub fn get_target_visits_paginated(
|
||||
date_from: Option<&str>,
|
||||
date_to: Option<&str>,
|
||||
) -> rusqlite::Result<Vec<VisitRecord>> {
|
||||
let mut sql = "SELECT id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code FROM visits WHERE target_type = ?1 AND target_id = ?2".to_string();
|
||||
let mut sql = "SELECT id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code, owner_user_id FROM visits WHERE target_type = ?1 AND target_id = ?2".to_string();
|
||||
let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![
|
||||
Box::new(target_type.to_string()),
|
||||
Box::new(target_id.to_string()),
|
||||
@@ -638,6 +639,7 @@ pub fn get_target_visits_paginated(
|
||||
accept_language: row.get("accept_language")?,
|
||||
country: row.get("country")?,
|
||||
status_code: row.get("status_code")?,
|
||||
owner_user_id: row.get("owner_user_id")?,
|
||||
})
|
||||
})?;
|
||||
|
||||
@@ -655,7 +657,7 @@ pub fn get_target_visits_all_in_memory(
|
||||
date_from: Option<&str>,
|
||||
date_to: Option<&str>,
|
||||
) -> rusqlite::Result<Vec<VisitRecord>> {
|
||||
let mut sql = "SELECT id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code FROM visits WHERE target_type = ?1 AND target_id = ?2".to_string();
|
||||
let mut sql = "SELECT id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code, owner_user_id FROM visits WHERE target_type = ?1 AND target_id = ?2".to_string();
|
||||
let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![
|
||||
Box::new(target_type.to_string()),
|
||||
Box::new(target_id.to_string()),
|
||||
@@ -693,6 +695,7 @@ pub fn get_target_visits_all_in_memory(
|
||||
accept_language: row.get("accept_language")?,
|
||||
country: row.get("country")?,
|
||||
status_code: row.get("status_code")?,
|
||||
owner_user_id: row.get("owner_user_id")?,
|
||||
})
|
||||
})?;
|
||||
|
||||
|
||||
+189
-5
@@ -111,10 +111,11 @@ pub fn print_migration_plan(
|
||||
// Migration definitions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub const ADMIN_MIGRATIONS: &[Migration] = &[Migration {
|
||||
version: 1,
|
||||
name: "initial_schema",
|
||||
sql: r#"
|
||||
pub const ADMIN_MIGRATIONS: &[Migration] = &[
|
||||
Migration {
|
||||
version: 1,
|
||||
name: "initial_schema",
|
||||
sql: r#"
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
@@ -156,7 +157,26 @@ pub const ADMIN_MIGRATIONS: &[Migration] = &[Migration {
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
"#,
|
||||
}];
|
||||
},
|
||||
Migration {
|
||||
version: 2,
|
||||
name: "remove_api_keys_fk",
|
||||
sql: r#"
|
||||
CREATE TABLE api_keys_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
key_hash TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
last_used_at TEXT
|
||||
);
|
||||
INSERT INTO api_keys_new (id, user_id, key_hash, name, created_at, last_used_at)
|
||||
SELECT id, user_id, key_hash, name, created_at, last_used_at FROM api_keys;
|
||||
DROP TABLE api_keys;
|
||||
ALTER TABLE api_keys_new RENAME TO api_keys;
|
||||
"#,
|
||||
},
|
||||
];
|
||||
|
||||
pub const CONTENT_MIGRATIONS: &[Migration] = &[
|
||||
Migration {
|
||||
@@ -315,6 +335,11 @@ pub const ANALYTICS_MIGRATIONS: &[Migration] = &[
|
||||
CREATE INDEX IF NOT EXISTS idx_qr_access_ts ON qr_access_log(timestamp);
|
||||
"#,
|
||||
},
|
||||
Migration {
|
||||
version: 3,
|
||||
name: "add_owner_user_id",
|
||||
sql: "ALTER TABLE visits ADD COLUMN owner_user_id INTEGER;",
|
||||
},
|
||||
];
|
||||
|
||||
pub const SYSTEM_MIGRATIONS: &[Migration] = &[
|
||||
@@ -384,4 +409,163 @@ pub const SYSTEM_MIGRATIONS: &[Migration] = &[
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_events(action);
|
||||
"#,
|
||||
},
|
||||
Migration {
|
||||
version: 3,
|
||||
name: "global_slugs_and_moderation",
|
||||
sql: r#"
|
||||
CREATE TABLE IF NOT EXISTS global_slugs (
|
||||
slug TEXT PRIMARY KEY,
|
||||
owner_user_id INTEGER NOT NULL,
|
||||
target_type TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
deleted_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_global_slugs_owner ON global_slugs(owner_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_global_slugs_status ON global_slugs(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_global_slugs_target ON global_slugs(target_type, target_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS moderation_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
timestamp TEXT NOT NULL,
|
||||
admin_username TEXT NOT NULL,
|
||||
target_user_id INTEGER NOT NULL,
|
||||
target_username TEXT,
|
||||
resource_type TEXT NOT NULL,
|
||||
resource_identifier TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
severity TEXT NOT NULL,
|
||||
reason TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS slug_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slug TEXT NOT NULL,
|
||||
old_owner_user_id INTEGER,
|
||||
new_owner_user_id INTEGER,
|
||||
action TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
admin_username TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reserved_slugs (
|
||||
slug TEXT PRIMARY KEY,
|
||||
reason TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Seed defaults
|
||||
INSERT OR IGNORE INTO schema_version (version, applied_at) VALUES (3, datetime('now'));
|
||||
|
||||
INSERT OR IGNORE INTO settings (key, value) VALUES ('soft_delete_retention_days', '30');
|
||||
INSERT OR IGNORE INTO settings (key, value) VALUES ('quota_reconcile_interval_hours', '24');
|
||||
INSERT OR IGNORE INTO settings (key, value) VALUES ('allow_registration', 'false');
|
||||
INSERT OR IGNORE INTO settings (key, value) VALUES ('maintenance_mode', 'false');
|
||||
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('admin', 'System route');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('login', 'System route');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('logout', 'System route');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('dashboard', 'System route');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('api', 'System route');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('docs', 'System route');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('assets', 'System route');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('static', 'System route');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('favicon.ico', 'System route');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('robots.txt', 'System route');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('health', 'System route');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('metrics', 'System route');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('install', 'System route');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('setup', 'System route');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('support', 'System route');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('help', 'System route');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('security', 'System route');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('abuse', 'System route');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('billing', 'System route');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('status', 'System route');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('legacy_admin', 'System reserved');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('administrator', 'System reserved');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('system', 'System reserved');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('root', 'System reserved');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('www', 'System reserved');
|
||||
"#,
|
||||
},
|
||||
];
|
||||
|
||||
pub const USERS_MIGRATIONS: &[Migration] = &[
|
||||
Migration {
|
||||
version: 1,
|
||||
name: "initial_schema",
|
||||
sql: r#"
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TEXT NOT NULL,
|
||||
last_login TEXT,
|
||||
account_type TEXT DEFAULT 'standard',
|
||||
organization_id INTEGER NULL,
|
||||
metadata TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS quotas (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
max_urls INTEGER DEFAULT 100,
|
||||
max_landings INTEGER DEFAULT 10,
|
||||
max_api_tokens INTEGER DEFAULT 5,
|
||||
max_storage_mb INTEGER DEFAULT 100,
|
||||
current_urls INTEGER DEFAULT 0,
|
||||
current_landings INTEGER DEFAULT 0,
|
||||
current_api_tokens INTEGER DEFAULT 0,
|
||||
current_storage_mb INTEGER DEFAULT 0,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
token_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS username_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
old_username TEXT NOT NULL,
|
||||
new_username TEXT NOT NULL,
|
||||
changed_at TEXT NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
"#,
|
||||
},
|
||||
Migration {
|
||||
version: 2,
|
||||
name: "repair_admin_account_type",
|
||||
sql: r#"
|
||||
UPDATE users
|
||||
SET account_type = 'admin'
|
||||
WHERE username = 'admin' AND account_type = 'standard';
|
||||
"#,
|
||||
},
|
||||
];
|
||||
+377
-46
@@ -1,6 +1,7 @@
|
||||
use crate::config::Config;
|
||||
use crate::db::migrations::{
|
||||
run_migrations, ADMIN_MIGRATIONS, ANALYTICS_MIGRATIONS, CONTENT_MIGRATIONS, SYSTEM_MIGRATIONS,
|
||||
USERS_MIGRATIONS,
|
||||
};
|
||||
use crate::db::sqlite::{enable_foreign_keys, enable_wal};
|
||||
use rusqlite::Connection;
|
||||
@@ -15,6 +16,7 @@ pub mod migrations;
|
||||
pub mod preview;
|
||||
pub mod qr;
|
||||
pub mod sqlite;
|
||||
pub mod users;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Db {
|
||||
@@ -22,70 +24,88 @@ pub struct Db {
|
||||
pub content: Arc<Mutex<Connection>>,
|
||||
pub analytics: Arc<Mutex<Connection>>,
|
||||
pub system: Arc<Mutex<Connection>>,
|
||||
pub users: Arc<Mutex<Connection>>,
|
||||
pub data_dir: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub fn init(config: &Config) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
use chrono::Utc;
|
||||
use tracing::info;
|
||||
|
||||
// Ensure data directory exists
|
||||
if !config.data_dir.exists() {
|
||||
fs::create_dir_all(&config.data_dir)?;
|
||||
}
|
||||
|
||||
let admin_path = config.data_dir.join("admin.db");
|
||||
let content_path = config.data_dir.join("content.db");
|
||||
let analytics_path = config.data_dir.join("analytics.db");
|
||||
let system_path = config.data_dir.join("system.db");
|
||||
let admin_dir = config.data_dir.join("admin");
|
||||
let users_dir = config.data_dir.join("users");
|
||||
fs::create_dir_all(&admin_dir)?;
|
||||
fs::create_dir_all(&users_dir)?;
|
||||
|
||||
use tracing::info;
|
||||
// Automated Legacy Migration: check if legacy files are at the root
|
||||
let legacy_admin_db = config.data_dir.join("admin.db");
|
||||
let legacy_content_db = config.data_dir.join("content.db");
|
||||
let legacy_analytics_db = config.data_dir.join("analytics.db");
|
||||
|
||||
// 1. If legacy admin.db exists at root, move admin/system DBs to config.data_dir/admin/
|
||||
if legacy_admin_db.exists() {
|
||||
info!("Legacy admin.db found at root. Moving administrative databases to admin/ subfolder...");
|
||||
let files = vec![
|
||||
"admin.db",
|
||||
"admin.db-wal",
|
||||
"admin.db-shm",
|
||||
"system.db",
|
||||
"system.db-wal",
|
||||
"system.db-shm",
|
||||
];
|
||||
for f in files {
|
||||
let src = config.data_dir.join(f);
|
||||
if src.exists() {
|
||||
let dst = admin_dir.join(f);
|
||||
let _ = fs::rename(&src, &dst);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let admin_path = admin_dir.join("admin.db");
|
||||
let system_path = admin_dir.join("system.db");
|
||||
let users_db_path = admin_dir.join("users.db");
|
||||
|
||||
info!("Opening admin.db");
|
||||
let mut admin_conn = Connection::open(admin_path)?;
|
||||
info!("Opening content.db");
|
||||
let mut content_conn = Connection::open(content_path)?;
|
||||
info!("Opening analytics.db");
|
||||
let mut analytics_conn = Connection::open(analytics_path)?;
|
||||
info!("Opening system.db");
|
||||
let mut system_conn = Connection::open(system_path)?;
|
||||
info!("Opening users.db");
|
||||
let mut users_conn = Connection::open(users_db_path)?;
|
||||
|
||||
// Enable WAL mode for better concurrency and write performance
|
||||
info!(database = "admin", "Enabling WAL mode on admin.db");
|
||||
enable_wal(&admin_conn, "admin")?;
|
||||
info!(database = "content", "Enabling WAL mode on content.db");
|
||||
enable_wal(&content_conn, "content")?;
|
||||
info!(database = "analytics", "Enabling WAL mode on analytics.db");
|
||||
enable_wal(&analytics_conn, "analytics")?;
|
||||
info!(database = "system", "Enabling WAL mode on system.db");
|
||||
enable_wal(&system_conn, "system")?;
|
||||
enable_wal(&users_conn, "users")?;
|
||||
|
||||
// Enable foreign key support
|
||||
info!(
|
||||
database = "admin",
|
||||
"Enabling foreign key enforcement on admin.db"
|
||||
);
|
||||
enable_foreign_keys(&admin_conn, "admin")?;
|
||||
info!(
|
||||
database = "content",
|
||||
"Enabling foreign key enforcement on content.db"
|
||||
);
|
||||
enable_foreign_keys(&content_conn, "content")?;
|
||||
info!(
|
||||
database = "analytics",
|
||||
"Enabling foreign key enforcement on analytics.db"
|
||||
);
|
||||
enable_foreign_keys(&analytics_conn, "analytics")?;
|
||||
info!(
|
||||
database = "system",
|
||||
"Enabling foreign key enforcement on system.db"
|
||||
);
|
||||
enable_foreign_keys(&system_conn, "system")?;
|
||||
enable_foreign_keys(&users_conn, "users")?;
|
||||
|
||||
// 1. Run migrations for system.db first, as it receives secondary audit records
|
||||
// Run migrations for system.db first
|
||||
info!("Running system migrations");
|
||||
run_migrations(&mut system_conn, "system", SYSTEM_MIGRATIONS, None)?;
|
||||
|
||||
let system_arc = Arc::new(Mutex::new(system_conn));
|
||||
|
||||
// 2. Run migrations for other databases with system.db logging
|
||||
// Pre-migration detection of admin account repair
|
||||
let repair_needed = {
|
||||
let stmt = users_conn.prepare(
|
||||
"SELECT EXISTS(SELECT 1 FROM users WHERE username = 'admin' AND account_type = 'standard');"
|
||||
);
|
||||
match stmt {
|
||||
Ok(mut s) => s
|
||||
.query_row([], |row| row.get::<_, bool>(0))
|
||||
.unwrap_or(false),
|
||||
Err(_) => false,
|
||||
}
|
||||
};
|
||||
|
||||
// Run migrations for admin.db and users.db
|
||||
info!("Running admin migrations");
|
||||
run_migrations(
|
||||
&mut admin_conn,
|
||||
@@ -93,14 +113,131 @@ impl Db {
|
||||
ADMIN_MIGRATIONS,
|
||||
Some(&system_arc),
|
||||
)?;
|
||||
info!("Running content migrations");
|
||||
info!("Running users migrations");
|
||||
run_migrations(
|
||||
&mut users_conn,
|
||||
"users",
|
||||
USERS_MIGRATIONS,
|
||||
Some(&system_arc),
|
||||
)?;
|
||||
|
||||
// Post-migration: audit log if repaired
|
||||
if repair_needed {
|
||||
let admin_is_now_admin: bool = users_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM users WHERE username = 'admin' AND account_type = 'admin');",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
if admin_is_now_admin {
|
||||
let system_conn = system_arc.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
"admin",
|
||||
"migration_repair",
|
||||
"users",
|
||||
"admin",
|
||||
Some("Repaired standard account type to admin"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up expired sessions from users.db on startup
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let _ = users_conn.execute("DELETE FROM sessions WHERE expires_at < ?1;", [now]);
|
||||
|
||||
// 2. If legacy content.db/analytics.db exists, move them to users/1/ (for legacy_admin)
|
||||
let legacy_migration_needed = legacy_content_db.exists() || legacy_analytics_db.exists();
|
||||
|
||||
// Ensure legacy_admin (user ID 1) exists in users.db
|
||||
let legacy_admin_id = 1i64;
|
||||
let legacy_admin_exists: bool = users_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
|
||||
[legacy_admin_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
if !legacy_admin_exists {
|
||||
// Get copied administrator password hash
|
||||
let admin_password_hash: String = admin_conn
|
||||
.query_row(
|
||||
"SELECT password_hash FROM users ORDER BY created_at ASC LIMIT 1;",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or_else(|_| {
|
||||
// If admin_db is empty, hash a default password
|
||||
crate::auth::password::hash_password("legacy_admin_pass").unwrap_or_default()
|
||||
});
|
||||
|
||||
let now = Utc::now().to_rfc3339();
|
||||
users_conn.execute(
|
||||
"INSERT INTO users (id, username, password_hash, status, created_at, account_type)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6);",
|
||||
rusqlite::params![
|
||||
legacy_admin_id,
|
||||
"legacy_admin",
|
||||
admin_password_hash,
|
||||
"disabled",
|
||||
now,
|
||||
"system"
|
||||
],
|
||||
)?;
|
||||
|
||||
// Seed quotas
|
||||
users_conn.execute(
|
||||
"INSERT INTO quotas (user_id) VALUES (?1);",
|
||||
[legacy_admin_id],
|
||||
)?;
|
||||
}
|
||||
|
||||
let legacy_user_dir = users_dir.join(legacy_admin_id.to_string());
|
||||
fs::create_dir_all(&legacy_user_dir)?;
|
||||
|
||||
if legacy_content_db.exists() || legacy_analytics_db.exists() {
|
||||
info!("Legacy content/analytics databases found at root. Moving to user ID 1 directory...");
|
||||
let content_files = vec!["content.db", "content.db-wal", "content.db-shm"];
|
||||
for f in content_files {
|
||||
let src = config.data_dir.join(f);
|
||||
if src.exists() {
|
||||
let dst = legacy_user_dir.join(f);
|
||||
let _ = fs::rename(&src, &dst);
|
||||
}
|
||||
}
|
||||
let analytics_files = vec!["analytics.db", "analytics.db-wal", "analytics.db-shm"];
|
||||
for f in analytics_files {
|
||||
let src = config.data_dir.join(f);
|
||||
if src.exists() {
|
||||
let dst = legacy_user_dir.join(f);
|
||||
let _ = fs::rename(&src, &dst);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Open the legacy_admin databases (user ID 1) as db.content and db.analytics
|
||||
let content_path = legacy_user_dir.join("content.db");
|
||||
let analytics_path = legacy_user_dir.join("analytics.db");
|
||||
|
||||
let mut content_conn = Connection::open(content_path)?;
|
||||
let mut analytics_conn = Connection::open(analytics_path)?;
|
||||
|
||||
enable_wal(&content_conn, "content")?;
|
||||
enable_wal(&analytics_conn, "analytics")?;
|
||||
|
||||
enable_foreign_keys(&content_conn, "content")?;
|
||||
enable_foreign_keys(&analytics_conn, "analytics")?;
|
||||
|
||||
// Run migrations for content.db and analytics.db
|
||||
run_migrations(
|
||||
&mut content_conn,
|
||||
"content",
|
||||
CONTENT_MIGRATIONS,
|
||||
Some(&system_arc),
|
||||
)?;
|
||||
info!("Running analytics migrations");
|
||||
run_migrations(
|
||||
&mut analytics_conn,
|
||||
"analytics",
|
||||
@@ -108,26 +245,220 @@ impl Db {
|
||||
Some(&system_arc),
|
||||
)?;
|
||||
|
||||
Ok(Self {
|
||||
// If we just migrated legacy content, populate the global_slugs table in system.db
|
||||
if legacy_migration_needed {
|
||||
info!("Populating global slug index with legacy content...");
|
||||
let mut sys_lock = system_arc.lock().unwrap();
|
||||
let tx = sys_lock.transaction()?;
|
||||
|
||||
// Extract urls from content.db and insert into global_slugs
|
||||
{
|
||||
let mut stmt =
|
||||
content_conn.prepare("SELECT code, id, created_at, status FROM urls;")?;
|
||||
let mut rows = stmt.query([])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let slug: String = row.get(0)?;
|
||||
let target_id: String = row.get(1)?;
|
||||
let created_at: String = row.get(2)?;
|
||||
let status: String = row.get(3)?;
|
||||
let global_status = if status == "dead" {
|
||||
"disabled"
|
||||
} else {
|
||||
"active"
|
||||
};
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let _ = tx.execute(
|
||||
"INSERT OR IGNORE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
|
||||
rusqlite::params![slug, legacy_admin_id, "url", target_id, created_at, now, global_status],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract landing pages from content.db and insert into global_slugs
|
||||
{
|
||||
let mut stmt = content_conn
|
||||
.prepare("SELECT code, id, created_at, state FROM landing_pages;")?;
|
||||
let mut rows = stmt.query([])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let slug: String = row.get(0)?;
|
||||
let target_id: String = row.get(1)?;
|
||||
let created_at: String = row.get(2)?;
|
||||
let state: String = row.get(3)?;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let status = if state == "published" {
|
||||
"active"
|
||||
} else {
|
||||
"disabled"
|
||||
};
|
||||
|
||||
let _ = tx.execute(
|
||||
"INSERT OR IGNORE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
|
||||
rusqlite::params![slug, legacy_admin_id, "page", target_id, created_at, now, status],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit()?;
|
||||
info!("Global slug index populated successfully.");
|
||||
}
|
||||
|
||||
let db = Self {
|
||||
admin: Arc::new(Mutex::new(admin_conn)),
|
||||
content: Arc::new(Mutex::new(content_conn)),
|
||||
analytics: Arc::new(Mutex::new(analytics_conn)),
|
||||
system: system_arc,
|
||||
})
|
||||
users: Arc::new(Mutex::new(users_conn)),
|
||||
data_dir: config.data_dir.clone(),
|
||||
};
|
||||
|
||||
let _ = db.reconcile_global_slugs(config);
|
||||
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
pub fn compact(&self) -> Result<(), rusqlite::Error> {
|
||||
let admin = self.admin.lock().unwrap();
|
||||
admin.execute("VACUUM;", [])?;
|
||||
let _ = admin.execute("VACUUM;", []);
|
||||
|
||||
let content = self.content.lock().unwrap();
|
||||
content.execute("VACUUM;", [])?;
|
||||
let _ = content.execute("VACUUM;", []);
|
||||
|
||||
let analytics = self.analytics.lock().unwrap();
|
||||
analytics.execute("VACUUM;", [])?;
|
||||
let _ = analytics.execute("VACUUM;", []);
|
||||
|
||||
let system = self.system.lock().unwrap();
|
||||
system.execute("VACUUM;", [])?;
|
||||
let _ = system.execute("VACUUM;", []);
|
||||
|
||||
let users = self.users.lock().unwrap();
|
||||
let _ = users.execute("VACUUM;", []);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn init_user_databases(&self, user_id: i64) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let user_dir = self.data_dir.join("users").join(user_id.to_string());
|
||||
fs::create_dir_all(&user_dir)?;
|
||||
|
||||
let content_path = user_dir.join("content.db");
|
||||
let analytics_path = user_dir.join("analytics.db");
|
||||
let profile_path = user_dir.join("profile.db");
|
||||
|
||||
let mut content_conn = Connection::open(content_path)?;
|
||||
let mut analytics_conn = Connection::open(analytics_path)?;
|
||||
let profile_conn = Connection::open(profile_path)?;
|
||||
|
||||
enable_wal(&content_conn, "content")?;
|
||||
enable_wal(&analytics_conn, "analytics")?;
|
||||
enable_wal(&profile_conn, "profile")?;
|
||||
|
||||
enable_foreign_keys(&content_conn, "content")?;
|
||||
enable_foreign_keys(&analytics_conn, "analytics")?;
|
||||
enable_foreign_keys(&profile_conn, "profile")?;
|
||||
|
||||
run_migrations(
|
||||
&mut content_conn,
|
||||
"content",
|
||||
CONTENT_MIGRATIONS,
|
||||
Some(&self.system),
|
||||
)?;
|
||||
run_migrations(
|
||||
&mut analytics_conn,
|
||||
"analytics",
|
||||
ANALYTICS_MIGRATIONS,
|
||||
Some(&self.system),
|
||||
)?;
|
||||
|
||||
profile_conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);",
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn reconcile_global_slugs(
|
||||
&self,
|
||||
config: &Config,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use chrono::Utc;
|
||||
|
||||
let system_conn = self.system.lock().unwrap();
|
||||
let users_conn = self.users.lock().unwrap();
|
||||
|
||||
// Get all user IDs
|
||||
let mut stmt = users_conn.prepare("SELECT id FROM users;")?;
|
||||
let mut rows = stmt.query([])?;
|
||||
let mut user_ids = vec![1i64]; // Start with legacy admin
|
||||
while let Some(row) = rows.next()? {
|
||||
user_ids.push(row.get(0)?);
|
||||
}
|
||||
drop(rows);
|
||||
drop(stmt);
|
||||
|
||||
for user_id in user_ids {
|
||||
let user_dir = config.data_dir.join("users").join(user_id.to_string());
|
||||
let content_path = if user_id == 1 {
|
||||
config.data_dir.join("content.db") // legacy admin content db path
|
||||
} else {
|
||||
user_dir.join("content.db")
|
||||
};
|
||||
|
||||
if content_path.exists() {
|
||||
let content_conn = Connection::open(&content_path)?;
|
||||
|
||||
// Sync URLs
|
||||
let mut stmt =
|
||||
content_conn.prepare("SELECT code, id, created_at, status FROM urls;")?;
|
||||
let mut rows = stmt.query([])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let code: String = row.get(0)?;
|
||||
let target_id: String = row.get(1)?;
|
||||
let created_at: String = row.get(2)?;
|
||||
let status: String = row.get(3)?;
|
||||
let global_status = if status == "dead" {
|
||||
"disabled"
|
||||
} else {
|
||||
"active"
|
||||
};
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let _ = system_conn.execute(
|
||||
"INSERT OR IGNORE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
|
||||
rusqlite::params![code, user_id, "url", target_id, created_at, now, global_status],
|
||||
);
|
||||
}
|
||||
|
||||
// Sync Landing Pages
|
||||
let mut stmt = content_conn
|
||||
.prepare("SELECT code, id, created_at, state FROM landing_pages;")?;
|
||||
let mut rows = stmt.query([])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let code: String = row.get(0)?;
|
||||
let target_id: String = row.get(1)?;
|
||||
let created_at: String = row.get(2)?;
|
||||
let state: String = row.get(3)?;
|
||||
let global_status = if state == "published" {
|
||||
"active"
|
||||
} else {
|
||||
"disabled"
|
||||
};
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let _ = system_conn.execute(
|
||||
"INSERT OR IGNORE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
|
||||
rusqlite::params![code, user_id, "page", target_id, created_at, now, global_status],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+545
@@ -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(())
|
||||
}
|
||||
+32
-15
@@ -10,23 +10,43 @@ pub async fn run_aggregator(db: Db, interval_mins: u64) {
|
||||
tokio::time::sleep(Duration::from_secs(interval_mins * 60)).await;
|
||||
info!("Running background analytics aggregator...");
|
||||
|
||||
let user_ids: Vec<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");
|
||||
match perform_aggregation(&db).await {
|
||||
Ok(_) => log_job_end(&db.system, &job_id, "success", None),
|
||||
Err(e) => {
|
||||
let err_str = e.to_string();
|
||||
error!("Error performing aggregation: {}", err_str);
|
||||
log_job_end(&db.system, &job_id, "failed", Some(&err_str));
|
||||
let mut failed = false;
|
||||
let mut err_msg = None;
|
||||
|
||||
for user_id in user_ids {
|
||||
if let Err(e) = perform_aggregation(&db, user_id).await {
|
||||
failed = true;
|
||||
err_msg = Some(e.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if failed {
|
||||
let err_str = err_msg.unwrap_or_else(|| "Unknown error".to_string());
|
||||
error!("Error performing aggregation: {}", err_str);
|
||||
log_job_end(&db.system, &job_id, "failed", Some(&err_str));
|
||||
} else {
|
||||
log_job_end(&db.system, &job_id, "success", None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn perform_aggregation(db: &Db) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let date_range = {
|
||||
let conn = db.analytics.lock().unwrap();
|
||||
crate::db::analytics::get_visits_date_range(&conn)?
|
||||
};
|
||||
pub async fn perform_aggregation(db: &Db, user_id: i64) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut conn = super::open_user_analytics_conn(db, user_id)?;
|
||||
let date_range = crate::db::analytics::get_visits_date_range(&conn)?;
|
||||
|
||||
if let Some((min_date, max_date)) = date_range {
|
||||
let min = chrono::NaiveDate::parse_from_str(&min_date, "%Y-%m-%d")?;
|
||||
@@ -35,10 +55,7 @@ pub async fn perform_aggregation(db: &Db) -> Result<(), Box<dyn std::error::Erro
|
||||
let mut curr = min;
|
||||
while curr <= max {
|
||||
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 {
|
||||
break;
|
||||
}
|
||||
|
||||
+24
-6
@@ -64,6 +64,22 @@ pub async fn perform_backup(
|
||||
if let Ok(conn) = db.system.lock() {
|
||||
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 tar_name = format!("{}-bzod-backup.tar.gz", date_str);
|
||||
@@ -73,12 +89,14 @@ pub async fn perform_backup(
|
||||
let enc = GzEncoder::new(file, Compression::default());
|
||||
let mut tar = Builder::new(enc);
|
||||
|
||||
let files = vec!["admin.db", "content.db", "analytics.db", "system.db"];
|
||||
for f in files {
|
||||
let db_file = config.data_dir.join(f);
|
||||
if db_file.exists() {
|
||||
tar.append_path_with_name(&db_file, f)?;
|
||||
}
|
||||
let admin_dir = config.data_dir.join("admin");
|
||||
if admin_dir.exists() {
|
||||
tar.append_dir_all("admin", &admin_dir)?;
|
||||
}
|
||||
|
||||
let users_dir = config.data_dir.join("users");
|
||||
if users_dir.exists() {
|
||||
tar.append_dir_all("users", &users_dir)?;
|
||||
}
|
||||
|
||||
tar.into_inner()?.finish()?;
|
||||
|
||||
+24
-5
@@ -10,13 +10,32 @@ pub async fn run_expiry_checker(db: Db) {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(60)).await;
|
||||
|
||||
let count = {
|
||||
let conn = db.content.lock().unwrap();
|
||||
crate::db::content::expire_urls(&conn).unwrap_or(0)
|
||||
let user_ids: Vec<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()
|
||||
};
|
||||
|
||||
if count > 0 {
|
||||
info!(expired_count = count, "Expired URLs marked");
|
||||
let mut total_expired = 0;
|
||||
for user_id in user_ids {
|
||||
if let Ok(conn) = super::open_user_content_conn(&db, user_id) {
|
||||
let count = crate::db::content::expire_urls(&conn).unwrap_or(0);
|
||||
total_expired += count;
|
||||
}
|
||||
}
|
||||
|
||||
if total_expired > 0 {
|
||||
info!(
|
||||
expired_count = total_expired,
|
||||
"Expired URLs marked across users"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
-30
@@ -37,40 +37,50 @@ pub async fn perform_link_check(
|
||||
db: &Db,
|
||||
client: &Client,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let urls = {
|
||||
let conn = db.content.lock().unwrap();
|
||||
crate::db::content::list_urls_for_health_check(&conn)?
|
||||
let user_ids: Vec<i64> = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
let mut stmt = conn.prepare("SELECT id FROM users;")?;
|
||||
let rows = stmt.query_map([], |row| row.get(0))?;
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
for (id, dest) in urls {
|
||||
let (status, detail_status, status_code, latency_ms, err_msg) =
|
||||
check_url_health(client, &dest).await;
|
||||
{
|
||||
let conn = db.content.lock().unwrap();
|
||||
crate::db::content::update_url_health_extended(
|
||||
&conn,
|
||||
&id,
|
||||
&status,
|
||||
&detail_status,
|
||||
Some(latency_ms),
|
||||
)?;
|
||||
}
|
||||
for user_id in user_ids {
|
||||
let conn = match super::open_user_content_conn(db, user_id) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
// Log to system.db.health_checks
|
||||
{
|
||||
let conn = db.system.lock().unwrap();
|
||||
let hc_id = Uuid::new_v4().to_string();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let is_healthy = if status == "healthy" { 1 } else { 0 };
|
||||
let _ = conn.execute(
|
||||
"INSERT INTO health_checks (id, object_type, object_id, checked_at, status_code, error_message, is_healthy)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
|
||||
params![hc_id, "url", id, now, status_code, err_msg, is_healthy],
|
||||
);
|
||||
}
|
||||
let urls = crate::db::content::list_urls_for_health_check(&conn)?;
|
||||
|
||||
// Rate limiting sleep between external requests
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
for (id, dest) in urls {
|
||||
let (status, detail_status, status_code, latency_ms, err_msg) =
|
||||
check_url_health(client, &dest).await;
|
||||
{
|
||||
crate::db::content::update_url_health_extended(
|
||||
&conn,
|
||||
&id,
|
||||
&status,
|
||||
&detail_status,
|
||||
Some(latency_ms),
|
||||
)?;
|
||||
}
|
||||
|
||||
// Log to system.db.health_checks
|
||||
{
|
||||
let sys_conn = db.system.lock().unwrap();
|
||||
let hc_id = Uuid::new_v4().to_string();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let is_healthy = if status == "healthy" { 1 } else { 0 };
|
||||
let _ = sys_conn.execute(
|
||||
"INSERT INTO health_checks (id, object_type, object_id, checked_at, status_code, error_message, is_healthy)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
|
||||
params![hc_id, "url", id, now, status_code, err_msg, is_healthy],
|
||||
);
|
||||
}
|
||||
|
||||
// Rate limiting sleep between external requests
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::db::Db;
|
||||
use chrono::Utc;
|
||||
use rusqlite::{params, Connection};
|
||||
use std::sync::Mutex;
|
||||
@@ -35,3 +36,38 @@ pub fn log_job_end(conn: &Mutex<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)
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
+42
-10
@@ -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;
|
||||
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 conn = db.analytics.lock().unwrap();
|
||||
match crate::db::analytics::retention_cleanup(&conn, retention_days) {
|
||||
Ok(count) => {
|
||||
info!("Cleaned up {} expired visits from database", count);
|
||||
log_job_end(&db.system, &job_id, "success", None);
|
||||
}
|
||||
Err(e) => {
|
||||
let err_str = e.to_string();
|
||||
error!("Error running retention cleaner: {:?}", err_str);
|
||||
log_job_end(&db.system, &job_id, "failed", Some(&err_str));
|
||||
let mut total_cleaned = 0;
|
||||
let mut failed = false;
|
||||
let mut err_msg = None;
|
||||
|
||||
for user_id in user_ids {
|
||||
match super::open_user_analytics_conn(&db, user_id) {
|
||||
Ok(conn) => match crate::db::analytics::retention_cleanup(&conn, retention_days) {
|
||||
Ok(count) => total_cleaned += count,
|
||||
Err(e) => {
|
||||
failed = true;
|
||||
err_msg = Some(e.to_string());
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
failed = true;
|
||||
err_msg = Some(e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if failed {
|
||||
let err_str = err_msg.unwrap_or_else(|| "Unknown error".to_string());
|
||||
error!("Error running retention cleaner: {:?}", err_str);
|
||||
log_job_end(&db.system, &job_id, "failed", Some(&err_str));
|
||||
} else {
|
||||
info!(
|
||||
"Cleaned up {} expired visits across all user databases",
|
||||
total_cleaned
|
||||
);
|
||||
log_job_end(&db.system, &job_id, "success", None);
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
@@ -54,6 +54,46 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
Commands::Expand { code, data_dir } => {
|
||||
bzod::cli::expand::run(code, data_dir, config).await?;
|
||||
}
|
||||
Commands::CreateUser {
|
||||
username,
|
||||
password,
|
||||
data_dir,
|
||||
} => {
|
||||
bzod::cli::create_user::run(username, password, data_dir, config).await?;
|
||||
}
|
||||
Commands::DeleteUser {
|
||||
user_id,
|
||||
force,
|
||||
data_dir,
|
||||
} => {
|
||||
bzod::cli::delete_user::run(user_id, force, data_dir, config).await?;
|
||||
}
|
||||
Commands::DisableUser { user_id, data_dir } => {
|
||||
bzod::cli::disable_user::run(user_id, data_dir, config).await?;
|
||||
}
|
||||
Commands::EnableUser { user_id, data_dir } => {
|
||||
bzod::cli::enable_user::run(user_id, data_dir, config).await?;
|
||||
}
|
||||
Commands::ResetPassword {
|
||||
user_id,
|
||||
password,
|
||||
data_dir,
|
||||
} => {
|
||||
bzod::cli::reset_password::run(user_id, password, data_dir, config).await?;
|
||||
}
|
||||
Commands::ListUsers { data_dir } => {
|
||||
bzod::cli::list_users::run(data_dir, config).await?;
|
||||
}
|
||||
Commands::BackupUser {
|
||||
username,
|
||||
out,
|
||||
data_dir,
|
||||
} => {
|
||||
bzod::cli::backup_user::run(username, out, data_dir, config).await?;
|
||||
}
|
||||
Commands::RestoreUser { file, data_dir } => {
|
||||
bzod::cli::restore_user::run(file, data_dir, config).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
+4
-1
@@ -9,5 +9,8 @@ pub use api_key::ApiKey;
|
||||
pub use audit::AuditLog;
|
||||
pub use page::LandingPage;
|
||||
pub use url::{AuditEvent, LinkPreview, QrCode, Url};
|
||||
pub use user::{Session, User};
|
||||
pub use user::{
|
||||
AccountType, ApiActor, ModerationSeverity, Session, SlugStatus, TenantUser, User, UserApiToken,
|
||||
UserQuotas, UserSession, UsernameHistory,
|
||||
};
|
||||
pub use visit::{SummaryEntry, VisitRecord};
|
||||
@@ -15,3 +15,193 @@ pub struct Session {
|
||||
pub expires_at: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct TenantUser {
|
||||
pub id: i64,
|
||||
pub username: String,
|
||||
pub password_hash: String,
|
||||
pub status: String, // 'active', 'disabled', 'suspended', 'pending', 'deleted'
|
||||
pub created_at: String,
|
||||
pub last_login: Option<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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ pub struct VisitRecord {
|
||||
pub accept_language: String,
|
||||
pub country: String,
|
||||
pub status_code: u16,
|
||||
pub owner_user_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
|
||||
@@ -2,15 +2,25 @@ use crate::analytics::queue::AnalyticsQueue;
|
||||
use crate::config::Config;
|
||||
use crate::db::Db;
|
||||
use rusqlite::Connection;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct UserDbs {
|
||||
pub content: Arc<Mutex<Connection>>,
|
||||
pub analytics: Arc<Mutex<Connection>>,
|
||||
pub profile: Arc<Mutex<Connection>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub admin_db: Arc<Mutex<Connection>>,
|
||||
pub content_db: Arc<Mutex<Connection>>,
|
||||
pub analytics_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 config: Config,
|
||||
pub analytics_queue: AnalyticsQueue,
|
||||
@@ -18,11 +28,71 @@ pub struct AppState {
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn get_user_dbs(&self, user_id: i64) -> Result<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> {
|
||||
self.admin_db.lock().unwrap().execute("VACUUM;", [])?;
|
||||
self.content_db.lock().unwrap().execute("VACUUM;", [])?;
|
||||
self.analytics_db.lock().unwrap().execute("VACUUM;", [])?;
|
||||
self.system_db.lock().unwrap().execute("VACUUM;", [])?;
|
||||
self.users_db.lock().unwrap().execute("VACUUM;", [])?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+343
-6
@@ -4,25 +4,38 @@ pub mod pages;
|
||||
pub mod settings;
|
||||
pub mod stats;
|
||||
pub mod urls;
|
||||
pub mod user_dashboard;
|
||||
pub mod user_pages;
|
||||
pub mod user_settings;
|
||||
pub mod user_urls;
|
||||
pub mod users;
|
||||
|
||||
pub use analytics::{PageAnalyticsTemplate, UrlAnalyticsTemplate, VisitorLogEntry};
|
||||
pub use dashboard::DashboardTemplate;
|
||||
pub use pages::PagesTemplate;
|
||||
pub use settings::SettingsTemplate;
|
||||
pub use stats::{AuditTemplate, StatusTemplate};
|
||||
pub use urls::UrlsTemplate;
|
||||
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
pub use dashboard::DashboardTemplate;
|
||||
pub use pages::PagesTemplate;
|
||||
pub use settings::SettingsTemplate;
|
||||
pub use stats::{AuditTemplate, StatusTemplate, UserAuditTemplate, UserStatusTemplate};
|
||||
pub use urls::UrlsTemplate;
|
||||
pub use user_dashboard::UserDashboardTemplate;
|
||||
pub use user_pages::UserPagesTemplate;
|
||||
pub use user_settings::UserSettingsTemplate;
|
||||
pub use user_urls::UserUrlsTemplate;
|
||||
pub use users::UsersTemplate;
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "login.html")]
|
||||
pub struct LoginTemplate {
|
||||
pub error: Option<String>,
|
||||
pub csrf_token: String,
|
||||
pub action: String,
|
||||
pub title: String,
|
||||
pub subtitle: String,
|
||||
pub button_text: String,
|
||||
}
|
||||
|
||||
impl IntoResponse for LoginTemplate {
|
||||
@@ -81,3 +94,327 @@ impl IntoResponse for PreviewTemplate {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "users_new.html")]
|
||||
pub struct UsersNewTemplate {
|
||||
pub admin_username: String,
|
||||
pub csrf_token: String,
|
||||
pub success: Option<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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
+3807
-75
File diff suppressed because it is too large.
Load diff
+14
-14
@@ -167,7 +167,7 @@ pub async fn api_create_url(
|
||||
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
|
||||
let _ = write_audit_log(
|
||||
&state.admin_db.lock().unwrap(),
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"URL_CREATION",
|
||||
Some("url"),
|
||||
Some(&url.id),
|
||||
@@ -180,7 +180,7 @@ pub async fn api_create_url(
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"URL_CREATION",
|
||||
"url",
|
||||
&url.id,
|
||||
@@ -314,7 +314,7 @@ pub async fn api_update_url(
|
||||
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
|
||||
let _ = write_audit_log(
|
||||
&state.admin_db.lock().unwrap(),
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"URL_UPDATE",
|
||||
Some("url"),
|
||||
Some(&uuid),
|
||||
@@ -327,7 +327,7 @@ pub async fn api_update_url(
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"URL_UPDATE",
|
||||
"url",
|
||||
&uuid,
|
||||
@@ -369,7 +369,7 @@ pub async fn api_delete_url(
|
||||
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
|
||||
let _ = write_audit_log(
|
||||
&state.admin_db.lock().unwrap(),
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"URL_DELETION",
|
||||
Some("url"),
|
||||
Some(&uuid),
|
||||
@@ -382,7 +382,7 @@ pub async fn api_delete_url(
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"URL_DELETION",
|
||||
"url",
|
||||
&uuid,
|
||||
@@ -448,7 +448,7 @@ pub async fn api_create_page(
|
||||
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
|
||||
let _ = write_audit_log(
|
||||
&state.admin_db.lock().unwrap(),
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"PAGE_CREATION",
|
||||
Some("page"),
|
||||
Some(&page.id),
|
||||
@@ -549,7 +549,7 @@ pub async fn api_update_page(
|
||||
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
|
||||
let _ = write_audit_log(
|
||||
&state.admin_db.lock().unwrap(),
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"PAGE_UPDATE",
|
||||
Some("page"),
|
||||
Some(&uuid),
|
||||
@@ -590,7 +590,7 @@ pub async fn api_delete_page(
|
||||
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
|
||||
let _ = write_audit_log(
|
||||
&state.admin_db.lock().unwrap(),
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"PAGE_DELETION",
|
||||
Some("page"),
|
||||
Some(&uuid),
|
||||
@@ -889,7 +889,7 @@ pub async fn api_set_preview(
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"SET_PREVIEW",
|
||||
"url",
|
||||
&uuid,
|
||||
@@ -976,7 +976,7 @@ pub async fn api_delete_preview(
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"DELETE_PREVIEW",
|
||||
"url",
|
||||
&uuid,
|
||||
@@ -1051,7 +1051,7 @@ pub async fn api_set_password(
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"SET_PASSWORD",
|
||||
"url",
|
||||
&uuid,
|
||||
@@ -1109,7 +1109,7 @@ pub async fn api_remove_password(
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"REMOVE_PASSWORD",
|
||||
"url",
|
||||
&uuid,
|
||||
@@ -1177,7 +1177,7 @@ pub async fn api_create_qr(
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"CREATE_QR",
|
||||
"qr_code",
|
||||
&payload.url_id,
|
||||
|
||||
+2
-2
@@ -120,7 +120,7 @@ pub async fn api_bulk_qr(
|
||||
let system_conn = state.db.system.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"BULK_QR_EXPORT",
|
||||
"bulk",
|
||||
"qr",
|
||||
@@ -271,7 +271,7 @@ pub async fn api_bulk_url(
|
||||
let system_conn = state.db.system.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"BULK_URL_CREATION",
|
||||
"bulk",
|
||||
"url",
|
||||
|
||||
@@ -2,6 +2,7 @@ pub mod admin;
|
||||
pub mod api;
|
||||
pub mod bulk;
|
||||
pub mod middleware;
|
||||
pub mod multi_user;
|
||||
pub mod pages;
|
||||
pub mod password_gate;
|
||||
pub mod qr;
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
+51
-3
@@ -4,12 +4,12 @@ use axum::{
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use rusqlite::OptionalExtension;
|
||||
use std::net::SocketAddr;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::analytics::get_client_country;
|
||||
use crate::models::VisitRecord;
|
||||
use crate::services::landing_pages::get_landing_page_by_code;
|
||||
use crate::state::AppState;
|
||||
use crate::utils::get_client_ip;
|
||||
|
||||
@@ -25,11 +25,58 @@ pub async fn resolve_page(
|
||||
return (StatusCode::NOT_FOUND, "Not Found").into_response();
|
||||
}
|
||||
|
||||
let page_opt = match get_landing_page_by_code(&state.db, &code) {
|
||||
Ok(page) => page,
|
||||
// 1. Query global slug namespace in system.db
|
||||
let slug_info = {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let mut stmt = match system_conn.prepare(
|
||||
"SELECT owner_user_id, target_type, target_id, status FROM global_slugs WHERE slug = ?1;"
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
stmt.query_row(rusqlite::params![code], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
))
|
||||
})
|
||||
.optional()
|
||||
};
|
||||
|
||||
let (owner_user_id, _target_type, _target_id, slug_status) = match slug_info {
|
||||
Ok(Some(info)) => info,
|
||||
Ok(None) => {
|
||||
// Fallback to legacy_admin's DB (user_id = 1) if not found in global_slugs
|
||||
(1, "page".to_string(), "".to_string(), "active".to_string())
|
||||
}
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
// If slug status is disabled, flagged, or soft_deleted, we return 410 Gone
|
||||
if slug_status != "active" {
|
||||
return (
|
||||
StatusCode::GONE,
|
||||
"This content has been disabled or moderated",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// 2. Get user specific database connections
|
||||
let user_dbs = match state.get_user_dbs(owner_user_id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
let page_opt = {
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
match crate::db::content::get_landing_page_by_code(&conn, &code) {
|
||||
Ok(page) => page,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
match page_opt {
|
||||
Some(page) => {
|
||||
// Check state
|
||||
@@ -67,6 +114,7 @@ pub async fn resolve_page(
|
||||
accept_language,
|
||||
country,
|
||||
status_code: 200,
|
||||
owner_user_id: Some(owner_user_id),
|
||||
};
|
||||
|
||||
state.analytics_queue.push(record);
|
||||
|
||||
+85
-10
@@ -1,5 +1,4 @@
|
||||
use crate::services::qr::{generate_qr_png, generate_qr_svg};
|
||||
use crate::services::shortener::get_url_by_code;
|
||||
use crate::state::AppState;
|
||||
use crate::utils::get_client_ip;
|
||||
use axum::{
|
||||
@@ -11,6 +10,7 @@ use std::net::SocketAddr;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
// GET /api/qr/:file (e.g. /api/qr/abcdef.png or /api/qr/abcdef.svg or JSON stats /api/qr/abcdef)
|
||||
// GET /api/qr/:file (e.g. /api/qr/abcdef.png or /api/qr/abcdef.svg or JSON stats /api/qr/abcdef)
|
||||
pub async fn qr_handler(
|
||||
State(state): State<AppState>,
|
||||
@@ -24,9 +24,10 @@ pub async fn qr_handler(
|
||||
let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok());
|
||||
|
||||
let authenticated = if let Some(auth) = auth_header {
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
let admin_conn = state.admin_db.lock().unwrap();
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
matches!(
|
||||
crate::auth::session::authenticate_api_key(&conn, auth),
|
||||
crate::auth::session::authenticate_api_key(&admin_conn, &users_conn, auth),
|
||||
Ok(Some(_user))
|
||||
)
|
||||
} else {
|
||||
@@ -37,23 +38,63 @@ pub async fn qr_handler(
|
||||
return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
|
||||
}
|
||||
|
||||
let url_opt = match get_url_by_code(&state.db, &file) {
|
||||
Ok(u) => u,
|
||||
// We need to look up owner_user_id and status from global_slugs
|
||||
let (owner_user_id, slug_status) = {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let mut stmt = match system_conn
|
||||
.prepare("SELECT owner_user_id, status FROM global_slugs WHERE slug = ?1;")
|
||||
{
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response()
|
||||
}
|
||||
};
|
||||
use rusqlite::OptionalExtension;
|
||||
match stmt
|
||||
.query_row(rusqlite::params![&file], |row| {
|
||||
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
|
||||
})
|
||||
.optional()
|
||||
{
|
||||
Ok(Some((uid, status))) => (uid, status),
|
||||
Ok(None) => (1, "active".to_string()), // fallback to admin
|
||||
Err(_) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if slug_status != "active" {
|
||||
return (StatusCode::NOT_FOUND, "URL not found").into_response();
|
||||
}
|
||||
|
||||
let user_dbs = match state.get_user_dbs(owner_user_id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
let url_opt = {
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
match crate::db::content::get_url_by_code(&conn, &file) {
|
||||
Ok(u) => u,
|
||||
Err(_) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let url = match url_opt {
|
||||
Some(u) => u,
|
||||
None => return (StatusCode::NOT_FOUND, "URL not found").into_response(),
|
||||
};
|
||||
|
||||
let qr_scans = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
crate::db::qr::get_qr_scan_count(&conn, &url.id).unwrap_or(0)
|
||||
};
|
||||
|
||||
let direct_clicks = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM visits WHERE target_type = 'url' AND target_id = ?1;",
|
||||
rusqlite::params![url.id],
|
||||
@@ -76,11 +117,45 @@ pub async fn qr_handler(
|
||||
return (StatusCode::NOT_FOUND, "Not Found").into_response();
|
||||
}
|
||||
|
||||
let url_opt = match get_url_by_code(&state.db, code) {
|
||||
Ok(u) => u,
|
||||
// We need to look up owner_user_id and status from global_slugs
|
||||
let (owner_user_id, slug_status) = {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let mut stmt = match system_conn
|
||||
.prepare("SELECT owner_user_id, status FROM global_slugs WHERE slug = ?1;")
|
||||
{
|
||||
Ok(s) => s,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
use rusqlite::OptionalExtension;
|
||||
match stmt
|
||||
.query_row(rusqlite::params![code], |row| {
|
||||
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
|
||||
})
|
||||
.optional()
|
||||
{
|
||||
Ok(Some((uid, status))) => (uid, status),
|
||||
Ok(None) => (1, "active".to_string()), // fallback to admin
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
if slug_status != "active" {
|
||||
return (StatusCode::NOT_FOUND, "Url not found").into_response();
|
||||
}
|
||||
|
||||
let user_dbs = match state.get_user_dbs(owner_user_id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
let url_opt = {
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
match crate::db::content::get_url_by_code(&conn, code) {
|
||||
Ok(u) => u,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
let url = match url_opt {
|
||||
Some(u) => u,
|
||||
None => return (StatusCode::NOT_FOUND, "Url not found").into_response(),
|
||||
@@ -144,7 +219,7 @@ pub async fn qr_handler(
|
||||
.map(|s| s.to_string());
|
||||
|
||||
{
|
||||
let analytics_conn = state.db.analytics.lock().unwrap();
|
||||
let analytics_conn = user_dbs.analytics.lock().unwrap();
|
||||
let _ = crate::db::qr::log_qr_access(
|
||||
&analytics_conn,
|
||||
&url.id,
|
||||
|
||||
+59
-11
@@ -5,12 +5,12 @@ use axum::{
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use chrono::Utc;
|
||||
use rusqlite::OptionalExtension;
|
||||
use std::net::SocketAddr;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::analytics::get_client_country;
|
||||
use crate::models::VisitRecord;
|
||||
use crate::services::shortener::get_url_by_code;
|
||||
use crate::state::AppState;
|
||||
use crate::templates::PreviewTemplate;
|
||||
use crate::utils::get_client_ip;
|
||||
@@ -29,17 +29,64 @@ pub async fn resolve_redirect(
|
||||
return (StatusCode::NOT_FOUND, "Not Found").into_response();
|
||||
}
|
||||
|
||||
let url_opt = match get_url_by_code(&state.db, &code) {
|
||||
Ok(url) => url,
|
||||
// 1. Query global slug namespace in system.db
|
||||
let slug_info = {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let mut stmt = match system_conn.prepare(
|
||||
"SELECT owner_user_id, target_type, target_id, status FROM global_slugs WHERE slug = ?1;"
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
stmt.query_row(rusqlite::params![code], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
))
|
||||
})
|
||||
.optional()
|
||||
};
|
||||
|
||||
let (owner_user_id, _target_type, _target_id, slug_status) = match slug_info {
|
||||
Ok(Some(info)) => info,
|
||||
Ok(None) => {
|
||||
// Fallback to legacy_admin's DB (user_id = 1) if not found in global_slugs
|
||||
(1, "url".to_string(), "".to_string(), "active".to_string())
|
||||
}
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
// If slug status is disabled, flagged, or soft_deleted, we return 410 Gone
|
||||
if slug_status != "active" {
|
||||
return (
|
||||
StatusCode::GONE,
|
||||
"This content has been disabled or moderated",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// 2. Get user specific database connections
|
||||
let user_dbs = match state.get_user_dbs(owner_user_id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
let url_opt = {
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
match crate::db::content::get_url_by_code(&conn, &code) {
|
||||
Ok(url) => url,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
let url = match url_opt {
|
||||
Some(u) => u,
|
||||
None => return (StatusCode::NOT_FOUND, "Short code not found").into_response(),
|
||||
};
|
||||
|
||||
// 1. Expiration check
|
||||
// 3. Expiration check
|
||||
if url.expired {
|
||||
return (StatusCode::GONE, "This link has expired").into_response();
|
||||
}
|
||||
@@ -49,7 +96,7 @@ pub async fn resolve_redirect(
|
||||
if expires_at.with_timezone(&Utc) < Utc::now() {
|
||||
// Mark as expired in DB asynchronously/immediately
|
||||
{
|
||||
let conn = state.db.content.lock().unwrap();
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
let _ = conn.execute(
|
||||
"UPDATE urls SET expired = 1 WHERE id = ?1;",
|
||||
[url.id.clone()],
|
||||
@@ -60,7 +107,7 @@ pub async fn resolve_redirect(
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Access limit check
|
||||
// 4. Access limit check
|
||||
if url.is_access_exhausted() {
|
||||
return (
|
||||
StatusCode::GONE,
|
||||
@@ -69,7 +116,7 @@ pub async fn resolve_redirect(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// 3. Password protection check
|
||||
// 5. Password protection check
|
||||
if url.is_password_protected() {
|
||||
let cookie_name = format!("bzod_gate_{}", code);
|
||||
let authorized = jar
|
||||
@@ -82,14 +129,14 @@ pub async fn resolve_redirect(
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Increment access count & retrieve preview config
|
||||
// 6. Increment access count & retrieve preview config
|
||||
let _new_access_count = {
|
||||
let conn = state.db.content.lock().unwrap();
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
crate::db::content::increment_access_count(&conn, &url.id).unwrap_or(url.access_count + 1)
|
||||
};
|
||||
|
||||
let preview_opt = {
|
||||
let conn = state.db.content.lock().unwrap();
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
crate::db::preview::get_preview(&conn, &url.id).unwrap_or(None)
|
||||
};
|
||||
|
||||
@@ -123,12 +170,13 @@ pub async fn resolve_redirect(
|
||||
accept_language,
|
||||
country,
|
||||
status_code: if preview_opt.is_some() { 200 } else { 302 },
|
||||
owner_user_id: Some(owner_user_id),
|
||||
};
|
||||
|
||||
// Push to memory queue (non-blocking)
|
||||
state.analytics_queue.push(record);
|
||||
|
||||
// 5. Render Preview or Redirect
|
||||
// 7. Render Preview or Redirect
|
||||
if let Some(preview) = preview_opt {
|
||||
PreviewTemplate {
|
||||
code,
|
||||
|
||||
+140
-2
@@ -1,7 +1,7 @@
|
||||
use crate::state::AppState;
|
||||
use crate::web::{admin, api, bulk, pages, password_gate, qr, redirect, system};
|
||||
use crate::web::{admin, api, bulk, multi_user, pages, password_gate, qr, redirect, system};
|
||||
use axum::{
|
||||
routing::{get, post},
|
||||
routing::{delete, get, post, put},
|
||||
Router,
|
||||
};
|
||||
|
||||
@@ -22,6 +22,46 @@ pub fn create_router(state: AppState) -> Router {
|
||||
// --- System Health & Diagnostics ---
|
||||
.route("/status", get(system::status_endpoint))
|
||||
.route("/metrics", get(system::metrics_endpoint))
|
||||
// --- Public User Login ---
|
||||
.route(
|
||||
"/login",
|
||||
get(admin::public_login_get).post(admin::public_login_post),
|
||||
)
|
||||
.route("/logout", get(admin::public_logout))
|
||||
.route("/user/dashboard", get(admin::user_dashboard_get))
|
||||
.route("/user/urls", get(admin::user_urls_get))
|
||||
.route("/user/urls/create", post(admin::user_urls_create))
|
||||
.route("/user/urls/delete/:id", post(admin::user_urls_delete))
|
||||
.route("/user/audit", get(admin::user_audit_get))
|
||||
.route("/user/status", get(admin::user_status_get))
|
||||
.route("/user/pages", get(admin::user_pages_get))
|
||||
.route("/user/pages/create", post(admin::user_pages_create))
|
||||
.route("/user/pages/delete/:id", post(admin::user_pages_delete))
|
||||
.route("/user/settings", get(admin::user_settings_get))
|
||||
.route(
|
||||
"/user/settings/password",
|
||||
post(admin::user_change_password_post),
|
||||
)
|
||||
.route("/user/settings/backup", get(admin::user_download_backup))
|
||||
.route(
|
||||
"/user/settings/restore",
|
||||
post(admin::user_restore_backup_post),
|
||||
)
|
||||
.route("/analytics", get(admin::user_analytics_get))
|
||||
.route(
|
||||
"/user/analytics/url/:id",
|
||||
get(admin::user_url_analytics_get),
|
||||
)
|
||||
.route(
|
||||
"/user/analytics/page/:id",
|
||||
get(admin::user_page_analytics_get),
|
||||
)
|
||||
.route("/api-tokens", get(admin::api_tokens_get))
|
||||
.route("/api-tokens/create", post(admin::api_tokens_create_post))
|
||||
.route(
|
||||
"/api-tokens/revoke/:id",
|
||||
post(admin::api_tokens_revoke_post),
|
||||
)
|
||||
// --- Admin UI Login/Logout ---
|
||||
.route("/admin", get(admin::admin_index))
|
||||
.route(
|
||||
@@ -57,6 +97,24 @@ pub fn create_router(state: AppState) -> Router {
|
||||
get(admin::page_analytics_json_export),
|
||||
)
|
||||
.route("/admin/settings", get(admin::settings_get))
|
||||
.route("/admin/users", get(admin::users_get))
|
||||
.route("/admin/users/new", get(admin::users_new_get))
|
||||
.route("/admin/users/:id", get(admin::user_detail_get))
|
||||
.route(
|
||||
"/admin/users/:id/edit",
|
||||
get(admin::user_edit_get).post(admin::user_edit_post),
|
||||
)
|
||||
.route("/admin/users/create", post(admin::users_create_post))
|
||||
.route(
|
||||
"/admin/users/status/:id",
|
||||
post(admin::users_update_status_post),
|
||||
)
|
||||
.route("/admin/users/type/:id", post(admin::users_update_type_post))
|
||||
.route(
|
||||
"/admin/users/password/:id",
|
||||
post(admin::users_reset_password_post),
|
||||
)
|
||||
.route("/admin/users/delete/:id", post(admin::users_delete_post))
|
||||
.route(
|
||||
"/admin/settings/password",
|
||||
post(admin::change_password_post),
|
||||
@@ -79,6 +137,39 @@ pub fn create_router(state: AppState) -> Router {
|
||||
)
|
||||
.route("/admin/audit", get(admin::audit_get))
|
||||
.route("/admin/status", get(admin::status_get))
|
||||
.route(
|
||||
"/admin/moderation",
|
||||
get(admin::moderation_get).post(admin::moderation_post),
|
||||
)
|
||||
.route("/admin/slugs", get(admin::slugs_get))
|
||||
.route("/admin/slugs/transfer", post(admin::slugs_transfer_post))
|
||||
.route("/admin/slugs/status", post(admin::slugs_status_post))
|
||||
.route("/admin/slugs/delete", post(admin::slugs_delete_post))
|
||||
.route("/admin/sessions", get(admin::sessions_get))
|
||||
.route(
|
||||
"/admin/sessions/revoke/:id",
|
||||
post(admin::sessions_revoke_post),
|
||||
)
|
||||
.route(
|
||||
"/admin/sessions/revoke-all",
|
||||
post(admin::sessions_revoke_all_post),
|
||||
)
|
||||
.route(
|
||||
"/admin/quotas",
|
||||
get(admin::quotas_get).post(admin::quotas_post),
|
||||
)
|
||||
.route("/admin/health", get(admin::health_get))
|
||||
.route("/admin/backups", get(admin::backups_get))
|
||||
.route("/admin/backups/create", post(admin::backups_create_post))
|
||||
.route(
|
||||
"/admin/backups/download/:filename",
|
||||
get(admin::backups_download_get),
|
||||
)
|
||||
.route(
|
||||
"/admin/backups/delete/:filename",
|
||||
post(admin::backups_delete_post),
|
||||
)
|
||||
.route("/admin/backups/restore", post(admin::backups_restore_post))
|
||||
// --- REST API v1 JSON Endpoints ---
|
||||
.route(
|
||||
"/api/v1/urls",
|
||||
@@ -124,6 +215,53 @@ pub fn create_router(state: AppState) -> Router {
|
||||
"/api/v1/urls/:uuid/password",
|
||||
post(api::api_set_password).delete(api::api_remove_password),
|
||||
)
|
||||
// --- Multi-User REST API v1 Admin Endpoints ---
|
||||
.route(
|
||||
"/api/v1/admin/users",
|
||||
get(multi_user::admin_list_users).post(multi_user::admin_create_user),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/admin/users/:id/status",
|
||||
put(multi_user::admin_update_user_status),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/admin/users/:id/quotas",
|
||||
put(multi_user::admin_update_user_quotas),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/admin/users/:id/password",
|
||||
post(multi_user::admin_reset_user_password),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/admin/users/:id",
|
||||
delete(multi_user::admin_delete_user),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/admin/transfers",
|
||||
post(multi_user::admin_transfer_slug),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/admin/moderation",
|
||||
post(multi_user::admin_moderate_slug),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/admin/moderation/events",
|
||||
get(multi_user::admin_list_moderation_events),
|
||||
)
|
||||
// --- Multi-User REST API v1 Tenant User Endpoints ---
|
||||
.route("/api/v1/user/profile", get(multi_user::user_get_profile))
|
||||
.route(
|
||||
"/api/v1/user/password",
|
||||
post(multi_user::user_change_password),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/user/api-tokens",
|
||||
get(multi_user::user_list_api_tokens).post(multi_user::user_create_api_token),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/user/api-tokens/:id",
|
||||
delete(multi_user::user_delete_api_token),
|
||||
)
|
||||
// --- Static Asset Stub ---
|
||||
.route(
|
||||
"/static/style.css",
|
||||
|
||||
+6
-5
@@ -6,7 +6,7 @@ use axum::{
|
||||
use axum_extra::extract::CookieJar;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::auth::{authenticate_api_key, authenticate_session};
|
||||
use crate::auth::{authenticate_admin_session, authenticate_api_key};
|
||||
use crate::db::admin::get_user_count;
|
||||
use crate::state::AppState;
|
||||
use crate::utils::{get_db_file_info, get_memory_usage};
|
||||
@@ -15,15 +15,16 @@ use crate::utils::{get_db_file_info, get_memory_usage};
|
||||
fn authenticate_request(state: &AppState, jar: &CookieJar, headers: &HeaderMap) -> bool {
|
||||
// 1. Try Authorization header
|
||||
if let Some(auth_header) = headers.get("Authorization").and_then(|h| h.to_str().ok()) {
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
if let Ok(Some(_)) = authenticate_api_key(&conn, auth_header) {
|
||||
let admin_conn = state.admin_db.lock().unwrap();
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
if let Ok(Some(_)) = authenticate_api_key(&admin_conn, &users_conn, auth_header) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try cookie session
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
if let Ok(Some(_)) = authenticate_session(&conn, jar) {
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
if let Ok(Some(_)) = authenticate_admin_session(&conn, jar) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -378,6 +378,7 @@
|
||||
</div>
|
||||
|
||||
<ul class="nav-links">
|
||||
{% block sidebar_links %}
|
||||
<li class="{% block active_dashboard %}{% endblock %}">
|
||||
<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>
|
||||
@@ -396,6 +397,12 @@
|
||||
Landing Pages
|
||||
</a>
|
||||
</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 %}">
|
||||
<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>
|
||||
@@ -414,8 +421,10 @@
|
||||
Status
|
||||
</a>
|
||||
</li>
|
||||
{% endblock %}
|
||||
</ul>
|
||||
|
||||
{% block sidebar_footer %}
|
||||
<div class="sidebar-footer">
|
||||
<div class="admin-user-info">
|
||||
<div class="avatar">{{ admin_username[0..1].to_uppercase() }}</div>
|
||||
@@ -423,6 +432,7 @@
|
||||
</div>
|
||||
<a href="/admin/logout" class="logout-btn">Log Out</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
||||
<!-- Main Workspace -->
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login - BZOD Platform</title>
|
||||
<title>{{ title }} - BZOD Platform</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg-base: #070a13;
|
||||
@@ -143,9 +143,9 @@
|
||||
|
||||
<div class="login-card">
|
||||
<div class="logo">
|
||||
BZOD <span class="logo-dot"></span>
|
||||
{{ title }} <span class="logo-dot"></span>
|
||||
</div>
|
||||
<p class="subtitle">Personal Redirects & Landing Pages</p>
|
||||
<p class="subtitle">{{ subtitle }}</p>
|
||||
|
||||
{% if let Some(err) = error %}
|
||||
<div class="alert-error">
|
||||
@@ -153,7 +153,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form action="/admin/login" method="POST">
|
||||
<form action="{{ action }}" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<div class="form-group">
|
||||
@@ -166,7 +166,7 @@
|
||||
<input type="password" id="password" name="password" class="form-input" required autocomplete="current-password">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn">Sign In</button>
|
||||
<button type="submit" class="btn">{{ button_text }}</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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;">
|
||||
← 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 %}
|
||||
@@ -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;">
|
||||
← 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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> <html> <head> <title>My Page</title> </head> <body> <h1>Hello</h1> </body> </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;"><< First</a>
|
||||
<a href="/user/pages?page={{ current_page - 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">< Prev</a>
|
||||
{% else %}
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;"><< First</span>
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">< 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 ></a>
|
||||
<a href="/user/pages?page={{ total_pages }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">Last >></a>
|
||||
{% else %}
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">Next ></span>
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">Last >></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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;"><< First</a>
|
||||
<a href="/user/urls?page={{ current_page - 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">< Prev</a>
|
||||
{% else %}
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;"><< First</span>
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">< 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 ></a>
|
||||
<a href="/user/urls?page={{ total_pages }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">Last >></a>
|
||||
{% else %}
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">Next ></span>
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">Last >></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
@@ -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;">
|
||||
← 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 %}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -51,6 +51,7 @@ fn test_advanced_analytics_pagination_and_sorting() {
|
||||
accept_language: "en".to_string(),
|
||||
country: "US".to_string(),
|
||||
status_code: 200,
|
||||
owner_user_id: None,
|
||||
});
|
||||
}
|
||||
insert_visits_batch(&mut conn, &records).unwrap();
|
||||
@@ -91,6 +92,7 @@ fn test_advanced_analytics_date_filtering() {
|
||||
accept_language: "en".to_string(),
|
||||
country: "US".to_string(),
|
||||
status_code: 200,
|
||||
owner_user_id: None,
|
||||
},
|
||||
VisitRecord {
|
||||
id: "v2".to_string(),
|
||||
@@ -103,6 +105,7 @@ fn test_advanced_analytics_date_filtering() {
|
||||
accept_language: "en".to_string(),
|
||||
country: "US".to_string(),
|
||||
status_code: 200,
|
||||
owner_user_id: None,
|
||||
},
|
||||
VisitRecord {
|
||||
id: "v3".to_string(),
|
||||
@@ -115,6 +118,7 @@ fn test_advanced_analytics_date_filtering() {
|
||||
accept_language: "en".to_string(),
|
||||
country: "US".to_string(),
|
||||
status_code: 200,
|
||||
owner_user_id: None,
|
||||
},
|
||||
];
|
||||
insert_visits_batch(&mut conn, &records).unwrap();
|
||||
|
||||
@@ -121,6 +121,7 @@ fn test_target_analytics_queries() {
|
||||
accept_language: "en".to_string(),
|
||||
country: "US".to_string(),
|
||||
status_code: 200,
|
||||
owner_user_id: None,
|
||||
},
|
||||
VisitRecord {
|
||||
id: "visit-2".to_string(),
|
||||
@@ -133,6 +134,7 @@ fn test_target_analytics_queries() {
|
||||
accept_language: "en".to_string(),
|
||||
country: "US".to_string(),
|
||||
status_code: 200,
|
||||
owner_user_id: None,
|
||||
},
|
||||
VisitRecord {
|
||||
id: "visit-3".to_string(),
|
||||
@@ -145,6 +147,7 @@ fn test_target_analytics_queries() {
|
||||
accept_language: "en".to_string(),
|
||||
country: "US".to_string(),
|
||||
status_code: 200,
|
||||
owner_user_id: None,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(¶ms)
|
||||
.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(¶ms)
|
||||
.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(¶ms)
|
||||
.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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(¶ms)
|
||||
.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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -139,10 +139,11 @@ async fn test_perform_restore_and_validation() {
|
||||
assert!(validation_res.is_ok());
|
||||
|
||||
// Verify database files were extracted
|
||||
assert!(restore_dir.join("admin.db").exists());
|
||||
assert!(restore_dir.join("content.db").exists());
|
||||
assert!(restore_dir.join("analytics.db").exists());
|
||||
assert!(restore_dir.join("system.db").exists());
|
||||
assert!(restore_dir.join("admin/admin.db").exists());
|
||||
assert!(restore_dir.join("admin/users.db").exists());
|
||||
assert!(restore_dir.join("admin/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
|
||||
let restore_config = create_temp_config(restore_dir.clone());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -4,11 +4,11 @@ use chrono::Utc;
|
||||
use rusqlite::Connection;
|
||||
|
||||
use bzod::auth::{
|
||||
authenticate_api_key, authenticate_session, generate_csrf_token, hash_password, verify_csrf,
|
||||
verify_password, verify_sha256,
|
||||
authenticate_admin_session, authenticate_api_key, generate_csrf_token, hash_password,
|
||||
verify_csrf, verify_password, verify_sha256,
|
||||
};
|
||||
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
|
||||
fn setup_test_db() -> Connection {
|
||||
@@ -17,6 +17,12 @@ fn setup_test_db() -> Connection {
|
||||
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]
|
||||
fn test_csrf_tampering_prevention() {
|
||||
let session_id = "secret_session_id_123456";
|
||||
@@ -35,6 +41,7 @@ fn test_csrf_tampering_prevention() {
|
||||
#[test]
|
||||
fn test_api_key_sql_injection_resistance() {
|
||||
let conn = setup_test_db();
|
||||
let users_conn = setup_users_db();
|
||||
|
||||
// Create an API key
|
||||
let user_hash = hash_password("admin_pass").unwrap();
|
||||
@@ -50,26 +57,26 @@ fn test_api_key_sql_injection_resistance() {
|
||||
|
||||
// 1. Test valid key passes
|
||||
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_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
|
||||
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());
|
||||
|
||||
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());
|
||||
|
||||
// 3. Test malformed header
|
||||
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());
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -86,7 +93,7 @@ fn test_expired_session_invalidation() {
|
||||
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 auth_future = authenticate_session(&conn, &jar_future).unwrap();
|
||||
let auth_future = authenticate_admin_session(&conn, &jar_future).unwrap();
|
||||
assert!(auth_future.is_some());
|
||||
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();
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(¶ms).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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user