use axum::{ extract::{ConnectInfo, Path, Query, State}, http::{HeaderMap, StatusCode}, response::{IntoResponse, Redirect, Response}, Form, }; use axum_extra::extract::cookie::Cookie; use axum_extra::extract::CookieJar; use chrono::Utc; use flate2::read::GzDecoder; use flate2::write::GzEncoder; use flate2::Compression; use rusqlite::{params, OptionalExtension}; use serde::Deserialize; use std::collections::HashMap; use std::fs::File; use std::net::SocketAddr; use tar::Builder; use uuid::Uuid; use crate::db::admin::{ create_api_key, delete_api_key, get_config, get_user_count, list_api_keys, set_config, write_audit_log as write_audit_log_legacy, }; #[allow(clippy::too_many_arguments)] fn write_audit_log( conn: &rusqlite::Connection, state: &AppState, username: &str, action: &str, object_type: Option<&str>, object_id: Option<&str>, ip_address: Option<&str>, user_agent: Option<&str>, ) -> rusqlite::Result { let res = write_audit_log_legacy( conn, username, action, object_type, object_id, ip_address, user_agent, ); // Also write to unified audit events in system.db let system_conn = state.system_db.lock().unwrap(); let metadata = format!("IP: {:?}, UA: {:?}", ip_address, user_agent); let _ = crate::db::audit_events::write_audit_event( &system_conn, username, action, object_type.unwrap_or(""), object_id.unwrap_or(""), Some(&metadata), ); res } use crate::auth::{ authenticate_admin_session, authenticate_user_session, generate_csrf_token, generate_token, hash_password, verify_csrf, verify_password, verify_sha256, }; use crate::charts::{generate_bar_chart, generate_line_chart}; use crate::db::analytics::{ clean_referrer, get_clicks_trend, get_clicks_trend_raw, get_metric_rankings, get_metric_rankings_raw, get_monthly_clicks_trend, get_target_unique_visitors, get_target_visit_total_filtered, get_target_visits_all_in_memory, get_target_visits_paginated, get_total_clicks, get_visits_schema_columns, parse_ua, }; use crate::db::content::{ create_landing_page, delete_landing_page, delete_url, get_landing_page_by_id, get_landing_page_count, get_url_by_id, get_url_count_by_tag, get_url_counts, list_landing_pages, list_urls, }; use crate::models::User; use crate::state::AppState; use crate::utils::{get_client_ip, get_db_file_info, get_memory_usage}; const PAGE_SIZE: usize = 25; const ANALYTICS_PAGE_SIZE: usize = 50; const MAX_JSON_EXPORT_ROWS: usize = 50_000; // Helper: Verify admin session and return user or redirect to login async fn require_auth(state: &AppState, jar: &CookieJar) -> Result<(User, String), Redirect> { let conn = state.users_db.lock().unwrap(); match authenticate_admin_session(&conn, jar) { Ok(Some((user, session_id))) => Ok((user, session_id)), _ => Err(Redirect::to("/admin/login")), } } // Helper: Verify tenant user session and return user or redirect to login async fn require_user_auth( state: &AppState, jar: &CookieJar, ) -> Result<(crate::models::TenantUser, String), Redirect> { let conn = state.users_db.lock().unwrap(); match authenticate_user_session(&conn, jar) { Ok(Some((user, session_id))) => Ok((user, session_id)), _ => Err(Redirect::to("/login")), } } // GET /admin pub async fn admin_index(State(state): State, jar: CookieJar) -> Response { match require_auth(&state, &jar).await { Ok(_) => Redirect::to("/admin/dashboard").into_response(), Err(redir) => redir.into_response(), } } // GET /admin/login pub async fn login_get( State(state): State, jar: CookieJar, Query(params): Query>, ) -> Response { let error = params.get("error").cloned(); let csrf_token = generate_token(16); let cookie = Cookie::build(("bzod_temp_csrf", csrf_token.clone())) .path("/admin/login") .secure(state.config.cookie_secure) .http_only(true) .same_site(axum_extra::extract::cookie::SameSite::Strict) .max_age(time::Duration::minutes(10)) .build(); let new_jar = jar.add(cookie); let template = crate::templates::LoginTemplate { error, csrf_token, action: "/admin/login".to_string(), title: "Admin Login".to_string(), subtitle: "Administrative Access".to_string(), button_text: "Sign In".to_string(), }; (new_jar, template).into_response() } #[derive(Deserialize)] pub struct LoginForm { pub username: String, pub password: String, pub csrf_token: String, } // POST /admin/login pub async fn login_post( State(state): State, jar: CookieJar, headers: HeaderMap, connect_info: Option>, Form(form): Form, ) -> Response { let temp_csrf = jar .get("bzod_temp_csrf") .map(|c| c.value().to_string()) .unwrap_or_default(); if temp_csrf.is_empty() || temp_csrf != form.csrf_token { return Redirect::to("/admin/login?error=Invalid CSRF token").into_response(); } let ip = get_client_ip(&headers, connect_info); let (user_count, admin_count, active_session_count) = { let conn = state.users_db.lock().unwrap(); let u_count: i64 = conn .query_row("SELECT COUNT(*) FROM users;", [], |r| r.get(0)) .unwrap_or(0); let a_count: i64 = conn .query_row( "SELECT COUNT(*) FROM users WHERE account_type = 'admin';", [], |r| r.get(0), ) .unwrap_or(0); let now = Utc::now().to_rfc3339(); let s_count: i64 = conn .query_row( "SELECT COUNT(*) FROM sessions WHERE expires_at > ?1;", [now], |r| r.get(0), ) .unwrap_or(0); (u_count, a_count, s_count) }; let bootstrap_allowed = user_count <= 1 && admin_count == 0 && active_session_count == 0; let user_opt = if bootstrap_allowed && form.username == state.config.admin_username && verify_sha256(&form.password, &state.config.bootstrap_password_sha256) { // Bootstrap Phase using BOOTSTRAP_PASSWORD_SHA256 let hash = match hash_password(&form.password) { Ok(h) => h, Err(_) => { return Redirect::to("/admin/login?error=Internal hashing error").into_response() } }; let conn = state.users_db.lock().unwrap(); match crate::db::users::create_admin_user(&conn, &form.username, &hash) { Ok(u) => { // Initialize user specific directory and DB files if let Err(e) = state.db.init_user_databases(u.id) { tracing::error!( "Failed to init user databases during admin bootstrap: {:?}", e ); } // Write audit event in system.db let system_conn = state.system_db.lock().unwrap(); let metadata = format!( "IP: {:?}, UA: {:?}", ip, headers.get("user-agent").and_then(|h| h.to_str().ok()) ); let _ = crate::db::audit_events::write_audit_event( &system_conn, &u.username, "BOOTSTRAP_USER_PROVISIONED", "user", &u.id.to_string(), Some(&metadata), ); Some(User { id: u.id.to_string(), username: u.username, password_hash: u.password_hash, created_at: u.created_at, }) } Err(e) => { tracing::error!("Failed to create admin user during bootstrap: {:?}", e); None } } } else { // Standard DB Verification let conn = state.users_db.lock().unwrap(); let user_res: Result, rusqlite::Error> = conn.query_row( "SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata FROM users WHERE username = ?1;", [&form.username], |row| { Ok(crate::models::TenantUser { id: row.get(0)?, username: row.get(1)?, password_hash: row.get(2)?, status: row.get(3)?, created_at: row.get(4)?, last_login: row.get(5)?, account_type: row.get(6)?, organization_id: row.get(7)?, metadata: row.get(8)?, }) } ).optional(); match user_res { Ok(Some(u)) => { if u.status != "active" { tracing::warn!( username = form.username, reason = "account_disabled", "login rejected" ); None } else if u.account_type != "admin" { tracing::warn!( username = form.username, reason = "insufficient_privileges", "login rejected" ); None } else if verify_password(&form.password, &u.password_hash) { Some(User { id: u.id.to_string(), username: u.username, password_hash: u.password_hash, created_at: u.created_at, }) } else { tracing::warn!( username = form.username, reason = "invalid_credentials", "login rejected" ); None } } _ => { tracing::warn!( username = form.username, reason = "user_not_found", "login rejected" ); None } } }; match user_opt { Some(user) => { let session_token = generate_token(32); let expires = (Utc::now() + chrono::Duration::days(30)).to_rfc3339(); { let conn = state.users_db.lock().unwrap(); let user_id_i64 = user.id.parse::().unwrap_or(0); let now = Utc::now().to_rfc3339(); let _ = conn.execute( "INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);", rusqlite::params![session_token, user_id_i64, expires, now], ); // Write audit event in system.db let system_conn = state.system_db.lock().unwrap(); let metadata = format!( "IP: {:?}, UA: {:?}", ip, headers.get("user-agent").and_then(|h| h.to_str().ok()) ); let _ = crate::db::audit_events::write_audit_event( &system_conn, &user.username, "USER_LOGIN", "session", &session_token, Some(&metadata), ); } let cookie = Cookie::build(("bzod_session", session_token)) .path("/") .secure(state.config.cookie_secure) .http_only(true) .same_site(axum_extra::extract::cookie::SameSite::Strict) .max_age(time::Duration::days(30)) .build(); let clear_temp = Cookie::build("bzod_temp_csrf") .path("/admin/login") .max_age(time::Duration::ZERO) .build(); let mut response_jar = jar.clone(); response_jar = response_jar.add(cookie).add(clear_temp); (response_jar, Redirect::to("/admin/dashboard")).into_response() } None => { { let system_conn = state.system_db.lock().unwrap(); let metadata = format!( "IP: {:?}, UA: {:?}", ip, headers.get("user-agent").and_then(|h| h.to_str().ok()) ); let _ = crate::db::audit_events::write_audit_event( &system_conn, "anonymous", "LOGIN_FAILED", "login", "", Some(&metadata), ); } Redirect::to("/admin/login?error=Invalid username or password").into_response() } } } // GET /logout pub async fn public_logout(State(_state): State, jar: CookieJar) -> Response { let cookie = Cookie::build("bzod_user_session") .path("/") .max_age(time::Duration::ZERO) .build(); let mut response_jar = jar.clone(); response_jar = response_jar.add(cookie); (response_jar, Redirect::to("/login")).into_response() } // GET /login pub async fn public_login_get( State(state): State, jar: CookieJar, Query(params): Query>, ) -> Response { let error = params.get("error").cloned(); let csrf_token = generate_token(16); let cookie = Cookie::build(("bzod_temp_csrf", csrf_token.clone())) .path("/login") .secure(state.config.cookie_secure) .http_only(true) .same_site(axum_extra::extract::cookie::SameSite::Strict) .max_age(time::Duration::minutes(10)) .build(); let new_jar = jar.add(cookie); let template = crate::templates::LoginTemplate { error, csrf_token, action: "/login".to_string(), title: "User Login".to_string(), subtitle: "Standard account access".to_string(), button_text: "Sign In".to_string(), }; (new_jar, template).into_response() } // POST /login pub async fn public_login_post( State(state): State, jar: CookieJar, headers: HeaderMap, connect_info: Option>, Form(form): Form, ) -> Response { let temp_csrf = jar .get("bzod_temp_csrf") .map(|c| c.value().to_string()) .unwrap_or_default(); if temp_csrf.is_empty() || temp_csrf != form.csrf_token { return Redirect::to("/login?error=Invalid CSRF token").into_response(); } let ip = get_client_ip(&headers, connect_info); let user_opt: Option = { let conn = state.users_db.lock().unwrap(); let user_res: Result, rusqlite::Error> = conn.query_row( "SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata \ FROM users WHERE username = ?1;", [&form.username], |row| { Ok(crate::models::TenantUser { id: row.get(0)?, username: row.get(1)?, password_hash: row.get(2)?, status: row.get(3)?, created_at: row.get(4)?, last_login: row.get(5)?, account_type: row.get(6)?, organization_id: row.get(7)?, metadata: row.get(8)?, }) } ).optional(); match user_res { Ok(Some(u)) => { if u.status != "active" { None } else if verify_password(&form.password, &u.password_hash) { Some(u) } else { None } } _ => None, } }; match user_opt { Some(user) => { let session_token = generate_token(32); let expires = (Utc::now() + chrono::Duration::days(30)).to_rfc3339(); { let conn = state.users_db.lock().unwrap(); let _ = crate::db::users::create_user_session(&conn, &session_token, user.id, &expires); let system_conn = state.system_db.lock().unwrap(); let metadata = format!( "IP: {:?}, UA: {:?}", ip, headers.get("user-agent").and_then(|h| h.to_str().ok()) ); let _ = crate::db::audit_events::write_audit_event( &system_conn, &user.username, "USER_LOGIN", "session", &session_token, Some(&metadata), ); } let cookie = Cookie::build(("bzod_user_session", session_token)) .path("/") .secure(state.config.cookie_secure) .http_only(true) .same_site(axum_extra::extract::cookie::SameSite::Strict) .max_age(time::Duration::days(30)) .build(); let clear_temp = Cookie::build("bzod_temp_csrf") .path("/login") .max_age(time::Duration::ZERO) .build(); let mut response_jar = jar.clone(); response_jar = response_jar.add(cookie).add(clear_temp); (response_jar, Redirect::to("/user/dashboard")).into_response() } None => { let system_conn = state.system_db.lock().unwrap(); let metadata = format!( "IP: {:?}, UA: {:?}", ip, headers.get("user-agent").and_then(|h| h.to_str().ok()) ); let _ = crate::db::audit_events::write_audit_event( &system_conn, "anonymous", "LOGIN_FAILED", "login", "", Some(&metadata), ); Redirect::to("/login?error=Invalid username or password").into_response() } } } // GET /user/dashboard pub async fn user_dashboard_get(State(state): State, jar: CookieJar) -> Response { let (user, _session_id) = match require_user_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let user_dbs = match state.get_user_dbs(user.id) { Ok(dbs) => dbs, Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), }; let (total_urls, active_links, dead_links) = { let conn = user_dbs.content.lock().unwrap(); get_url_counts(&conn).unwrap_or((0, 0, 0)) }; let total_pages = { let conn = user_dbs.content.lock().unwrap(); get_landing_page_count(&conn).unwrap_or(0) }; let total_clicks = { let conn = user_dbs.analytics.lock().unwrap(); get_total_clicks(&conn).unwrap_or(0) }; let clicks_data = { let conn = user_dbs.analytics.lock().unwrap(); get_clicks_trend(&conn, "url", "all", 30) .or_else(|_| get_clicks_trend_raw(&conn, "url", "all", 30)) .unwrap_or_default() }; let mut trend_map = std::collections::BTreeMap::new(); for i in (0..30).rev() { let date_str = (Utc::now() - chrono::Duration::days(i)) .format("%Y-%m-%d") .to_string(); trend_map.insert(date_str, 0i64); } for (d, c) in clicks_data { trend_map.insert(d, c); } let formatted_trend: Vec<(String, i64)> = trend_map.into_iter().collect(); let traffic_chart = generate_line_chart(&formatted_trend); let countries_data = { let conn = user_dbs.analytics.lock().unwrap(); get_metric_rankings(&conn, "url", "all", "country", 5) .or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "country", 5)) .unwrap_or_default() }; let countries_chart = generate_bar_chart(&countries_data); let referrers_data = { let conn = user_dbs.analytics.lock().unwrap(); get_metric_rankings(&conn, "url", "all", "referrer", 5) .or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "referrer", 5)) .unwrap_or_default() }; let referrers_chart = generate_bar_chart(&referrers_data); let browsers_data = { let conn = user_dbs.analytics.lock().unwrap(); get_metric_rankings(&conn, "url", "all", "browser", 5) .or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "browser", 5)) .unwrap_or_default() }; let browsers_chart = generate_bar_chart(&browsers_data); let template = crate::templates::UserDashboardTemplate { admin_username: user.username, total_urls, total_pages, total_clicks, active_links, dead_links, traffic_chart, countries_chart, browsers_chart, referrers_chart, }; template.into_response() } #[derive(Deserialize)] pub struct UserUrlsQuery { pub tag: Option, pub error: Option, pub page: Option, } #[derive(Deserialize)] pub struct CreateUserUrlForm { pub destination: String, #[serde(default)] pub code: String, #[serde(default)] pub custom_slug: String, #[serde(default)] pub title: String, #[serde(default)] pub description: String, #[serde(default)] pub tags: String, pub csrf_token: String, #[serde(default)] pub expires_at: String, #[serde(default)] pub password: String, #[serde(default)] pub max_access_count: String, #[serde(default)] pub utm_source: String, #[serde(default)] pub utm_medium: String, #[serde(default)] pub utm_campaign: String, } pub async fn user_urls_get( State(state): State, jar: CookieJar, Query(query): Query, ) -> Response { let (user, session_id) = match require_user_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let user_dbs = match state.get_user_dbs(user.id) { Ok(dbs) => dbs, Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), }; let (urls, total_pages, page, visible_pages) = { let conn = user_dbs.content.lock().unwrap(); let total_records = if let Some(tag_str) = query.tag.as_deref() { get_url_count_by_tag(&conn, tag_str).unwrap_or(0) } else { get_url_counts(&conn).map(|(t, _, _)| t).unwrap_or(0) }; let calculated_total_pages = (total_records as usize).div_ceil(PAGE_SIZE); let total_pages = std::cmp::max(1, calculated_total_pages); let requested_page = query.page.unwrap_or(1); let current_page = if requested_page == 0 { 1 } else { requested_page } .clamp(1, total_pages); let offset = (current_page - 1) * PAGE_SIZE; let urls = list_urls(&conn, PAGE_SIZE as i64, offset as i64, query.tag.as_deref()) .unwrap_or_default(); let start_page = current_page.saturating_sub(3).max(1); let end_page = std::cmp::min(total_pages, current_page + 3); let visible_pages: Vec = (start_page..=end_page).collect(); (urls, total_pages, current_page, visible_pages) }; let csrf_token = generate_csrf_token(&session_id); let proto = if state.config.cookie_secure { "https" } else { "http" }; let base_url = state .config .base_url .clone() .unwrap_or_else(|| format!("{}://localhost:{}", proto, state.config.port)); let template = crate::templates::UserUrlsTemplate { admin_username: user.username.clone(), username: user.username, urls, csrf_token, error: query.error, tag_filter: query.tag, base_url, current_page: page, total_pages, visible_pages, }; template.into_response() } pub async fn user_urls_create( State(state): State, jar: CookieJar, headers: HeaderMap, connect_info: Option>, Form(form): Form, ) -> Response { let (user, session_id) = match require_user_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; if !verify_csrf(&session_id, &form.csrf_token) { return Redirect::to("/user/urls?error=Invalid CSRF token").into_response(); } let user_dbs = match state.get_user_dbs(user.id) { Ok(dbs) => dbs, Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), }; let ip = get_client_ip(&headers, connect_info); let mut code = form.custom_slug.trim().to_lowercase(); if code.is_empty() { code = form.code.trim().to_lowercase(); if code.is_empty() { code = generate_token(3); } else if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) { return Redirect::to("/user/urls?error=Custom code must be exactly 6 hex characters") .into_response(); } } else if !crate::utils::validation::validate_custom_slug(&code) { return Redirect::to( "/user/urls?error=Custom slug must start with ! followed by 1-24 a-z, 0-9, -, _", ) .into_response(); } { let users_conn = state.users_db.lock().unwrap(); if !crate::db::users::check_quota_limit(&users_conn, user.id, "urls").unwrap_or(false) { return Redirect::to("/user/urls?error=Quota limit exceeded").into_response(); } } { let system_conn = state.system_db.lock().unwrap(); if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) { return Redirect::to("/user/urls?error=Short code/slug already exists").into_response(); } if let Err(e) = crate::db::users::register_global_slug( &system_conn, &code, user.id, "url", "", "reserving", ) { return Redirect::to(&format!("/user/urls?error=Failed to reserve slug: {}", e)) .into_response(); } } let mut dest = form.destination.trim().to_string(); if let Ok(mut parsed) = reqwest::Url::parse(&dest) { let mut has_utm = false; { let mut query = parsed.query_pairs_mut(); if !form.utm_source.trim().is_empty() { query.append_pair("utm_source", form.utm_source.trim()); has_utm = true; } if !form.utm_medium.trim().is_empty() { query.append_pair("utm_medium", form.utm_medium.trim()); has_utm = true; } if !form.utm_campaign.trim().is_empty() { query.append_pair("utm_campaign", form.utm_campaign.trim()); has_utm = true; } } if has_utm { dest = parsed.to_string(); } } let expires_at_opt = if form.expires_at.trim().is_empty() { None } else { let mut rfc = form.expires_at.trim().to_string(); if rfc.len() == 16 { rfc.push_str(":00Z"); } Some(rfc) }; let password_hash_opt = if form.password.trim().is_empty() { None } else { match hash_password(&form.password) { Ok(h) => Some(h), Err(_) => return Redirect::to("/user/urls?error=Hashing error").into_response(), } }; let max_access_count_opt = if form.max_access_count.trim().is_empty() { None } else { match form.max_access_count.trim().parse::() { Ok(c) => Some(c), Err(_) => { return Redirect::to("/user/urls?error=Invalid max access count").into_response() } } }; let tags_list: Vec = form .tags .split(',') .map(|t| t.trim().to_string()) .filter(|t| !t.is_empty()) .collect(); let title_opt = if form.title.trim().is_empty() { None } else { Some(form.title.trim()) }; let desc_opt = if form.description.trim().is_empty() { None } else { Some(form.description.trim()) }; let res = { let conn = user_dbs.content.lock().unwrap(); crate::db::content::create_url_extended( &conn, &code, &dest, title_opt, desc_opt, &tags_list, expires_at_opt.as_deref(), password_hash_opt.as_deref(), max_access_count_opt, ) }; match res { Ok(url) => { { let system_conn = state.system_db.lock().unwrap(); let _ = system_conn.execute( "UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;", rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), code], ); } { let users_conn = state.users_db.lock().unwrap(); let _ = crate::db::users::increment_quota_counter(&users_conn, user.id, "urls"); } let _ = write_audit_log( &state.admin_db.lock().unwrap(), &state, &user.username, "USER_URL_CREATION", Some("url"), Some(&url.id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()), ); Redirect::to("/user/urls").into_response() } Err(e) => { let system_conn = state.system_db.lock().unwrap(); let _ = crate::db::users::release_global_slug(&system_conn, &code, user.id); Redirect::to(&format!("/user/urls?error=Database error: {}", e)).into_response() } } } pub async fn user_urls_delete( State(state): State, jar: CookieJar, headers: HeaderMap, connect_info: Option>, Path(id): Path, Form(form): Form>, ) -> Response { let (user, session_id) = match require_user_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let user_dbs = match state.get_user_dbs(user.id) { Ok(dbs) => dbs, Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), }; let csrf_token = form.get("csrf_token").cloned().unwrap_or_default(); if !verify_csrf(&session_id, &csrf_token) { return Redirect::to("/user/urls?error=Invalid CSRF token").into_response(); } let conn = user_dbs.content.lock().unwrap(); match get_url_by_id(&conn, &id) { Ok(Some(url)) => { let _ = crate::db::users::decrement_quota_counter( &state.users_db.lock().unwrap(), user.id, "urls", ); match delete_url(&conn, &id) { Ok(_) => { let _ = crate::db::users::release_global_slug( &state.system_db.lock().unwrap(), &url.code, user.id, ); let ip = get_client_ip(&headers, connect_info); let _ = write_audit_log( &state.admin_db.lock().unwrap(), &state, &user.username, "USER_URL_DELETION", Some("url"), Some(&id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()), ); Redirect::to("/user/urls").into_response() } Err(e) => Redirect::to(&format!("/user/urls?error=Failed to delete link: {}", e)) .into_response(), } } _ => Redirect::to("/user/urls?error=Link not found").into_response(), } } #[derive(Deserialize)] pub struct UserPagesQuery { pub error: Option, pub page: Option, } #[derive(Deserialize)] pub struct CreateUserPageForm { pub title: String, pub slug: String, pub code: String, pub custom_slug: String, pub state: String, pub html_content: String, pub csrf_token: String, } pub async fn user_pages_get( State(state): State, jar: CookieJar, Query(query): Query, ) -> Response { let (user, session_id) = match require_user_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let user_dbs = match state.get_user_dbs(user.id) { Ok(dbs) => dbs, Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), }; let (pages, total_pages, page, visible_pages) = { let conn = user_dbs.content.lock().unwrap(); let total_records = get_landing_page_count(&conn).unwrap_or(0); let calculated_total_pages = (total_records as usize).div_ceil(PAGE_SIZE); let total_pages = std::cmp::max(1, calculated_total_pages); let requested_page = query.page.unwrap_or(1); let current_page = if requested_page == 0 { 1 } else { requested_page } .clamp(1, total_pages); let offset = (current_page - 1) * PAGE_SIZE; let pages = list_landing_pages(&conn, PAGE_SIZE as i64, offset as i64).unwrap_or_default(); let start_page = current_page.saturating_sub(3).max(1); let end_page = std::cmp::min(total_pages, current_page + 3); let visible_pages: Vec = (start_page..=end_page).collect(); (pages, total_pages, current_page, visible_pages) }; let csrf_token = generate_csrf_token(&session_id); let template = crate::templates::UserPagesTemplate { admin_username: user.username.clone(), username: user.username, pages, csrf_token, error: query.error, current_page: page, total_pages, visible_pages, }; template.into_response() } pub async fn user_pages_create( State(state): State, jar: CookieJar, headers: HeaderMap, connect_info: Option>, Form(form): Form, ) -> Response { let (user, session_id) = match require_user_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; if !verify_csrf(&session_id, &form.csrf_token) { return Redirect::to("/user/pages?error=Invalid CSRF token").into_response(); } let user_dbs = match state.get_user_dbs(user.id) { Ok(dbs) => dbs, Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), }; let mut code = form.custom_slug.trim().to_lowercase(); if code.is_empty() { code = form.code.trim().to_lowercase(); if code.is_empty() { code = generate_token(2); } else if code.len() != 4 || !code.chars().all(|c| c.is_ascii_hexdigit()) { return Redirect::to("/user/pages?error=Custom code must be exactly 4 hex characters") .into_response(); } } else if !crate::utils::validation::validate_custom_slug(&code) { return Redirect::to( "/user/pages?error=Custom slug must start with ! followed by 1-24 a-z, 0-9, -, _", ) .into_response(); } let clean_slug = form.slug.trim().to_lowercase(); if clean_slug.is_empty() { return Redirect::to("/user/pages?error=Slug is required").into_response(); } { let users_conn = state.users_db.lock().unwrap(); if !crate::db::users::check_quota_limit(&users_conn, user.id, "landings").unwrap_or(false) { return Redirect::to("/user/pages?error=Quota limit exceeded").into_response(); } } { let system_conn = state.system_db.lock().unwrap(); if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) { return Redirect::to("/user/pages?error=Short code/slug already exists") .into_response(); } if let Err(e) = crate::db::users::register_global_slug( &system_conn, &code, user.id, "page", "", "reserving", ) { return Redirect::to(&format!("/user/pages?error=Failed to reserve slug: {}", e)) .into_response(); } } let res = { let conn = user_dbs.content.lock().unwrap(); create_landing_page( &conn, &code, &clean_slug, &form.title, &form.html_content, &form.state, ) }; match res { Ok(page) => { { let system_conn = state.system_db.lock().unwrap(); let global_status = if form.state == "published" { "active" } else { "disabled" }; let _ = system_conn.execute( "UPDATE global_slugs SET target_id = ?1, status = ?2, updated_at = ?3 WHERE slug = ?4;", rusqlite::params![page.id, global_status, chrono::Utc::now().to_rfc3339(), code], ); } { let users_conn = state.users_db.lock().unwrap(); let _ = crate::db::users::increment_quota_counter(&users_conn, user.id, "landings"); } let ip = get_client_ip(&headers, connect_info); let _ = write_audit_log( &state.admin_db.lock().unwrap(), &state, &user.username, "USER_PAGE_CREATION", Some("page"), Some(&page.id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()), ); Redirect::to("/user/pages").into_response() } Err(e) => { let system_conn = state.system_db.lock().unwrap(); let _ = crate::db::users::release_global_slug(&system_conn, &code, user.id); Redirect::to(&format!("/user/pages?error=Database error: {}", e)).into_response() } } } pub async fn user_pages_delete( State(state): State, jar: CookieJar, headers: HeaderMap, connect_info: Option>, Path(id): Path, Form(form): Form>, ) -> Response { let (user, session_id) = match require_user_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let user_dbs = match state.get_user_dbs(user.id) { Ok(dbs) => dbs, Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), }; let csrf_token = form.get("csrf_token").cloned().unwrap_or_default(); if !verify_csrf(&session_id, &csrf_token) { return Redirect::to("/user/pages?error=Invalid CSRF token").into_response(); } let conn = user_dbs.content.lock().unwrap(); match get_landing_page_by_id(&conn, &id) { Ok(Some(page)) => { let _ = crate::db::users::decrement_quota_counter( &state.users_db.lock().unwrap(), user.id, "landings", ); match delete_landing_page(&conn, &id) { Ok(_) => { let _ = crate::db::users::release_global_slug( &state.system_db.lock().unwrap(), &page.code, user.id, ); let ip = get_client_ip(&headers, connect_info); let _ = write_audit_log( &state.admin_db.lock().unwrap(), &state, &user.username, "USER_PAGE_DELETION", Some("page"), Some(&id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()), ); Redirect::to("/user/pages").into_response() } Err(e) => Redirect::to(&format!("/user/pages?error=Failed to delete page: {}", e)) .into_response(), } } _ => Redirect::to("/user/pages?error=Page not found").into_response(), } } #[derive(Deserialize)] pub struct UserSettingsQuery { pub success: Option, pub error: Option, } pub async fn user_settings_get( State(state): State, jar: CookieJar, Query(query): Query, ) -> Response { let (user, session_id) = match require_user_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let csrf_token = generate_csrf_token(&session_id); let template = crate::templates::UserSettingsTemplate { admin_username: user.username.clone(), username: user.username, csrf_token, success: query.success, error: query.error, }; template.into_response() } pub async fn user_change_password_post( State(state): State, jar: CookieJar, headers: HeaderMap, connect_info: Option>, Form(form): Form, ) -> Response { let (user, session_id) = match require_user_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; if !verify_csrf(&session_id, &form.csrf_token) { return Redirect::to("/user/settings?error=Invalid CSRF token").into_response(); } if form.current_password.trim().is_empty() || form.new_password.trim().len() < 8 { return Redirect::to("/user/settings?error=Password must be at least 8 characters") .into_response(); } let conn = state.users_db.lock().unwrap(); if !verify_password(&form.current_password, &user.password_hash) { let _ = write_audit_log( &state.admin_db.lock().unwrap(), &state, &user.username, "USER_PASSWORD_CHANGE_FAIL", Some("user"), Some(&user.id.to_string()), Some(&get_client_ip(&headers, connect_info)), headers.get("user-agent").and_then(|h| h.to_str().ok()), ); return Redirect::to("/user/settings?error=Incorrect current password").into_response(); } let new_hash = match hash_password(&form.new_password) { Ok(h) => h, Err(_) => return Redirect::to("/user/settings?error=Hashing error").into_response(), }; match conn.execute( "UPDATE users SET password_hash = ?1 WHERE id = ?2;", params![new_hash, user.id], ) { Ok(_) => { let _ = write_audit_log( &state.admin_db.lock().unwrap(), &state, &user.username, "USER_PASSWORD_CHANGED", Some("user"), Some(&user.id.to_string()), Some(&get_client_ip(&headers, connect_info)), headers.get("user-agent").and_then(|h| h.to_str().ok()), ); Redirect::to("/user/settings?success=Password updated successfully").into_response() } Err(e) => Redirect::to(&format!( "/user/settings?error=Failed to update password: {}", e )) .into_response(), } } pub async fn user_download_backup( State(state): State, jar: CookieJar, headers: HeaderMap, connect_info: Option>, ) -> Response { let (user, _) = match require_user_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let ip = get_client_ip(&headers, connect_info); let user_dir = state .config .data_dir .join("users") .join(user.id.to_string()); let mut buffer = Vec::new(); let res = { let enc = GzEncoder::new(&mut buffer, Compression::default()); let mut tar = Builder::new(enc); let files = vec!["content.db", "analytics.db", "profile.db"]; let mut add_err = None; for f in files { let path = user_dir.join(f); if path.exists() { if let Err(e) = tar.append_path_with_name(&path, f) { add_err = Some(e); break; } } } match add_err { Some(e) => Err(e), None => match tar.into_inner().and_then(|encoder| encoder.finish()) { Ok(_) => Ok(()), Err(e) => Err(e), }, } }; match res { Ok(_) => { let _ = write_audit_log( &state.admin_db.lock().unwrap(), &state, &user.username, "USER_BACKUP", Some("user"), Some(&user.id.to_string()), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()), ); let date_str = Utc::now().format("%Y-%m-%d").to_string(); let filename = format!("user-{}-bzod-backup.tar.gz", date_str); ( StatusCode::OK, [ ("Content-Type", "application/gzip"), ( "Content-Disposition", &format!("attachment; filename=\"{}\"", filename), ), ], buffer, ) .into_response() } Err(e) => { Redirect::to(&format!("/user/settings?error=Backup failed: {}", e)).into_response() } } } pub async fn user_restore_backup_post( State(state): State, jar: CookieJar, headers: HeaderMap, connect_info: Option>, mut multipart: axum::extract::Multipart, ) -> Response { let (user, session_id) = match require_user_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let ip = get_client_ip(&headers, connect_info); let mut file_bytes = Vec::new(); let mut confirm_text = String::new(); let mut csrf_token = String::new(); while let Ok(Some(field)) = multipart.next_field().await { let name = field.name().unwrap_or("").to_string(); if name == "backup_file" { if let Ok(bytes) = field.bytes().await { file_bytes = bytes.to_vec(); } } else if name == "confirm_text" { if let Ok(text) = field.text().await { confirm_text = text.trim().to_string(); } } else if name == "csrf_token" { if let Ok(token) = field.text().await { csrf_token = token.trim().to_string(); } } } if !verify_csrf(&session_id, &csrf_token) { return Redirect::to("/user/settings?error=Invalid CSRF token").into_response(); } if confirm_text != "RESTORE" { return Redirect::to("/user/settings?error=Confirmation text must be exactly 'RESTORE'") .into_response(); } if file_bytes.is_empty() { return Redirect::to("/user/settings?error=No backup file uploaded").into_response(); } let temp_file_path = std::env::temp_dir().join(format!("bzod_user_restore_{}.tar.gz", uuid::Uuid::new_v4())); if let Err(e) = std::fs::write(&temp_file_path, &file_bytes) { return Redirect::to(&format!( "/user/settings?error=Failed to write temp file: {}", e )) .into_response(); } let user_dir = state .config .data_dir .join("users") .join(user.id.to_string()); let temp_unpack_dir = std::env::temp_dir().join(format!("bzod_restore_unpack_{}", uuid::Uuid::new_v4())); if let Err(e) = std::fs::create_dir_all(&temp_unpack_dir) { let _ = std::fs::remove_file(&temp_file_path); return Redirect::to(&format!( "/user/settings?error=Failed to create temp dir: {}", e )) .into_response(); } let restore_res = { let file = match File::open(&temp_file_path) { Ok(f) => f, Err(e) => { let _ = std::fs::remove_file(&temp_file_path); let _ = std::fs::remove_dir_all(&temp_unpack_dir); return Redirect::to(&format!( "/user/settings?error=Failed to open upload: {}", e )) .into_response(); } }; let tar_gz = GzDecoder::new(file); let mut archive = tar::Archive::new(tar_gz); if let Err(e) = archive.unpack(&temp_unpack_dir) { Err(Box::new(e) as Box) } else { // Check for collision using temp content.db let system_conn = state.system_db.lock().unwrap(); let temp_content_db = temp_unpack_dir.join("content.db"); if temp_content_db.exists() { if let Err(e) = crate::db::users::register_restored_user_slugs( &system_conn, user.id, &temp_content_db, ) { Err(e) } else { Ok(()) } } else { Err("Backup is missing content.db".into()) } } }; let _ = std::fs::remove_file(&temp_file_path); match restore_res { Ok(_) => { // Success! Copy unpacked files from temp_unpack_dir to user_dir if let Err(e) = std::fs::create_dir_all(&user_dir) { let _ = std::fs::remove_dir_all(&temp_unpack_dir); return Redirect::to(&format!( "/user/settings?error=Failed to create user directory: {}", e )) .into_response(); } for file_name in &["content.db", "analytics.db", "profile.db"] { let src = temp_unpack_dir.join(file_name); if src.exists() { let dst = user_dir.join(file_name); if let Err(e) = std::fs::copy(&src, &dst) { let _ = std::fs::remove_dir_all(&temp_unpack_dir); return Redirect::to(&format!( "/user/settings?error=Failed to copy database: {}", e )) .into_response(); } } } let _ = std::fs::remove_dir_all(&temp_unpack_dir); // Reconcile quotas let users_conn = state.users_db.lock().unwrap(); if let Ok(content_conn) = rusqlite::Connection::open(user_dir.join("content.db")) { let _ = crate::db::users::reconcile_user_quotas(&users_conn, user.id, &content_conn); } let mut pool = state.user_dbs.lock().unwrap(); pool.remove(&user.id); let _ = write_audit_log( &state.admin_db.lock().unwrap(), &state, &user.username, "USER_RESTORE", Some("user"), Some(&user.id.to_string()), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()), ); Redirect::to("/user/settings?success=User database restored successfully") .into_response() } Err(e) => { let _ = std::fs::remove_dir_all(&temp_unpack_dir); Redirect::to(&format!("/user/settings?error=Restore failed: {}", e)).into_response() } } } // GET /admin/logout pub async fn logout(State(state): State, jar: CookieJar) -> Response { if let Ok((_, session_id)) = require_auth(&state, &jar).await { let conn = state.users_db.lock().unwrap(); let _ = conn.execute("DELETE FROM sessions WHERE id = ?1;", [&session_id]); } let cookie = Cookie::build("bzod_session") .path("/") .max_age(time::Duration::ZERO) .build(); let mut response_jar = jar.clone(); response_jar = response_jar.add(cookie); (response_jar, Redirect::to("/admin/login")).into_response() } // GET /admin/dashboard pub async fn dashboard_get(State(state): State, jar: CookieJar) -> Response { let (user, _) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let (total_urls, active_links, dead_links) = { let conn = state.content_db.lock().unwrap(); get_url_counts(&conn).unwrap_or((0, 0, 0)) }; let total_pages = { let conn = state.content_db.lock().unwrap(); get_landing_page_count(&conn).unwrap_or(0) }; let total_clicks = { let conn = state.analytics_db.lock().unwrap(); get_total_clicks(&conn).unwrap_or(0) }; let clicks_data = { let conn = state.analytics_db.lock().unwrap(); get_clicks_trend(&conn, "url", "all", 30) .or_else(|_| get_clicks_trend_raw(&conn, "url", "all", 30)) .unwrap_or_default() }; let mut trend_map = std::collections::BTreeMap::new(); for i in (0..30).rev() { let date_str = (Utc::now() - chrono::Duration::days(i)) .format("%Y-%m-%d") .to_string(); trend_map.insert(date_str, 0i64); } for (d, c) in clicks_data { trend_map.insert(d, c); } let formatted_trend: Vec<(String, i64)> = trend_map.into_iter().collect(); let traffic_chart = generate_line_chart(&formatted_trend); let countries_data = { let conn = state.analytics_db.lock().unwrap(); get_metric_rankings(&conn, "url", "all", "country", 5) .or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "country", 5)) .unwrap_or_default() }; let countries_chart = generate_bar_chart(&countries_data); let referrers_data = { let conn = state.analytics_db.lock().unwrap(); get_metric_rankings(&conn, "url", "all", "referrer", 5) .or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "referrer", 5)) .unwrap_or_default() }; let referrers_chart = generate_bar_chart(&referrers_data); let browsers_data = { let conn = state.analytics_db.lock().unwrap(); get_metric_rankings(&conn, "url", "all", "browser", 5) .or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "browser", 5)) .unwrap_or_default() }; let browsers_chart = generate_bar_chart(&browsers_data); let template = crate::templates::DashboardTemplate { admin_username: user.username, total_urls, total_pages, total_clicks, active_links, dead_links, traffic_chart, countries_chart, browsers_chart, referrers_chart, }; template.into_response() } // GET /admin/urls #[derive(Deserialize)] pub struct UrlsQuery { pub tag: Option, pub error: Option, pub page: Option, } pub async fn urls_get( State(state): State, jar: CookieJar, Query(query): Query, ) -> Response { let (user, session_id) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let (urls, total_pages, page, visible_pages) = { let conn = state.content_db.lock().unwrap(); let total_records = if let Some(tag_str) = query.tag.as_deref() { get_url_count_by_tag(&conn, tag_str).unwrap_or(0) } else { get_url_counts(&conn).map(|(t, _, _)| t).unwrap_or(0) }; let calculated_total_pages = (total_records as usize).div_ceil(PAGE_SIZE); let total_pages = std::cmp::max(1, calculated_total_pages); let requested_page = query.page.unwrap_or(1); let current_page = if requested_page == 0 { 1 } else { requested_page } .clamp(1, total_pages); let offset = (current_page - 1) * PAGE_SIZE; let urls = list_urls(&conn, PAGE_SIZE as i64, offset as i64, query.tag.as_deref()) .unwrap_or_default(); let start_page = current_page.saturating_sub(3).max(1); let end_page = std::cmp::min(total_pages, current_page + 3); let visible_pages: Vec = (start_page..=end_page).collect(); (urls, total_pages, current_page, visible_pages) }; let csrf_token = generate_csrf_token(&session_id); let proto = if state.config.cookie_secure { "https" } else { "http" }; let base_url = state .config .base_url .clone() .unwrap_or_else(|| format!("{}://localhost:{}", proto, state.config.port)); let template = crate::templates::UrlsTemplate { admin_username: user.username, urls, csrf_token, error: query.error, tag_filter: query.tag, base_url, current_page: page, total_pages, visible_pages, }; template.into_response() } #[derive(Deserialize)] pub struct CreateUrlForm { pub destination: String, pub code: String, pub custom_slug: String, pub title: String, pub description: String, pub tags: String, pub csrf_token: String, pub expires_at: String, pub password: String, pub max_access_count: String, pub utm_source: String, pub utm_medium: String, pub utm_campaign: String, } // POST /admin/urls/create pub async fn urls_create( State(state): State, jar: CookieJar, headers: HeaderMap, connect_info: Option>, Form(form): Form, ) -> Response { let (user, session_id) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; if !verify_csrf(&session_id, &form.csrf_token) { return Redirect::to("/admin/urls?error=Invalid CSRF token").into_response(); } let ip = get_client_ip(&headers, connect_info); let admin_user_id = user.id.parse::().unwrap_or(1); // Custom Slug takes priority if provided let mut code = form.custom_slug.trim().to_lowercase(); if code.is_empty() { code = form.code.trim().to_lowercase(); if code.is_empty() { code = generate_token(3); } else { if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) { return Redirect::to( "/admin/urls?error=Custom code must be exactly 6 hex characters", ) .into_response(); } } } else { if !crate::utils::validation::validate_custom_slug(&code) { return Redirect::to("/admin/urls?error=Custom slug must start with ! followed by 1-24 characters of a-z, 0-9, -, _") .into_response(); } } let mut dest = form.destination.trim().to_string(); if let Ok(mut parsed) = reqwest::Url::parse(&dest) { let mut has_utm = false; { let mut query = parsed.query_pairs_mut(); if !form.utm_source.trim().is_empty() { query.append_pair("utm_source", form.utm_source.trim()); has_utm = true; } if !form.utm_medium.trim().is_empty() { query.append_pair("utm_medium", form.utm_medium.trim()); has_utm = true; } if !form.utm_campaign.trim().is_empty() { query.append_pair("utm_campaign", form.utm_campaign.trim()); has_utm = true; } } if has_utm { dest = parsed.to_string(); } } let expires_at_opt = if form.expires_at.trim().is_empty() { None } else { let mut rfc = form.expires_at.trim().to_string(); if rfc.len() == 16 { rfc.push_str(":00Z"); // convert HTML datetime-local to standard UTC RFC3339 } Some(rfc) }; let password_hash_opt = if form.password.trim().is_empty() { None } else { match hash_password(&form.password) { Ok(h) => Some(h), Err(_) => return Redirect::to("/admin/urls?error=Hashing error").into_response(), } }; let max_access_count_opt = if form.max_access_count.trim().is_empty() { None } else { match form.max_access_count.trim().parse::() { Ok(c) => Some(c), Err(_) => { return Redirect::to("/admin/urls?error=Invalid max access count").into_response() } } }; let tags_list: Vec = form .tags .split(',') .map(|t| t.trim().to_string()) .filter(|t| !t.is_empty()) .collect(); let title_opt = if form.title.trim().is_empty() { None } else { Some(form.title.trim()) }; let desc_opt = if form.description.trim().is_empty() { None } else { Some(form.description.trim()) }; { let users_conn = state.users_db.lock().unwrap(); if !crate::db::users::check_quota_limit(&users_conn, admin_user_id, "urls").unwrap_or(false) { return Redirect::to("/admin/urls?error=Quota limit exceeded").into_response(); } } { let system_conn = state.system_db.lock().unwrap(); if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) { return Redirect::to("/admin/urls?error=Short code/slug already exists") .into_response(); } // Always use owner_user_id = 1 for admin content so it resolves via state.content_db if let Err(e) = crate::db::users::register_global_slug(&system_conn, &code, 1, "url", "", "reserving") { return Redirect::to(&format!("/admin/urls?error=Failed to reserve slug: {}", e)) .into_response(); } } let res = { let conn = state.content_db.lock().unwrap(); crate::db::content::create_url_extended( &conn, &code, &dest, title_opt, desc_opt, &tags_list, expires_at_opt.as_deref(), password_hash_opt.as_deref(), max_access_count_opt, ) }; match res { Ok(url) => { { let system_conn = state.system_db.lock().unwrap(); let _ = system_conn.execute( "UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;", rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), code], ); } { let users_conn = state.users_db.lock().unwrap(); let _ = crate::db::users::increment_quota_counter(&users_conn, admin_user_id, "urls"); } { let conn = state.admin_db.lock().unwrap(); let _ = write_audit_log( &conn, &state, &user.username, "URL_CREATION", Some("url"), Some(&url.id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()), ); } Redirect::to("/admin/urls").into_response() } Err(e) => { let system_conn = state.system_db.lock().unwrap(); let _ = crate::db::users::release_global_slug(&system_conn, &code, 1); Redirect::to(&format!("/admin/urls?error=Database error: {}", e)).into_response() } } } // POST /admin/urls/delete/:id pub async fn urls_delete( State(state): State, jar: CookieJar, headers: HeaderMap, connect_info: Option>, Path(id): Path, Form(form): Form>, ) -> Response { let (user, session_id) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let csrf_token = form.get("csrf_token").cloned().unwrap_or_default(); if !verify_csrf(&session_id, &csrf_token) { return Redirect::to("/admin/urls?error=Invalid CSRF token").into_response(); } let ip = get_client_ip(&headers, connect_info); let conn = state.content_db.lock().unwrap(); match delete_url(&conn, &id) { Ok(_) => { { let conn_admin = state.admin_db.lock().unwrap(); let _ = write_audit_log( &conn_admin, &state, &user.username, "URL_DELETION", Some("url"), Some(&id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()), ); } Redirect::to("/admin/urls").into_response() } Err(e) => { Redirect::to(&format!("/admin/urls?error=Failed to delete link: {}", e)).into_response() } } } // GET /admin/pages #[derive(Deserialize)] pub struct PagesQuery { pub error: Option, pub page: Option, } #[derive(Deserialize)] pub struct UsersQuery { pub success: Option, pub error: Option, } #[derive(Deserialize)] pub struct CreateUserForm { pub username: String, pub password: String, pub account_type: String, pub metadata: String, pub csrf_token: String, } #[derive(Deserialize)] pub struct UpdateUserStatusForm { pub status: String, pub csrf_token: String, } #[derive(Deserialize)] pub struct UpdateUserTypeForm { pub account_type: String, pub csrf_token: String, } #[derive(Deserialize)] pub struct ResetPasswordForm { pub new_password: String, pub csrf_token: String, } #[derive(Deserialize)] pub struct DeleteUserForm { pub csrf_token: String, } pub async fn users_get( State(state): State, jar: CookieJar, Query(query): Query, ) -> Response { let (user, session_id) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let users = { let conn = state.users_db.lock().unwrap(); crate::db::users::list_users(&conn).unwrap_or_default() }; let csrf_token = generate_csrf_token(&session_id); let template = crate::templates::UsersTemplate { admin_username: user.username, users, csrf_token, success: query.success, error: query.error, }; template.into_response() } pub async fn users_create_post( State(state): State, jar: CookieJar, Form(form): Form, ) -> Response { let (user, session_id) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; if !verify_csrf(&session_id, &form.csrf_token) { return Redirect::to("/admin/users?error=Invalid CSRF token").into_response(); } let username = form.username.trim().to_lowercase(); if username.len() < 3 { return Redirect::to("/admin/users?error=Username must be at least 3 characters") .into_response(); } if !username .chars() .all(|c| c.is_alphanumeric() || c == '-' || c == '_') { return Redirect::to( "/admin/users?error=Username must contain only alphanumeric characters, hyphens, or underscores", ) .into_response(); } if form.password.trim().len() < 8 { return Redirect::to("/admin/users?error=Password must be at least 8 characters") .into_response(); } let account_type = if form.account_type.trim().is_empty() { "standard" } else { form.account_type.trim() }; let metadata = if form.metadata.trim().is_empty() { None } else { Some(form.metadata.trim()) }; let hash = match hash_password(&form.password) { Ok(h) => h, Err(_) => return Redirect::to("/admin/users?error=Internal hashing error").into_response(), }; let new_user = { let conn = state.users_db.lock().unwrap(); match crate::db::users::create_user(&conn, &username, &hash, account_type, metadata) { Ok(u) => u, Err(rusqlite::Error::SqliteFailure(err, _)) if err.code == rusqlite::ErrorCode::ConstraintViolation => { return Redirect::to("/admin/users?error=Username already exists").into_response(); } Err(e) => { return Redirect::to(&format!("/admin/users?error=Database error: {}", e)) .into_response(); } } }; if let Err(e) = state.db.init_user_databases(new_user.id) { return Redirect::to(&format!( "/admin/users?error=Failed to initialize user databases: {}", e )) .into_response(); } { let conn = state.admin_db.lock().unwrap(); let _ = write_audit_log( &conn, &state, &user.username, "USER_CREATION", Some("user"), Some(&new_user.id.to_string()), None, None, ); } Redirect::to("/admin/users?success=User created successfully").into_response() } pub async fn users_update_status_post( State(state): State, jar: CookieJar, Path(id): Path, Form(form): Form, ) -> Response { let (user, session_id) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; if !verify_csrf(&session_id, &form.csrf_token) { return Redirect::to("/admin/users?error=Invalid CSRF token").into_response(); } let status = form.status.trim().to_lowercase(); if !["active", "disabled", "suspended", "pending", "deleted"].contains(&status.as_str()) { return Redirect::to("/admin/users?error=Invalid user status").into_response(); } let conn = state.users_db.lock().unwrap(); match crate::db::users::update_user_status(&conn, id, &status) { Ok(_) => { let conn_admin = state.admin_db.lock().unwrap(); let _ = write_audit_log( &conn_admin, &state, &user.username, "USER_STATUS_UPDATE", Some("user"), Some(&id.to_string()), None, None, ); Redirect::to("/admin/users?success=User status updated").into_response() } Err(e) => Redirect::to(&format!( "/admin/users?error=Failed to update status: {}", e )) .into_response(), } } pub async fn users_update_type_post( State(state): State, jar: CookieJar, Path(id): Path, Form(form): Form, ) -> Response { let (user, session_id) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; if !verify_csrf(&session_id, &form.csrf_token) { return Redirect::to("/admin/users?error=Invalid CSRF token").into_response(); } let account_type = form.account_type.trim().to_lowercase(); if !["admin", "standard", "organization", "service", "system"].contains(&account_type.as_str()) { return Redirect::to("/admin/users?error=Invalid account type").into_response(); } let conn = state.users_db.lock().unwrap(); match crate::db::users::update_user_account_type(&conn, id, &account_type) { Ok(_) => { let conn_admin = state.admin_db.lock().unwrap(); let _ = write_audit_log( &conn_admin, &state, &user.username, "USER_ACCOUNT_TYPE_UPDATE", Some("user"), Some(&id.to_string()), None, None, ); Redirect::to("/admin/users?success=User account type updated").into_response() } Err(e) => Redirect::to(&format!( "/admin/users?error=Failed to update account type: {}", e )) .into_response(), } } pub async fn users_reset_password_post( State(state): State, jar: CookieJar, headers: HeaderMap, connect_info: Option>, Path(id): Path, Form(form): Form, ) -> Response { let (user, session_id) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; if !verify_csrf(&session_id, &form.csrf_token) { return Redirect::to("/admin/users?error=Invalid CSRF token").into_response(); } if form.new_password.trim().len() < 8 { return Redirect::to("/admin/users?error=Password must be at least 8 characters") .into_response(); } let hash = match hash_password(&form.new_password) { Ok(h) => h, Err(_) => return Redirect::to("/admin/users?error=Internal hashing error").into_response(), }; let conn = state.users_db.lock().unwrap(); match crate::db::users::reset_user_password(&conn, id, &hash) { Ok(_) => { let ip = get_client_ip(&headers, connect_info); let conn_admin = state.admin_db.lock().unwrap(); let _ = write_audit_log( &conn_admin, &state, &user.username, "USER_PASSWORD_RESET", Some("user"), Some(&id.to_string()), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()), ); Redirect::to("/admin/users?success=Password reset successfully").into_response() } Err(e) => Redirect::to(&format!( "/admin/users?error=Failed to reset password: {}", e )) .into_response(), } } pub async fn users_delete_post( State(state): State, jar: CookieJar, headers: HeaderMap, connect_info: Option>, Path(id): Path, Form(form): Form, ) -> Response { let (user, session_id) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; if !verify_csrf(&session_id, &form.csrf_token) { return Redirect::to("/admin/users?error=Invalid CSRF token").into_response(); } match crate::web::multi_user::delete_user_resources(&state, id, &user.username, false) { Ok(_) => { let ip = get_client_ip(&headers, connect_info); let conn_admin = state.admin_db.lock().unwrap(); let _ = write_audit_log( &conn_admin, &state, &user.username, "USER_DELETION", Some("user"), Some(&id.to_string()), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()), ); Redirect::to("/admin/users?success=User deleted successfully").into_response() } Err(err) => Redirect::to(&format!("/admin/users?error={}", err)).into_response(), } } pub async fn pages_get( State(state): State, jar: CookieJar, Query(query): Query, ) -> Response { let (user, session_id) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let (pages, total_pages, page, visible_pages) = { let conn = state.content_db.lock().unwrap(); let total_records = get_landing_page_count(&conn).unwrap_or(0); let calculated_total_pages = (total_records as usize).div_ceil(PAGE_SIZE); let total_pages = std::cmp::max(1, calculated_total_pages); let requested_page = query.page.unwrap_or(1); let current_page = if requested_page == 0 { 1 } else { requested_page } .clamp(1, total_pages); let offset = (current_page - 1) * PAGE_SIZE; let pages = list_landing_pages(&conn, PAGE_SIZE as i64, offset as i64).unwrap_or_default(); let start_page = current_page.saturating_sub(3).max(1); let end_page = std::cmp::min(total_pages, current_page + 3); let visible_pages: Vec = (start_page..=end_page).collect(); (pages, total_pages, current_page, visible_pages) }; let csrf_token = generate_csrf_token(&session_id); let template = crate::templates::PagesTemplate { admin_username: user.username, pages, csrf_token, error: query.error, current_page: page, total_pages, visible_pages, }; template.into_response() } #[derive(Deserialize)] pub struct CreatePageForm { pub title: String, pub slug: String, pub code: String, pub custom_slug: String, pub state: String, pub html_content: String, pub csrf_token: String, } // POST /admin/pages/create pub async fn pages_create( State(state): State, jar: CookieJar, headers: HeaderMap, connect_info: Option>, Form(form): Form, ) -> Response { let (user, session_id) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; if !verify_csrf(&session_id, &form.csrf_token) { return Redirect::to("/admin/pages?error=Invalid CSRF token").into_response(); } let ip = get_client_ip(&headers, connect_info); let admin_user_id = user.id.parse::().unwrap_or(1); // Custom Slug takes priority if provided let mut code = form.custom_slug.trim().to_lowercase(); if code.is_empty() { code = form.code.trim().to_lowercase(); if code.is_empty() { code = generate_token(2); } else { if code.len() != 4 || !code.chars().all(|c| c.is_ascii_hexdigit()) { return Redirect::to( "/admin/pages?error=Custom code must be exactly 4 hex characters", ) .into_response(); } } } else { if !crate::utils::validation::validate_custom_slug(&code) { return Redirect::to("/admin/pages?error=Custom slug must start with ! followed by 1-24 characters of a-z, 0-9, -, _") .into_response(); } } let clean_slug = form.slug.trim().to_lowercase(); if clean_slug.is_empty() { return Redirect::to("/admin/pages?error=Slug is required").into_response(); } { let users_conn = state.users_db.lock().unwrap(); if !crate::db::users::check_quota_limit(&users_conn, admin_user_id, "landings") .unwrap_or(false) { return Redirect::to("/admin/pages?error=Quota limit exceeded").into_response(); } } { let system_conn = state.system_db.lock().unwrap(); if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) { return Redirect::to("/admin/pages?error=Short code already exists").into_response(); } // Always use owner_user_id = 1 for admin content so it resolves via state.content_db if let Err(e) = crate::db::users::register_global_slug(&system_conn, &code, 1, "page", "", "reserving") { return Redirect::to(&format!("/admin/pages?error=Failed to reserve slug: {}", e)) .into_response(); } } let res = { let conn = state.content_db.lock().unwrap(); create_landing_page( &conn, &code, &clean_slug, &form.title, &form.html_content, &form.state, ) }; match res { Ok(page) => { { let system_conn = state.system_db.lock().unwrap(); let global_status = if form.state == "published" { "active" } else { "disabled" }; let _ = system_conn.execute( "UPDATE global_slugs SET target_id = ?1, status = ?2, updated_at = ?3 WHERE slug = ?4;", rusqlite::params![page.id, global_status, chrono::Utc::now().to_rfc3339(), code], ); } { let users_conn = state.users_db.lock().unwrap(); let _ = crate::db::users::increment_quota_counter( &users_conn, admin_user_id, "landings", ); } { let conn_admin = state.admin_db.lock().unwrap(); let _ = write_audit_log( &conn_admin, &state, &user.username, "PAGE_CREATION", Some("page"), Some(&page.id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()), ); } Redirect::to("/admin/pages").into_response() } Err(e) => { let system_conn = state.system_db.lock().unwrap(); let _ = crate::db::users::release_global_slug(&system_conn, &code, 1); Redirect::to(&format!("/admin/pages?error=Database error: {}", e)).into_response() } } } // POST /admin/pages/delete/:id pub async fn pages_delete( State(state): State, jar: CookieJar, headers: HeaderMap, connect_info: Option>, Path(id): Path, Form(form): Form>, ) -> Response { let (user, session_id) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let csrf_token = form.get("csrf_token").cloned().unwrap_or_default(); if !verify_csrf(&session_id, &csrf_token) { return Redirect::to("/admin/pages?error=Invalid CSRF token").into_response(); } let ip = get_client_ip(&headers, connect_info); let conn = state.content_db.lock().unwrap(); match delete_landing_page(&conn, &id) { Ok(_) => { { let conn_admin = state.admin_db.lock().unwrap(); let _ = write_audit_log( &conn_admin, &state, &user.username, "PAGE_DELETION", Some("page"), Some(&id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()), ); } Redirect::to("/admin/pages").into_response() } Err(e) => Redirect::to(&format!("/admin/pages?error=Failed to delete page: {}", e)) .into_response(), } } // GET /admin/settings #[derive(Deserialize)] pub struct SettingsQuery { pub success: Option, pub error: Option, } pub async fn settings_get( State(state): State, jar: CookieJar, Query(query): Query, ) -> Response { let (user, session_id) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let api_keys = { let conn = state.admin_db.lock().unwrap(); list_api_keys(&conn, &user.id).unwrap_or_default() }; let data_retention = { let conn = state.admin_db.lock().unwrap(); get_config(&conn, "retention_days") .unwrap_or(None) .unwrap_or_else(|| { state .config .data_retention_days .map(|d| d.to_string()) .unwrap_or_else(|| "unlimited".to_string()) }) }; let csrf_token = generate_csrf_token(&session_id); let template = crate::templates::SettingsTemplate { admin_username: user.username, api_keys, data_retention, csrf_token, success: query.success, error: query.error, }; template.into_response() } #[derive(Deserialize)] pub struct ChangePasswordForm { pub current_password: String, pub new_password: String, pub csrf_token: String, } // POST /admin/settings/password pub async fn change_password_post( State(state): State, jar: CookieJar, headers: HeaderMap, connect_info: Option>, Form(form): Form, ) -> Response { let (user, session_id) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; if !verify_csrf(&session_id, &form.csrf_token) { return Redirect::to("/admin/settings?error=Invalid CSRF token").into_response(); } let ip = get_client_ip(&headers, connect_info); let conn = state.admin_db.lock().unwrap(); if !verify_password(&form.current_password, &user.password_hash) { let _ = write_audit_log( &conn, &state, &user.username, "PASSWORD_CHANGE_FAIL", Some("user"), Some(&user.id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()), ); return Redirect::to("/admin/settings?error=Incorrect current password").into_response(); } let new_hash = match hash_password(&form.new_password) { Ok(h) => h, Err(_) => return Redirect::to("/admin/settings?error=Hashing error").into_response(), }; let res = conn.execute( "UPDATE users SET password_hash = ?1 WHERE id = ?2;", params![new_hash, user.id], ); match res { Ok(_) => { let _ = write_audit_log( &conn, &state, &user.username, "PASSWORD_CHANGE_SUCCESS", Some("user"), Some(&user.id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()), ); Redirect::to("/admin/settings?success=Password updated successfully").into_response() } Err(e) => Redirect::to(&format!( "/admin/settings?error=Failed to update password: {}", e )) .into_response(), } } #[derive(Deserialize)] pub struct RetentionForm { pub retention: String, pub csrf_token: String, } // POST /admin/settings/retention pub async fn change_retention_post( State(state): State, jar: CookieJar, headers: HeaderMap, connect_info: Option>, Form(form): Form, ) -> Response { let (user, session_id) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; if !verify_csrf(&session_id, &form.csrf_token) { return Redirect::to("/admin/settings?error=Invalid CSRF token").into_response(); } let ip = get_client_ip(&headers, connect_info); let conn = state.admin_db.lock().unwrap(); match set_config(&conn, "retention_days", &form.retention) { Ok(_) => { let _ = write_audit_log( &conn, &state, &user.username, "RETENTION_POLICY_CHANGED", Some("config"), Some("retention_days"), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()), ); Redirect::to("/admin/settings?success=Retention policy saved").into_response() } Err(e) => { Redirect::to(&format!("/admin/settings?error=Database error: {}", e)).into_response() } } } // POST /admin/settings/compact pub async fn compact_db_post( State(state): State, jar: CookieJar, headers: HeaderMap, connect_info: Option>, ) -> Response { let (user, _session_id) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let ip = get_client_ip(&headers, connect_info); match state.db_compact() { Ok(_) => { let conn = state.admin_db.lock().unwrap(); let _ = write_audit_log( &conn, &state, &user.username, "DATABASE_COMPACTION", Some("system"), Some("all_dbs"), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()), ); Redirect::to("/admin/settings?success=Database files compacted successfully") .into_response() } Err(e) => { Redirect::to(&format!("/admin/settings?error=Failed to compact: {}", e)).into_response() } } } // GET /admin/settings/backup pub async fn download_backup( State(state): State, jar: CookieJar, headers: HeaderMap, connect_info: Option>, ) -> Response { let (user, _) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let ip = get_client_ip(&headers, connect_info); // Create tar.gz in memory let mut buffer = Vec::new(); let res = { let enc = GzEncoder::new(&mut buffer, Compression::default()); let mut tar = Builder::new(enc); let files = vec![ ("admin.db", state.config.data_dir.join("admin/admin.db")), ("system.db", state.config.data_dir.join("admin/system.db")), ("users.db", state.config.data_dir.join("admin/users.db")), ( "content.db", state.config.data_dir.join("users/1/content.db"), ), ( "analytics.db", state.config.data_dir.join("users/1/analytics.db"), ), ]; let mut add_err = None; let mut manifest_files = Vec::new(); for (name, path) in files { if path.exists() { if let Err(e) = tar.append_path_with_name(&path, name) { add_err = Some(e); break; } manifest_files.push(name.to_string()); } } if add_err.is_none() { let manifest = serde_json::json!({ "created_at": chrono::Utc::now().to_rfc3339(), "type": "legacy_flat_backup", "files_included": manifest_files, "note": "Multi-tenant databases flattened for backward compatibility.", }); let manifest_str = manifest.to_string(); let mut header = tar::Header::new_gnu(); header.set_size(manifest_str.len() as u64); header.set_cksum(); if let Err(e) = tar.append_data(&mut header, "backup_manifest.json", manifest_str.as_bytes()) { add_err = Some(e); } } match add_err { Some(e) => Err(e), None => match tar.into_inner().and_then(|encoder| encoder.finish()) { Ok(_) => Ok(()), Err(e) => Err(e), }, } }; match res { Ok(_) => { { let conn = state.admin_db.lock().unwrap(); let _ = write_audit_log( &conn, &state, &user.username, "DATABASE_BACKUP", Some("system"), Some("tarball"), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()), ); } let date_str = Utc::now().format("%Y-%m-%d").to_string(); let filename = format!("{}-bzod-backup.tar.gz", date_str); ( StatusCode::OK, [ ("Content-Type", "application/gzip"), ( "Content-Disposition", &format!("attachment; filename=\"{}\"", filename), ), ], buffer, ) .into_response() } Err(e) => { Redirect::to(&format!("/admin/settings?error=Backup failed: {}", e)).into_response() } } } #[derive(Deserialize)] pub struct CreateApiKeyForm { pub key_name: String, pub csrf_token: String, } // POST /admin/settings/api-keys/create pub async fn create_api_key_post( State(state): State, jar: CookieJar, headers: HeaderMap, connect_info: Option>, Form(form): Form, ) -> Response { let (user, session_id) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; if !verify_csrf(&session_id, &form.csrf_token) { return Redirect::to("/admin/settings?error=Invalid CSRF token").into_response(); } let ip = get_client_ip(&headers, connect_info); let key_secret = format!("bzo_{}", generate_token(16)); use sha2::{Digest, Sha256}; let mut hasher = Sha256::new(); hasher.update(key_secret.as_bytes()); let hashed_key = hex::encode(hasher.finalize()); let conn = state.admin_db.lock().unwrap(); match create_api_key(&conn, &user.id, &form.key_name, &hashed_key) { Ok(api_key) => { let _ = write_audit_log( &conn, &state, &user.username, "API_KEY_CREATED", Some("api_key"), Some(&api_key.id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()), ); Redirect::to(&format!( "/admin/settings?success=Token generated successfully. **IMPORTANT: Copy your token now, it will never be shown again!** Token value: {}", key_secret )).into_response() } Err(e) => { Redirect::to(&format!("/admin/settings?error=Database error: {}", e)).into_response() } } } // POST /admin/settings/api-keys/revoke/:id pub async fn revoke_api_key_post( State(state): State, jar: CookieJar, headers: HeaderMap, connect_info: Option>, Path(id): Path, Form(form): Form>, ) -> Response { let (user, session_id) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let csrf_token = form.get("csrf_token").cloned().unwrap_or_default(); if !verify_csrf(&session_id, &csrf_token) { return Redirect::to("/admin/settings?error=Invalid CSRF token").into_response(); } let ip = get_client_ip(&headers, connect_info); let conn = state.admin_db.lock().unwrap(); match delete_api_key(&conn, &id) { Ok(_) => { let _ = write_audit_log( &conn, &state, &user.username, "API_KEY_REVOKED", Some("api_key"), Some(&id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()), ); Redirect::to("/admin/settings?success=API Token revoked").into_response() } Err(e) => Redirect::to(&format!( "/admin/settings?error=Failed to revoke key: {}", e )) .into_response(), } } #[derive(Deserialize)] pub struct BulkQrExportForm { pub format: String, pub csrf_token: String, } // POST /admin/settings/bulk-qr pub async fn bulk_qr_export_post( State(state): State, jar: CookieJar, headers: HeaderMap, connect_info: Option>, Form(form): Form, ) -> Response { let (user, session_id) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; if !verify_csrf(&session_id, &form.csrf_token) { return Redirect::to("/admin/settings?error=Invalid CSRF token").into_response(); } let ip = get_client_ip(&headers, connect_info); let urls = { let conn = state.content_db.lock().unwrap(); crate::db::content::list_urls(&conn, 500, 0, None).unwrap_or_default() }; if urls.is_empty() { return Redirect::to("/admin/settings?error=No shortened URLs found to export") .into_response(); } let proto = if state.config.cookie_secure { "https" } else { "http" }; let host_header = headers .get("host") .and_then(|h| h.to_str().ok()) .unwrap_or("localhost:8654"); let base_url = state .config .base_url .clone() .unwrap_or_else(|| format!("{}://{}", proto, host_header)); match crate::services::bulk::export_qr_zip(&urls, &form.format, &base_url) { Ok(zip_data) => { // Write Audit Log let _ = write_audit_log( &state.admin_db.lock().unwrap(), &state, &user.username, "BULK_QR_EXPORT", Some("bulk"), Some("qr"), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()), ); Response::builder() .header("content-type", "application/zip") .header( "content-disposition", "attachment; filename=\"qr_codes.zip\"", ) .body(axum::body::Body::from(zip_data)) .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()) } Err(e) => { Redirect::to(&format!("/admin/settings?error=Export failed: {}", e)).into_response() } } } // GET /admin/audit pub async fn audit_get(State(state): State, jar: CookieJar) -> Response { let (user, _) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let logs = { let conn = state.system_db.lock().unwrap(); let events = crate::db::audit_events::list_audit_events(&conn, 100, 0, None, None) .unwrap_or_default(); events .into_iter() .map(|e| { let (ip, ua) = if let Some(ref m) = e.metadata { if m.starts_with("IP: ") { let parts: Vec<&str> = m.split(", UA: ").collect(); let ip = parts[0] .trim_start_matches("IP: ") .trim_matches('"') .trim_matches('\'') .replace("Some(", "") .replace(")", ""); let ua = if parts.len() > 1 { parts[1] .trim_matches('"') .trim_matches('\'') .replace("Some(", "") .replace(")", "") } else { "Unknown".to_string() }; (Some(ip), Some(ua)) } else { (None, None) } } else { (None, None) }; crate::models::AuditLog { id: e.id, timestamp: e.timestamp, username: e.actor, action: e.action, object_type: Some(e.object_type), object_id: Some(e.object_id), ip_address: ip, user_agent: ua, } }) .collect() }; let template = crate::templates::AuditTemplate { admin_username: user.username, logs, }; template.into_response() } // GET /admin/status pub async fn status_get(State(state): State, jar: CookieJar) -> Response { let (user, _) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let app_status = "Healthy"; let db_status = { let conn_ok = { let conn = state.admin_db.lock().unwrap(); get_user_count(&conn).is_ok() }; if conn_ok { format!( "Operational\n\nDatabase Files:\n{}", get_db_file_info(&state.config.data_dir) ) } else { "Degraded (Database connections failed)".to_string() } }; let queue_size = 0; let memory_usage = get_memory_usage(); let uptime_duration = state.start_time.elapsed(); let uptime = crate::utils::format_duration(uptime_duration); let urls = { let conn = state.content_db.lock().unwrap(); crate::db::content::list_urls(&conn, 50, 0, None).unwrap_or_default() }; let template = crate::templates::StatusTemplate { admin_username: user.username, app_status, db_status, queue_size, memory_usage, uptime, version: "0.1.0", git_commit: "unknown", urls, }; template.into_response() } // GET /user/audit pub async fn user_audit_get(State(state): State, jar: CookieJar) -> Response { let (user, _) = match require_user_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let logs = { let conn = state.system_db.lock().unwrap(); let events = crate::db::audit_events::list_audit_events(&conn, 100, 0, Some(&user.username), None) .unwrap_or_default(); events .into_iter() .map(|e| { let (ip, ua) = if let Some(ref m) = e.metadata { if m.starts_with("IP: ") { let parts: Vec<&str> = m.split(", UA: ").collect(); let ip = parts[0] .trim_start_matches("IP: ") .trim_matches('"') .trim_matches('\'') .replace("Some(", "") .replace(")", ""); let ua = if parts.len() > 1 { parts[1] .trim_matches('"') .trim_matches('\'') .replace("Some(", "") .replace(")", "") } else { "Unknown".to_string() }; (Some(ip), Some(ua)) } else { (None, None) } } else { (None, None) }; crate::models::AuditLog { id: e.id, timestamp: e.timestamp, username: e.actor, action: e.action, object_type: Some(e.object_type), object_id: Some(e.object_id), ip_address: ip, user_agent: ua, } }) .collect() }; let template = crate::templates::UserAuditTemplate { admin_username: user.username, logs, }; template.into_response() } // GET /user/status pub async fn user_status_get(State(state): State, jar: CookieJar) -> Response { let (user, _) = match require_user_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let user_dbs = match state.get_user_dbs(user.id) { Ok(dbs) => dbs, Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), }; let app_status = "Healthy"; let db_status = { let conn_ok = { let conn = user_dbs.content.lock().unwrap(); get_url_counts(&conn).is_ok() }; if conn_ok { "Operational".to_string() } else { "Degraded (Database connection failed)".to_string() } }; let queue_size = 0; let memory_usage = get_memory_usage(); let uptime_duration = state.start_time.elapsed(); let uptime = crate::utils::format_duration(uptime_duration); let urls = { let conn = user_dbs.content.lock().unwrap(); crate::db::content::list_urls(&conn, 50, 0, None).unwrap_or_default() }; let template = crate::templates::UserStatusTemplate { admin_username: user.username, app_status, db_status, queue_size, memory_usage, uptime, version: "0.1.0", git_commit: "unknown", urls, }; template.into_response() } // POST /admin/settings/restore pub async fn restore_backup_post( State(state): State, jar: CookieJar, headers: HeaderMap, connect_info: Option>, mut multipart: axum::extract::Multipart, ) -> Response { let (user, session_id) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let ip = get_client_ip(&headers, connect_info); let mut file_bytes = Vec::new(); let mut confirm_text = String::new(); let mut csrf_token = String::new(); while let Ok(Some(field)) = multipart.next_field().await { let name = field.name().unwrap_or("").to_string(); if name == "backup_file" { if let Ok(bytes) = field.bytes().await { file_bytes = bytes.to_vec(); } } else if name == "confirm_text" { if let Ok(text) = field.text().await { confirm_text = text.trim().to_string(); } } else if name == "csrf_token" { if let Ok(token) = field.text().await { csrf_token = token.trim().to_string(); } } } if !verify_csrf(&session_id, &csrf_token) { return Redirect::to("/admin/settings?error=Invalid CSRF token").into_response(); } if confirm_text != "RESTORE" { return Redirect::to("/admin/settings?error=Confirmation text must be exactly 'RESTORE'") .into_response(); } if file_bytes.is_empty() { return Redirect::to("/admin/settings?error=No backup file uploaded").into_response(); } // Save uploaded archive to a temporary file let temp_file_path = std::env::temp_dir().join(format!("bzod_restore_{}.tar.gz", uuid::Uuid::new_v4())); if let Err(e) = std::fs::write(&temp_file_path, &file_bytes) { return Redirect::to(&format!( "/admin/settings?error=Failed to write temp file: {}", e )) .into_response(); } // Log RESTORE_INITIATED audit event before restore { let conn = state.admin_db.lock().unwrap(); let _ = write_audit_log( &conn, &state, &user.username, "RESTORE_INITIATED", Some("system"), Some("tarball"), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()), ); } // Call the perform_restore engine inside closed connection blocks let restore_res = { // Temporarily suspend access to active SQLite connections let mut admin_conn = state.admin_db.lock().unwrap(); let mut content_conn = state.content_db.lock().unwrap(); let mut analytics_conn = state.analytics_db.lock().unwrap(); let mut system_conn = state.system_db.lock().unwrap(); // 1. Close current connections by replacing them with dummy in-memory DBs *admin_conn = match rusqlite::Connection::open_in_memory() { Ok(c) => c, Err(e) => { return Redirect::to(&format!( "/admin/settings?error=Failed to open temp in-memory DB: {}", e )) .into_response() } }; *content_conn = match rusqlite::Connection::open_in_memory() { Ok(c) => c, Err(e) => { return Redirect::to(&format!( "/admin/settings?error=Failed to open temp in-memory DB: {}", e )) .into_response() } }; *analytics_conn = match rusqlite::Connection::open_in_memory() { Ok(c) => c, Err(e) => { return Redirect::to(&format!( "/admin/settings?error=Failed to open temp in-memory DB: {}", e )) .into_response() } }; *system_conn = match rusqlite::Connection::open_in_memory() { Ok(c) => c, Err(e) => { return Redirect::to(&format!( "/admin/settings?error=Failed to open temp in-memory DB: {}", e )) .into_response() } }; // 2. Perform restore unpacking/validation let res = crate::cli::restore::perform_restore(&temp_file_path, &state.config.data_dir); // 3. Post-Restore Path Normalization (Move flat files to multi-tenant structure) let admin_dir = state.config.data_dir.join("admin"); let users_1_dir = state.config.data_dir.join("users").join("1"); let _ = std::fs::create_dir_all(&admin_dir); let _ = std::fs::create_dir_all(&users_1_dir); let admin_files = vec![ "admin.db", "admin.db-wal", "admin.db-shm", "system.db", "system.db-wal", "system.db-shm", "users.db", "users.db-wal", "users.db-shm", ]; for f in admin_files { let src = state.config.data_dir.join(f); if src.exists() { let _ = std::fs::rename(&src, admin_dir.join(f)); } } let content_files = vec![ "content.db", "content.db-wal", "content.db-shm", "analytics.db", "analytics.db-wal", "analytics.db-shm", ]; for f in content_files { let src = state.config.data_dir.join(f); if src.exists() { let _ = std::fs::rename(&src, users_1_dir.join(f)); } } // 4. Reinitialize database connections using correct multi-tenant paths let new_admin = rusqlite::Connection::open(state.config.data_dir.join("admin/admin.db")); let new_system = rusqlite::Connection::open(state.config.data_dir.join("admin/system.db")); let new_users = rusqlite::Connection::open(state.config.data_dir.join("admin/users.db")); let new_content = rusqlite::Connection::open(state.config.data_dir.join("users/1/content.db")); let new_analytics = rusqlite::Connection::open(state.config.data_dir.join("users/1/analytics.db")); match (new_admin, new_content, new_analytics, new_system, new_users) { (Ok(adm), Ok(cnt), Ok(any), Ok(sys), Ok(usr)) => { let _ = crate::db::sqlite::enable_wal(&adm, "admin"); let _ = crate::db::sqlite::enable_wal(&cnt, "content"); let _ = crate::db::sqlite::enable_wal(&any, "analytics"); let _ = crate::db::sqlite::enable_wal(&sys, "system"); let _ = crate::db::sqlite::enable_wal(&usr, "users"); let _ = crate::db::sqlite::enable_foreign_keys(&adm, "admin"); let _ = crate::db::sqlite::enable_foreign_keys(&cnt, "content"); let _ = crate::db::sqlite::enable_foreign_keys(&any, "analytics"); let _ = crate::db::sqlite::enable_foreign_keys(&sys, "system"); let _ = crate::db::sqlite::enable_foreign_keys(&usr, "users"); *admin_conn = adm; *content_conn = cnt; *analytics_conn = any; *system_conn = sys; *state.db.users.lock().unwrap() = usr; } _ => { return Redirect::to("/admin/settings?error=Failed to reopen restored databases") .into_response(); } } res }; let _ = std::fs::remove_file(&temp_file_path); match restore_res { Ok(_) => { // Write database restore success log to newly restored admin db { let conn = state.admin_db.lock().unwrap(); let _ = write_audit_log( &conn, &state, &user.username, "DATABASE_RESTORE", Some("system"), Some("tarball"), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()), ); } // Run doctor check to verify integrity after restore (spawn in background since we can't easily await here) tracing::info!("Running post-restore diagnostics..."); let config_clone = state.config.clone(); tokio::spawn(async move { let _ = crate::cli::doctor::run(None, config_clone).await; }); Redirect::to("/admin/login").into_response() } Err(e) => { Redirect::to(&format!("/admin/settings?error=Restore failed: {}", e)).into_response() } } } #[derive(Deserialize)] pub struct AnalyticsQuery { pub analytics_page: Option, pub date_from: Option, pub date_to: Option, } fn validate_date_filters( date_from: Option<&str>, date_to: Option<&str>, ) -> Result<(Option, Option), StatusCode> { let from_parsed = match date_from { Some(df) if !df.is_empty() => match chrono::NaiveDate::parse_from_str(df, "%Y-%m-%d") { Ok(d) => Some(d), Err(_) => return Err(StatusCode::BAD_REQUEST), }, _ => None, }; let to_parsed = match date_to { Some(dt) if !dt.is_empty() => match chrono::NaiveDate::parse_from_str(dt, "%Y-%m-%d") { Ok(d) => Some(d), Err(_) => return Err(StatusCode::BAD_REQUEST), }, _ => None, }; if let (Some(f), Some(t)) = (from_parsed, to_parsed) { if f > t { return Err(StatusCode::BAD_REQUEST); } } Ok(( from_parsed.map(|d| d.format("%Y-%m-%d").to_string()), to_parsed.map(|d| d.format("%Y-%m-%d").to_string()), )) } fn escape_csv_field(field: &str) -> String { let needs_escaping = field.contains(',') || field.contains('"') || field.contains('\n') || field.contains('\r'); if needs_escaping { let escaped = field.replace('"', "\"\""); format!("\"{}\"", escaped) } else { field.to_string() } } struct DbExportStream { receiver: tokio::sync::mpsc::Receiver>, } impl futures_util::stream::Stream for DbExportStream { type Item = Result; fn poll_next( mut self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>, ) -> std::task::Poll> { self.receiver.poll_recv(cx) } } async fn perform_csv_export( state: AppState, target_type: &'static str, id: String, date_from: Option, date_to: Option, ) -> Response { let (clean_date_from, clean_date_to) = match validate_date_filters(date_from.as_deref(), date_to.as_deref()) { Ok(res) => res, Err(status) => return status.into_response(), }; let target_exists = { let conn = state.content_db.lock().unwrap(); if target_type == "url" { get_url_by_id(&conn, &id) .map(|u| u.is_some()) .unwrap_or(false) } else { get_landing_page_by_id(&conn, &id) .map(|p| p.is_some()) .unwrap_or(false) } }; if !target_exists { return (StatusCode::NOT_FOUND, "Target not found").into_response(); } let count = { let conn = state.analytics_db.lock().unwrap(); get_target_visit_total_filtered( &conn, target_type, &id, clean_date_from.as_deref(), clean_date_to.as_deref(), ) .unwrap_or(0) }; let (has_utm_source, has_utm_campaign) = { let conn = state.analytics_db.lock().unwrap(); let cols = get_visits_schema_columns(&conn).unwrap_or_default(); (cols.contains("utm_source"), cols.contains("utm_campaign")) }; let (tx, rx) = tokio::sync::mpsc::channel::>(32); let analytics_db = state.analytics_db.clone(); let target_id = id.clone(); tokio::task::spawn_blocking(move || { let conn = analytics_db.lock().unwrap(); let mut header = "Timestamp,IP Address,Country,Referrer,Browser,User-Agent".to_string(); if has_utm_source { header.push_str(",UTM Source"); } if has_utm_campaign { header.push_str(",UTM Campaign"); } header.push('\n'); if tx .blocking_send(Ok(axum::body::Bytes::from(header))) .is_err() { return; } let select_fields = if has_utm_source && has_utm_campaign { "timestamp, ip_address, country, referer, user_agent, utm_source, utm_campaign" } else if has_utm_source { "timestamp, ip_address, country, referer, user_agent, utm_source" } else if has_utm_campaign { "timestamp, ip_address, country, referer, user_agent, utm_campaign" } else { "timestamp, ip_address, country, referer, user_agent" }; let mut sql = format!( "SELECT {} FROM visits WHERE target_type = ?1 AND target_id = ?2", select_fields ); let mut params: Vec> = vec![Box::new(target_type.to_string()), Box::new(target_id)]; if let Some(df) = clean_date_from.as_deref() { sql.push_str(&format!(" AND timestamp >= ?{}", params.len() + 1)); params.push(Box::new(format!("{}T00:00:00Z", df))); } if let Some(dt) = clean_date_to.as_deref() { if let Ok(parsed_date) = chrono::NaiveDate::parse_from_str(dt, "%Y-%m-%d") { let next_day = parsed_date + chrono::Duration::days(1); sql.push_str(&format!(" AND timestamp < ?{}", params.len() + 1)); params.push(Box::new(format!( "{}T00:00:00Z", next_day.format("%Y-%m-%d") ))); } } sql.push_str(" ORDER BY timestamp DESC, id DESC"); let mut stmt = match conn.prepare(&sql) { Ok(s) => s, Err(_) => return, }; let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); let mut rows = match stmt.query(rusqlite::params_from_iter(param_refs)) { Ok(r) => r, Err(_) => return, }; let mut csv_buffer = String::new(); while let Ok(Some(row)) = rows.next() { let timestamp: String = row.get(0).unwrap_or_default(); let ip_address: String = row.get(1).unwrap_or_default(); let country: String = row.get(2).unwrap_or_default(); let referer: String = row.get(3).unwrap_or_default(); let user_agent: String = row.get(4).unwrap_or_default(); let (browser, _, _) = parse_ua(&user_agent); let referrer = clean_referrer(&referer); let country_display = if country.is_empty() { "Unknown".to_string() } else { country }; let mut line = format!( "{},{},{},{},{},{}", escape_csv_field(×tamp), escape_csv_field(&ip_address), escape_csv_field(&country_display), escape_csv_field(&referrer), escape_csv_field(&browser), escape_csv_field(&user_agent) ); let mut col_idx = 5; if has_utm_source { let utm_src: String = row.get(col_idx).unwrap_or_default(); line.push_str(&format!(",{}", escape_csv_field(&utm_src))); col_idx += 1; } if has_utm_campaign { let utm_camp: String = row.get(col_idx).unwrap_or_default(); line.push_str(&format!(",{}", escape_csv_field(&utm_camp))); } line.push('\n'); csv_buffer.push_str(&line); if csv_buffer.len() >= 8192 { let bytes = axum::body::Bytes::from(csv_buffer); if tx.blocking_send(Ok(bytes)).is_err() { return; } csv_buffer = String::new(); } } if !csv_buffer.is_empty() { let _ = tx.blocking_send(Ok(axum::body::Bytes::from(csv_buffer))); } }); let stream = DbExportStream { receiver: rx }; let filename = if target_type == "url" { format!("url_{}_analytics.csv", id) } else { format!("page_{}_analytics.csv", id) }; ( StatusCode::OK, [ ("Content-Type", "text/csv"), ( "Content-Disposition", &format!("attachment; filename=\"{}\"", filename), ), ("X-BZOD-Export-Records", &count.to_string()), ], axum::body::Body::from_stream(stream), ) .into_response() } async fn perform_json_export( state: AppState, target_type: &'static str, id: String, date_from: Option, date_to: Option, ) -> Response { let (clean_date_from, clean_date_to) = match validate_date_filters(date_from.as_deref(), date_to.as_deref()) { Ok(res) => res, Err(status) => return status.into_response(), }; let target_exists = { let conn = state.content_db.lock().unwrap(); if target_type == "url" { get_url_by_id(&conn, &id) .map(|u| u.is_some()) .unwrap_or(false) } else { get_landing_page_by_id(&conn, &id) .map(|p| p.is_some()) .unwrap_or(false) } }; if !target_exists { return (StatusCode::NOT_FOUND, "Target not found").into_response(); } let count = { let conn = state.analytics_db.lock().unwrap(); get_target_visit_total_filtered( &conn, target_type, &id, clean_date_from.as_deref(), clean_date_to.as_deref(), ) .unwrap_or(0) }; if count > MAX_JSON_EXPORT_ROWS as i64 { return StatusCode::PAYLOAD_TOO_LARGE.into_response(); } let visits_raw = { let conn = state.analytics_db.lock().unwrap(); match get_target_visits_all_in_memory( &conn, target_type, &id, clean_date_from.as_deref(), clean_date_to.as_deref(), ) { Ok(v) => v, Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), } }; #[derive(serde::Serialize)] struct JsonExportRow { timestamp: String, ip_address: String, country: String, referrer: String, browser: String, user_agent: String, } let export_rows: Vec = visits_raw .into_iter() .map(|r| { let (browser, _, _) = parse_ua(&r.user_agent); let referrer = clean_referrer(&r.referer); let country_display = if r.country.is_empty() { "Unknown".to_string() } else { r.country }; JsonExportRow { timestamp: r.timestamp, ip_address: r.ip_address, country: country_display, referrer, browser, user_agent: r.user_agent, } }) .collect(); let body_str = match serde_json::to_string(&export_rows) { Ok(s) => s, Err(_) => { return (StatusCode::INTERNAL_SERVER_ERROR, "Serialization error").into_response() } }; let filename = if target_type == "url" { format!("url_{}_analytics.json", id) } else { format!("page_{}_analytics.json", id) }; ( StatusCode::OK, [ ("Content-Type", "application/json"), ( "Content-Disposition", &format!("attachment; filename=\"{}\"", filename), ), ("X-BZOD-Export-Records", &count.to_string()), ], body_str, ) .into_response() } // GET /admin/analytics/url/:id pub async fn url_analytics_get( State(state): State, jar: CookieJar, Path(id): Path, Query(query): Query, ) -> Response { let (user, _) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let url = { let conn = state.content_db.lock().unwrap(); match get_url_by_id(&conn, &id) { Ok(Some(u)) => u, Ok(None) => return (StatusCode::NOT_FOUND, "URL not found").into_response(), Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), } }; let conn = state.analytics_db.lock().unwrap(); let schema_cols = get_visits_schema_columns(&conn).unwrap_or_default(); let has_utm_source = schema_cols.contains("utm_source"); let has_utm_campaign = schema_cols.contains("utm_campaign"); if has_utm_source || has_utm_campaign { return ( StatusCode::INTERNAL_SERVER_ERROR, "UTM mapping verification failed", ) .into_response(); } let (clean_date_from, clean_date_to) = match validate_date_filters(query.date_from.as_deref(), query.date_to.as_deref()) { Ok(res) => res, Err(status) => return status.into_response(), }; let total_clicks = get_target_visit_total_filtered( &conn, "url", &id, clean_date_from.as_deref(), clean_date_to.as_deref(), ) .unwrap_or(0); let unique_visitors = get_target_unique_visitors(&conn, "url", &id).unwrap_or(0); let qr_scans = crate::db::qr::get_qr_scan_count(&conn, &id).unwrap_or(0); let direct_clicks = (total_clicks - qr_scans).max(0); let clicks_data = get_clicks_trend(&conn, "url", &id, 30) .or_else(|_| get_clicks_trend_raw(&conn, "url", &id, 30)) .unwrap_or_default(); let mut trend_map = std::collections::BTreeMap::new(); for i in (0..30).rev() { let date_str = (Utc::now() - chrono::Duration::days(i)) .format("%Y-%m-%d") .to_string(); trend_map.insert(date_str, 0i64); } for (d, c) in clicks_data { trend_map.insert(d, c); } let formatted_trend: Vec<(String, i64)> = trend_map.into_iter().collect(); let traffic_chart = generate_line_chart(&formatted_trend); let monthly_data = get_monthly_clicks_trend(&conn, "url", &id, 12).unwrap_or_default(); let monthly_chart = generate_line_chart(&monthly_data); let countries_data = get_metric_rankings(&conn, "url", &id, "country", 5) .or_else(|_| get_metric_rankings_raw(&conn, "url", &id, "country", 5)) .unwrap_or_default(); let countries_chart = generate_bar_chart(&countries_data); let referrers_data = get_metric_rankings(&conn, "url", &id, "referrer", 5) .or_else(|_| get_metric_rankings_raw(&conn, "url", &id, "referrer", 5)) .unwrap_or_default(); let referrers_chart = generate_bar_chart(&referrers_data); let browsers_data = get_metric_rankings(&conn, "url", &id, "browser", 5) .or_else(|_| get_metric_rankings_raw(&conn, "url", &id, "browser", 5)) .unwrap_or_default(); let browsers_chart = generate_bar_chart(&browsers_data); let calculated_total_pages = (total_clicks as usize).div_ceil(ANALYTICS_PAGE_SIZE); let total_pages = std::cmp::max(1, calculated_total_pages); let requested_page = query.analytics_page.unwrap_or(1); let current_page = if requested_page == 0 { 1 } else { requested_page } .clamp(1, total_pages); let offset = (current_page - 1) * ANALYTICS_PAGE_SIZE; let visits_raw = get_target_visits_paginated( &conn, "url", &id, ANALYTICS_PAGE_SIZE as i64, offset as i64, clean_date_from.as_deref(), clean_date_to.as_deref(), ) .unwrap_or_default(); let visits: Vec = visits_raw .into_iter() .enumerate() .map(|(idx, r)| { let (browser, _, _) = parse_ua(&r.user_agent); let referrer = clean_referrer(&r.referer); let sr = offset + idx + 1; crate::templates::VisitorLogEntry { sr, timestamp: r.timestamp, ip_address: r.ip_address, country: if r.country.is_empty() { "Unknown".to_string() } else { r.country }, referrer, browser, user_agent: r.user_agent, utm_source: "-".to_string(), utm_campaign: "-".to_string(), } }) .collect(); let start_page = current_page.saturating_sub(3).max(1); let end_page = std::cmp::min(total_pages, current_page + 3); let visible_pages: Vec = (start_page..=end_page).collect(); let page_start = if total_clicks == 0 { 0 } else { offset + 1 }; let page_end = if total_clicks == 0 { 0 } else { std::cmp::min(total_clicks as usize, offset + ANALYTICS_PAGE_SIZE) }; let template = crate::templates::UrlAnalyticsTemplate { admin_username: user.username, url, total_clicks, unique_visitors, qr_scans, direct_clicks, traffic_chart, monthly_chart, countries_chart, referrers_chart, browsers_chart, visits, current_page, total_pages, visible_pages, total_records: total_clicks, page_start, page_end, date_from: clean_date_from, date_to: clean_date_to, is_admin: true, }; template.into_response() } // GET /admin/analytics/page/:id pub async fn page_analytics_get( State(state): State, jar: CookieJar, Path(id): Path, Query(query): Query, ) -> Response { let (user, _) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let page = { let conn = state.content_db.lock().unwrap(); match get_landing_page_by_id(&conn, &id) { Ok(Some(p)) => p, Ok(None) => return (StatusCode::NOT_FOUND, "Landing page not found").into_response(), Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), } }; let conn = state.analytics_db.lock().unwrap(); let schema_cols = get_visits_schema_columns(&conn).unwrap_or_default(); let has_utm_source = schema_cols.contains("utm_source"); let has_utm_campaign = schema_cols.contains("utm_campaign"); if has_utm_source || has_utm_campaign { return ( StatusCode::INTERNAL_SERVER_ERROR, "UTM mapping verification failed", ) .into_response(); } let (clean_date_from, clean_date_to) = match validate_date_filters(query.date_from.as_deref(), query.date_to.as_deref()) { Ok(res) => res, Err(status) => return status.into_response(), }; let total_views = get_target_visit_total_filtered( &conn, "page", &id, clean_date_from.as_deref(), clean_date_to.as_deref(), ) .unwrap_or(0); let unique_visitors = get_target_unique_visitors(&conn, "page", &id).unwrap_or(0); let clicks_data = get_clicks_trend(&conn, "page", &id, 30) .or_else(|_| get_clicks_trend_raw(&conn, "page", &id, 30)) .unwrap_or_default(); let mut trend_map = std::collections::BTreeMap::new(); for i in (0..30).rev() { let date_str = (Utc::now() - chrono::Duration::days(i)) .format("%Y-%m-%d") .to_string(); trend_map.insert(date_str, 0i64); } for (d, c) in clicks_data { trend_map.insert(d, c); } let formatted_trend: Vec<(String, i64)> = trend_map.into_iter().collect(); let traffic_chart = generate_line_chart(&formatted_trend); let monthly_data = get_monthly_clicks_trend(&conn, "page", &id, 12).unwrap_or_default(); let monthly_chart = generate_line_chart(&monthly_data); let countries_data = get_metric_rankings(&conn, "page", &id, "country", 5) .or_else(|_| get_metric_rankings_raw(&conn, "page", &id, "country", 5)) .unwrap_or_default(); let countries_chart = generate_bar_chart(&countries_data); let referrers_data = get_metric_rankings(&conn, "page", &id, "referrer", 5) .or_else(|_| get_metric_rankings_raw(&conn, "page", &id, "referrer", 5)) .unwrap_or_default(); let referrers_chart = generate_bar_chart(&referrers_data); let calculated_total_pages = (total_views as usize).div_ceil(ANALYTICS_PAGE_SIZE); let total_pages = std::cmp::max(1, calculated_total_pages); let requested_page = query.analytics_page.unwrap_or(1); let current_page = if requested_page == 0 { 1 } else { requested_page } .clamp(1, total_pages); let offset = (current_page - 1) * ANALYTICS_PAGE_SIZE; let visits_raw = get_target_visits_paginated( &conn, "page", &id, ANALYTICS_PAGE_SIZE as i64, offset as i64, clean_date_from.as_deref(), clean_date_to.as_deref(), ) .unwrap_or_default(); let visits: Vec = visits_raw .into_iter() .enumerate() .map(|(idx, r)| { let (browser, _, _) = parse_ua(&r.user_agent); let referrer = clean_referrer(&r.referer); let sr = offset + idx + 1; crate::templates::VisitorLogEntry { sr, timestamp: r.timestamp, ip_address: r.ip_address, country: if r.country.is_empty() { "Unknown".to_string() } else { r.country }, referrer, browser, user_agent: r.user_agent, utm_source: "-".to_string(), utm_campaign: "-".to_string(), } }) .collect(); let start_page = current_page.saturating_sub(3).max(1); let end_page = std::cmp::min(total_pages, current_page + 3); let visible_pages: Vec = (start_page..=end_page).collect(); let page_start = if total_views == 0 { 0 } else { offset + 1 }; let page_end = if total_views == 0 { 0 } else { std::cmp::min(total_views as usize, offset + ANALYTICS_PAGE_SIZE) }; let template = crate::templates::PageAnalyticsTemplate { admin_username: user.username, page, total_views, unique_visitors, traffic_chart, monthly_chart, countries_chart, referrers_chart, visits, current_page, total_pages, visible_pages, total_records: total_views, page_start, page_end, date_from: clean_date_from, date_to: clean_date_to, is_admin: true, }; template.into_response() } pub async fn url_analytics_csv_export( State(state): State, jar: CookieJar, Path(id): Path, Query(query): Query, ) -> Response { if require_auth(&state, &jar).await.is_err() { return StatusCode::UNAUTHORIZED.into_response(); } perform_csv_export(state, "url", id, query.date_from, query.date_to).await } pub async fn url_analytics_json_export( State(state): State, jar: CookieJar, Path(id): Path, Query(query): Query, ) -> Response { if require_auth(&state, &jar).await.is_err() { return StatusCode::UNAUTHORIZED.into_response(); } perform_json_export(state, "url", id, query.date_from, query.date_to).await } pub async fn page_analytics_csv_export( State(state): State, jar: CookieJar, Path(id): Path, Query(query): Query, ) -> Response { if require_auth(&state, &jar).await.is_err() { return StatusCode::UNAUTHORIZED.into_response(); } perform_csv_export(state, "page", id, query.date_from, query.date_to).await } pub async fn page_analytics_json_export( State(state): State, jar: CookieJar, Path(id): Path, Query(query): Query, ) -> Response { if require_auth(&state, &jar).await.is_err() { return StatusCode::UNAUTHORIZED.into_response(); } perform_json_export(state, "page", id, query.date_from, query.date_to).await } // --------------------------------------------------------------------------- // GA Hardening UI Handlers and Structs // --------------------------------------------------------------------------- #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct UserDetailStats { pub max_urls: i64, pub max_landings: i64, pub max_api_tokens: i64, pub max_storage_mb: i64, pub current_urls: i64, pub current_landings: i64, pub current_api_tokens: i64, pub current_storage_mb: i64, pub url_pct: i64, pub landing_pct: i64, pub token_pct: i64, pub storage_pct: i64, pub total_visits: i64, } #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct GlobalSlugRow { pub slug: String, pub owner_user_id: i64, pub target_type: String, pub target_id: String, pub created_at: String, pub updated_at: String, pub status: String, pub deleted_at: Option, } #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct ModerationLogEntry { pub id: String, pub timestamp: String, pub admin_username: String, pub target_user_id: i64, pub target_username: Option, pub resource_type: String, pub resource_identifier: String, pub action: String, pub severity: String, pub reason: String, } #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct SlugHistoryRow { pub id: i64, pub slug: String, pub old_owner_user_id: Option, pub new_owner_user_id: Option, pub action: String, pub timestamp: String, pub admin_username: Option, } #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct JobHistoryRow { pub id: String, pub job_name: String, pub status: String, pub started_at: String, pub finished_at: Option, pub error_message: Option, } #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct HealthCheckRow { pub id: String, pub object_type: String, pub object_id: String, pub checked_at: String, pub status_code: Option, pub error_message: Option, pub is_healthy: i64, } #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct BackupFileRow { pub filename: String, pub size_str: String, pub created_str: String, } #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct BackupHistoryRow { pub id: String, pub backup_path: String, pub status: String, pub created_at: String, pub size_bytes: i64, pub error_message: Option, } // Helpers fn format_size(bytes: u64) -> String { if bytes < 1024 { format!("{} B", bytes) } else if bytes < 1024 * 1024 { format!("{:.2} KB", bytes as f64 / 1024.0) } else { format!("{:.2} MB", bytes as f64 / (1024.0 * 1024.0)) } } fn get_dir_size(dir: &std::path::Path) -> std::io::Result { let mut total = 0; if dir.is_dir() { for entry in std::fs::read_dir(dir)? { let entry = entry?; let path = entry.path(); if path.is_dir() { total += get_dir_size(&path)?; } else { total += entry.metadata()?.len(); } } } Ok(total) } pub fn get_user_detail_stats( state: &AppState, user_id: i64, ) -> Result> { use rusqlite::OptionalExtension; let quotas = { let conn = state.users_db.lock().unwrap(); conn.query_row( "SELECT max_urls, max_landings, max_api_tokens, max_storage_mb, current_urls, current_landings, current_api_tokens, current_storage_mb FROM quotas WHERE user_id = ?1;", [user_id], |row| Ok(crate::models::UserQuotas { user_id, max_urls: row.get(0)?, max_landings: row.get(1)?, max_api_tokens: row.get(2)?, max_storage_mb: row.get(3)?, current_urls: row.get(4)?, current_landings: row.get(5)?, current_api_tokens: row.get(6)?, current_storage_mb: row.get(7)?, }) ).optional()? }; let quotas = quotas.unwrap_or(crate::models::UserQuotas { user_id, max_urls: 100, max_landings: 10, max_api_tokens: 5, max_storage_mb: 100, current_urls: 0, current_landings: 0, current_api_tokens: 0, current_storage_mb: 0, }); let total_visits = { if let Ok(dbs) = state.get_user_dbs(user_id) { let conn = dbs.analytics.lock().unwrap(); conn.query_row("SELECT COUNT(*) FROM visits;", [], |row| { row.get::<_, i64>(0) }) .unwrap_or(0) } else { 0 } }; let url_pct = if quotas.max_urls > 0 { (quotas.current_urls * 100) / quotas.max_urls } else { 0 }; let landing_pct = if quotas.max_landings > 0 { (quotas.current_landings * 100) / quotas.max_landings } else { 0 }; let token_pct = if quotas.max_api_tokens > 0 { (quotas.current_api_tokens * 100) / quotas.max_api_tokens } else { 0 }; let storage_pct = if quotas.max_storage_mb > 0 { (quotas.current_storage_mb * 100) / quotas.max_storage_mb } else { 0 }; Ok(UserDetailStats { max_urls: quotas.max_urls, max_landings: quotas.max_landings, max_api_tokens: quotas.max_api_tokens, max_storage_mb: quotas.max_storage_mb, current_urls: quotas.current_urls, current_landings: quotas.current_landings, current_api_tokens: quotas.current_api_tokens, current_storage_mb: quotas.current_storage_mb, url_pct, landing_pct, token_pct, storage_pct, total_visits, }) } // GET /admin/users/new pub async fn users_new_get( State(state): State, jar: CookieJar, Query(params): Query>, ) -> Response { let (user, session_id) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let csrf_token = generate_csrf_token(&session_id); let template = crate::templates::UsersNewTemplate { admin_username: user.username, csrf_token, success: params.get("success").cloned(), error: params.get("error").cloned(), }; template.into_response() } // GET /admin/users/:id pub async fn user_detail_get( State(state): State, jar: CookieJar, Path(id): Path, Query(params): Query>, ) -> Response { let (user, session_id) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let target_user = { let conn = state.users_db.lock().unwrap(); let u_res = conn.query_row( "SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata FROM users WHERE id = ?1;", [id], |row| Ok(crate::models::TenantUser { id: row.get(0)?, username: row.get(1)?, password_hash: row.get(2)?, status: row.get(3)?, created_at: row.get(4)?, last_login: row.get(5)?, account_type: row.get(6)?, organization_id: row.get(7)?, metadata: row.get(8)?, }) ); match u_res { Ok(u) => u, Err(_) => return Redirect::to("/admin/users?error=User not found").into_response(), } }; let stats = match get_user_detail_stats(&state, id) { Ok(s) => s, Err(_) => return Redirect::to("/admin/users?error=Database error").into_response(), }; let sessions = { let conn = state.users_db.lock().unwrap(); let mut stmt = conn .prepare("SELECT id, user_id, expires_at, created_at FROM sessions WHERE user_id = ?1;") .unwrap(); let rows = stmt .query_map([id], |row| { Ok(crate::models::UserSession { id: row.get(0)?, user_id: row.get(1)?, expires_at: row.get(2)?, created_at: row.get(3)?, }) }) .unwrap(); rows.filter_map(|r| r.ok()).collect() }; let tokens = { let conn = state.users_db.lock().unwrap(); let mut stmt = conn .prepare( "SELECT id, user_id, token_hash, created_at FROM api_tokens WHERE user_id = ?1;", ) .unwrap(); let rows = stmt .query_map([id], |row| { Ok(crate::models::UserApiToken { id: row.get(0)?, user_id: row.get(1)?, token_hash: row.get(2)?, created_at: row.get(3)?, }) }) .unwrap(); rows.filter_map(|r| r.ok()).collect() }; let csrf_token = generate_csrf_token(&session_id); let template = crate::templates::UserDetailTemplate { admin_username: user.username, target_user, stats, sessions, tokens, csrf_token, success: params.get("success").cloned(), error: params.get("error").cloned(), }; template.into_response() } // GET /admin/users/:id/edit pub async fn user_edit_get( State(state): State, jar: CookieJar, Path(id): Path, Query(params): Query>, ) -> Response { let (user, session_id) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let target_user = { let conn = state.users_db.lock().unwrap(); let u_res = conn.query_row( "SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata FROM users WHERE id = ?1;", [id], |row| Ok(crate::models::TenantUser { id: row.get(0)?, username: row.get(1)?, password_hash: row.get(2)?, status: row.get(3)?, created_at: row.get(4)?, last_login: row.get(5)?, account_type: row.get(6)?, organization_id: row.get(7)?, metadata: row.get(8)?, }) ); match u_res { Ok(u) => u, Err(_) => return Redirect::to("/admin/users?error=User not found").into_response(), } }; let quotas = { let conn = state.users_db.lock().unwrap(); conn.query_row( "SELECT max_urls, max_landings, max_api_tokens, max_storage_mb, current_urls, current_landings, current_api_tokens, current_storage_mb FROM quotas WHERE user_id = ?1;", [id], |row| Ok(crate::models::UserQuotas { user_id: id, max_urls: row.get(0)?, max_landings: row.get(1)?, max_api_tokens: row.get(2)?, max_storage_mb: row.get(3)?, current_urls: row.get(4)?, current_landings: row.get(5)?, current_api_tokens: row.get(6)?, current_storage_mb: row.get(7)?, }) ).unwrap_or(crate::models::UserQuotas { user_id: id, max_urls: 100, max_landings: 10, max_api_tokens: 5, max_storage_mb: 100, current_urls: 0, current_landings: 0, current_api_tokens: 0, current_storage_mb: 0, }) }; let csrf_token = generate_csrf_token(&session_id); let template = crate::templates::UserEditTemplate { admin_username: user.username, target_user, quotas, csrf_token, success: params.get("success").cloned(), error: params.get("error").cloned(), }; template.into_response() } #[derive(Deserialize)] pub struct UserEditForm { pub account_type: String, pub metadata: String, pub max_urls: i64, pub max_landings: i64, pub max_api_tokens: i64, pub max_storage_mb: i64, pub csrf_token: String, } // POST /admin/users/:id/edit pub async fn user_edit_post( State(state): State, jar: CookieJar, Path(id): Path, Form(form): Form, ) -> Response { let (_user, session_id) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; if !verify_csrf(&session_id, &form.csrf_token) { return Redirect::to(&format!( "/admin/users/{}/edit?error=Invalid CSRF token", id )) .into_response(); } let conn = state.users_db.lock().unwrap(); let _ = conn.execute( "UPDATE users SET account_type = ?1, metadata = ?2 WHERE id = ?3;", rusqlite::params![form.account_type, form.metadata, id], ); let _ = conn.execute( "INSERT INTO quotas (user_id, max_urls, max_landings, max_api_tokens, max_storage_mb) VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT(user_id) DO UPDATE SET max_urls = excluded.max_urls, max_landings = excluded.max_landings, max_api_tokens = excluded.max_api_tokens, max_storage_mb = excluded.max_storage_mb;", rusqlite::params![ id, form.max_urls, form.max_landings, form.max_api_tokens, form.max_storage_mb ], ); Redirect::to(&format!( "/admin/users/{}?success=User updated successfully", id )) .into_response() } // GET /admin/moderation pub async fn moderation_get( State(state): State, jar: CookieJar, Query(params): Query>, ) -> Response { let (user, session_id) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let flagged_items = { let conn = state.system_db.lock().unwrap(); let mut stmt = conn.prepare( "SELECT slug, owner_user_id, target_type, target_id, created_at, updated_at, status, deleted_at FROM global_slugs WHERE status = 'flagged' OR status = 'disabled' ORDER BY updated_at DESC;" ).unwrap(); let rows = stmt .query_map([], |row| { Ok(GlobalSlugRow { slug: row.get(0)?, owner_user_id: row.get(1)?, target_type: row.get(2)?, target_id: row.get(3)?, created_at: row.get(4)?, updated_at: row.get(5)?, status: row.get(6)?, deleted_at: row.get(7)?, }) }) .unwrap(); rows.filter_map(|r| r.ok()).collect() }; let logs = { let conn = state.system_db.lock().unwrap(); let mut stmt = conn.prepare( "SELECT id, timestamp, admin_username, target_user_id, target_username, resource_type, resource_identifier, action, severity, reason FROM moderation_events ORDER BY timestamp DESC LIMIT 50;" ).unwrap(); let rows = stmt .query_map([], |row| { Ok(ModerationLogEntry { id: row.get(0)?, timestamp: row.get(1)?, admin_username: row.get(2)?, target_user_id: row.get(3)?, target_username: row.get(4)?, resource_type: row.get(5)?, resource_identifier: row.get(6)?, action: row.get(7)?, severity: row.get(8)?, reason: row.get(9)?, }) }) .unwrap(); rows.filter_map(|r| r.ok()).collect() }; let csrf_token = generate_csrf_token(&session_id); let template = crate::templates::ModerationTemplate { admin_username: user.username, flagged_items, logs, csrf_token, success: params.get("success").cloned(), error: params.get("error").cloned(), }; template.into_response() } #[derive(Deserialize)] pub struct AdminModerateForm { pub slug: String, pub action: String, pub severity: String, pub reason: String, pub csrf_token: String, } // POST /admin/moderation pub async fn moderation_post( State(state): State, jar: CookieJar, Form(form): Form, ) -> Response { let (user, session_id) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; if !verify_csrf(&session_id, &form.csrf_token) { return Redirect::to("/admin/moderation?error=Invalid CSRF token").into_response(); } let action = form.action.trim().to_lowercase(); if !["flagged", "disabled", "active", "deleted"].contains(&action.as_str()) { return Redirect::to("/admin/moderation?error=Invalid moderation action").into_response(); } let (owner_user_id, target_type) = { let system_conn = state.system_db.lock().unwrap(); let row_opt: Option<(i64, String)> = system_conn .query_row( "SELECT owner_user_id, target_type FROM global_slugs WHERE slug = ?1;", [&form.slug], |row| Ok((row.get(0)?, row.get(1)?)), ) .optional() .unwrap_or(None); match row_opt { Some(r) => r, None => return Redirect::to("/admin/moderation?error=Slug not found").into_response(), } }; let owner_username = { let users_conn = state.users_db.lock().unwrap(); crate::db::users::get_user_by_id(&users_conn, owner_user_id) .unwrap_or(None) .map(|u| u.username) }; { let system_conn = state.system_db.lock().unwrap(); let now = Utc::now().to_rfc3339(); if action == "deleted" { let _ = system_conn.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&form.slug]); } else { let _ = system_conn.execute( "UPDATE global_slugs SET status = ?1, updated_at = ?2 WHERE slug = ?3;", rusqlite::params![action, now, form.slug], ); } let event_id = Uuid::new_v4().to_string(); let _ = system_conn.execute( "INSERT INTO moderation_events (id, timestamp, admin_username, target_user_id, target_username, resource_type, resource_identifier, action, severity, reason) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10);", rusqlite::params![ event_id, now, user.username, owner_user_id, owner_username, target_type, form.slug, action, form.severity, form.reason ], ); let _ = crate::db::audit_events::write_audit_event( &system_conn, &user.username, "CONTENT_MODERATION", "slug", &form.slug, Some(&format!("Action: {}, Reason: {}", action, form.reason)), ); } Redirect::to(&format!( "/admin/moderation?success=Moderation action '{}' applied", action )) .into_response() } #[derive(Deserialize)] pub struct SlugsQuery { pub search: Option, pub owner: Option, pub status: Option, pub success: Option, pub error: Option, } // GET /admin/slugs pub async fn slugs_get( State(state): State, jar: CookieJar, Query(query): Query, ) -> Response { let (user, session_id) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let system_conn = state.system_db.lock().unwrap(); let mut sql = "SELECT slug, owner_user_id, target_type, target_id, created_at, updated_at, status, deleted_at FROM global_slugs WHERE 1=1".to_string(); let mut values = vec![]; if let Some(ref s) = query.search { if !s.trim().is_empty() { sql.push_str(" AND slug LIKE ?"); values.push(rusqlite::types::Value::Text(format!("%{}%", s.trim()))); } } if let Some(o) = query.owner { sql.push_str(" AND owner_user_id = ?"); values.push(rusqlite::types::Value::Integer(o)); } if let Some(ref st) = query.status { if !st.trim().is_empty() { sql.push_str(" AND status = ?"); values.push(rusqlite::types::Value::Text(st.trim().to_string())); } } sql.push_str(" ORDER BY created_at DESC LIMIT 100;"); let slugs = { let mut stmt = system_conn.prepare(&sql).unwrap(); let rows = stmt .query_map(rusqlite::params_from_iter(values.iter()), |row| { Ok(GlobalSlugRow { slug: row.get(0)?, owner_user_id: row.get(1)?, target_type: row.get(2)?, target_id: row.get(3)?, created_at: row.get(4)?, updated_at: row.get(5)?, status: row.get(6)?, deleted_at: row.get(7)?, }) }) .unwrap(); rows.filter_map(|r| r.ok()).collect() }; let history = { let mut stmt = system_conn.prepare( "SELECT id, slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username FROM slug_history ORDER BY timestamp DESC LIMIT 50;" ).unwrap(); let rows = stmt .query_map([], |row| { Ok(SlugHistoryRow { id: row.get(0)?, slug: row.get(1)?, old_owner_user_id: row.get(2)?, new_owner_user_id: row.get(3)?, action: row.get(4)?, timestamp: row.get(5)?, admin_username: row.get(6)?, }) }) .unwrap(); rows.filter_map(|r| r.ok()).collect() }; let csrf_token = generate_csrf_token(&session_id); let template = crate::templates::SlugsTemplate { admin_username: user.username, slugs, history, csrf_token, search_filter: query.search, owner_filter: query.owner, status_filter: query.status, success: query.success, error: query.error, }; template.into_response() } #[derive(Deserialize)] pub struct AdminTransferForm { pub slug: String, pub new_owner_user_id: i64, pub csrf_token: String, } // POST /admin/slugs/transfer pub async fn slugs_transfer_post( State(state): State, jar: CookieJar, Form(form): Form, ) -> Response { let (user, session_id) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; if !verify_csrf(&session_id, &form.csrf_token) { return Redirect::to("/admin/slugs?error=Invalid CSRF token").into_response(); } let user_exists = { let conn = state.users_db.lock().unwrap(); conn.query_row( "SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);", [form.new_owner_user_id], |row| row.get::<_, bool>(0), ) .unwrap_or(false) }; if !user_exists { return Redirect::to("/admin/slugs?error=New owner user ID does not exist").into_response(); } let (old_owner, target_type, mut new_target_id) = { let conn = state.system_db.lock().unwrap(); let row_opt: Option<(i64, String, String)> = conn.query_row( "SELECT owner_user_id, target_type, target_id FROM global_slugs WHERE slug = ?1;", [&form.slug], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)) ).optional().unwrap_or(None); match row_opt { Some(r) => r, None => return Redirect::to("/admin/slugs?error=Slug not found").into_response(), } }; if old_owner == form.new_owner_user_id { return Redirect::to("/admin/slugs?error=Slug is already owned by this user") .into_response(); } let old_dbs = match state.get_user_dbs(old_owner) { Ok(dbs) => dbs, Err(_) => { return Redirect::to("/admin/slugs?error=Failed to load current owner's database") .into_response() } }; let new_dbs = match state.get_user_dbs(form.new_owner_user_id) { Ok(dbs) => dbs, Err(_) => { return Redirect::to("/admin/slugs?error=Failed to load new owner's database") .into_response() } }; { let old_conn = old_dbs.content.lock().unwrap(); let new_conn = new_dbs.content.lock().unwrap(); if target_type == "url" { let url_opt = match crate::db::content::get_url_by_code(&old_conn, &form.slug) { Ok(u) => u, Err(e) => { return Redirect::to(&format!( "/admin/slugs?error=Failed to retrieve old URL: {}", e )) .into_response() } }; if let Some(url) = url_opt { // Check quota let users_conn = state.users_db.lock().unwrap(); let quota_opt = crate::db::users::get_user_quotas(&users_conn, form.new_owner_user_id) .unwrap_or(None); if let Some(quota) = quota_opt { if quota.current_urls >= quota.max_urls { return Redirect::to( "/admin/slugs?error=New owner has exceeded URL quota limit", ) .into_response(); } } // Copy let new_url = match crate::db::content::create_url_extended( &new_conn, &url.code, &url.destination, url.title.as_deref(), url.description.as_deref(), &url.tags, url.expires_at.as_deref(), url.password_hash.as_deref(), url.max_access_count, ) { Ok(u) => u, Err(e) => { return Redirect::to(&format!( "/admin/slugs?error=Failed to copy URL record: {}", e )) .into_response() } }; new_target_id = new_url.id; // Delete old let _ = crate::db::content::delete_url(&old_conn, &url.id); } } else if target_type == "page" { let page_opt = match crate::db::content::get_landing_page_by_code(&old_conn, &form.slug) { Ok(p) => p, Err(e) => { return Redirect::to(&format!( "/admin/slugs?error=Failed to retrieve old page: {}", e )) .into_response() } }; if let Some(page) = page_opt { // Check quota let users_conn = state.users_db.lock().unwrap(); let quota_opt = crate::db::users::get_user_quotas(&users_conn, form.new_owner_user_id) .unwrap_or(None); if let Some(quota) = quota_opt { if quota.current_landings >= quota.max_landings { return Redirect::to( "/admin/slugs?error=New owner has exceeded landing page quota limit", ) .into_response(); } } // Copy let new_page = match crate::db::content::create_landing_page( &new_conn, &page.code, &page.slug, &page.title, &page.html_content, &page.state, ) { Ok(p) => p, Err(e) => { return Redirect::to(&format!( "/admin/slugs?error=Failed to copy page record: {}", e )) .into_response() } }; new_target_id = new_page.id; // Delete old let _ = crate::db::content::delete_landing_page(&old_conn, &page.id); } } } { let conn = state.system_db.lock().unwrap(); let now = Utc::now().to_rfc3339(); let _ = conn.execute( "UPDATE global_slugs SET owner_user_id = ?1, target_id = ?2, updated_at = ?3 WHERE slug = ?4;", rusqlite::params![form.new_owner_user_id, new_target_id, now, form.slug], ); let _ = conn.execute( "INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username) VALUES (?1, ?2, ?3, 'transferred', ?4, ?5);", rusqlite::params![form.slug, old_owner, form.new_owner_user_id, now, user.username], ); let _ = crate::db::audit_events::write_audit_event( &conn, &user.username, "SLUG_TRANSFER", "slug", &form.slug, Some(&format!( "Transferred from {} to {}", old_owner, form.new_owner_user_id )), ); } Redirect::to(&format!( "/admin/slugs?success=Slug /{} successfully transferred to user {}", form.slug, form.new_owner_user_id )) .into_response() } #[derive(Deserialize)] pub struct AdminSlugStatusForm { pub slug: String, pub status: String, pub csrf_token: String, } // POST /admin/slugs/status pub async fn slugs_status_post( State(state): State, jar: CookieJar, Form(form): Form, ) -> Response { let (user, session_id) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; if !verify_csrf(&session_id, &form.csrf_token) { return Redirect::to("/admin/slugs?error=Invalid CSRF token").into_response(); } let status = form.status.trim().to_lowercase(); if !["active", "flagged", "disabled"].contains(&status.as_str()) { return Redirect::to("/admin/slugs?error=Invalid status").into_response(); } { let conn = state.system_db.lock().unwrap(); let now = Utc::now().to_rfc3339(); let _ = conn.execute( "UPDATE global_slugs SET status = ?1, updated_at = ?2 WHERE slug = ?3;", rusqlite::params![status, now, form.slug], ); let _ = crate::db::audit_events::write_audit_event( &conn, &user.username, "SLUG_STATUS_UPDATE", "slug", &form.slug, Some(&format!("Status set to {}", status)), ); } Redirect::to(&format!( "/admin/slugs?success=Slug /{} status updated to {}", form.slug, status )) .into_response() } #[derive(Deserialize)] pub struct AdminSlugDeleteForm { pub slug: String, pub csrf_token: String, } // POST /admin/slugs/delete pub async fn slugs_delete_post( State(state): State, jar: CookieJar, Form(form): Form, ) -> Response { let (user, session_id) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; if !verify_csrf(&session_id, &form.csrf_token) { return Redirect::to("/admin/slugs?error=Invalid CSRF token").into_response(); } { let conn = state.system_db.lock().unwrap(); let now = Utc::now().to_rfc3339(); let old_owner_user_id: Option = conn .query_row( "SELECT owner_user_id FROM global_slugs WHERE slug = ?1;", [&form.slug], |row| row.get(0), ) .optional() .unwrap_or(None); let _ = conn.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&form.slug]); let _ = conn.execute( "INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username) VALUES (?1, ?2, NULL, 'deleted', ?3, ?4);", rusqlite::params![form.slug, old_owner_user_id, now, user.username], ); let _ = crate::db::audit_events::write_audit_event( &conn, &user.username, "SLUG_RELEASE", "slug", &form.slug, Some("Slug released/deleted from global index"), ); } Redirect::to(&format!( "/admin/slugs?success=Slug /{} released successfully", form.slug )) .into_response() } // GET /admin/sessions pub async fn sessions_get( State(state): State, jar: CookieJar, Query(params): Query>, ) -> Response { let (user, session_id) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let sessions = { let conn = state.users_db.lock().unwrap(); let mut stmt = conn.prepare("SELECT id, user_id, expires_at, created_at FROM sessions ORDER BY created_at DESC;").unwrap(); let rows = stmt .query_map([], |row| { Ok(crate::models::UserSession { id: row.get(0)?, user_id: row.get(1)?, expires_at: row.get(2)?, created_at: row.get(3)?, }) }) .unwrap(); rows.filter_map(|r| r.ok()).collect() }; let csrf_token = generate_csrf_token(&session_id); let template = crate::templates::SessionsTemplate { admin_username: user.username, sessions, csrf_token, success: params.get("success").cloned(), error: params.get("error").cloned(), }; template.into_response() } // POST /admin/sessions/revoke/:id pub async fn sessions_revoke_post( State(state): State, jar: CookieJar, Path(id): Path, Form(form): Form>, ) -> Response { let (user, session_id) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let form_csrf = form.get("csrf_token").cloned().unwrap_or_default(); if !verify_csrf(&session_id, &form_csrf) { return Redirect::to("/admin/sessions?error=Invalid CSRF token").into_response(); } { let conn = state.users_db.lock().unwrap(); let _ = conn.execute("DELETE FROM sessions WHERE id = ?1;", [&id]); } { let conn_sys = state.system_db.lock().unwrap(); let _ = crate::db::audit_events::write_audit_event( &conn_sys, &user.username, "SESSION_REVOKED", "session", &id, None, ); } Redirect::to("/admin/sessions?success=Session revoked").into_response() } // POST /admin/sessions/revoke-all pub async fn sessions_revoke_all_post( State(state): State, jar: CookieJar, Form(form): Form>, ) -> Response { let (user, session_id) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let form_csrf = form.get("csrf_token").cloned().unwrap_or_default(); if !verify_csrf(&session_id, &form_csrf) { return Redirect::to("/admin/sessions?error=Invalid CSRF token").into_response(); } { let conn = state.users_db.lock().unwrap(); let _ = conn.execute("DELETE FROM sessions;", []); } { let conn_sys = state.system_db.lock().unwrap(); let _ = crate::db::audit_events::write_audit_event( &conn_sys, &user.username, "SESSIONS_ALL_REVOKED", "session", "all", None, ); } Redirect::to("/admin/sessions?success=All active sessions revoked").into_response() } // GET /admin/quotas pub async fn quotas_get( State(state): State, jar: CookieJar, Query(params): Query>, ) -> Response { let (user, session_id) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let quotas = { let conn = state.users_db.lock().unwrap(); let mut stmt = conn.prepare("SELECT user_id, max_urls, max_landings, max_api_tokens, max_storage_mb, current_urls, current_landings, current_api_tokens, current_storage_mb FROM quotas ORDER BY user_id ASC;").unwrap(); let rows = stmt .query_map([], |row| { Ok(crate::models::UserQuotas { user_id: row.get(0)?, max_urls: row.get(1)?, max_landings: row.get(2)?, max_api_tokens: row.get(3)?, max_storage_mb: row.get(4)?, current_urls: row.get(5)?, current_landings: row.get(6)?, current_api_tokens: row.get(7)?, current_storage_mb: row.get(8)?, }) }) .unwrap(); rows.filter_map(|r| r.ok()).collect() }; let csrf_token = generate_csrf_token(&session_id); let template = crate::templates::QuotasTemplate { admin_username: user.username, quotas, csrf_token, success: params.get("success").cloned(), error: params.get("error").cloned(), }; template.into_response() } // POST /admin/quotas pub async fn quotas_post( State(state): State, jar: CookieJar, Form(form): Form>, ) -> Response { let (user, session_id) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let form_csrf = form.get("csrf_token").cloned().unwrap_or_default(); if !verify_csrf(&session_id, &form_csrf) { return Redirect::to("/admin/quotas?error=Invalid CSRF token").into_response(); } let action = form.get("action").cloned().unwrap_or_default(); let users_conn = state.users_db.lock().unwrap(); if action == "reconcile_all" { let user_ids: Vec = { let mut stmt = users_conn.prepare("SELECT id FROM users;").unwrap(); let rows = stmt.query_map([], |row| row.get(0)).unwrap(); rows.filter_map(|r| r.ok()).collect() }; for uid in user_ids { if let Ok(user_dbs) = state.get_user_dbs(uid) { let user_content_conn = user_dbs.content.lock().unwrap(); let _ = crate::db::users::reconcile_user_quotas(&users_conn, uid, &user_content_conn); } } let system_conn = state.system_db.lock().unwrap(); let _ = crate::db::audit_events::write_audit_event( &system_conn, &user.username, "QUOTAS_RECONCILE_ALL", "quota", "all", None, ); Redirect::to("/admin/quotas?success=All user quotas reconciled successfully") .into_response() } else if action == "reconcile" { let uid_str = form.get("user_id").cloned().unwrap_or_default(); let uid = uid_str.parse::().unwrap_or(0); if let Ok(user_dbs) = state.get_user_dbs(uid) { let user_content_conn = user_dbs.content.lock().unwrap(); let _ = crate::db::users::reconcile_user_quotas(&users_conn, uid, &user_content_conn); let system_conn = state.system_db.lock().unwrap(); let _ = crate::db::audit_events::write_audit_event( &system_conn, &user.username, "QUOTAS_RECONCILE", "quota", &uid.to_string(), None, ); Redirect::to(&format!("/admin/users/{}?success=Quotas reconciled", uid)).into_response() } else { Redirect::to("/admin/quotas?error=User databases not found").into_response() } } else { Redirect::to("/admin/quotas?error=Invalid action").into_response() } } // GET /admin/health pub async fn health_get( State(state): State, jar: CookieJar, Query(params): Query>, ) -> Response { let (user, session_id) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let mut db_reports = vec![]; if let Ok(r) = crate::db::sqlite::collect_health_report(&state.admin_db.lock().unwrap(), "admin") { db_reports.push(r); } if let Ok(r) = crate::db::sqlite::collect_health_report(&state.system_db.lock().unwrap(), "system") { db_reports.push(r); } if let Ok(r) = crate::db::sqlite::collect_health_report(&state.users_db.lock().unwrap(), "users") { db_reports.push(r); } let system_db_path = state.config.data_dir.join("admin").join("system.db"); let users_db_path = state.config.data_dir.join("admin").join("users.db"); let admin_db_path = state.config.data_dir.join("admin").join("admin.db"); let system_db_size = format_size( std::fs::metadata(&system_db_path) .map(|m| m.len()) .unwrap_or(0), ); let users_db_size = format_size( std::fs::metadata(&users_db_path) .map(|m| m.len()) .unwrap_or(0), ); let admin_db_size = format_size( std::fs::metadata(&admin_db_path) .map(|m| m.len()) .unwrap_or(0), ); let users_dir = state.config.data_dir.join("users"); let tenants_db_size = format_size(get_dir_size(&users_dir).unwrap_or(0)); let total_data_size = format_size(get_dir_size(&state.config.data_dir).unwrap_or(0)); let job_history = { let conn = state.system_db.lock().unwrap(); let mut stmt = conn.prepare("SELECT id, job_name, status, started_at, finished_at, error_message FROM job_history ORDER BY started_at DESC LIMIT 20;").unwrap(); let rows = stmt .query_map([], |row| { Ok(JobHistoryRow { id: row.get(0)?, job_name: row.get(1)?, status: row.get(2)?, started_at: row.get(3)?, finished_at: row.get(4)?, error_message: row.get(5)?, }) }) .unwrap(); rows.filter_map(|r| r.ok()).collect() }; let health_checks = { let conn = state.system_db.lock().unwrap(); let mut stmt = conn.prepare("SELECT id, object_type, object_id, checked_at, status_code, error_message, is_healthy FROM health_checks ORDER BY checked_at DESC LIMIT 20;").unwrap(); let rows = stmt .query_map([], |row| { Ok(HealthCheckRow { id: row.get(0)?, object_type: row.get(1)?, object_id: row.get(2)?, checked_at: row.get(3)?, status_code: row.get(4)?, error_message: row.get(5)?, is_healthy: row.get(6)?, }) }) .unwrap(); rows.filter_map(|r| r.ok()).collect() }; let csrf_token = generate_csrf_token(&session_id); let (registry_errors, registry_warnings) = { let system_conn = state.system_db.lock().unwrap(); let users_conn = state.users_db.lock().unwrap(); crate::db::users::verify_global_slug_registry_integrity( &system_conn, &users_conn, &state.config.data_dir, ) .unwrap_or_else(|e| { ( vec![format!("Failed to run integrity check: {}", e)], vec![], ) }) }; let template = crate::templates::HealthTemplate { admin_username: user.username, db_reports, total_data_size, system_db_size, users_db_size, admin_db_size, tenants_db_size, job_history, health_checks, registry_errors, registry_warnings, csrf_token, success: params.get("success").cloned(), error: params.get("error").cloned(), }; template.into_response() } // GET /admin/backups pub async fn backups_get( State(state): State, jar: CookieJar, Query(params): Query>, ) -> Response { let (user, session_id) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let mut files = vec![]; if let Ok(dir_entries) = std::fs::read_dir(&state.config.backup_dir) { for entry in dir_entries.flatten() { let path = entry.path(); if path.is_file() { if let Some(filename) = path .file_name() .and_then(|n| n.to_str()) .map(|s| s.to_string()) { if filename.ends_with(".tar.gz") { let meta = entry.metadata().unwrap(); let size_str = format_size(meta.len()); let created_str = meta .created() .ok() .map(|c| { let datetime: chrono::DateTime = c.into(); datetime.format("%Y-%m-%d %H:%M:%S").to_string() }) .unwrap_or_else(|| "-".to_string()); files.push(BackupFileRow { filename, size_str, created_str, }); } } } } } files.sort_by(|a, b| b.filename.cmp(&a.filename)); let history = { let conn = state.system_db.lock().unwrap(); let mut stmt = conn.prepare("SELECT id, backup_path, status, created_at, size_bytes, error_message FROM backup_history ORDER BY created_at DESC LIMIT 30;").unwrap(); let rows = stmt .query_map([], |row| { Ok(BackupHistoryRow { id: row.get(0)?, backup_path: row.get(1)?, status: row.get(2)?, created_at: row.get(3)?, size_bytes: row.get(4)?, error_message: row.get(5)?, }) }) .unwrap(); rows.filter_map(|r| r.ok()).collect() }; let csrf_token = generate_csrf_token(&session_id); let template = crate::templates::BackupsTemplate { admin_username: user.username, files, history, csrf_token, success: params.get("success").cloned(), error: params.get("error").cloned(), }; template.into_response() } // POST /admin/backups/create pub async fn backups_create_post( State(state): State, jar: CookieJar, Form(form): Form>, ) -> Response { let (_user, session_id) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let form_csrf = form.get("csrf_token").cloned().unwrap_or_default(); if !verify_csrf(&session_id, &form_csrf) { return Redirect::to("/admin/backups?error=Invalid CSRF token").into_response(); } match crate::jobs::backup::perform_backup(&state.db, &state.config).await { Ok(path) => { let filename = std::path::Path::new(&path) .file_name() .and_then(|n| n.to_str()) .unwrap_or("backup.tar.gz"); Redirect::to(&format!( "/admin/backups?success=Backup created successfully: {}", filename )) .into_response() } Err(e) => Redirect::to(&format!( "/admin/backups?error=Failed to generate backup: {}", e )) .into_response(), } } // GET /admin/backups/download/:filename pub async fn backups_download_get( State(state): State, jar: CookieJar, Path(filename): Path, ) -> Response { if require_auth(&state, &jar).await.is_err() { return StatusCode::UNAUTHORIZED.into_response(); } if filename.contains('/') || filename.contains('\\') || filename.contains("..") { return StatusCode::BAD_REQUEST.into_response(); } let backup_path = state.config.backup_dir.join(&filename); if !backup_path.exists() { return StatusCode::NOT_FOUND.into_response(); } match std::fs::read(&backup_path) { Ok(bytes) => { let body = axum::body::Body::from(bytes); Response::builder() .header("content-type", "application/octet-stream") .header( "content-disposition", format!("attachment; filename=\"{}\"", filename), ) .body(body) .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()) } Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), } } // POST /admin/backups/delete/:filename pub async fn backups_delete_post( State(state): State, jar: CookieJar, Path(filename): Path, Form(form): Form>, ) -> Response { let (user, session_id) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let form_csrf = form.get("csrf_token").cloned().unwrap_or_default(); if !verify_csrf(&session_id, &form_csrf) { return Redirect::to("/admin/backups?error=Invalid CSRF token").into_response(); } if filename.contains('/') || filename.contains('\\') || filename.contains("..") { return Redirect::to("/admin/backups?error=Invalid filename").into_response(); } let backup_path = state.config.backup_dir.join(&filename); if !backup_path.exists() { return Redirect::to("/admin/backups?error=Backup file not found").into_response(); } match std::fs::remove_file(&backup_path) { Ok(_) => { let system_conn = state.system_db.lock().unwrap(); let _ = crate::db::audit_events::write_audit_event( &system_conn, &user.username, "BACKUP_DELETE", "backup", &filename, None, ); Redirect::to("/admin/backups?success=Backup archive deleted").into_response() } Err(e) => Redirect::to(&format!( "/admin/backups?error=Failed to delete backup file: {}", e )) .into_response(), } } // POST /admin/backups/restore pub async fn backups_restore_post( State(state): State, jar: CookieJar, mut multipart: axum::extract::Multipart, ) -> Response { let (user, session_id) = match require_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let mut backup_bytes = vec![]; let mut confirm_text = String::new(); let mut csrf_token = String::new(); while let Ok(Some(field)) = multipart.next_field().await { let name = field.name().unwrap_or_default().to_string(); if name == "backup_file" { if let Ok(bytes) = field.bytes().await { backup_bytes = bytes.to_vec(); } } else if name == "confirm_text" { if let Ok(text) = field.text().await { confirm_text = text.trim().to_string(); } } else if name == "csrf_token" { if let Ok(text) = field.text().await { csrf_token = text.trim().to_string(); } } } if !verify_csrf(&session_id, &csrf_token) { return Redirect::to("/admin/backups?error=Invalid CSRF token").into_response(); } if confirm_text != "RESTORE" { return Redirect::to( "/admin/backups?error=Confirmation text mismatch. Please type RESTORE.", ) .into_response(); } if backup_bytes.is_empty() { return Redirect::to("/admin/backups?error=Backup file is empty or missing.") .into_response(); } let temp_file_path = state.config.data_dir.join("temp-restore-upload.tar.gz"); if let Err(e) = std::fs::write(&temp_file_path, &backup_bytes) { return Redirect::to(&format!( "/admin/backups?error=Failed to save uploaded file: {}", e )) .into_response(); } match crate::cli::restore::run( temp_file_path.to_string_lossy().to_string(), None, state.config.clone(), ) .await { Ok(_) => { let _ = std::fs::remove_file(&temp_file_path); let conn = state.users_db.lock().unwrap(); let _ = conn.execute("DELETE FROM sessions;", []); let system_conn = state.system_db.lock().unwrap(); let _ = crate::db::audit_events::write_audit_event( &system_conn, &user.username, "RESTORE_EXECUTION", "backup", "upload", None, ); Redirect::to("/admin/login?success=Restore successful. Please log in again.") .into_response() } Err(e) => { let _ = std::fs::remove_file(&temp_file_path); Redirect::to(&format!("/admin/backups?error=Restore failed: {}", e)).into_response() } } } // GET /api-tokens pub async fn api_tokens_get( State(state): State, jar: CookieJar, Query(params): Query>, ) -> Response { let (user, session_id) = match require_user_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let tokens = { let conn = state.users_db.lock().unwrap(); let mut stmt = conn .prepare( "SELECT id, user_id, token_hash, created_at FROM api_tokens WHERE user_id = ?1;", ) .unwrap(); let rows = stmt .query_map([user.id], |row| { Ok(crate::models::UserApiToken { id: row.get(0)?, user_id: row.get(1)?, token_hash: row.get(2)?, created_at: row.get(3)?, }) }) .unwrap(); rows.filter_map(|r| r.ok()).collect() }; let csrf_token = generate_csrf_token(&session_id); let template = crate::templates::ApiTokensTemplate { admin_username: user.username.clone(), username: user.username, tokens, new_token: params.get("new_token").cloned(), csrf_token, success: params.get("success").cloned(), error: params.get("error").cloned(), }; template.into_response() } // POST /api-tokens/create pub async fn api_tokens_create_post( State(state): State, jar: CookieJar, Form(form): Form>, ) -> Response { let (user, session_id) = match require_user_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let form_csrf = form.get("csrf_token").cloned().unwrap_or_default(); if !verify_csrf(&session_id, &form_csrf) { return Redirect::to("/api-tokens?error=Invalid CSRF token").into_response(); } use sha2::Digest; let raw_token = format!("key_{}", generate_token(32)); let mut hasher = sha2::Sha256::new(); hasher.update(raw_token.as_bytes()); let hashed_token = hex::encode(hasher.finalize()); { let conn = state.users_db.lock().unwrap(); let now = Utc::now().to_rfc3339(); let _ = conn.execute( "INSERT INTO api_tokens (user_id, token_hash, created_at) VALUES (?1, ?2, ?3);", rusqlite::params![user.id, hashed_token, now], ); } { let conn_sys = state.system_db.lock().unwrap(); let _ = crate::db::audit_events::write_audit_event( &conn_sys, &user.username, "API_TOKEN_CREATED", "api_token", "new", None, ); } Redirect::to(&format!( "/api-tokens?new_token={}&success=Token generated successfully", raw_token )) .into_response() } // POST /api-tokens/revoke/:id pub async fn api_tokens_revoke_post( State(state): State, jar: CookieJar, Path(token_id): Path, Form(form): Form>, ) -> Response { let (user, session_id) = match require_user_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let form_csrf = form.get("csrf_token").cloned().unwrap_or_default(); if !verify_csrf(&session_id, &form_csrf) { return Redirect::to("/api-tokens?error=Invalid CSRF token").into_response(); } { let conn = state.users_db.lock().unwrap(); let _ = conn.execute( "DELETE FROM api_tokens WHERE id = ?1 AND user_id = ?2;", [token_id, user.id], ); } { let conn_sys = state.system_db.lock().unwrap(); let _ = crate::db::audit_events::write_audit_event( &conn_sys, &user.username, "API_TOKEN_REVOKED", "api_token", &token_id.to_string(), None, ); } Redirect::to("/api-tokens?success=API token revoked").into_response() } // GET /analytics pub async fn user_analytics_get( State(state): State, jar: CookieJar, Query(params): Query>, ) -> Response { let (user, session_id) = match require_user_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; let user_dbs = match state.get_user_dbs(user.id) { Ok(dbs) => dbs, Err(_) => return Redirect::to("/user/dashboard?error=Database error").into_response(), }; let conn = user_dbs.analytics.lock().unwrap(); let total_clicks = conn .query_row("SELECT COUNT(*) FROM visits;", [], |row| { row.get::<_, i64>(0) }) .unwrap_or(0); let unique_visitors = conn .query_row( "SELECT COUNT(DISTINCT ip_address) FROM visits;", [], |row| row.get::<_, i64>(0), ) .unwrap_or(0); let direct_clicks = conn .query_row( "SELECT COUNT(*) FROM visits WHERE referer = '' OR referer = 'direct';", [], |row| row.get::<_, i64>(0), ) .unwrap_or(0); let referred_clicks = total_clicks - direct_clicks; let referrers_chart = "
No referrer channels logged yet
".to_string(); let browsers_chart = "
No browser traffic logged yet
".to_string(); let visits = { let mut stmt = conn.prepare("SELECT id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code FROM visits ORDER BY timestamp DESC LIMIT 50;").unwrap(); let rows = stmt .query_map([], |row| { Ok(crate::models::VisitRecord { id: row.get(0)?, target_type: row.get(1)?, target_id: row.get(2)?, timestamp: row.get(3)?, ip_address: row.get(4)?, user_agent: row.get(5)?, referer: row.get(6)?, accept_language: row.get(7)?, country: row.get(8)?, status_code: row.get(9)?, owner_user_id: Some(user.id), }) }) .unwrap(); rows.filter_map(|r| r.ok()).collect() }; let csrf_token = generate_csrf_token(&session_id); let template = crate::templates::UserAnalyticsTemplate { admin_username: user.username.clone(), username: user.username, total_clicks, unique_visitors, direct_clicks, referred_clicks, referrers_chart, browsers_chart, visits, csrf_token, success: params.get("success").cloned(), error: params.get("error").cloned(), }; template.into_response() } pub async fn user_url_analytics_get( State(state): State, jar: CookieJar, Path(id): Path, Query(query): Query, ) -> Response { let (user, _session_id) = match require_user_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; // Ownership check let (owner_user_id, target_type) = { let conn = state.system_db.lock().unwrap(); match conn.query_row( "SELECT owner_user_id, target_type FROM global_slugs WHERE target_id = ?1", [&id], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), ) { Ok(val) => val, Err(rusqlite::Error::QueryReturnedNoRows) => { return Redirect::to("/user/urls?error=Link not found").into_response(); } Err(_) => return Redirect::to("/user/urls?error=Database error").into_response(), } }; if owner_user_id != user.id || target_type != "url" { return StatusCode::FORBIDDEN.into_response(); } let user_dbs = match state.get_user_dbs(user.id) { Ok(dbs) => dbs, Err(_) => return Redirect::to("/user/urls?error=Database error").into_response(), }; let url = { let conn = user_dbs.content.lock().unwrap(); match crate::db::content::get_url_by_id(&conn, &id) { Ok(Some(u)) => u, Ok(None) => return Redirect::to("/user/urls?error=Link not found").into_response(), Err(_) => return Redirect::to("/user/urls?error=Database error").into_response(), } }; let conn = user_dbs.analytics.lock().unwrap(); let schema_cols = get_visits_schema_columns(&conn).unwrap_or_default(); let has_utm_source = schema_cols.contains("utm_source"); let has_utm_campaign = schema_cols.contains("utm_campaign"); if has_utm_source || has_utm_campaign { return ( StatusCode::INTERNAL_SERVER_ERROR, "UTM mapping verification failed", ) .into_response(); } let (clean_date_from, clean_date_to) = match validate_date_filters(query.date_from.as_deref(), query.date_to.as_deref()) { Ok(res) => res, Err(status) => return status.into_response(), }; let total_clicks = get_target_visit_total_filtered( &conn, "url", &id, clean_date_from.as_deref(), clean_date_to.as_deref(), ) .unwrap_or(0); let unique_visitors = get_target_unique_visitors(&conn, "url", &id).unwrap_or(0); let qr_scans = crate::db::qr::get_qr_scan_count(&conn, &id).unwrap_or(0); let direct_clicks = (total_clicks - qr_scans).max(0); let clicks_data = get_clicks_trend(&conn, "url", &id, 30) .or_else(|_| get_clicks_trend_raw(&conn, "url", &id, 30)) .unwrap_or_default(); let mut trend_map = std::collections::BTreeMap::new(); for i in (0..30).rev() { let date_str = (Utc::now() - chrono::Duration::days(i)) .format("%Y-%m-%d") .to_string(); trend_map.insert(date_str, 0i64); } for (d, c) in clicks_data { trend_map.insert(d, c); } let formatted_trend: Vec<(String, i64)> = trend_map.into_iter().collect(); let traffic_chart = generate_line_chart(&formatted_trend); let monthly_data = get_monthly_clicks_trend(&conn, "url", &id, 12).unwrap_or_default(); let monthly_chart = generate_line_chart(&monthly_data); let countries_data = get_metric_rankings(&conn, "url", &id, "country", 5) .or_else(|_| get_metric_rankings_raw(&conn, "url", &id, "country", 5)) .unwrap_or_default(); let countries_chart = generate_bar_chart(&countries_data); let referrers_data = get_metric_rankings(&conn, "url", &id, "referrer", 5) .or_else(|_| get_metric_rankings_raw(&conn, "url", &id, "referrer", 5)) .unwrap_or_default(); let referrers_chart = generate_bar_chart(&referrers_data); let browsers_data = get_metric_rankings(&conn, "url", &id, "browser", 5) .or_else(|_| get_metric_rankings_raw(&conn, "url", &id, "browser", 5)) .unwrap_or_default(); let browsers_chart = generate_bar_chart(&browsers_data); let calculated_total_pages = (total_clicks as usize).div_ceil(ANALYTICS_PAGE_SIZE); let total_pages = std::cmp::max(1, calculated_total_pages); let requested_page = query.analytics_page.unwrap_or(1); let current_page = if requested_page == 0 { 1 } else { requested_page } .clamp(1, total_pages); let offset = (current_page - 1) * ANALYTICS_PAGE_SIZE; let visits_raw = get_target_visits_paginated( &conn, "url", &id, ANALYTICS_PAGE_SIZE as i64, offset as i64, clean_date_from.as_deref(), clean_date_to.as_deref(), ) .unwrap_or_default(); let visits: Vec = visits_raw .into_iter() .enumerate() .map(|(idx, r)| { let (browser, _, _) = parse_ua(&r.user_agent); let referrer = clean_referrer(&r.referer); let sr = offset + idx + 1; crate::templates::VisitorLogEntry { sr, timestamp: r.timestamp, ip_address: r.ip_address, country: if r.country.is_empty() { "Unknown".to_string() } else { r.country }, referrer, browser, user_agent: r.user_agent, utm_source: "-".to_string(), utm_campaign: "-".to_string(), } }) .collect(); let start_page = current_page.saturating_sub(3).max(1); let end_page = std::cmp::min(total_pages, current_page + 3); let visible_pages: Vec = (start_page..=end_page).collect(); let page_start = if total_clicks == 0 { 0 } else { offset + 1 }; let page_end = if total_clicks == 0 { 0 } else { std::cmp::min(total_clicks as usize, offset + ANALYTICS_PAGE_SIZE) }; let template = crate::templates::UrlAnalyticsTemplate { admin_username: user.username, url, total_clicks, unique_visitors, qr_scans, direct_clicks, traffic_chart, monthly_chart, countries_chart, referrers_chart, browsers_chart, visits, current_page, total_pages, visible_pages, total_records: total_clicks, page_start, page_end, date_from: clean_date_from, date_to: clean_date_to, is_admin: false, }; template.into_response() } pub async fn user_page_analytics_get( State(state): State, jar: CookieJar, Path(id): Path, Query(query): Query, ) -> Response { let (user, _session_id) = match require_user_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; // Ownership check let (owner_user_id, target_type) = { let conn = state.system_db.lock().unwrap(); match conn.query_row( "SELECT owner_user_id, target_type FROM global_slugs WHERE target_id = ?1", [&id], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), ) { Ok(val) => val, Err(rusqlite::Error::QueryReturnedNoRows) => { return Redirect::to("/user/pages?error=Landing page not found").into_response(); } Err(_) => return Redirect::to("/user/pages?error=Database error").into_response(), } }; if owner_user_id != user.id || target_type != "page" { return StatusCode::FORBIDDEN.into_response(); } let user_dbs = match state.get_user_dbs(user.id) { Ok(dbs) => dbs, Err(_) => return Redirect::to("/user/pages?error=Database error").into_response(), }; let page = { let conn = user_dbs.content.lock().unwrap(); match crate::db::content::get_landing_page_by_id(&conn, &id) { Ok(Some(p)) => p, Ok(None) => { return Redirect::to("/user/pages?error=Landing page not found").into_response() } Err(_) => return Redirect::to("/user/pages?error=Database error").into_response(), } }; let conn = user_dbs.analytics.lock().unwrap(); let schema_cols = get_visits_schema_columns(&conn).unwrap_or_default(); let has_utm_source = schema_cols.contains("utm_source"); let has_utm_campaign = schema_cols.contains("utm_campaign"); if has_utm_source || has_utm_campaign { return ( StatusCode::INTERNAL_SERVER_ERROR, "UTM mapping verification failed", ) .into_response(); } let (clean_date_from, clean_date_to) = match validate_date_filters(query.date_from.as_deref(), query.date_to.as_deref()) { Ok(res) => res, Err(status) => return status.into_response(), }; let total_views = get_target_visit_total_filtered( &conn, "page", &id, clean_date_from.as_deref(), clean_date_to.as_deref(), ) .unwrap_or(0); let unique_visitors = get_target_unique_visitors(&conn, "page", &id).unwrap_or(0); let clicks_data = get_clicks_trend(&conn, "page", &id, 30) .or_else(|_| get_clicks_trend_raw(&conn, "page", &id, 30)) .unwrap_or_default(); let mut trend_map = std::collections::BTreeMap::new(); for i in (0..30).rev() { let date_str = (Utc::now() - chrono::Duration::days(i)) .format("%Y-%m-%d") .to_string(); trend_map.insert(date_str, 0i64); } for (d, c) in clicks_data { trend_map.insert(d, c); } let formatted_trend: Vec<(String, i64)> = trend_map.into_iter().collect(); let traffic_chart = generate_line_chart(&formatted_trend); let monthly_data = get_monthly_clicks_trend(&conn, "page", &id, 12).unwrap_or_default(); let monthly_chart = generate_line_chart(&monthly_data); let countries_data = get_metric_rankings(&conn, "page", &id, "country", 5) .or_else(|_| get_metric_rankings_raw(&conn, "page", &id, "country", 5)) .unwrap_or_default(); let countries_chart = generate_bar_chart(&countries_data); let referrers_data = get_metric_rankings(&conn, "page", &id, "referrer", 5) .or_else(|_| get_metric_rankings_raw(&conn, "page", &id, "referrer", 5)) .unwrap_or_default(); let referrers_chart = generate_bar_chart(&referrers_data); let calculated_total_pages = (total_views as usize).div_ceil(ANALYTICS_PAGE_SIZE); let total_pages = std::cmp::max(1, calculated_total_pages); let requested_page = query.analytics_page.unwrap_or(1); let current_page = if requested_page == 0 { 1 } else { requested_page } .clamp(1, total_pages); let offset = (current_page - 1) * ANALYTICS_PAGE_SIZE; let visits_raw = get_target_visits_paginated( &conn, "page", &id, ANALYTICS_PAGE_SIZE as i64, offset as i64, clean_date_from.as_deref(), clean_date_to.as_deref(), ) .unwrap_or_default(); let visits: Vec = visits_raw .into_iter() .enumerate() .map(|(idx, r)| { let (browser, _, _) = parse_ua(&r.user_agent); let referrer = clean_referrer(&r.referer); let sr = offset + idx + 1; crate::templates::VisitorLogEntry { sr, timestamp: r.timestamp, ip_address: r.ip_address, country: if r.country.is_empty() { "Unknown".to_string() } else { r.country }, referrer, browser, user_agent: r.user_agent, utm_source: "-".to_string(), utm_campaign: "-".to_string(), } }) .collect(); let start_page = current_page.saturating_sub(3).max(1); let end_page = std::cmp::min(total_pages, current_page + 3); let visible_pages: Vec = (start_page..=end_page).collect(); let page_start = if total_views == 0 { 0 } else { offset + 1 }; let page_end = if total_views == 0 { 0 } else { std::cmp::min(total_views as usize, offset + ANALYTICS_PAGE_SIZE) }; let template = crate::templates::PageAnalyticsTemplate { admin_username: user.username, page, total_views, unique_visitors, traffic_chart, monthly_chart, countries_chart, referrers_chart, visits, current_page, total_pages, visible_pages, total_records: total_views, page_start, page_end, date_from: clean_date_from, date_to: clean_date_to, is_admin: false, }; template.into_response() } async fn perform_user_csv_export( user_dbs: crate::state::UserDbs, target_type: &'static str, id: String, date_from: Option, date_to: Option, ) -> Response { let (clean_date_from, clean_date_to) = match validate_date_filters(date_from.as_deref(), date_to.as_deref()) { Ok(res) => res, Err(status) => return status.into_response(), }; let target_exists = { let conn = user_dbs.content.lock().unwrap(); if target_type == "url" { get_url_by_id(&conn, &id) .map(|u| u.is_some()) .unwrap_or(false) } else { get_landing_page_by_id(&conn, &id) .map(|p| p.is_some()) .unwrap_or(false) } }; if !target_exists { return (StatusCode::NOT_FOUND, "Target not found").into_response(); } let count = { let conn = user_dbs.analytics.lock().unwrap(); get_target_visit_total_filtered( &conn, target_type, &id, clean_date_from.as_deref(), clean_date_to.as_deref(), ) .unwrap_or(0) }; let (has_utm_source, has_utm_campaign) = { let conn = user_dbs.analytics.lock().unwrap(); let cols = get_visits_schema_columns(&conn).unwrap_or_default(); (cols.contains("utm_source"), cols.contains("utm_campaign")) }; let (tx, rx) = tokio::sync::mpsc::channel::>(32); let analytics_db = user_dbs.analytics.clone(); let target_id = id.clone(); tokio::task::spawn_blocking(move || { let conn = analytics_db.lock().unwrap(); let mut header = "Timestamp,IP Address,Country,Referrer,Browser,User-Agent".to_string(); if has_utm_source { header.push_str(",UTM Source"); } if has_utm_campaign { header.push_str(",UTM Campaign"); } header.push('\n'); if tx .blocking_send(Ok(axum::body::Bytes::from(header))) .is_err() { return; } let select_fields = if has_utm_source && has_utm_campaign { "timestamp, ip_address, country, referer, user_agent, utm_source, utm_campaign" } else if has_utm_source { "timestamp, ip_address, country, referer, user_agent, utm_source" } else if has_utm_campaign { "timestamp, ip_address, country, referer, user_agent, utm_campaign" } else { "timestamp, ip_address, country, referer, user_agent" }; let mut sql = format!( "SELECT {} FROM visits WHERE target_type = ?1 AND target_id = ?2", select_fields ); let mut params: Vec> = vec![Box::new(target_type.to_string()), Box::new(target_id)]; if let Some(df) = clean_date_from.as_deref() { sql.push_str(&format!(" AND timestamp >= ?{}", params.len() + 1)); params.push(Box::new(format!("{}T00:00:00Z", df))); } if let Some(dt) = clean_date_to.as_deref() { if let Ok(parsed_date) = chrono::NaiveDate::parse_from_str(dt, "%Y-%m-%d") { let next_day = parsed_date + chrono::Duration::days(1); sql.push_str(&format!(" AND timestamp < ?{}", params.len() + 1)); params.push(Box::new(format!( "{}T00:00:00Z", next_day.format("%Y-%m-%d") ))); } } sql.push_str(" ORDER BY timestamp DESC, id DESC"); let mut stmt = match conn.prepare(&sql) { Ok(s) => s, Err(_) => return, }; let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); let mut rows = match stmt.query(rusqlite::params_from_iter(param_refs)) { Ok(r) => r, Err(_) => return, }; let mut csv_buffer = String::new(); while let Ok(Some(row)) = rows.next() { let timestamp: String = row.get(0).unwrap_or_default(); let ip_address: String = row.get(1).unwrap_or_default(); let country: String = row.get(2).unwrap_or_default(); let referer: String = row.get(3).unwrap_or_default(); let user_agent: String = row.get(4).unwrap_or_default(); let (browser, _, _) = parse_ua(&user_agent); let referrer = clean_referrer(&referer); let country_display = if country.is_empty() { "Unknown".to_string() } else { country }; let mut line = format!( "{},{},{},{},{},{}", escape_csv_field(×tamp), escape_csv_field(&ip_address), escape_csv_field(&country_display), escape_csv_field(&referrer), escape_csv_field(&browser), escape_csv_field(&user_agent) ); let mut col_idx = 5; if has_utm_source { let utm_src: String = row.get(col_idx).unwrap_or_default(); line.push_str(&format!(",{}", escape_csv_field(&utm_src))); col_idx += 1; } if has_utm_campaign { let utm_camp: String = row.get(col_idx).unwrap_or_default(); line.push_str(&format!(",{}", escape_csv_field(&utm_camp))); } line.push('\n'); csv_buffer.push_str(&line); if csv_buffer.len() >= 8192 { let bytes = axum::body::Bytes::from(csv_buffer); if tx.blocking_send(Ok(bytes)).is_err() { return; } csv_buffer = String::new(); } } if !csv_buffer.is_empty() { let _ = tx.blocking_send(Ok(axum::body::Bytes::from(csv_buffer))); } }); let stream = DbExportStream { receiver: rx }; let filename = if target_type == "url" { format!("url_{}_analytics.csv", id) } else { format!("page_{}_analytics.csv", id) }; ( StatusCode::OK, [ ("Content-Type", "text/csv"), ( "Content-Disposition", &format!("attachment; filename=\"{}\"", filename), ), ("X-BZOD-Export-Records", &count.to_string()), ], axum::body::Body::from_stream(stream), ) .into_response() } async fn perform_user_json_export( user_dbs: crate::state::UserDbs, target_type: &'static str, id: String, date_from: Option, date_to: Option, ) -> Response { let (clean_date_from, clean_date_to) = match validate_date_filters(date_from.as_deref(), date_to.as_deref()) { Ok(res) => res, Err(status) => return status.into_response(), }; let target_exists = { let conn = user_dbs.content.lock().unwrap(); if target_type == "url" { get_url_by_id(&conn, &id) .map(|u| u.is_some()) .unwrap_or(false) } else { get_landing_page_by_id(&conn, &id) .map(|p| p.is_some()) .unwrap_or(false) } }; if !target_exists { return (StatusCode::NOT_FOUND, "Target not found").into_response(); } let count = { let conn = user_dbs.analytics.lock().unwrap(); get_target_visit_total_filtered( &conn, target_type, &id, clean_date_from.as_deref(), clean_date_to.as_deref(), ) .unwrap_or(0) }; if count > MAX_JSON_EXPORT_ROWS as i64 { return StatusCode::PAYLOAD_TOO_LARGE.into_response(); } let visits_raw = { let conn = user_dbs.analytics.lock().unwrap(); match get_target_visits_all_in_memory( &conn, target_type, &id, clean_date_from.as_deref(), clean_date_to.as_deref(), ) { Ok(v) => v, Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), } }; #[derive(serde::Serialize)] struct JsonExportRow { timestamp: String, ip_address: String, country: String, referrer: String, browser: String, user_agent: String, } let export_rows: Vec = visits_raw .into_iter() .map(|r| { let (browser, _, _) = parse_ua(&r.user_agent); let referrer = clean_referrer(&r.referer); let country_display = if r.country.is_empty() { "Unknown".to_string() } else { r.country }; JsonExportRow { timestamp: r.timestamp, ip_address: r.ip_address, country: country_display, referrer, browser, user_agent: r.user_agent, } }) .collect(); let body_str = match serde_json::to_string(&export_rows) { Ok(s) => s, Err(_) => { return (StatusCode::INTERNAL_SERVER_ERROR, "Serialization error").into_response() } }; let filename = if target_type == "url" { format!("url_{}_analytics.json", id) } else { format!("page_{}_analytics.json", id) }; ( StatusCode::OK, [ ("Content-Type", "application/json"), ( "Content-Disposition", &format!("attachment; filename=\"{}\"", filename), ), ("X-BZOD-Export-Records", &count.to_string()), ], body_str, ) .into_response() } pub async fn user_url_analytics_csv_export( State(state): State, jar: CookieJar, Path(id): Path, Query(query): Query, ) -> Response { let (user, _session_id) = match require_user_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; // Ownership check let (owner_user_id, target_type) = { let conn = state.system_db.lock().unwrap(); match conn.query_row( "SELECT owner_user_id, target_type FROM global_slugs WHERE target_id = ?1", [&id], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), ) { Ok(val) => val, Err(rusqlite::Error::QueryReturnedNoRows) => { return StatusCode::NOT_FOUND.into_response(); } Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), } }; if owner_user_id != user.id || target_type != "url" { return StatusCode::FORBIDDEN.into_response(); } let user_dbs = match state.get_user_dbs(user.id) { Ok(dbs) => dbs, Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), }; perform_user_csv_export(user_dbs, "url", id, query.date_from, query.date_to).await } pub async fn user_url_analytics_json_export( State(state): State, jar: CookieJar, Path(id): Path, Query(query): Query, ) -> Response { let (user, _session_id) = match require_user_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; // Ownership check let (owner_user_id, target_type) = { let conn = state.system_db.lock().unwrap(); match conn.query_row( "SELECT owner_user_id, target_type FROM global_slugs WHERE target_id = ?1", [&id], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), ) { Ok(val) => val, Err(rusqlite::Error::QueryReturnedNoRows) => { return StatusCode::NOT_FOUND.into_response(); } Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), } }; if owner_user_id != user.id || target_type != "url" { return StatusCode::FORBIDDEN.into_response(); } let user_dbs = match state.get_user_dbs(user.id) { Ok(dbs) => dbs, Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), }; perform_user_json_export(user_dbs, "url", id, query.date_from, query.date_to).await } pub async fn user_page_analytics_csv_export( State(state): State, jar: CookieJar, Path(id): Path, Query(query): Query, ) -> Response { let (user, _session_id) = match require_user_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; // Ownership check let (owner_user_id, target_type) = { let conn = state.system_db.lock().unwrap(); match conn.query_row( "SELECT owner_user_id, target_type FROM global_slugs WHERE target_id = ?1", [&id], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), ) { Ok(val) => val, Err(rusqlite::Error::QueryReturnedNoRows) => { return StatusCode::NOT_FOUND.into_response(); } Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), } }; if owner_user_id != user.id || target_type != "page" { return StatusCode::FORBIDDEN.into_response(); } let user_dbs = match state.get_user_dbs(user.id) { Ok(dbs) => dbs, Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), }; perform_user_csv_export(user_dbs, "page", id, query.date_from, query.date_to).await } pub async fn user_page_analytics_json_export( State(state): State, jar: CookieJar, Path(id): Path, Query(query): Query, ) -> Response { let (user, _session_id) = match require_user_auth(&state, &jar).await { Ok(u) => u, Err(redir) => return redir.into_response(), }; // Ownership check let (owner_user_id, target_type) = { let conn = state.system_db.lock().unwrap(); match conn.query_row( "SELECT owner_user_id, target_type FROM global_slugs WHERE target_id = ?1", [&id], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), ) { Ok(val) => val, Err(rusqlite::Error::QueryReturnedNoRows) => { return StatusCode::NOT_FOUND.into_response(); } Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), } }; if owner_user_id != user.id || target_type != "page" { return StatusCode::FORBIDDEN.into_response(); } let user_dbs = match state.get_user_dbs(user.id) { Ok(dbs) => dbs, Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), }; perform_user_json_export(user_dbs, "page", id, query.date_from, query.date_to).await }