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

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

No files matched your search

+42 -6
View File
@@ -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
);
}
}
}
}
+6 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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,
})));
}
}
+150
View File
@@ -0,0 +1,150 @@
use crate::config::Config;
use crate::db::Db;
use chrono::Utc;
use std::fs::File;
use std::path::{Path, PathBuf};
use tar::{Builder, Header};
use tracing::{error, info};
use zstd::Encoder;
#[derive(serde::Serialize, serde::Deserialize)]
struct UserBackupMetadata {
id: i64,
username: String,
password_hash: String,
status: String,
created_at: String,
account_type: String,
metadata: Option<String>,
quotas: UserBackupQuotas,
}
#[derive(serde::Serialize, serde::Deserialize)]
struct UserBackupQuotas {
max_urls: i64,
max_landings: i64,
max_api_tokens: i64,
max_storage_mb: i64,
}
pub async fn run(
username: String,
out: Option<String>,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let db = Db::init(&config)?;
let username_clean = username.trim().to_lowercase();
// 1. Get user details from users.db
let user_details = {
let conn = db.users.lock().unwrap();
crate::db::users::get_user_by_username(&conn, &username_clean)?
};
let user = match user_details {
Some(u) => u,
None => {
error!("User '{}' not found", username_clean);
return Ok(());
}
};
let user_id = user.id;
// 2. Fetch user's quotas
let quotas = {
let conn = db.users.lock().unwrap();
conn.query_row(
"SELECT max_urls, max_landings, max_api_tokens, max_storage_mb FROM quotas WHERE user_id = ?1;",
[user_id],
|row| {
Ok(UserBackupQuotas {
max_urls: row.get(0)?,
max_landings: row.get(1)?,
max_api_tokens: row.get(2)?,
max_storage_mb: row.get(3)?,
})
}
)?
};
// 3. Define output path
let tar_path = match out {
Some(p) => PathBuf::from(p),
None => {
if !config.backup_dir.exists() {
std::fs::create_dir_all(&config.backup_dir)?;
}
config.backup_dir.join(format!(
"{}-{}.tar.zst",
username_clean,
Utc::now().format("%Y%m%d")
))
}
};
info!(
"Backing up user {} (ID: {}) to {:?}",
username_clean, user_id, tar_path
);
// 4. Force checkpoint on user's databases
let user_dir = config.data_dir.join("users").join(user_id.to_string());
if let Ok(c) = rusqlite::Connection::open(user_dir.join("content.db")) {
let _ = c.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
if let Ok(c) = rusqlite::Connection::open(user_dir.join("analytics.db")) {
let _ = c.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
if let Ok(c) = rusqlite::Connection::open(user_dir.join("profile.db")) {
let _ = c.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
// 5. Create tar.zst archive
let file = File::create(&tar_path)?;
let zst_enc = Encoder::new(file, 3)?;
let mut tar = Builder::new(zst_enc);
// Write metadata.json directly into tar
let metadata_obj = UserBackupMetadata {
id: user.id,
username: user.username,
password_hash: user.password_hash,
status: user.status,
created_at: user.created_at,
account_type: user.account_type,
metadata: user.metadata,
quotas,
};
let metadata_bytes = serde_json::to_vec_pretty(&metadata_obj)?;
let mut header = Header::new_gnu();
header.set_size(metadata_bytes.len() as u64);
header.set_path("metadata.json")?;
header.set_mode(0o644);
header.set_cksum();
tar.append(&header, &metadata_bytes[..])?;
// Append database files
let mut append_file =
|name_in_archive: &str, path_on_disk: &Path| -> Result<(), Box<dyn std::error::Error>> {
if path_on_disk.exists() {
let mut file = File::open(path_on_disk)?;
tar.append_file(name_in_archive, &mut file)?;
}
Ok(())
};
append_file("content.db", &user_dir.join("content.db"))?;
append_file("analytics.db", &user_dir.join("analytics.db"))?;
append_file("profile.db", &user_dir.join("profile.db"))?;
tar.into_inner()?.finish()?;
info!("User backup generated successfully at {:?}", tar_path);
Ok(())
}
+3 -2
View File
@@ -32,8 +32,9 @@ pub async fn run(
}
let hash = hash_password(&password).map_err(|e| e.to_string())?;
let 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
+86
View File
@@ -0,0 +1,86 @@
use crate::auth::hash_password;
use crate::config::Config;
use crate::db::Db;
use std::io::{self, Write};
use std::path::PathBuf;
use tracing::{error, info};
pub async fn run(
username: Option<String>,
password: Option<String>,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let db = Db::init(&config)?;
let final_username = match username {
Some(u) => u,
None => read_input("Enter username: "),
};
let username_clean = final_username.trim().to_lowercase();
if username_clean.is_empty() {
error!("Username cannot be empty");
return Ok(());
}
if username_clean.len() < 3 {
error!("Username must be at least 3 characters");
return Ok(());
}
if !username_clean
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
error!("Username must contain only alphanumeric characters, hyphens, or underscores");
return Ok(());
}
let final_password = match password {
Some(p) => p,
None => read_input("Enter password: "),
};
if final_password.trim().is_empty() {
error!("Password cannot be empty");
return Ok(());
}
let hash = hash_password(&final_password).map_err(|e| e.to_string())?;
// Check if user already exists
{
let conn = db.users.lock().unwrap();
if crate::db::users::get_user_by_username(&conn, &username_clean)?.is_some() {
error!("User already exists: {}", username_clean);
return Ok(());
}
}
// Create user in DB (this seeds default quotas too)
let new_user = {
let conn = db.users.lock().unwrap();
crate::db::users::create_user(&conn, &username_clean, &hash, "standard", None)?
};
// Initialize their user specific directory and DB files (content.db, analytics.db, profile.db)
db.init_user_databases(new_user.id)?;
info!(
"Successfully created standard user: {} (ID: {})",
new_user.username, new_user.id
);
Ok(())
}
fn read_input(prompt: &str) -> String {
print!("{}", prompt);
let _ = io::stdout().flush();
let mut input = String::new();
let _ = io::stdin().read_line(&mut input);
input.trim().to_string()
}
+92
View File
@@ -0,0 +1,92 @@
use crate::config::Config;
use crate::db::Db;
use chrono::Utc;
use std::path::PathBuf;
use tracing::{error, info};
pub async fn run(
user_id: i64,
force: bool,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let db = Db::init(&config)?;
if user_id == 1 && !force {
error!("Deleting legacy_admin system account requires --force flag");
return Ok(());
}
// Capture user details
let user_details = {
let conn = db.users.lock().unwrap();
match crate::db::users::get_user_by_id(&conn, user_id)? {
Some(u) => u,
None => {
error!("User ID {} not found", user_id);
return Ok(());
}
}
};
// 1. Transactional clean up on system.db (deleting their global slug mappings)
{
let mut system_conn = db.system.lock().unwrap();
let tx = system_conn.transaction()?;
// Get all slugs owned by the user
let slugs: Vec<String> = {
let mut stmt = tx.prepare("SELECT slug FROM global_slugs WHERE owner_user_id = ?1;")?;
let rows = stmt.query_map([user_id], |row| row.get(0))?;
rows.filter_map(|r| r.ok()).collect()
};
// Delete from global_slugs and write to history
let now = Utc::now().to_rfc3339();
for slug in slugs {
let _ = tx.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&slug]);
let _ = tx.execute(
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username)
VALUES (?1, ?2, NULL, 'deleted', ?3, ?4);",
rusqlite::params![slug, user_id, now, "cli"],
);
}
tx.commit()?;
}
// 2. Delete user folder and database files from disk
let user_dir = config.data_dir.join("users").join(user_id.to_string());
if user_dir.exists() {
let _ = std::fs::remove_dir_all(&user_dir);
}
// 3. Remove user entry from users.db (cascading deletes quotas/sessions/tokens)
{
let conn = db.users.lock().unwrap();
crate::db::users::delete_user(&conn, user_id)?;
}
// Write audit event
{
let system_conn = db.system.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
"cli",
"USER_DELETION",
"user",
&user_id.to_string(),
Some(&format!("Username: {}", user_details.username)),
);
}
info!(
"Successfully deleted user {} (ID: {}) and all associated content",
user_details.username, user_id
);
Ok(())
}
+55
View File
@@ -0,0 +1,55 @@
use crate::config::Config;
use crate::db::Db;
use std::path::PathBuf;
use tracing::{error, info};
pub async fn run(
user_id: i64,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let db = Db::init(&config)?;
// Check user exists
let user = {
let conn = db.users.lock().unwrap();
crate::db::users::get_user_by_id(&conn, user_id)?
};
let user = match user {
Some(u) => u,
None => {
error!("User ID {} not found", user_id);
return Ok(());
}
};
if user.status == "disabled" {
info!("User {} is already disabled", user.username);
return Ok(());
}
{
let conn = db.users.lock().unwrap();
crate::db::users::update_user_status(&conn, user_id, "disabled")?;
}
// Write audit event
{
let system_conn = db.system.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
"cli",
"USER_DISABLED",
"user",
&user_id.to_string(),
Some(&format!("Username: {}", user.username)),
);
}
info!("User {} (ID: {}) has been disabled", user.username, user_id);
Ok(())
}
+58
View File
@@ -0,0 +1,58 @@
use crate::config::Config;
use crate::db::Db;
use std::path::PathBuf;
use tracing::{error, info};
pub async fn run(
user_id: i64,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let db = Db::init(&config)?;
// Check user exists
let user = {
let conn = db.users.lock().unwrap();
crate::db::users::get_user_by_id(&conn, user_id)?
};
let user = match user {
Some(u) => u,
None => {
error!("User ID {} not found", user_id);
return Ok(());
}
};
if user.status == "active" {
info!("User {} is already active", user.username);
return Ok(());
}
{
let conn = db.users.lock().unwrap();
crate::db::users::update_user_status(&conn, user_id, "active")?;
}
// Write audit event
{
let system_conn = db.system.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
"cli",
"USER_ENABLED",
"user",
&user_id.to_string(),
Some(&format!("Username: {}", user.username)),
);
}
info!(
"User {} (ID: {}) has been enabled (active)",
user.username, user_id
);
Ok(())
}
+36
View File
@@ -0,0 +1,36 @@
use crate::config::Config;
use crate::db::Db;
use std::path::PathBuf;
pub async fn run(
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let db = Db::init(&config)?;
let users = {
let conn = db.users.lock().unwrap();
crate::db::users::list_users(&conn)?
};
println!(
"{:<6} | {:<20} | {:<10} | {:<12} | {:<24}",
"ID", "Username", "Status", "Type", "Created At"
);
println!(
"{:-<6}-+-{:-<20}-+-{:-<10}-+-{:-<12}-+-{:-<24}",
"", "", "", "", ""
);
for u in users {
println!(
"{:<6} | {:<20} | {:<10} | {:<12} | {:<24}",
u.id, u.username, u.status, u.account_type, u.created_at
);
}
Ok(())
}
+74
View File
@@ -11,6 +11,15 @@ pub mod shorten;
pub mod stats;
pub mod 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>,
},
}
+75
View File
@@ -0,0 +1,75 @@
use crate::auth::hash_password;
use crate::config::Config;
use crate::db::Db;
use std::io::{self, Write};
use std::path::PathBuf;
use tracing::{error, info};
pub async fn run(
user_id: i64,
password: Option<String>,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let db = Db::init(&config)?;
// Check user exists
let user = {
let conn = db.users.lock().unwrap();
crate::db::users::get_user_by_id(&conn, user_id)?
};
let user = match user {
Some(u) => u,
None => {
error!("User ID {} not found", user_id);
return Ok(());
}
};
let final_password = match password {
Some(p) => p,
None => read_input("Enter new password: "),
};
if final_password.trim().is_empty() {
error!("Password cannot be empty");
return Ok(());
}
let hash = hash_password(&final_password).map_err(|e| e.to_string())?;
{
let conn = db.users.lock().unwrap();
crate::db::users::reset_user_password(&conn, user_id, &hash)?;
}
// Write audit event
{
let system_conn = db.system.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
"cli",
"USER_PASSWORD_RESET",
"user",
&user_id.to_string(),
Some(&format!("Username: {}", user.username)),
);
}
info!(
"Password for user {} (ID: {}) has been reset successfully",
user.username, user_id
);
Ok(())
}
fn read_input(prompt: &str) -> String {
print!("{}", prompt);
let _ = io::stdout().flush();
let mut input = String::new();
let _ = io::stdin().read_line(&mut input);
input.trim().to_string()
}
+269
View File
@@ -0,0 +1,269 @@
use crate::config::Config;
use crate::db::Db;
use chrono::Utc;
use rusqlite::OptionalExtension;
use std::fs::File;
use std::path::PathBuf;
use tar::Archive;
use tracing::{error, info};
use zstd::Decoder;
#[derive(serde::Serialize, serde::Deserialize)]
struct UserBackupMetadata {
id: i64,
username: String,
password_hash: String,
status: String,
created_at: String,
account_type: String,
metadata: Option<String>,
quotas: UserBackupQuotas,
}
#[derive(serde::Serialize, serde::Deserialize)]
struct UserBackupQuotas {
max_urls: i64,
max_landings: i64,
max_api_tokens: i64,
max_storage_mb: i64,
}
pub async fn run(
file: String,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let file_path = PathBuf::from(file);
if !file_path.exists() {
error!("Backup file not found: {:?}", file_path);
return Ok(());
}
let db = Db::init(&config)?;
// 1. Read metadata.json from the tar.zst archive
let f = File::open(&file_path)?;
let zst_dec = Decoder::new(f)?;
let mut archive = Archive::new(zst_dec);
let mut metadata_opt: Option<UserBackupMetadata> = None;
for entry_res in archive.entries()? {
let mut entry = entry_res?;
let path = entry.path()?;
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if file_name == "metadata.json" {
let meta: UserBackupMetadata = serde_json::from_reader(&mut entry)?;
metadata_opt = Some(meta);
break;
}
}
let metadata = match metadata_opt {
Some(m) => m,
None => {
error!("Archive is missing metadata.json");
return Ok(());
}
};
info!("Restoring user {} from backup...", metadata.username);
// 2. Resolve target user ID and upsert user record in users.db
let target_user_id = {
let users_conn = db.users.lock().unwrap();
let existing_user =
crate::db::users::get_user_by_username(&users_conn, &metadata.username)?;
match existing_user {
Some(u) => {
users_conn.execute(
"UPDATE users SET password_hash = ?1, status = ?2, account_type = ?3, metadata = ?4 WHERE id = ?5;",
rusqlite::params![metadata.password_hash, metadata.status, metadata.account_type, metadata.metadata, u.id],
)?;
users_conn.execute(
"INSERT OR REPLACE INTO quotas (user_id, max_urls, max_landings, max_api_tokens, max_storage_mb)
VALUES (?1, ?2, ?3, ?4, ?5);",
rusqlite::params![u.id, metadata.quotas.max_urls, metadata.quotas.max_landings, metadata.quotas.max_api_tokens, metadata.quotas.max_storage_mb],
)?;
u.id
}
None => {
let id_taken: bool = users_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
[metadata.id],
|row| row.get(0),
)
.unwrap_or(false);
let new_id = if !id_taken {
users_conn.execute(
"INSERT INTO users (id, username, password_hash, status, created_at, account_type, metadata)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
rusqlite::params![metadata.id, metadata.username, metadata.password_hash, metadata.status, metadata.created_at, metadata.account_type, metadata.metadata],
)?;
metadata.id
} else {
users_conn.execute(
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata)
VALUES (?1, ?2, ?3, ?4, ?5, ?6);",
rusqlite::params![metadata.username, metadata.password_hash, metadata.status, metadata.created_at, metadata.account_type, metadata.metadata],
)?;
users_conn.last_insert_rowid()
};
users_conn.execute(
"INSERT OR REPLACE INTO quotas (user_id, max_urls, max_landings, max_api_tokens, max_storage_mb)
VALUES (?1, ?2, ?3, ?4, ?5);",
rusqlite::params![new_id, metadata.quotas.max_urls, metadata.quotas.max_landings, metadata.quotas.max_api_tokens, metadata.quotas.max_storage_mb],
)?;
new_id
}
}
};
// 3. Extract database files to /data/users/<target_user_id>/
let dest_dir = config
.data_dir
.join("users")
.join(target_user_id.to_string());
std::fs::create_dir_all(&dest_dir)?;
let f2 = File::open(&file_path)?;
let zst_dec2 = Decoder::new(f2)?;
let mut archive2 = Archive::new(zst_dec2);
for entry_res in archive2.entries()? {
let mut entry = entry_res?;
let path = entry.path()?;
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
match file_name {
"content.db" => {
let mut out_file = File::create(dest_dir.join("content.db"))?;
std::io::copy(&mut entry, &mut out_file)?;
}
"analytics.db" => {
let mut out_file = File::create(dest_dir.join("analytics.db"))?;
std::io::copy(&mut entry, &mut out_file)?;
}
"profile.db" => {
let mut out_file = File::create(dest_dir.join("profile.db"))?;
std::io::copy(&mut entry, &mut out_file)?;
}
_ => {}
}
}
// 4. Register slugs in global_slugs
let restored_content_conn = rusqlite::Connection::open(dest_dir.join("content.db"))?;
{
let mut system_conn = db.system.lock().unwrap();
let tx = system_conn.transaction()?;
// Delete any existing global slugs owned by this user
tx.execute(
"DELETE FROM global_slugs WHERE owner_user_id = ?1;",
[target_user_id],
)?;
// Register URLs
{
let mut stmt =
restored_content_conn.prepare("SELECT code, id, created_at, status FROM urls;")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let slug: String = row.get(0)?;
let target_id: String = row.get(1)?;
let created_at: String = row.get(2)?;
let status: String = row.get(3)?;
let now = Utc::now().to_rfc3339();
let existing_owner: Option<i64> = tx
.query_row(
"SELECT owner_user_id FROM global_slugs WHERE slug = ?1;",
[&slug],
|r| r.get(0),
)
.optional()?;
if let Some(owner) = existing_owner {
if owner != target_user_id {
error!(
"Conflict: Slug '{}' is already owned by user ID {}. Skipping.",
slug, owner
);
continue;
}
}
tx.execute(
"INSERT OR REPLACE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
VALUES (?1, ?2, 'url', ?3, ?4, ?5, ?6);",
rusqlite::params![slug, target_user_id, target_id, created_at, now, status],
)?;
}
}
// Register Landing Pages
{
let mut stmt = restored_content_conn
.prepare("SELECT code, id, created_at, state FROM landing_pages;")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let slug: String = row.get(0)?;
let target_id: String = row.get(1)?;
let created_at: String = row.get(2)?;
let state: String = row.get(3)?;
let now = Utc::now().to_rfc3339();
let status = if state == "published" {
"active"
} else {
"disabled"
};
let existing_owner: Option<i64> = tx
.query_row(
"SELECT owner_user_id FROM global_slugs WHERE slug = ?1;",
[&slug],
|r| r.get(0),
)
.optional()?;
if let Some(owner) = existing_owner {
if owner != target_user_id {
error!(
"Conflict: Slug '{}' is already owned by user ID {}. Skipping.",
slug, owner
);
continue;
}
}
tx.execute(
"INSERT OR REPLACE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
VALUES (?1, ?2, 'page', ?3, ?4, ?5, ?6);",
rusqlite::params![slug, target_user_id, target_id, created_at, now, status],
)?;
}
}
tx.commit()?;
}
// 5. Reconcile quotas for restored user
crate::db::users::reconcile_user_quotas(
&db.users.lock().unwrap(),
target_user_id,
&restored_content_conn,
)?;
info!(
"User '{}' (ID: {}) successfully restored from backup.",
metadata.username, target_user_id
);
Ok(())
}
+18
View File
@@ -63,11 +63,29 @@ pub async fn run(
crate::jobs::run_expiry_checker(expiry_db).await;
});
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
View File
@@ -71,10 +71,20 @@ pub fn create_session(
) -> rusqlite::Result<Session> {
let created_at = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);",
params![session_id, user_id, expires_at_rfc3339, created_at],
)?;
// Bind `user_id` as integer when it appears to be numeric so that numeric
// user IDs inserted into `users.db` keep the integer affinity and avoid
// InvalidColumnType errors when read as i64 elsewhere.
if let Ok(id_i64) = user_id.parse::<i64>() {
conn.execute(
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);",
params![session_id, id_i64, expires_at_rfc3339, created_at],
)?;
} else {
conn.execute(
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);",
params![session_id, user_id, expires_at_rfc3339, created_at],
)?;
}
Ok(Session {
id: session_id.to_string(),
+8 -5
View File
@@ -82,8 +82,8 @@ pub fn insert_visits_batch(conn: &mut Connection, records: &[VisitRecord]) -> ru
let tx = conn.transaction()?;
{
let mut stmt = tx.prepare(
"INSERT INTO visits (id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10);"
"INSERT INTO visits (id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code, owner_user_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11);"
)?;
for r in records {
@@ -97,7 +97,8 @@ pub fn insert_visits_batch(conn: &mut Connection, records: &[VisitRecord]) -> ru
r.referer,
r.accept_language,
r.country,
r.status_code
r.status_code,
r.owner_user_id
])?;
}
}
@@ -594,7 +595,7 @@ pub fn get_target_visits_paginated(
date_from: Option<&str>,
date_to: Option<&str>,
) -> rusqlite::Result<Vec<VisitRecord>> {
let mut sql = "SELECT id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code FROM visits WHERE target_type = ?1 AND target_id = ?2".to_string();
let mut sql = "SELECT id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code, owner_user_id FROM visits WHERE target_type = ?1 AND target_id = ?2".to_string();
let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![
Box::new(target_type.to_string()),
Box::new(target_id.to_string()),
@@ -638,6 +639,7 @@ pub fn get_target_visits_paginated(
accept_language: row.get("accept_language")?,
country: row.get("country")?,
status_code: row.get("status_code")?,
owner_user_id: row.get("owner_user_id")?,
})
})?;
@@ -655,7 +657,7 @@ pub fn get_target_visits_all_in_memory(
date_from: Option<&str>,
date_to: Option<&str>,
) -> rusqlite::Result<Vec<VisitRecord>> {
let mut sql = "SELECT id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code FROM visits WHERE target_type = ?1 AND target_id = ?2".to_string();
let mut sql = "SELECT id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code, owner_user_id FROM visits WHERE target_type = ?1 AND target_id = ?2".to_string();
let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![
Box::new(target_type.to_string()),
Box::new(target_id.to_string()),
@@ -693,6 +695,7 @@ pub fn get_target_visits_all_in_memory(
accept_language: row.get("accept_language")?,
country: row.get("country")?,
status_code: row.get("status_code")?,
owner_user_id: row.get("owner_user_id")?,
})
})?;
+189 -5
View File
@@ -111,10 +111,11 @@ pub fn print_migration_plan(
// Migration definitions
// ---------------------------------------------------------------------------
pub const ADMIN_MIGRATIONS: &[Migration] = &[Migration {
version: 1,
name: "initial_schema",
sql: r#"
pub const ADMIN_MIGRATIONS: &[Migration] = &[
Migration {
version: 1,
name: "initial_schema",
sql: r#"
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
@@ -156,7 +157,26 @@ pub const ADMIN_MIGRATIONS: &[Migration] = &[Migration {
value TEXT NOT NULL
);
"#,
}];
},
Migration {
version: 2,
name: "remove_api_keys_fk",
sql: r#"
CREATE TABLE api_keys_new (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at TEXT NOT NULL,
last_used_at TEXT
);
INSERT INTO api_keys_new (id, user_id, key_hash, name, created_at, last_used_at)
SELECT id, user_id, key_hash, name, created_at, last_used_at FROM api_keys;
DROP TABLE api_keys;
ALTER TABLE api_keys_new RENAME TO api_keys;
"#,
},
];
pub const CONTENT_MIGRATIONS: &[Migration] = &[
Migration {
@@ -315,6 +335,11 @@ pub const ANALYTICS_MIGRATIONS: &[Migration] = &[
CREATE INDEX IF NOT EXISTS idx_qr_access_ts ON qr_access_log(timestamp);
"#,
},
Migration {
version: 3,
name: "add_owner_user_id",
sql: "ALTER TABLE visits ADD COLUMN owner_user_id INTEGER;",
},
];
pub const SYSTEM_MIGRATIONS: &[Migration] = &[
@@ -384,4 +409,163 @@ pub const SYSTEM_MIGRATIONS: &[Migration] = &[
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_events(action);
"#,
},
Migration {
version: 3,
name: "global_slugs_and_moderation",
sql: r#"
CREATE TABLE IF NOT EXISTS global_slugs (
slug TEXT PRIMARY KEY,
owner_user_id INTEGER NOT NULL,
target_type TEXT NOT NULL,
target_id TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
status TEXT NOT NULL,
deleted_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_global_slugs_owner ON global_slugs(owner_user_id);
CREATE INDEX IF NOT EXISTS idx_global_slugs_status ON global_slugs(status);
CREATE INDEX IF NOT EXISTS idx_global_slugs_target ON global_slugs(target_type, target_id);
CREATE TABLE IF NOT EXISTS moderation_events (
id TEXT PRIMARY KEY,
timestamp TEXT NOT NULL,
admin_username TEXT NOT NULL,
target_user_id INTEGER NOT NULL,
target_username TEXT,
resource_type TEXT NOT NULL,
resource_identifier TEXT NOT NULL,
action TEXT NOT NULL,
severity TEXT NOT NULL,
reason TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS slug_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL,
old_owner_user_id INTEGER,
new_owner_user_id INTEGER,
action TEXT NOT NULL,
timestamp TEXT NOT NULL,
admin_username TEXT
);
CREATE TABLE IF NOT EXISTS reserved_slugs (
slug TEXT PRIMARY KEY,
reason TEXT
);
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- Seed defaults
INSERT OR IGNORE INTO schema_version (version, applied_at) VALUES (3, datetime('now'));
INSERT OR IGNORE INTO settings (key, value) VALUES ('soft_delete_retention_days', '30');
INSERT OR IGNORE INTO settings (key, value) VALUES ('quota_reconcile_interval_hours', '24');
INSERT OR IGNORE INTO settings (key, value) VALUES ('allow_registration', 'false');
INSERT OR IGNORE INTO settings (key, value) VALUES ('maintenance_mode', 'false');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('admin', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('login', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('logout', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('dashboard', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('api', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('docs', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('assets', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('static', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('favicon.ico', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('robots.txt', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('health', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('metrics', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('install', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('setup', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('support', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('help', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('security', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('abuse', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('billing', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('status', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('legacy_admin', 'System reserved');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('administrator', 'System reserved');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('system', 'System reserved');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('root', 'System reserved');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('www', 'System reserved');
"#,
},
];
pub const USERS_MIGRATIONS: &[Migration] = &[
Migration {
version: 1,
name: "initial_schema",
sql: r#"
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
last_login TEXT,
account_type TEXT DEFAULT 'standard',
organization_id INTEGER NULL,
metadata TEXT
);
CREATE TABLE IF NOT EXISTS quotas (
user_id INTEGER PRIMARY KEY,
max_urls INTEGER DEFAULT 100,
max_landings INTEGER DEFAULT 10,
max_api_tokens INTEGER DEFAULT 5,
max_storage_mb INTEGER DEFAULT 100,
current_urls INTEGER DEFAULT 0,
current_landings INTEGER DEFAULT 0,
current_api_tokens INTEGER DEFAULT 0,
current_storage_mb INTEGER DEFAULT 0,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS api_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token_hash TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS username_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
old_username TEXT NOT NULL,
new_username TEXT NOT NULL,
changed_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
"#,
},
Migration {
version: 2,
name: "repair_admin_account_type",
sql: r#"
UPDATE users
SET account_type = 'admin'
WHERE username = 'admin' AND account_type = 'standard';
"#,
},
];
+377 -46
View File
@@ -1,6 +1,7 @@
use crate::config::Config;
use crate::db::migrations::{
run_migrations, ADMIN_MIGRATIONS, ANALYTICS_MIGRATIONS, CONTENT_MIGRATIONS, SYSTEM_MIGRATIONS,
USERS_MIGRATIONS,
};
use crate::db::sqlite::{enable_foreign_keys, enable_wal};
use rusqlite::Connection;
@@ -15,6 +16,7 @@ pub mod migrations;
pub mod preview;
pub mod qr;
pub mod sqlite;
pub mod users;
#[derive(Clone)]
pub struct Db {
@@ -22,70 +24,88 @@ pub struct Db {
pub content: Arc<Mutex<Connection>>,
pub analytics: Arc<Mutex<Connection>>,
pub system: Arc<Mutex<Connection>>,
pub users: Arc<Mutex<Connection>>,
pub data_dir: std::path::PathBuf,
}
impl Db {
pub fn init(config: &Config) -> Result<Self, Box<dyn std::error::Error>> {
use chrono::Utc;
use tracing::info;
// Ensure data directory exists
if !config.data_dir.exists() {
fs::create_dir_all(&config.data_dir)?;
}
let admin_path = config.data_dir.join("admin.db");
let content_path = config.data_dir.join("content.db");
let analytics_path = config.data_dir.join("analytics.db");
let system_path = config.data_dir.join("system.db");
let admin_dir = config.data_dir.join("admin");
let users_dir = config.data_dir.join("users");
fs::create_dir_all(&admin_dir)?;
fs::create_dir_all(&users_dir)?;
use tracing::info;
// Automated Legacy Migration: check if legacy files are at the root
let legacy_admin_db = config.data_dir.join("admin.db");
let legacy_content_db = config.data_dir.join("content.db");
let legacy_analytics_db = config.data_dir.join("analytics.db");
// 1. If legacy admin.db exists at root, move admin/system DBs to config.data_dir/admin/
if legacy_admin_db.exists() {
info!("Legacy admin.db found at root. Moving administrative databases to admin/ subfolder...");
let files = vec![
"admin.db",
"admin.db-wal",
"admin.db-shm",
"system.db",
"system.db-wal",
"system.db-shm",
];
for f in files {
let src = config.data_dir.join(f);
if src.exists() {
let dst = admin_dir.join(f);
let _ = fs::rename(&src, &dst);
}
}
}
let admin_path = admin_dir.join("admin.db");
let system_path = admin_dir.join("system.db");
let users_db_path = admin_dir.join("users.db");
info!("Opening admin.db");
let mut admin_conn = Connection::open(admin_path)?;
info!("Opening content.db");
let mut content_conn = Connection::open(content_path)?;
info!("Opening analytics.db");
let mut analytics_conn = Connection::open(analytics_path)?;
info!("Opening system.db");
let mut system_conn = Connection::open(system_path)?;
info!("Opening users.db");
let mut users_conn = Connection::open(users_db_path)?;
// Enable WAL mode for better concurrency and write performance
info!(database = "admin", "Enabling WAL mode on admin.db");
enable_wal(&admin_conn, "admin")?;
info!(database = "content", "Enabling WAL mode on content.db");
enable_wal(&content_conn, "content")?;
info!(database = "analytics", "Enabling WAL mode on analytics.db");
enable_wal(&analytics_conn, "analytics")?;
info!(database = "system", "Enabling WAL mode on system.db");
enable_wal(&system_conn, "system")?;
enable_wal(&users_conn, "users")?;
// Enable foreign key support
info!(
database = "admin",
"Enabling foreign key enforcement on admin.db"
);
enable_foreign_keys(&admin_conn, "admin")?;
info!(
database = "content",
"Enabling foreign key enforcement on content.db"
);
enable_foreign_keys(&content_conn, "content")?;
info!(
database = "analytics",
"Enabling foreign key enforcement on analytics.db"
);
enable_foreign_keys(&analytics_conn, "analytics")?;
info!(
database = "system",
"Enabling foreign key enforcement on system.db"
);
enable_foreign_keys(&system_conn, "system")?;
enable_foreign_keys(&users_conn, "users")?;
// 1. Run migrations for system.db first, as it receives secondary audit records
// Run migrations for system.db first
info!("Running system migrations");
run_migrations(&mut system_conn, "system", SYSTEM_MIGRATIONS, None)?;
let system_arc = Arc::new(Mutex::new(system_conn));
// 2. Run migrations for other databases with system.db logging
// Pre-migration detection of admin account repair
let repair_needed = {
let stmt = users_conn.prepare(
"SELECT EXISTS(SELECT 1 FROM users WHERE username = 'admin' AND account_type = 'standard');"
);
match stmt {
Ok(mut s) => s
.query_row([], |row| row.get::<_, bool>(0))
.unwrap_or(false),
Err(_) => false,
}
};
// Run migrations for admin.db and users.db
info!("Running admin migrations");
run_migrations(
&mut admin_conn,
@@ -93,14 +113,131 @@ impl Db {
ADMIN_MIGRATIONS,
Some(&system_arc),
)?;
info!("Running content migrations");
info!("Running users migrations");
run_migrations(
&mut users_conn,
"users",
USERS_MIGRATIONS,
Some(&system_arc),
)?;
// Post-migration: audit log if repaired
if repair_needed {
let admin_is_now_admin: bool = users_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM users WHERE username = 'admin' AND account_type = 'admin');",
[],
|row| row.get(0),
)
.unwrap_or(false);
if admin_is_now_admin {
let system_conn = system_arc.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
"admin",
"migration_repair",
"users",
"admin",
Some("Repaired standard account type to admin"),
);
}
}
// Clean up expired sessions from users.db on startup
let now = Utc::now().to_rfc3339();
let _ = users_conn.execute("DELETE FROM sessions WHERE expires_at < ?1;", [now]);
// 2. If legacy content.db/analytics.db exists, move them to users/1/ (for legacy_admin)
let legacy_migration_needed = legacy_content_db.exists() || legacy_analytics_db.exists();
// Ensure legacy_admin (user ID 1) exists in users.db
let legacy_admin_id = 1i64;
let legacy_admin_exists: bool = users_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
[legacy_admin_id],
|row| row.get(0),
)
.unwrap_or(false);
if !legacy_admin_exists {
// Get copied administrator password hash
let admin_password_hash: String = admin_conn
.query_row(
"SELECT password_hash FROM users ORDER BY created_at ASC LIMIT 1;",
[],
|row| row.get(0),
)
.unwrap_or_else(|_| {
// If admin_db is empty, hash a default password
crate::auth::password::hash_password("legacy_admin_pass").unwrap_or_default()
});
let now = Utc::now().to_rfc3339();
users_conn.execute(
"INSERT INTO users (id, username, password_hash, status, created_at, account_type)
VALUES (?1, ?2, ?3, ?4, ?5, ?6);",
rusqlite::params![
legacy_admin_id,
"legacy_admin",
admin_password_hash,
"disabled",
now,
"system"
],
)?;
// Seed quotas
users_conn.execute(
"INSERT INTO quotas (user_id) VALUES (?1);",
[legacy_admin_id],
)?;
}
let legacy_user_dir = users_dir.join(legacy_admin_id.to_string());
fs::create_dir_all(&legacy_user_dir)?;
if legacy_content_db.exists() || legacy_analytics_db.exists() {
info!("Legacy content/analytics databases found at root. Moving to user ID 1 directory...");
let content_files = vec!["content.db", "content.db-wal", "content.db-shm"];
for f in content_files {
let src = config.data_dir.join(f);
if src.exists() {
let dst = legacy_user_dir.join(f);
let _ = fs::rename(&src, &dst);
}
}
let analytics_files = vec!["analytics.db", "analytics.db-wal", "analytics.db-shm"];
for f in analytics_files {
let src = config.data_dir.join(f);
if src.exists() {
let dst = legacy_user_dir.join(f);
let _ = fs::rename(&src, &dst);
}
}
}
// Open the legacy_admin databases (user ID 1) as db.content and db.analytics
let content_path = legacy_user_dir.join("content.db");
let analytics_path = legacy_user_dir.join("analytics.db");
let mut content_conn = Connection::open(content_path)?;
let mut analytics_conn = Connection::open(analytics_path)?;
enable_wal(&content_conn, "content")?;
enable_wal(&analytics_conn, "analytics")?;
enable_foreign_keys(&content_conn, "content")?;
enable_foreign_keys(&analytics_conn, "analytics")?;
// Run migrations for content.db and analytics.db
run_migrations(
&mut content_conn,
"content",
CONTENT_MIGRATIONS,
Some(&system_arc),
)?;
info!("Running analytics migrations");
run_migrations(
&mut analytics_conn,
"analytics",
@@ -108,26 +245,220 @@ impl Db {
Some(&system_arc),
)?;
Ok(Self {
// If we just migrated legacy content, populate the global_slugs table in system.db
if legacy_migration_needed {
info!("Populating global slug index with legacy content...");
let mut sys_lock = system_arc.lock().unwrap();
let tx = sys_lock.transaction()?;
// Extract urls from content.db and insert into global_slugs
{
let mut stmt =
content_conn.prepare("SELECT code, id, created_at, status FROM urls;")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let slug: String = row.get(0)?;
let target_id: String = row.get(1)?;
let created_at: String = row.get(2)?;
let status: String = row.get(3)?;
let global_status = if status == "dead" {
"disabled"
} else {
"active"
};
let now = Utc::now().to_rfc3339();
let _ = tx.execute(
"INSERT OR IGNORE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
rusqlite::params![slug, legacy_admin_id, "url", target_id, created_at, now, global_status],
);
}
}
// Extract landing pages from content.db and insert into global_slugs
{
let mut stmt = content_conn
.prepare("SELECT code, id, created_at, state FROM landing_pages;")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let slug: String = row.get(0)?;
let target_id: String = row.get(1)?;
let created_at: String = row.get(2)?;
let state: String = row.get(3)?;
let now = Utc::now().to_rfc3339();
let status = if state == "published" {
"active"
} else {
"disabled"
};
let _ = tx.execute(
"INSERT OR IGNORE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
rusqlite::params![slug, legacy_admin_id, "page", target_id, created_at, now, status],
);
}
}
tx.commit()?;
info!("Global slug index populated successfully.");
}
let db = Self {
admin: Arc::new(Mutex::new(admin_conn)),
content: Arc::new(Mutex::new(content_conn)),
analytics: Arc::new(Mutex::new(analytics_conn)),
system: system_arc,
})
users: Arc::new(Mutex::new(users_conn)),
data_dir: config.data_dir.clone(),
};
let _ = db.reconcile_global_slugs(config);
Ok(db)
}
pub fn compact(&self) -> Result<(), rusqlite::Error> {
let admin = self.admin.lock().unwrap();
admin.execute("VACUUM;", [])?;
let _ = admin.execute("VACUUM;", []);
let content = self.content.lock().unwrap();
content.execute("VACUUM;", [])?;
let _ = content.execute("VACUUM;", []);
let analytics = self.analytics.lock().unwrap();
analytics.execute("VACUUM;", [])?;
let _ = analytics.execute("VACUUM;", []);
let system = self.system.lock().unwrap();
system.execute("VACUUM;", [])?;
let _ = system.execute("VACUUM;", []);
let users = self.users.lock().unwrap();
let _ = users.execute("VACUUM;", []);
Ok(())
}
pub fn init_user_databases(&self, user_id: i64) -> Result<(), Box<dyn std::error::Error>> {
let user_dir = self.data_dir.join("users").join(user_id.to_string());
fs::create_dir_all(&user_dir)?;
let content_path = user_dir.join("content.db");
let analytics_path = user_dir.join("analytics.db");
let profile_path = user_dir.join("profile.db");
let mut content_conn = Connection::open(content_path)?;
let mut analytics_conn = Connection::open(analytics_path)?;
let profile_conn = Connection::open(profile_path)?;
enable_wal(&content_conn, "content")?;
enable_wal(&analytics_conn, "analytics")?;
enable_wal(&profile_conn, "profile")?;
enable_foreign_keys(&content_conn, "content")?;
enable_foreign_keys(&analytics_conn, "analytics")?;
enable_foreign_keys(&profile_conn, "profile")?;
run_migrations(
&mut content_conn,
"content",
CONTENT_MIGRATIONS,
Some(&self.system),
)?;
run_migrations(
&mut analytics_conn,
"analytics",
ANALYTICS_MIGRATIONS,
Some(&self.system),
)?;
profile_conn.execute_batch(
"CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);",
)?;
Ok(())
}
pub fn reconcile_global_slugs(
&self,
config: &Config,
) -> Result<(), Box<dyn std::error::Error>> {
use chrono::Utc;
let system_conn = self.system.lock().unwrap();
let users_conn = self.users.lock().unwrap();
// Get all user IDs
let mut stmt = users_conn.prepare("SELECT id FROM users;")?;
let mut rows = stmt.query([])?;
let mut user_ids = vec![1i64]; // Start with legacy admin
while let Some(row) = rows.next()? {
user_ids.push(row.get(0)?);
}
drop(rows);
drop(stmt);
for user_id in user_ids {
let user_dir = config.data_dir.join("users").join(user_id.to_string());
let content_path = if user_id == 1 {
config.data_dir.join("content.db") // legacy admin content db path
} else {
user_dir.join("content.db")
};
if content_path.exists() {
let content_conn = Connection::open(&content_path)?;
// Sync URLs
let mut stmt =
content_conn.prepare("SELECT code, id, created_at, status FROM urls;")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let code: String = row.get(0)?;
let target_id: String = row.get(1)?;
let created_at: String = row.get(2)?;
let status: String = row.get(3)?;
let global_status = if status == "dead" {
"disabled"
} else {
"active"
};
let now = Utc::now().to_rfc3339();
let _ = system_conn.execute(
"INSERT OR IGNORE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
rusqlite::params![code, user_id, "url", target_id, created_at, now, global_status],
);
}
// Sync Landing Pages
let mut stmt = content_conn
.prepare("SELECT code, id, created_at, state FROM landing_pages;")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let code: String = row.get(0)?;
let target_id: String = row.get(1)?;
let created_at: String = row.get(2)?;
let state: String = row.get(3)?;
let global_status = if state == "published" {
"active"
} else {
"disabled"
};
let now = Utc::now().to_rfc3339();
let _ = system_conn.execute(
"INSERT OR IGNORE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
rusqlite::params![code, user_id, "page", target_id, created_at, now, global_status],
);
}
}
}
Ok(())
}
+545
View File
@@ -0,0 +1,545 @@
use crate::models::{TenantUser, UserApiToken, UserQuotas, UserSession};
use chrono::Utc;
use rusqlite::{params, Connection, OptionalExtension};
// --- User Operations ---
pub fn is_reserved_username(username: &str) -> bool {
let u = username.trim().to_lowercase();
u == "admin" || u == "legacy_admin" || u == "administrator" || u == "system" || u == "root"
}
pub fn create_admin_user(
conn: &Connection,
username: &str,
password_hash: &str,
) -> rusqlite::Result<TenantUser> {
let created_at = Utc::now().to_rfc3339();
let status = "active";
let account_type = "admin";
conn.execute(
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata)
VALUES (?1, ?2, ?3, ?4, ?5, NULL);",
params![username, password_hash, status, created_at, account_type],
)?;
let id = conn.last_insert_rowid();
// Seed default quotas
conn.execute("INSERT INTO quotas (user_id) VALUES (?1);", params![id])?;
Ok(TenantUser {
id,
username: username.to_string(),
password_hash: password_hash.to_string(),
status: status.to_string(),
created_at,
last_login: None,
account_type: account_type.to_string(),
organization_id: None,
metadata: None,
})
}
pub fn create_user(
conn: &Connection,
username: &str,
password_hash: &str,
account_type: &str,
metadata: Option<&str>,
) -> rusqlite::Result<TenantUser> {
if is_reserved_username(username) {
return Err(rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
Some("Username is reserved".to_string()),
));
}
let created_at = Utc::now().to_rfc3339();
let status = "active";
conn.execute(
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata)
VALUES (?1, ?2, ?3, ?4, ?5, ?6);",
params![
username,
password_hash,
status,
created_at,
account_type,
metadata
],
)?;
let id = conn.last_insert_rowid();
// Seed default quotas
conn.execute("INSERT INTO quotas (user_id) VALUES (?1);", params![id])?;
Ok(TenantUser {
id,
username: username.to_string(),
password_hash: password_hash.to_string(),
status: status.to_string(),
created_at,
last_login: None,
account_type: account_type.to_string(),
organization_id: None,
metadata: metadata.map(|s| s.to_string()),
})
}
pub fn get_user_by_id(conn: &Connection, id: i64) -> rusqlite::Result<Option<TenantUser>> {
conn.query_row(
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
FROM users WHERE id = ?1;",
params![id],
|row| {
Ok(TenantUser {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
status: row.get(3)?,
created_at: row.get(4)?,
last_login: row.get(5)?,
account_type: row.get(6)?,
organization_id: row.get(7)?,
metadata: row.get(8)?,
})
},
)
.optional()
}
pub fn get_user_by_username(
conn: &Connection,
username: &str,
) -> rusqlite::Result<Option<TenantUser>> {
conn.query_row(
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
FROM users WHERE username = ?1;",
params![username],
|row| {
Ok(TenantUser {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
status: row.get(3)?,
created_at: row.get(4)?,
last_login: row.get(5)?,
account_type: row.get(6)?,
organization_id: row.get(7)?,
metadata: row.get(8)?,
})
},
)
.optional()
}
pub fn delete_user(conn: &Connection, id: i64) -> rusqlite::Result<()> {
conn.execute("DELETE FROM users WHERE id = ?1;", params![id])?;
Ok(())
}
pub fn update_user_status(conn: &Connection, id: i64, status: &str) -> rusqlite::Result<()> {
conn.execute(
"UPDATE users SET status = ?1 WHERE id = ?2;",
params![status, id],
)?;
Ok(())
}
pub fn update_user_account_type(
conn: &Connection,
id: i64,
account_type: &str,
) -> rusqlite::Result<()> {
conn.execute(
"UPDATE users SET account_type = ?1 WHERE id = ?2;",
params![account_type, id],
)?;
Ok(())
}
pub fn reset_user_password(
conn: &Connection,
id: i64,
new_password_hash: &str,
) -> rusqlite::Result<()> {
conn.execute(
"UPDATE users SET password_hash = ?1 WHERE id = ?2;",
params![new_password_hash, id],
)?;
Ok(())
}
pub fn update_user_last_login(conn: &Connection, id: i64) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
conn.execute(
"UPDATE users SET last_login = ?1 WHERE id = ?2;",
params![now, id],
)?;
Ok(())
}
pub fn list_users(conn: &Connection) -> rusqlite::Result<Vec<TenantUser>> {
let mut stmt = conn.prepare(
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
FROM users ORDER BY username ASC;",
)?;
let rows = stmt.query_map([], |row| {
Ok(TenantUser {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
status: row.get(3)?,
created_at: row.get(4)?,
last_login: row.get(5)?,
account_type: row.get(6)?,
organization_id: row.get(7)?,
metadata: row.get(8)?,
})
})?;
let mut users = Vec::new();
for u in rows {
users.push(u?);
}
Ok(users)
}
pub fn log_username_change(
conn: &Connection,
user_id: i64,
old_username: &str,
new_username: &str,
) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO username_history (user_id, old_username, new_username, changed_at) VALUES (?1, ?2, ?3, ?4);",
params![user_id, old_username, new_username, now],
)?;
conn.execute(
"UPDATE users SET username = ?1 WHERE id = ?2;",
params![new_username, user_id],
)?;
Ok(())
}
// --- Session Operations ---
pub fn create_user_session(
conn: &Connection,
session_id: &str,
user_id: i64,
expires_at_rfc3339: &str,
) -> rusqlite::Result<UserSession> {
let created_at = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);",
params![session_id, user_id, expires_at_rfc3339, created_at],
)?;
Ok(UserSession {
id: session_id.to_string(),
user_id,
expires_at: expires_at_rfc3339.to_string(),
created_at,
})
}
pub fn get_user_session(
conn: &Connection,
session_id: &str,
) -> rusqlite::Result<Option<UserSession>> {
conn.query_row(
"SELECT id, user_id, expires_at, created_at FROM sessions WHERE id = ?1;",
params![session_id],
|row| {
Ok(UserSession {
id: row.get(0)?,
user_id: row.get(1)?,
expires_at: row.get(2)?,
created_at: row.get(3)?,
})
},
)
.optional()
}
pub fn delete_user_session(conn: &Connection, session_id: &str) -> rusqlite::Result<()> {
conn.execute("DELETE FROM sessions WHERE id = ?1;", params![session_id])?;
Ok(())
}
pub fn cleanup_expired_user_sessions(conn: &Connection) -> rusqlite::Result<usize> {
let now = Utc::now().to_rfc3339();
let count = conn.execute("DELETE FROM sessions WHERE expires_at < ?1;", params![now])?;
Ok(count)
}
// --- Quota Operations ---
pub fn get_user_quotas(conn: &Connection, user_id: i64) -> rusqlite::Result<Option<UserQuotas>> {
conn.query_row(
"SELECT user_id, max_urls, max_landings, max_api_tokens, max_storage_mb,
current_urls, current_landings, current_api_tokens, current_storage_mb
FROM quotas WHERE user_id = ?1;",
params![user_id],
|row| {
Ok(UserQuotas {
user_id: row.get(0)?,
max_urls: row.get(1)?,
max_landings: row.get(2)?,
max_api_tokens: row.get(3)?,
max_storage_mb: row.get(4)?,
current_urls: row.get(5)?,
current_landings: row.get(6)?,
current_api_tokens: row.get(7)?,
current_storage_mb: row.get(8)?,
})
},
)
.optional()
}
pub fn update_user_quotas(
conn: &Connection,
user_id: i64,
max_urls: i64,
max_landings: i64,
max_api_tokens: i64,
max_storage_mb: i64,
) -> rusqlite::Result<()> {
conn.execute(
"UPDATE quotas SET max_urls = ?1, max_landings = ?2, max_api_tokens = ?3, max_storage_mb = ?4
WHERE user_id = ?5;",
params![max_urls, max_landings, max_api_tokens, max_storage_mb, user_id],
)?;
Ok(())
}
pub fn increment_quota_counter(
conn: &Connection,
user_id: i64,
field: &str,
) -> rusqlite::Result<()> {
let sql = match field {
"urls" => "UPDATE quotas SET current_urls = current_urls + 1 WHERE user_id = ?1;",
"landings" => {
"UPDATE quotas SET current_landings = current_landings + 1 WHERE user_id = ?1;"
}
"api_tokens" => {
"UPDATE quotas SET current_api_tokens = current_api_tokens + 1 WHERE user_id = ?1;"
}
_ => return Err(rusqlite::Error::InvalidQuery),
};
conn.execute(sql, params![user_id])?;
Ok(())
}
pub fn decrement_quota_counter(
conn: &Connection,
user_id: i64,
field: &str,
) -> rusqlite::Result<()> {
let sql = match field {
"urls" => "UPDATE quotas SET current_urls = MAX(0, current_urls - 1) WHERE user_id = ?1;",
"landings" => "UPDATE quotas SET current_landings = MAX(0, current_landings - 1) WHERE user_id = ?1;",
"api_tokens" => "UPDATE quotas SET current_api_tokens = MAX(0, current_api_tokens - 1) WHERE user_id = ?1;",
_ => return Err(rusqlite::Error::InvalidQuery),
};
conn.execute(sql, params![user_id])?;
Ok(())
}
pub fn update_quota_storage(
conn: &Connection,
user_id: i64,
storage_mb: i64,
) -> rusqlite::Result<()> {
conn.execute(
"UPDATE quotas SET current_storage_mb = ?1 WHERE user_id = ?2;",
params![storage_mb, user_id],
)?;
Ok(())
}
// --- API Token Operations ---
pub fn create_user_api_token(
conn: &Connection,
user_id: i64,
token_hash: &str,
) -> rusqlite::Result<UserApiToken> {
let created_at = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO api_tokens (user_id, token_hash, created_at) VALUES (?1, ?2, ?3);",
params![user_id, token_hash, created_at],
)?;
let id = conn.last_insert_rowid();
// Increment api token counter
let _ = increment_quota_counter(conn, user_id, "api_tokens");
Ok(UserApiToken {
id,
user_id,
token_hash: token_hash.to_string(),
created_at,
})
}
pub fn list_user_api_tokens(
conn: &Connection,
user_id: i64,
) -> rusqlite::Result<Vec<UserApiToken>> {
let mut stmt = conn.prepare(
"SELECT id, user_id, token_hash, created_at FROM api_tokens WHERE user_id = ?1 ORDER BY id DESC;",
)?;
let rows = stmt.query_map(params![user_id], |row| {
Ok(UserApiToken {
id: row.get(0)?,
user_id: row.get(1)?,
token_hash: row.get(2)?,
created_at: row.get(3)?,
})
})?;
let mut tokens = Vec::new();
for t in rows {
tokens.push(t?);
}
Ok(tokens)
}
pub fn delete_user_api_token(conn: &Connection, id: i64, user_id: i64) -> rusqlite::Result<()> {
let deleted = conn.execute(
"DELETE FROM api_tokens WHERE id = ?1 AND user_id = ?2;",
params![id, user_id],
)?;
if deleted > 0 {
let _ = decrement_quota_counter(conn, user_id, "api_tokens");
}
Ok(())
}
// --- Global Slug & Quota Reconciliation Helpers ---
pub fn is_slug_available(system_conn: &Connection, slug: &str) -> rusqlite::Result<bool> {
// 1. Check reserved list
let reserved: bool = system_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM reserved_slugs WHERE slug = ?1);",
[slug],
|row| row.get(0),
)
.unwrap_or(false);
if reserved {
return Ok(false);
}
// 2. Check global slugs
let exists: bool = system_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = ?1);",
[slug],
|row| row.get(0),
)
.unwrap_or(false);
Ok(!exists)
}
pub fn register_global_slug(
system_conn: &Connection,
slug: &str,
owner_user_id: i64,
target_type: &str,
target_id: &str,
) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
system_conn.execute(
"INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
rusqlite::params![slug, owner_user_id, target_type, target_id, now, now, "active"],
)?;
// Insert history
system_conn.execute(
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp)
VALUES (?1, NULL, ?2, 'created', ?3);",
rusqlite::params![slug, owner_user_id, now],
)?;
Ok(())
}
pub fn release_global_slug(
system_conn: &Connection,
slug: &str,
owner_user_id: i64,
) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
system_conn.execute("DELETE FROM global_slugs WHERE slug = ?1;", [slug])?;
// Insert history
system_conn.execute(
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp)
VALUES (?1, ?2, NULL, 'released', ?3);",
rusqlite::params![slug, owner_user_id, now],
)?;
Ok(())
}
pub fn soft_delete_global_slug(
system_conn: &Connection,
slug: &str,
owner_user_id: i64,
) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
system_conn.execute(
"UPDATE global_slugs SET status = 'soft_deleted', deleted_at = ?1 WHERE slug = ?2;",
rusqlite::params![now, slug],
)?;
// Insert history
system_conn.execute(
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp)
VALUES (?1, ?2, NULL, 'deleted', ?3);",
rusqlite::params![slug, owner_user_id, now],
)?;
Ok(())
}
pub fn reconcile_user_quotas(
users_conn: &Connection,
user_id: i64,
content_conn: &Connection,
) -> rusqlite::Result<()> {
let urls_count: i64 = content_conn
.query_row("SELECT COUNT(*) FROM urls;", [], |row| row.get(0))
.unwrap_or(0);
let landings_count: i64 = content_conn
.query_row("SELECT COUNT(*) FROM landing_pages;", [], |row| row.get(0))
.unwrap_or(0);
let api_tokens_count: i64 = users_conn
.query_row(
"SELECT COUNT(*) FROM api_tokens WHERE user_id = ?1;",
[user_id],
|row| row.get(0),
)
.unwrap_or(0);
users_conn.execute(
"UPDATE quotas SET current_urls = ?1, current_landings = ?2, current_api_tokens = ?3 WHERE user_id = ?4;",
rusqlite::params![urls_count, landings_count, api_tokens_count, user_id],
)?;
Ok(())
}
+32 -15
View File
@@ -10,23 +10,43 @@ pub async fn run_aggregator(db: Db, interval_mins: u64) {
tokio::time::sleep(Duration::from_secs(interval_mins * 60)).await;
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
View File
@@ -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
View File
@@ -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
View File
@@ -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(())
}
+36
View File
@@ -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)
}
+42
View File
@@ -0,0 +1,42 @@
use crate::db::Db;
use std::time::Duration;
use tracing::{error, info};
pub async fn run_quota_reconciliation(db: Db, interval_hours: u64) {
loop {
// Sleep first
tokio::time::sleep(Duration::from_secs(interval_hours * 3600)).await;
info!("Running background quota reconciliation...");
let user_ids: Vec<i64> = {
let conn = db.users.lock().unwrap();
let mut stmt = match conn.prepare("SELECT id FROM users;") {
Ok(s) => s,
Err(e) => {
error!("Failed to prepare select user IDs: {:?}", e);
continue;
}
};
let rows = match stmt.query_map([], |row| row.get(0)) {
Ok(r) => r,
Err(e) => {
error!("Failed to query user IDs: {:?}", e);
continue;
}
};
rows.filter_map(|r| r.ok()).collect()
};
let users_conn = db.users.lock().unwrap();
for user_id in user_ids {
if let Ok(content_conn) = super::open_user_content_conn(&db, user_id) {
if let Err(e) =
crate::db::users::reconcile_user_quotas(&users_conn, user_id, &content_conn)
{
error!("Failed to reconcile quotas for user {}: {:?}", user_id, e);
}
}
}
info!("Quota reconciliation finished.");
}
}
+42 -10
View File
@@ -15,18 +15,50 @@ pub async fn run_retention_cleaner(db: Db, retention_days_opt: Option<i64>) {
tokio::time::sleep(Duration::from_secs(24 * 3600)).await;
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
View File
@@ -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
View File
@@ -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};
+190
View File
@@ -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,
}
}
}
+1
View File
@@ -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)]
+70
View File
@@ -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
View File
@@ -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(),
}
}
}
+47
View File
@@ -51,3 +51,50 @@ impl IntoResponse for AuditTemplate {
}
}
}
#[derive(Template)]
#[template(path = "user_status.html")]
pub struct UserStatusTemplate {
pub admin_username: String,
pub app_status: &'static str,
pub db_status: String,
pub queue_size: usize,
pub memory_usage: String,
pub uptime: String,
pub version: &'static str,
pub git_commit: &'static str,
pub urls: Vec<crate::models::Url>,
}
#[derive(Template)]
#[template(path = "user_audit.html")]
pub struct UserAuditTemplate {
pub admin_username: String,
pub logs: Vec<AuditLog>,
}
impl IntoResponse for UserStatusTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
impl IntoResponse for UserAuditTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
+33
View File
@@ -0,0 +1,33 @@
use askama::Template;
use axum::{
http::StatusCode,
response::{Html, IntoResponse, Response},
};
#[derive(Template)]
#[template(path = "user_dashboard.html")]
pub struct UserDashboardTemplate {
pub admin_username: String,
pub total_urls: i64,
pub total_pages: i64,
pub total_clicks: i64,
pub active_links: i64,
pub dead_links: i64,
pub traffic_chart: String,
pub countries_chart: String,
pub browsers_chart: String,
pub referrers_chart: String,
}
impl IntoResponse for UserDashboardTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
+38
View File
@@ -0,0 +1,38 @@
use crate::models::LandingPage;
use askama::Template;
use axum::{
http::StatusCode,
response::{Html, IntoResponse, Response},
};
#[derive(Template)]
#[template(path = "user_pages.html")]
pub struct UserPagesTemplate {
pub admin_username: String,
pub username: String,
pub pages: Vec<LandingPage>,
pub csrf_token: String,
pub error: Option<String>,
pub current_page: usize,
pub total_pages: usize,
pub visible_pages: Vec<usize>,
}
impl UserPagesTemplate {
pub fn is_current(&self, page: &usize) -> bool {
*page == self.current_page
}
}
impl IntoResponse for UserPagesTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
+28
View File
@@ -0,0 +1,28 @@
use askama::Template;
use axum::{
http::StatusCode,
response::{Html, IntoResponse, Response},
};
#[derive(Template)]
#[template(path = "user_settings.html")]
pub struct UserSettingsTemplate {
pub admin_username: String,
pub username: String,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
impl IntoResponse for UserSettingsTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
+40
View File
@@ -0,0 +1,40 @@
use crate::models::Url;
use askama::Template;
use axum::{
http::StatusCode,
response::{Html, IntoResponse, Response},
};
#[derive(Template)]
#[template(path = "user_urls.html")]
pub struct UserUrlsTemplate {
pub admin_username: String,
pub username: String,
pub urls: Vec<Url>,
pub csrf_token: String,
pub error: Option<String>,
pub tag_filter: Option<String>,
pub base_url: String,
pub current_page: usize,
pub total_pages: usize,
pub visible_pages: Vec<usize>,
}
impl UserUrlsTemplate {
pub fn is_current(&self, page: &usize) -> bool {
*page == self.current_page
}
}
impl IntoResponse for UserUrlsTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
+26
View File
@@ -0,0 +1,26 @@
use crate::models::TenantUser;
use askama::Template;
use axum::response::{Html, IntoResponse, Response};
#[derive(Template)]
#[template(path = "users.html")]
pub struct UsersTemplate {
pub admin_username: String,
pub users: Vec<TenantUser>,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
impl IntoResponse for UsersTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
+3807 -75
View File
File diff suppressed because it is too large. Load diff
+14 -14
View File
@@ -167,7 +167,7 @@ pub async fn api_create_url(
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
let _ = 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
View File
@@ -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",
+1
View File
@@ -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;
+991
View File
@@ -0,0 +1,991 @@
use axum::{
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Json, Response},
};
use chrono::Utc;
use rusqlite::OptionalExtension;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::auth::ApiUser;
use crate::models::ApiActor;
use crate::state::AppState;
#[derive(Serialize, Deserialize)]
pub struct CreateUserRequest {
pub username: String,
pub password: String,
pub account_type: Option<String>,
pub metadata: Option<String>,
}
#[derive(Serialize)]
pub struct UserResponse {
pub id: i64,
pub username: String,
pub status: String,
pub account_type: String,
pub created_at: String,
pub metadata: Option<String>,
}
#[derive(Serialize, Deserialize)]
pub struct UpdateUserStatusRequest {
pub status: String,
}
#[derive(Serialize, Deserialize)]
pub struct UpdateUserQuotasRequest {
pub max_urls: i64,
pub max_landings: i64,
pub max_api_tokens: i64,
pub max_storage_mb: i64,
}
#[derive(Serialize, Deserialize)]
pub struct ResetPasswordRequest {
pub password: String,
}
#[derive(Serialize, Deserialize)]
pub struct TransferSlugRequest {
pub slug: String,
pub new_owner_user_id: i64,
}
#[derive(Serialize, Deserialize)]
pub struct ModerateSlugRequest {
pub slug: String,
pub action: String, // 'flagged', 'disabled', 'active'
pub severity: String, // 'low', 'medium', 'high', 'critical'
pub reason: String,
}
#[derive(Serialize)]
pub struct ModerationEventResponse {
pub id: String,
pub timestamp: String,
pub admin_username: String,
pub target_user_id: i64,
pub target_username: Option<String>,
pub resource_type: String,
pub resource_identifier: String,
pub action: String,
pub severity: String,
pub reason: String,
}
#[derive(Serialize, Deserialize)]
pub struct ChangeOwnPasswordRequest {
pub old_password: String,
pub new_password: String,
}
#[derive(Serialize, Deserialize)]
pub struct CreateApiTokenRequest {
// No request body needed, token is generated securely
}
#[derive(Serialize)]
pub struct CreateApiTokenResponse {
pub id: i64,
pub token: String, // Cleartext token returned once
pub created_at: String,
}
// Helper: Ensure the request actor is an Admin
#[allow(clippy::result_large_err)]
fn require_admin_role(user: &ApiUser) -> Result<&crate::models::User, Response> {
match &user.0 {
ApiActor::Admin(admin) => Ok(admin),
_ => Err((StatusCode::FORBIDDEN, "Admin privileges required").into_response()),
}
}
// --- Admin: User CRUD Endpoints ---
// GET /api/v1/admin/users
pub async fn admin_list_users(State(state): State<AppState>, user: ApiUser) -> Response {
if let Err(err_resp) = require_admin_role(&user) {
return err_resp;
}
let conn = state.users_db.lock().unwrap();
match crate::db::users::list_users(&conn) {
Ok(users) => {
let resp: Vec<UserResponse> = users
.into_iter()
.map(|u| UserResponse {
id: u.id,
username: u.username,
status: u.status,
account_type: u.account_type,
created_at: u.created_at,
metadata: u.metadata,
})
.collect();
Json(resp).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
// POST /api/v1/admin/users
pub async fn admin_create_user(
State(state): State<AppState>,
user: ApiUser,
Json(payload): Json<CreateUserRequest>,
) -> Response {
let admin = match require_admin_role(&user) {
Ok(a) => a,
Err(err_resp) => return err_resp,
};
// Username validation: minimum 3 chars, alphanumeric, hyphen, underscore
let username = payload.username.trim().to_lowercase();
if username.len() < 3 {
return (
StatusCode::BAD_REQUEST,
"Username must be at least 3 characters",
)
.into_response();
}
if !username
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
return (
StatusCode::BAD_REQUEST,
"Username must contain only alphanumeric characters, hyphens, or underscores",
)
.into_response();
}
// Hash password
let hash = match crate::auth::password::hash_password(&payload.password) {
Ok(h) => h,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Hashing error: {}", e),
)
.into_response()
}
};
let conn = state.users_db.lock().unwrap();
let account_type = payload.account_type.as_deref().unwrap_or("standard");
match crate::db::users::create_user(
&conn,
&username,
&hash,
account_type,
payload.metadata.as_deref(),
) {
Ok(new_user) => {
// Write system audit event
{
let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&admin.username,
"USER_CREATION",
"user",
&new_user.id.to_string(),
Some(&format!("Username: {}", new_user.username)),
);
}
Json(UserResponse {
id: new_user.id,
username: new_user.username,
status: new_user.status,
account_type: new_user.account_type,
created_at: new_user.created_at,
metadata: new_user.metadata,
})
.into_response()
}
Err(rusqlite::Error::SqliteFailure(err, _))
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
{
(StatusCode::CONFLICT, "Username already exists").into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
// PUT /api/v1/admin/users/:id/status
pub async fn admin_update_user_status(
State(state): State<AppState>,
user: ApiUser,
Path(target_id): Path<i64>,
Json(payload): Json<UpdateUserStatusRequest>,
) -> Response {
let admin = match require_admin_role(&user) {
Ok(a) => a,
Err(err_resp) => return err_resp,
};
let status = payload.status.trim().to_lowercase();
if !["active", "disabled", "suspended", "pending", "deleted"].contains(&status.as_str()) {
return (StatusCode::BAD_REQUEST, "Invalid user status").into_response();
}
let conn = state.users_db.lock().unwrap();
match crate::db::users::update_user_status(&conn, target_id, &status) {
Ok(_) => {
let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&admin.username,
"USER_STATUS_UPDATE",
"user",
&target_id.to_string(),
Some(&format!("New Status: {}", status)),
);
StatusCode::OK.into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
// PUT /api/v1/admin/users/:id/quotas
pub async fn admin_update_user_quotas(
State(state): State<AppState>,
user: ApiUser,
Path(target_id): Path<i64>,
Json(payload): Json<UpdateUserQuotasRequest>,
) -> Response {
let admin = match require_admin_role(&user) {
Ok(a) => a,
Err(err_resp) => return err_resp,
};
let conn = state.users_db.lock().unwrap();
match crate::db::users::update_user_quotas(
&conn,
target_id,
payload.max_urls,
payload.max_landings,
payload.max_api_tokens,
payload.max_storage_mb,
) {
Ok(_) => {
let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&admin.username,
"USER_QUOTA_UPDATE",
"user",
&target_id.to_string(),
Some(&format!(
"max_urls: {}, max_landings: {}, max_api_tokens: {}, max_storage_mb: {}",
payload.max_urls,
payload.max_landings,
payload.max_api_tokens,
payload.max_storage_mb
)),
);
StatusCode::OK.into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
// POST /api/v1/admin/users/:id/password
pub async fn admin_reset_user_password(
State(state): State<AppState>,
user: ApiUser,
Path(target_id): Path<i64>,
Json(payload): Json<ResetPasswordRequest>,
) -> Response {
let admin = match require_admin_role(&user) {
Ok(a) => a,
Err(err_resp) => return err_resp,
};
let hash = match crate::auth::password::hash_password(&payload.password) {
Ok(h) => h,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Hashing error: {}", e),
)
.into_response()
}
};
let conn = state.users_db.lock().unwrap();
match crate::db::users::reset_user_password(&conn, target_id, &hash) {
Ok(_) => {
let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&admin.username,
"USER_PASSWORD_RESET",
"user",
&target_id.to_string(),
None,
);
StatusCode::OK.into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
pub fn delete_user_resources(
state: &AppState,
target_id: i64,
admin_username: &str,
force: bool,
) -> Result<(), String> {
if target_id == 1 && !force {
return Err("Deleting legacy_admin system account requires force flag".to_string());
}
let user_details = {
let conn = state.users_db.lock().unwrap();
match crate::db::users::get_user_by_id(&conn, target_id) {
Ok(Some(u)) => u,
Ok(None) => return Err("User not found".to_string()),
Err(e) => return Err(e.to_string()),
}
};
// 1. Transactional clean up on system.db (deleting their global slug mappings)
{
let mut system_conn = state.system_db.lock().unwrap();
let tx = system_conn.transaction().map_err(|e| e.to_string())?;
let slugs: Vec<String> = {
let mut stmt = tx
.prepare("SELECT slug FROM global_slugs WHERE owner_user_id = ?1;")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([target_id], |row| row.get(0))
.map_err(|e| e.to_string())?;
rows.filter_map(|r| r.ok()).collect()
};
let now = Utc::now().to_rfc3339();
for slug in slugs {
let _ = tx.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&slug]);
let _ = tx.execute(
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username)
VALUES (?1, ?2, NULL, 'deleted', ?3, ?4);",
rusqlite::params![slug, target_id, now, admin_username],
);
}
tx.commit()
.map_err(|e| format!("Failed to release slugs: {}", e))?;
}
let user_dir = state
.config
.data_dir
.join("users")
.join(target_id.to_string());
if user_dir.exists() {
let _ = std::fs::remove_dir_all(&user_dir);
}
let conn = state.users_db.lock().unwrap();
crate::db::users::delete_user(&conn, target_id).map_err(|e| e.to_string())?;
let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
admin_username,
"USER_DELETION",
"user",
&target_id.to_string(),
Some(&format!("Username: {}", user_details.username)),
);
Ok(())
}
// DELETE /api/v1/admin/users/:id
pub async fn admin_delete_user(
State(state): State<AppState>,
user: ApiUser,
Path(target_id): Path<i64>,
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
) -> Response {
let admin = match require_admin_role(&user) {
Ok(a) => a,
Err(err_resp) => return err_resp,
};
let force = params.get("force").map(|v| v == "true").unwrap_or(false);
match delete_user_resources(&state, target_id, &admin.username, force) {
Ok(_) => StatusCode::OK.into_response(),
Err(err) if err == "User not found" => StatusCode::NOT_FOUND.into_response(),
Err(err) if err == "Deleting legacy_admin system account requires force flag" => {
(StatusCode::BAD_REQUEST, err).into_response()
}
Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err).into_response(),
}
}
// --- Admin: Slug Transfer ---
// POST /api/v1/admin/transfers
pub async fn admin_transfer_slug(
State(state): State<AppState>,
user: ApiUser,
Json(payload): Json<TransferSlugRequest>,
) -> Response {
let admin = match require_admin_role(&user) {
Ok(a) => a,
Err(err_resp) => return err_resp,
};
// 1. Check if the slug exists and get details
let (old_owner_user_id, target_type, _target_id) = {
let system_conn = state.system_db.lock().unwrap();
let mut stmt = match system_conn.prepare(
"SELECT owner_user_id, target_type, target_id FROM global_slugs WHERE slug = ?1;",
) {
Ok(s) => s,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
let row_opt = stmt
.query_row([&payload.slug], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
})
.optional();
match row_opt {
Ok(Some(r)) => r,
Ok(None) => return (StatusCode::NOT_FOUND, "Slug not found").into_response(),
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
};
if old_owner_user_id == payload.new_owner_user_id {
return (
StatusCode::BAD_REQUEST,
"New owner must be different from the current owner",
)
.into_response();
}
// 2. Fetch destination databases
let old_dbs = match state.get_user_dbs(old_owner_user_id) {
Ok(dbs) => dbs,
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to load current owner's database",
)
.into_response()
}
};
let new_dbs = match state.get_user_dbs(payload.new_owner_user_id) {
Ok(dbs) => dbs,
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to load new owner's database",
)
.into_response()
}
};
// 3. Perform transfer: copy record from old owner's content.db to new owner's content.db
let mut new_target_id = String::new();
let transfer_success = {
let old_conn = old_dbs.content.lock().unwrap();
let new_conn = new_dbs.content.lock().unwrap();
if target_type == "url" {
// Get URL record
let url_opt = match crate::db::content::get_url_by_code(&old_conn, &payload.slug) {
Ok(u) => u,
Err(e) => {
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()
}
};
if let Some(url) = url_opt {
// Check new owner quotas
let new_users_conn = state.users_db.lock().unwrap();
let quota_opt =
crate::db::users::get_user_quotas(&new_users_conn, payload.new_owner_user_id)
.unwrap_or(None);
if let Some(quota) = quota_opt {
if quota.current_urls >= quota.max_urls {
return (
StatusCode::BAD_REQUEST,
"New owner has exceeded URL quota limit",
)
.into_response();
}
}
// Insert into new owner database
let ins_res = crate::db::content::create_url_extended(
&new_conn,
&url.code,
&url.destination,
url.title.as_deref(),
url.description.as_deref(),
&url.tags,
url.expires_at.as_deref(),
url.password_hash.as_deref(),
url.max_access_count,
);
match ins_res {
Ok(new_url) => {
new_target_id = new_url.id;
// Delete from old owner database
let _ = crate::db::content::delete_url(&old_conn, &url.id);
true
}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to copy URL to new owner: {}", e),
)
.into_response();
}
}
} else {
false
}
} else if target_type == "page" {
// Get page record
let page_opt =
match crate::db::content::get_landing_page_by_code(&old_conn, &payload.slug) {
Ok(p) => p,
Err(e) => {
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()
}
};
if let Some(page) = page_opt {
// Check new owner quotas
let new_users_conn = state.users_db.lock().unwrap();
let quota_opt =
crate::db::users::get_user_quotas(&new_users_conn, payload.new_owner_user_id)
.unwrap_or(None);
if let Some(quota) = quota_opt {
if quota.current_landings >= quota.max_landings {
return (
StatusCode::BAD_REQUEST,
"New owner has exceeded landing page quota limit",
)
.into_response();
}
}
// Insert into new owner database
let ins_res = crate::db::content::create_landing_page(
&new_conn,
&page.code,
&page.slug,
&page.title,
&page.html_content,
&page.state,
);
match ins_res {
Ok(new_page) => {
new_target_id = new_page.id;
// Delete from old owner database
let _ = crate::db::content::delete_landing_page(&old_conn, &page.id);
true
}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to copy Page to new owner: {}", e),
)
.into_response();
}
}
} else {
false
}
} else {
false
}
};
if !transfer_success {
return (StatusCode::NOT_FOUND, "Content not found in owner database").into_response();
}
// 4. Update system global_slugs, slug_history and adjust quotas
{
let system_conn = state.system_db.lock().unwrap();
let now = Utc::now().to_rfc3339();
let _ = system_conn.execute(
"UPDATE global_slugs SET owner_user_id = ?1, target_id = ?2, updated_at = ?3 WHERE slug = ?4;",
rusqlite::params![payload.new_owner_user_id, new_target_id, now, payload.slug],
);
let _ = system_conn.execute(
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username)
VALUES (?1, ?2, ?3, 'transferred', ?4, ?5);",
rusqlite::params![payload.slug, old_owner_user_id, payload.new_owner_user_id, now, admin.username],
);
// Adjust quotas
let users_conn = state.users_db.lock().unwrap();
let field = if target_type == "url" {
"urls"
} else {
"landings"
};
let _ = crate::db::users::decrement_quota_counter(&users_conn, old_owner_user_id, field);
let _ = crate::db::users::increment_quota_counter(
&users_conn,
payload.new_owner_user_id,
field,
);
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&admin.username,
"SLUG_TRANSFER",
"slug",
&payload.slug,
Some(&format!(
"From owner {} to owner {}",
old_owner_user_id, payload.new_owner_user_id
)),
);
}
StatusCode::OK.into_response()
}
// --- Admin: Content Moderation ---
// POST /api/v1/admin/moderation
pub async fn admin_moderate_slug(
State(state): State<AppState>,
user: ApiUser,
Json(payload): Json<ModerateSlugRequest>,
) -> Response {
let admin = match require_admin_role(&user) {
Ok(a) => a,
Err(err_resp) => return err_resp,
};
let action = payload.action.trim().to_lowercase();
if !["flagged", "disabled", "active"].contains(&action.as_str()) {
return (StatusCode::BAD_REQUEST, "Invalid moderation action").into_response();
}
// 1. Verify slug and get owner user ID
let (owner_user_id, target_type) = {
let system_conn = state.system_db.lock().unwrap();
let row_opt: Option<(i64, String)> = system_conn
.query_row(
"SELECT owner_user_id, target_type FROM global_slugs WHERE slug = ?1;",
[&payload.slug],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()
.unwrap_or(None);
match row_opt {
Some(r) => r,
None => return (StatusCode::NOT_FOUND, "Slug not found").into_response(),
}
};
// Resolve owner username for log snapshot
let owner_username = {
let users_conn = state.users_db.lock().unwrap();
crate::db::users::get_user_by_id(&users_conn, owner_user_id)
.unwrap_or(None)
.map(|u| u.username)
};
// 2. Perform moderation update in global_slugs
{
let system_conn = state.system_db.lock().unwrap();
let now = Utc::now().to_rfc3339();
let _ = system_conn.execute(
"UPDATE global_slugs SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
rusqlite::params![action, now, payload.slug],
);
// Record moderation event
let event_id = Uuid::new_v4().to_string();
let _ = system_conn.execute(
"INSERT INTO moderation_events (id, timestamp, admin_username, target_user_id, target_username, resource_type, resource_identifier, action, severity, reason)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10);",
rusqlite::params![
event_id,
now,
admin.username,
owner_user_id,
owner_username,
target_type,
payload.slug,
action,
payload.severity,
payload.reason
],
);
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&admin.username,
"CONTENT_MODERATION",
"slug",
&payload.slug,
Some(&format!("Action: {}, Reason: {}", action, payload.reason)),
);
}
StatusCode::OK.into_response()
}
// GET /api/v1/admin/moderation/events
pub async fn admin_list_moderation_events(
State(state): State<AppState>,
user: ApiUser,
) -> Response {
if let Err(err_resp) = require_admin_role(&user) {
return err_resp;
}
let system_conn = state.system_db.lock().unwrap();
let mut stmt = match system_conn.prepare(
"SELECT id, timestamp, admin_username, target_user_id, target_username, resource_type, resource_identifier, action, severity, reason
FROM moderation_events ORDER BY timestamp DESC;"
) {
Ok(s) => s,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
let rows = stmt.query_map([], |row| {
Ok(ModerationEventResponse {
id: row.get(0)?,
timestamp: row.get(1)?,
admin_username: row.get(2)?,
target_user_id: row.get(3)?,
target_username: row.get(4)?,
resource_type: row.get(5)?,
resource_identifier: row.get(6)?,
action: row.get(7)?,
severity: row.get(8)?,
reason: row.get(9)?,
})
});
match rows {
Ok(mapped) => {
let events: Vec<ModerationEventResponse> = mapped.filter_map(|r| r.ok()).collect();
Json(events).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
// --- User: Dashboard, profile settings, API tokens ---
// GET /api/v1/user/profile
pub async fn user_get_profile(State(state): State<AppState>, user: ApiUser) -> Response {
let tenant_user = match user.0 {
ApiActor::User(u) => u,
ApiActor::Admin(_) => {
return (
StatusCode::BAD_REQUEST,
"Profile endpoints are for tenant users only",
)
.into_response();
}
};
let users_conn = state.users_db.lock().unwrap();
let quotas = crate::db::users::get_user_quotas(&users_conn, tenant_user.id).unwrap_or(None);
Json(serde_json::json!({
"id": tenant_user.id,
"username": tenant_user.username,
"status": tenant_user.status,
"account_type": tenant_user.account_type,
"created_at": tenant_user.created_at,
"metadata": tenant_user.metadata,
"quotas": quotas,
}))
.into_response()
}
// POST /api/v1/user/password
pub async fn user_change_password(
State(state): State<AppState>,
user: ApiUser,
Json(payload): Json<ChangeOwnPasswordRequest>,
) -> Response {
let tenant_user = match user.0 {
ApiActor::User(u) => u,
ApiActor::Admin(_) => {
return (
StatusCode::BAD_REQUEST,
"Change password is for tenant users only",
)
.into_response();
}
};
// Verify old password
if !crate::auth::password::verify_password(&payload.old_password, &tenant_user.password_hash) {
return (StatusCode::UNAUTHORIZED, "Invalid current password").into_response();
}
// Hash new password
let hash = match crate::auth::password::hash_password(&payload.new_password) {
Ok(h) => h,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Hashing error: {}", e),
)
.into_response()
}
};
let users_conn = state.users_db.lock().unwrap();
match crate::db::users::reset_user_password(&users_conn, tenant_user.id, &hash) {
Ok(_) => {
let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&tenant_user.username,
"PASSWORD_CHANGE",
"user",
&tenant_user.id.to_string(),
None,
);
StatusCode::OK.into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
// GET /api/v1/user/api-tokens
pub async fn user_list_api_tokens(State(state): State<AppState>, user: ApiUser) -> Response {
let tenant_user = match user.0 {
ApiActor::User(u) => u,
ApiActor::Admin(_) => {
return (
StatusCode::BAD_REQUEST,
"API tokens are for tenant users only",
)
.into_response();
}
};
let users_conn = state.users_db.lock().unwrap();
match crate::db::users::list_user_api_tokens(&users_conn, tenant_user.id) {
Ok(tokens) => Json(tokens).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
// POST /api/v1/user/api-tokens
pub async fn user_create_api_token(State(state): State<AppState>, user: ApiUser) -> Response {
let tenant_user = match user.0 {
ApiActor::User(u) => u,
ApiActor::Admin(_) => {
return (
StatusCode::BAD_REQUEST,
"API tokens are for tenant users only",
)
.into_response();
}
};
// 1. Quota check
let users_conn = state.users_db.lock().unwrap();
let quotas = crate::db::users::get_user_quotas(&users_conn, tenant_user.id).unwrap_or(None);
if let Some(quota) = quotas {
if quota.current_api_tokens >= quota.max_api_tokens {
return (StatusCode::BAD_REQUEST, "API tokens quota limit exceeded").into_response();
}
}
// 2. Generate secure token
let token_secret = format!("bzo_{}", crate::auth::session::generate_token(16)); // bzo_ followed by 32 hex chars
// Hash token using SHA-256 for storing
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(token_secret.as_bytes());
let token_hash = hex::encode(hasher.finalize());
match crate::db::users::create_user_api_token(&users_conn, tenant_user.id, &token_hash) {
Ok(api_token) => {
let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&tenant_user.username,
"API_TOKEN_CREATION",
"api_token",
&api_token.id.to_string(),
None,
);
Json(CreateApiTokenResponse {
id: api_token.id,
token: token_secret, // Return cleartext once
created_at: api_token.created_at,
})
.into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
// DELETE /api/v1/user/api-tokens/:id
pub async fn user_delete_api_token(
State(state): State<AppState>,
user: ApiUser,
Path(token_id): Path<i64>,
) -> Response {
let tenant_user = match user.0 {
ApiActor::User(u) => u,
ApiActor::Admin(_) => {
return (
StatusCode::BAD_REQUEST,
"API tokens are for tenant users only",
)
.into_response();
}
};
let users_conn = state.users_db.lock().unwrap();
match crate::db::users::delete_user_api_token(&users_conn, token_id, tenant_user.id) {
Ok(_) => {
let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&tenant_user.username,
"API_TOKEN_DELETION",
"api_token",
&token_id.to_string(),
None,
);
StatusCode::OK.into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
+51 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}