Add QR codes, link expiry, password protection, previews, audit trail and bulk operations
This commit is contained in:
1 parent
157aa81252
commit
bcbcc90d98
88 files changed
+5354
-815
No files matched your search
+67
-24
@@ -1,6 +1,6 @@
|
||||
use rusqlite::{Connection, params};
|
||||
use crate::db::analytics::{clean_referrer, parse_ua};
|
||||
use rusqlite::{params, Connection};
|
||||
use std::collections::HashMap;
|
||||
use crate::db::analytics::{parse_ua, clean_referrer};
|
||||
|
||||
// Run aggregation for a specific day
|
||||
pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()> {
|
||||
@@ -10,7 +10,7 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT target_type, target_id, user_agent, referer, country, status_code FROM visits WHERE date(timestamp) = ?1;"
|
||||
)?;
|
||||
|
||||
|
||||
struct RawVisit {
|
||||
target_type: String,
|
||||
target_id: String,
|
||||
@@ -18,7 +18,7 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
|
||||
referer: String,
|
||||
country: String,
|
||||
}
|
||||
|
||||
|
||||
let rows = stmt.query_map(params![date], |row| {
|
||||
Ok(RawVisit {
|
||||
target_type: row.get(0)?,
|
||||
@@ -28,7 +28,7 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
|
||||
country: row.get(4)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
|
||||
for r in rows {
|
||||
visits.push(r?);
|
||||
}
|
||||
@@ -46,7 +46,11 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
|
||||
for v in visits {
|
||||
let (browser, os, device) = parse_ua(&v.user_agent);
|
||||
let referrer = clean_referrer(&v.referer);
|
||||
let country = if v.country.is_empty() { "Unknown".to_string() } else { v.country.clone() };
|
||||
let country = if v.country.is_empty() {
|
||||
"Unknown".to_string()
|
||||
} else {
|
||||
v.country.clone()
|
||||
};
|
||||
|
||||
let targets = vec![
|
||||
(v.target_type.clone(), v.target_id.clone()),
|
||||
@@ -55,22 +59,59 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
|
||||
|
||||
for (t_type, t_id) in targets {
|
||||
// Clicks
|
||||
*aggregates.entry((t_type.clone(), t_id.clone(), "clicks".to_string(), "".to_string())).or_insert(0) += 1;
|
||||
|
||||
*aggregates
|
||||
.entry((
|
||||
t_type.clone(),
|
||||
t_id.clone(),
|
||||
"clicks".to_string(),
|
||||
"".to_string(),
|
||||
))
|
||||
.or_insert(0) += 1;
|
||||
|
||||
// Country
|
||||
*aggregates.entry((t_type.clone(), t_id.clone(), "country".to_string(), country.clone())).or_insert(0) += 1;
|
||||
*aggregates
|
||||
.entry((
|
||||
t_type.clone(),
|
||||
t_id.clone(),
|
||||
"country".to_string(),
|
||||
country.clone(),
|
||||
))
|
||||
.or_insert(0) += 1;
|
||||
|
||||
// Browser
|
||||
*aggregates.entry((t_type.clone(), t_id.clone(), "browser".to_string(), browser.clone())).or_insert(0) += 1;
|
||||
*aggregates
|
||||
.entry((
|
||||
t_type.clone(),
|
||||
t_id.clone(),
|
||||
"browser".to_string(),
|
||||
browser.clone(),
|
||||
))
|
||||
.or_insert(0) += 1;
|
||||
|
||||
// OS
|
||||
*aggregates.entry((t_type.clone(), t_id.clone(), "os".to_string(), os.clone())).or_insert(0) += 1;
|
||||
*aggregates
|
||||
.entry((t_type.clone(), t_id.clone(), "os".to_string(), os.clone()))
|
||||
.or_insert(0) += 1;
|
||||
|
||||
// Device
|
||||
*aggregates.entry((t_type.clone(), t_id.clone(), "device".to_string(), device.clone())).or_insert(0) += 1;
|
||||
*aggregates
|
||||
.entry((
|
||||
t_type.clone(),
|
||||
t_id.clone(),
|
||||
"device".to_string(),
|
||||
device.clone(),
|
||||
))
|
||||
.or_insert(0) += 1;
|
||||
|
||||
// Referrer
|
||||
*aggregates.entry((t_type.clone(), t_id.clone(), "referrer".to_string(), referrer.clone())).or_insert(0) += 1;
|
||||
*aggregates
|
||||
.entry((
|
||||
t_type.clone(),
|
||||
t_id.clone(),
|
||||
"referrer".to_string(),
|
||||
referrer.clone(),
|
||||
))
|
||||
.or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +119,10 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
// Delete old aggregates for this day
|
||||
tx.execute("DELETE FROM daily_summaries WHERE date = ?1;", params![date])?;
|
||||
tx.execute(
|
||||
"DELETE FROM daily_summaries WHERE date = ?1;",
|
||||
params![date],
|
||||
)?;
|
||||
|
||||
let mut insert_stmt = tx.prepare(
|
||||
"INSERT INTO daily_summaries (date, target_type, target_id, metric_type, metric_key, metric_value)
|
||||
@@ -86,14 +130,7 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
|
||||
)?;
|
||||
|
||||
for ((t_type, t_id, m_type, m_key), value) in aggregates {
|
||||
insert_stmt.execute(params![
|
||||
date,
|
||||
t_type,
|
||||
t_id,
|
||||
m_type,
|
||||
m_key,
|
||||
value
|
||||
])?;
|
||||
insert_stmt.execute(params![date, t_type, t_id, m_type, m_key, value])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
@@ -108,7 +145,10 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
|
||||
pub fn aggregate_month_from_daily(conn: &mut Connection, year_month: &str) -> rusqlite::Result<()> {
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
tx.execute("DELETE FROM monthly_summaries WHERE year_month = ?1;", params![year_month])?;
|
||||
tx.execute(
|
||||
"DELETE FROM monthly_summaries WHERE year_month = ?1;",
|
||||
params![year_month],
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT INTO monthly_summaries (year_month, target_type, target_id, metric_type, metric_key, metric_value)
|
||||
SELECT ?1, target_type, target_id, metric_type, metric_key, SUM(metric_value)
|
||||
@@ -125,7 +165,10 @@ pub fn aggregate_month_from_daily(conn: &mut Connection, year_month: &str) -> ru
|
||||
pub fn aggregate_year_from_daily(conn: &mut Connection, year: &str) -> rusqlite::Result<()> {
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
tx.execute("DELETE FROM yearly_summaries WHERE year = ?1;", params![year])?;
|
||||
tx.execute(
|
||||
"DELETE FROM yearly_summaries WHERE year = ?1;",
|
||||
params![year],
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT INTO yearly_summaries (year, target_type, target_id, metric_type, metric_key, metric_value)
|
||||
SELECT ?1, target_type, target_id, metric_type, metric_key, SUM(metric_value)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub enum AnalyticsEvent {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
pub mod aggregate;
|
||||
pub mod events;
|
||||
pub mod location;
|
||||
pub mod queue;
|
||||
pub mod worker;
|
||||
pub mod location;
|
||||
pub mod events;
|
||||
pub mod aggregate;
|
||||
|
||||
pub use queue::AnalyticsQueue;
|
||||
pub use location::get_client_country;
|
||||
pub use events::AnalyticsEvent;
|
||||
pub use aggregate::{aggregate_day, aggregate_month_from_daily, aggregate_year_from_daily};
|
||||
pub use events::AnalyticsEvent;
|
||||
pub use location::get_client_country;
|
||||
pub use queue::AnalyticsQueue;
|
||||
@@ -1,6 +1,6 @@
|
||||
use tokio::sync::mpsc;
|
||||
use crate::models::VisitRecord;
|
||||
use crate::db::Db;
|
||||
use crate::models::VisitRecord;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AnalyticsQueue {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::{interval, MissedTickBehavior};
|
||||
use std::time::Duration;
|
||||
use tracing::{info, error};
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::db::analytics::insert_visits_batch;
|
||||
use crate::db::Db;
|
||||
use crate::models::VisitRecord;
|
||||
use crate::db::analytics::insert_visits_batch;
|
||||
|
||||
pub async fn run_worker(db: Db, mut receiver: mpsc::Receiver<VisitRecord>) {
|
||||
let mut batch = Vec::new();
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
use sha2::{Sha256, Digest};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
// Deterministic CSRF token derived from session token
|
||||
pub fn generate_csrf_token(session_id: &str) -> String {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use crate::auth::session::authenticate_api_key;
|
||||
use crate::models::User;
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
extract::{FromRequestParts, FromRef},
|
||||
extract::{FromRef, FromRequestParts},
|
||||
http::{request::Parts, StatusCode},
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use crate::models::User;
|
||||
use crate::auth::session::authenticate_api_key;
|
||||
|
||||
// Extractor: Authenticate API requests using Bearer token
|
||||
pub struct ApiUser(pub User);
|
||||
@@ -19,7 +19,8 @@ where
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
let app_state = AppState::from_ref(state);
|
||||
let auth_header = parts.headers
|
||||
let auth_header = parts
|
||||
.headers
|
||||
.get("Authorization")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Missing Authorization header"))?;
|
||||
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
pub mod password;
|
||||
pub mod session;
|
||||
pub mod csrf;
|
||||
pub mod middleware;
|
||||
pub mod password;
|
||||
pub mod session;
|
||||
|
||||
pub use password::{hash_password, verify_password, verify_sha256};
|
||||
pub use session::{generate_token, authenticate_session, authenticate_api_key};
|
||||
pub use csrf::{generate_csrf_token, verify_csrf};
|
||||
pub use middleware::ApiUser;
|
||||
pub use password::{hash_password, verify_password, verify_sha256};
|
||||
pub use session::{authenticate_api_key, authenticate_session, generate_token};
|
||||
@@ -1,21 +1,25 @@
|
||||
use sha2::{Sha256, Digest};
|
||||
use argon2::{
|
||||
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||
Argon2,
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
// Hashing password with Argon2id
|
||||
pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::default();
|
||||
let password_hash = argon2.hash_password(password.as_bytes(), &salt)?.to_string();
|
||||
let password_hash = argon2
|
||||
.hash_password(password.as_bytes(), &salt)?
|
||||
.to_string();
|
||||
Ok(password_hash)
|
||||
}
|
||||
|
||||
// Verifying Argon2id password hash
|
||||
pub fn verify_password(password: &str, hash: &str) -> bool {
|
||||
if let Ok(parsed_hash) = PasswordHash::new(hash) {
|
||||
Argon2::default().verify_password(password.as_bytes(), &parsed_hash).is_ok()
|
||||
Argon2::default()
|
||||
.verify_password(password.as_bytes(), &parsed_hash)
|
||||
.is_ok()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
+16
-14
@@ -1,10 +1,12 @@
|
||||
use sha2::{Sha256, Digest};
|
||||
use rand::{RngCore, thread_rng};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use rusqlite::Connection;
|
||||
use chrono::Utc;
|
||||
use crate::db::admin::{get_session, get_user_by_id, update_api_key_last_used, get_api_key_by_hash};
|
||||
use crate::db::admin::{
|
||||
get_api_key_by_hash, get_session, get_user_by_id, update_api_key_last_used,
|
||||
};
|
||||
use crate::models::User;
|
||||
use axum_extra::extract::CookieJar;
|
||||
use chrono::Utc;
|
||||
use rand::{thread_rng, RngCore};
|
||||
use rusqlite::Connection;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
// Generate a secure random token (hex-encoded)
|
||||
pub fn generate_token(bytes_len: usize) -> String {
|
||||
@@ -22,13 +24,13 @@ pub fn authenticate_session(
|
||||
Some(c) => c,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
|
||||
let session_id = cookie.value();
|
||||
let session = match get_session(conn, session_id)? {
|
||||
Some(s) => s,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
|
||||
// Check expiration
|
||||
if let Ok(expires) = chrono::DateTime::parse_from_rfc3339(&session.expires_at) {
|
||||
if expires.with_timezone(&Utc) < Utc::now() {
|
||||
@@ -38,7 +40,7 @@ pub fn authenticate_session(
|
||||
} else {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
|
||||
// Get user
|
||||
if let Some(user) = get_user_by_id(conn, &session.user_id)? {
|
||||
Ok(Some((user, session.id)))
|
||||
@@ -55,26 +57,26 @@ pub fn authenticate_api_key(
|
||||
if !auth_header.starts_with("Bearer ") {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
|
||||
let key = auth_header.trim_start_matches("Bearer ").trim();
|
||||
if key.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
|
||||
// Hash the API key using SHA-256 to compare with stored hash
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(key.as_bytes());
|
||||
let hashed_key = hex::encode(hasher.finalize());
|
||||
|
||||
|
||||
if let Some(api_key_rec) = get_api_key_by_hash(conn, &hashed_key)? {
|
||||
// Update last used timestamp
|
||||
update_api_key_last_used(conn, &api_key_rec.id)?;
|
||||
|
||||
|
||||
// Get user
|
||||
if let Some(user) = get_user_by_id(conn, &api_key_rec.user_id)? {
|
||||
return Ok(Some(user));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
+2
-2
@@ -14,7 +14,7 @@ pub fn generate_bar_chart(data: &[(String, i64)]) -> String {
|
||||
let pad_left = 130.0;
|
||||
let pad_right = 70.0;
|
||||
let width = 600.0;
|
||||
|
||||
|
||||
let height = pad_top + pad_bottom + (data.len() as f64 * (bar_height + gap)) - gap;
|
||||
let chart_w = width - pad_left - pad_right;
|
||||
|
||||
@@ -27,7 +27,7 @@ pub fn generate_bar_chart(data: &[(String, i64)]) -> String {
|
||||
for (i, (label, val)) in data.iter().enumerate() {
|
||||
let y = pad_top + (i as f64 * (bar_height + gap));
|
||||
let bar_w = (*val as f64 / max_x) * chart_w;
|
||||
|
||||
|
||||
let pct = if total_count > 0 {
|
||||
(*val as f64 / total_count as f64) * 100.0
|
||||
} else {
|
||||
|
||||
+16
-4
@@ -37,7 +37,11 @@ pub fn generate_line_chart(data: &[(String, i64)]) -> String {
|
||||
|
||||
// Coordinates calculations
|
||||
let count = data.len();
|
||||
let step_x = if count > 1 { chart_w / (count - 1) as f64 } else { chart_w };
|
||||
let step_x = if count > 1 {
|
||||
chart_w / (count - 1) as f64
|
||||
} else {
|
||||
chart_w
|
||||
};
|
||||
|
||||
let mut points = Vec::new();
|
||||
for (i, &(_, val)) in data.iter().enumerate() {
|
||||
@@ -70,15 +74,23 @@ pub fn generate_line_chart(data: &[(String, i64)]) -> String {
|
||||
for (i, (label, _)) in data.iter().enumerate() {
|
||||
if i % label_step == 0 || i == count - 1 {
|
||||
let x = points[i].0;
|
||||
let short_label = if label.len() == 10 { &label[5..] } else { label };
|
||||
let short_label = if label.len() == 10 {
|
||||
&label[5..]
|
||||
} else {
|
||||
label
|
||||
};
|
||||
x_labels.push_str(&format!(
|
||||
r##"<text x="{}" y="{}" fill="{}" font-size="11" font-family="system-ui, sans-serif" text-anchor="middle">{}</text>"##,
|
||||
x, height - 15.0, DEFAULT_TEXT_COLOR, short_label
|
||||
));
|
||||
|
||||
|
||||
x_labels.push_str(&format!(
|
||||
r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{}" stroke-width="1"/>"##,
|
||||
x, pad_top + chart_h, x, pad_top + chart_h + 5.0, DEFAULT_GRID_COLOR
|
||||
x,
|
||||
pad_top + chart_h,
|
||||
x,
|
||||
pad_top + chart_h + 5.0,
|
||||
DEFAULT_GRID_COLOR
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -1,10 +1,10 @@
|
||||
pub mod svg;
|
||||
pub mod line;
|
||||
pub mod bar;
|
||||
pub mod line;
|
||||
pub mod pie;
|
||||
pub mod svg;
|
||||
pub mod timeseries;
|
||||
|
||||
pub use line::generate_line_chart;
|
||||
pub use bar::generate_bar_chart;
|
||||
pub use line::generate_line_chart;
|
||||
pub use pie::generate_pie_chart;
|
||||
pub use timeseries::generate_timeseries_chart;
|
||||
+9
-5
@@ -1,20 +1,24 @@
|
||||
use std::path::PathBuf;
|
||||
use tracing::info;
|
||||
use crate::config::Config;
|
||||
use crate::db::Db;
|
||||
use crate::jobs::backup::perform_backup;
|
||||
use std::path::PathBuf;
|
||||
use tracing::info;
|
||||
|
||||
pub async fn run(
|
||||
out: Option<String>,
|
||||
data_dir: Option<String>,
|
||||
mut config: Config,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); }
|
||||
if let Some(o) = out { config.backup_dir = PathBuf::from(o); }
|
||||
if let Some(d) = data_dir {
|
||||
config.data_dir = PathBuf::from(d);
|
||||
}
|
||||
if let Some(o) = out {
|
||||
config.backup_dir = PathBuf::from(o);
|
||||
}
|
||||
|
||||
// Init DB connections to ensure databases exist and migrate if needed
|
||||
let db = Db::init(&config)?;
|
||||
|
||||
|
||||
info!("Starting database backup...");
|
||||
let backup_path = perform_backup(&db, &config).await?;
|
||||
info!("Database backup generated successfully: {}", backup_path);
|
||||
|
||||
+11
-6
@@ -1,16 +1,18 @@
|
||||
use std::path::PathBuf;
|
||||
use std::io::{self, Write};
|
||||
use tracing::{info, error};
|
||||
use crate::auth::hash_password;
|
||||
use crate::config::Config;
|
||||
use crate::db::Db;
|
||||
use crate::auth::hash_password;
|
||||
use std::io::{self, Write};
|
||||
use std::path::PathBuf;
|
||||
use tracing::{error, info};
|
||||
|
||||
pub async fn run(
|
||||
username: Option<String>,
|
||||
data_dir: Option<String>,
|
||||
mut config: Config,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); }
|
||||
if let Some(d) = data_dir {
|
||||
config.data_dir = PathBuf::from(d);
|
||||
}
|
||||
let db = Db::init(&config)?;
|
||||
|
||||
let final_username = match username {
|
||||
@@ -32,7 +34,10 @@ pub async fn run(
|
||||
let hash = hash_password(&password).map_err(|e| e.to_string())?;
|
||||
let conn = db.admin.lock().unwrap();
|
||||
let u = crate::db::admin::create_user(&conn, &final_username, &hash)?;
|
||||
info!("Successfully created admin user: {} (ID: {})", u.username, u.id);
|
||||
info!(
|
||||
"Successfully created admin user: {} (ID: {})",
|
||||
u.username, u.id
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+30
-20
@@ -1,8 +1,8 @@
|
||||
use std::path::PathBuf;
|
||||
use tracing::info;
|
||||
use crate::config::Config;
|
||||
use crate::db::sqlite;
|
||||
use rusqlite::Connection;
|
||||
use std::path::PathBuf;
|
||||
use tracing::info;
|
||||
|
||||
/// Run comprehensive database diagnostics.
|
||||
///
|
||||
@@ -12,7 +12,9 @@ pub async fn run(
|
||||
data_dir: Option<String>,
|
||||
mut config: Config,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); }
|
||||
if let Some(d) = data_dir {
|
||||
config.data_dir = PathBuf::from(d);
|
||||
}
|
||||
|
||||
info!("Running BZOD database diagnostics...");
|
||||
println!("BZOD Database Doctor");
|
||||
@@ -35,27 +37,35 @@ pub async fn run(
|
||||
}
|
||||
|
||||
match Connection::open(&db_path) {
|
||||
Ok(conn) => {
|
||||
match sqlite::collect_health_report(&conn, db_name) {
|
||||
Ok(report) => {
|
||||
println!("Database: {}", report.database);
|
||||
println!(" Path: {:?}", db_path);
|
||||
println!(" Schema version: {}", report.schema_version);
|
||||
println!(" Journal mode: {}", report.journal_mode);
|
||||
println!(" Foreign keys: {}", if report.foreign_keys_enabled { "enabled" } else { "DISABLED" });
|
||||
println!(" Integrity: {}", if report.integrity_ok { "ok" } else { "FAILED" });
|
||||
|
||||
if !report.integrity_ok || !report.foreign_keys_enabled {
|
||||
all_healthy = false;
|
||||
Ok(conn) => match sqlite::collect_health_report(&conn, db_name) {
|
||||
Ok(report) => {
|
||||
println!("Database: {}", report.database);
|
||||
println!(" Path: {:?}", db_path);
|
||||
println!(" Schema version: {}", report.schema_version);
|
||||
println!(" Journal mode: {}", report.journal_mode);
|
||||
println!(
|
||||
" Foreign keys: {}",
|
||||
if report.foreign_keys_enabled {
|
||||
"enabled"
|
||||
} else {
|
||||
"DISABLED"
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Database: {}", db_name);
|
||||
println!(" Status: ERROR collecting health report: {}", e);
|
||||
);
|
||||
println!(
|
||||
" Integrity: {}",
|
||||
if report.integrity_ok { "ok" } else { "FAILED" }
|
||||
);
|
||||
|
||||
if !report.integrity_ok || !report.foreign_keys_enabled {
|
||||
all_healthy = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Database: {}", db_name);
|
||||
println!(" Status: ERROR collecting health report: {}", e);
|
||||
all_healthy = false;
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
println!("Database: {}", db_name);
|
||||
println!(" Status: FAILED to open: {}", e);
|
||||
|
||||
+5
-3
@@ -1,14 +1,16 @@
|
||||
use std::path::PathBuf;
|
||||
use tracing::info;
|
||||
use crate::config::Config;
|
||||
use crate::db::Db;
|
||||
use std::path::PathBuf;
|
||||
use tracing::info;
|
||||
|
||||
pub async fn run(
|
||||
data_dir: Option<String>,
|
||||
dry_run: bool,
|
||||
mut config: Config,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); }
|
||||
if let Some(d) = data_dir {
|
||||
config.data_dir = PathBuf::from(d);
|
||||
}
|
||||
|
||||
if dry_run {
|
||||
info!("Dry run enabled: pending database migrations will be reported but not applied.");
|
||||
|
||||
+5
-5
@@ -1,13 +1,13 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
pub mod serve;
|
||||
pub mod backup;
|
||||
pub mod restore;
|
||||
pub mod migrate;
|
||||
pub mod stats;
|
||||
pub mod validate;
|
||||
pub mod create_admin;
|
||||
pub mod doctor;
|
||||
pub mod migrate;
|
||||
pub mod restore;
|
||||
pub mod serve;
|
||||
pub mod stats;
|
||||
pub mod validate;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "bzod")]
|
||||
|
||||
+12
-7
@@ -1,17 +1,19 @@
|
||||
use std::path::PathBuf;
|
||||
use crate::config::Config;
|
||||
use flate2::read::GzDecoder;
|
||||
use std::fs::File;
|
||||
use std::io::{self, Write};
|
||||
use tracing::{info, error};
|
||||
use flate2::read::GzDecoder;
|
||||
use std::path::PathBuf;
|
||||
use tar::Archive;
|
||||
use crate::config::Config;
|
||||
use tracing::{error, info};
|
||||
|
||||
pub async fn run(
|
||||
file: String,
|
||||
data_dir: Option<String>,
|
||||
mut config: Config,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); }
|
||||
if let Some(d) = data_dir {
|
||||
config.data_dir = PathBuf::from(d);
|
||||
}
|
||||
let file_path = PathBuf::from(file);
|
||||
|
||||
if !file_path.exists() {
|
||||
@@ -19,12 +21,15 @@ pub async fn run(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!("WARNING: Restoring will overwrite existing databases in {:?}", config.data_dir);
|
||||
info!(
|
||||
"WARNING: Restoring will overwrite existing databases in {:?}",
|
||||
config.data_dir
|
||||
);
|
||||
print!("Are you sure you want to restore? (y/N): ");
|
||||
let _ = io::stdout().flush();
|
||||
let mut confirm = String::new();
|
||||
let _ = io::stdin().read_line(&mut confirm);
|
||||
|
||||
|
||||
if !confirm.trim().eq_ignore_ascii_case("y") {
|
||||
info!("Restore cancelled.");
|
||||
return Ok(());
|
||||
|
||||
+21
-10
@@ -1,11 +1,11 @@
|
||||
use crate::analytics::AnalyticsQueue;
|
||||
use crate::config::Config;
|
||||
use crate::db::Db;
|
||||
use crate::state::AppState;
|
||||
use crate::web::create_router;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
use tracing::info;
|
||||
use crate::config::Config;
|
||||
use crate::db::Db;
|
||||
use crate::analytics::AnalyticsQueue;
|
||||
use crate::state::AppState;
|
||||
use crate::web::create_router;
|
||||
|
||||
pub async fn run(
|
||||
host: Option<String>,
|
||||
@@ -13,16 +13,22 @@ pub async fn run(
|
||||
data_dir: Option<String>,
|
||||
mut config: Config,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Some(h) = host { config.host = h; }
|
||||
if let Some(p) = port { config.port = p; }
|
||||
if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); }
|
||||
if let Some(h) = host {
|
||||
config.host = h;
|
||||
}
|
||||
if let Some(p) = port {
|
||||
config.port = p;
|
||||
}
|
||||
if let Some(d) = data_dir {
|
||||
config.data_dir = PathBuf::from(d);
|
||||
}
|
||||
|
||||
info!("Starting BZOD server on {}:{}", config.host, config.port);
|
||||
info!("Database directory: {:?}", config.data_dir);
|
||||
|
||||
// Init DBs
|
||||
let db = Db::init(&config)?;
|
||||
|
||||
|
||||
// Init Queue
|
||||
let queue = AnalyticsQueue::new(db.clone(), 1000);
|
||||
|
||||
@@ -52,6 +58,11 @@ pub async fn run(
|
||||
crate::jobs::backup::run_backup_scheduler(backup_db, backup_config).await;
|
||||
});
|
||||
|
||||
let expiry_db = db.clone();
|
||||
tokio::spawn(async move {
|
||||
crate::jobs::run_expiry_checker(expiry_db).await;
|
||||
});
|
||||
|
||||
let state = AppState {
|
||||
admin_db: db.admin.clone(),
|
||||
content_db: db.content.clone(),
|
||||
@@ -67,7 +78,7 @@ pub async fn run(
|
||||
let router = create_router(state);
|
||||
let addr = format!("{}:{}", config.host, config.port);
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||
|
||||
|
||||
info!("Listening for requests on http://{}", addr);
|
||||
axum::serve(listener, router).await?;
|
||||
|
||||
|
||||
+16
-6
@@ -1,23 +1,30 @@
|
||||
use std::path::PathBuf;
|
||||
use crate::config::Config;
|
||||
use crate::db::Db;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub async fn run(
|
||||
data_dir: Option<String>,
|
||||
mut config: Config,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); }
|
||||
if let Some(d) = data_dir {
|
||||
config.data_dir = PathBuf::from(d);
|
||||
}
|
||||
let db = Db::init(&config)?;
|
||||
|
||||
|
||||
println!("=== BZOD Database Stats ===");
|
||||
println!("Storage Directory: {:?}", config.data_dir);
|
||||
|
||||
|
||||
let files = vec!["admin.db", "content.db", "analytics.db", "system.db"];
|
||||
for f in files {
|
||||
let p = config.data_dir.join(f);
|
||||
if p.exists() {
|
||||
let sz = std::fs::metadata(&p)?.len();
|
||||
println!(" File: {} - Size: {} bytes ({:.2} MB)", f, sz, sz as f64 / 1_048_576.0);
|
||||
println!(
|
||||
" File: {} - Size: {} bytes ({:.2} MB)",
|
||||
f,
|
||||
sz,
|
||||
sz as f64 / 1_048_576.0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +38,10 @@ pub async fn run(
|
||||
let conn = db.content.lock().unwrap();
|
||||
crate::db::content::get_url_counts(&conn)?
|
||||
};
|
||||
println!("Shortened URLs: {} total ({} active / {} dead)", urls_total, urls_active, urls_dead);
|
||||
println!(
|
||||
"Shortened URLs: {} total ({} active / {} dead)",
|
||||
urls_total, urls_active, urls_dead
|
||||
);
|
||||
|
||||
let pages_count = {
|
||||
let conn = db.content.lock().unwrap();
|
||||
|
||||
+9
-7
@@ -1,24 +1,26 @@
|
||||
use std::path::PathBuf;
|
||||
use tracing::info;
|
||||
use reqwest::Client;
|
||||
use std::time::Duration;
|
||||
use crate::config::Config;
|
||||
use crate::db::Db;
|
||||
use reqwest::Client;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use tracing::info;
|
||||
|
||||
pub async fn run(
|
||||
data_dir: Option<String>,
|
||||
mut config: Config,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); }
|
||||
if let Some(d) = data_dir {
|
||||
config.data_dir = PathBuf::from(d);
|
||||
}
|
||||
|
||||
let db = Db::init(&config)?;
|
||||
|
||||
|
||||
info!("Running one-shot link validation...");
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.user_agent("bzod-cli-checker/0.1")
|
||||
.build()?;
|
||||
|
||||
|
||||
crate::jobs::perform_link_check(&db, &client).await?;
|
||||
info!("Link validation complete.");
|
||||
|
||||
|
||||
+86
-27
@@ -1,7 +1,7 @@
|
||||
use std::path::PathBuf;
|
||||
use serde::Deserialize;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use serde::Deserialize;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Config {
|
||||
@@ -18,6 +18,7 @@ pub struct Config {
|
||||
pub backup_enabled: bool,
|
||||
pub backup_interval_mins: u64,
|
||||
pub backup_dir: PathBuf,
|
||||
pub base_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
@@ -33,6 +34,7 @@ struct TomlConfig {
|
||||
link_check_interval_mins: Option<u64>,
|
||||
aggregation_interval_mins: Option<u64>,
|
||||
backup: Option<TomlBackupConfig>,
|
||||
base_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
@@ -50,7 +52,8 @@ impl Config {
|
||||
let mut data_dir = PathBuf::from("./data");
|
||||
let mut admin_username = "admin".to_string();
|
||||
let mut bootstrap_password_sha256 = "".to_string();
|
||||
let mut session_secret = "bzod-default-session-secret-change-me-in-production-please-do-it".to_string();
|
||||
let mut session_secret =
|
||||
"bzod-default-session-secret-change-me-in-production-please-do-it".to_string();
|
||||
let mut cookie_secure = true;
|
||||
let mut data_retention_days = None;
|
||||
let mut link_check_interval_mins = 60u64;
|
||||
@@ -58,6 +61,7 @@ impl Config {
|
||||
let mut backup_enabled = false;
|
||||
let mut backup_interval_mins = 1440u64; // Default: once per day
|
||||
let mut backup_dir = PathBuf::from("./backups");
|
||||
let mut base_url = None;
|
||||
|
||||
// 2. Load bzod.toml if it exists
|
||||
let mut toml_path = "bzod.toml".to_string();
|
||||
@@ -66,13 +70,27 @@ impl Config {
|
||||
}
|
||||
if let Ok(toml_content) = fs::read_to_string(&toml_path) {
|
||||
if let Ok(toml_config) = toml::from_str::<TomlConfig>(&toml_content) {
|
||||
if let Some(h) = toml_config.host { host = h; }
|
||||
if let Some(p) = toml_config.port { port = p; }
|
||||
if let Some(d) = toml_config.data_dir { data_dir = PathBuf::from(d); }
|
||||
if let Some(u) = toml_config.admin_username { admin_username = u; }
|
||||
if let Some(s) = toml_config.bootstrap_password_sha256 { bootstrap_password_sha256 = s; }
|
||||
if let Some(sec) = toml_config.session_secret { session_secret = sec; }
|
||||
if let Some(c) = toml_config.cookie_secure { cookie_secure = c; }
|
||||
if let Some(h) = toml_config.host {
|
||||
host = h;
|
||||
}
|
||||
if let Some(p) = toml_config.port {
|
||||
port = p;
|
||||
}
|
||||
if let Some(d) = toml_config.data_dir {
|
||||
data_dir = PathBuf::from(d);
|
||||
}
|
||||
if let Some(u) = toml_config.admin_username {
|
||||
admin_username = u;
|
||||
}
|
||||
if let Some(s) = toml_config.bootstrap_password_sha256 {
|
||||
bootstrap_password_sha256 = s;
|
||||
}
|
||||
if let Some(sec) = toml_config.session_secret {
|
||||
session_secret = sec;
|
||||
}
|
||||
if let Some(c) = toml_config.cookie_secure {
|
||||
cookie_secure = c;
|
||||
}
|
||||
if let Some(ret) = toml_config.data_retention_days {
|
||||
if ret.eq_ignore_ascii_case("unlimited") {
|
||||
data_retention_days = None;
|
||||
@@ -80,12 +98,25 @@ impl Config {
|
||||
data_retention_days = Some(parsed);
|
||||
}
|
||||
}
|
||||
if let Some(lc) = toml_config.link_check_interval_mins { link_check_interval_mins = lc; }
|
||||
if let Some(ag) = toml_config.aggregation_interval_mins { aggregation_interval_mins = ag; }
|
||||
if let Some(lc) = toml_config.link_check_interval_mins {
|
||||
link_check_interval_mins = lc;
|
||||
}
|
||||
if let Some(ag) = toml_config.aggregation_interval_mins {
|
||||
aggregation_interval_mins = ag;
|
||||
}
|
||||
if let Some(b) = toml_config.backup {
|
||||
if let Some(be) = b.enabled { backup_enabled = be; }
|
||||
if let Some(bi) = b.interval_mins { backup_interval_mins = bi; }
|
||||
if let Some(bo) = b.out_dir { backup_dir = PathBuf::from(bo); }
|
||||
if let Some(be) = b.enabled {
|
||||
backup_enabled = be;
|
||||
}
|
||||
if let Some(bi) = b.interval_mins {
|
||||
backup_interval_mins = bi;
|
||||
}
|
||||
if let Some(bo) = b.out_dir {
|
||||
backup_dir = PathBuf::from(bo);
|
||||
}
|
||||
}
|
||||
if let Some(bu) = toml_config.base_url {
|
||||
base_url = Some(bu);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,16 +128,30 @@ impl Config {
|
||||
}
|
||||
|
||||
// 4. Load from Environment Variables (taking highest precedence)
|
||||
if let Ok(h) = env::var("HOST") { host = h; }
|
||||
if let Ok(h) = env::var("HOST") {
|
||||
host = h;
|
||||
}
|
||||
if let Ok(p_str) = env::var("PORT") {
|
||||
if let Ok(p) = p_str.parse::<u16>() { port = p; }
|
||||
if let Ok(p) = p_str.parse::<u16>() {
|
||||
port = p;
|
||||
}
|
||||
}
|
||||
if let Ok(d_str) = env::var("DATA_DIR") {
|
||||
data_dir = PathBuf::from(d_str);
|
||||
}
|
||||
if let Ok(u) = env::var("ADMIN_USERNAME") {
|
||||
admin_username = u;
|
||||
}
|
||||
if let Ok(s) = env::var("BOOTSTRAP_PASSWORD_SHA256") {
|
||||
bootstrap_password_sha256 = s;
|
||||
}
|
||||
if let Ok(sec) = env::var("SESSION_SECRET") {
|
||||
session_secret = sec;
|
||||
}
|
||||
if let Ok(d_str) = env::var("DATA_DIR") { data_dir = PathBuf::from(d_str); }
|
||||
if let Ok(u) = env::var("ADMIN_USERNAME") { admin_username = u; }
|
||||
if let Ok(s) = env::var("BOOTSTRAP_PASSWORD_SHA256") { bootstrap_password_sha256 = s; }
|
||||
if let Ok(sec) = env::var("SESSION_SECRET") { session_secret = sec; }
|
||||
if let Ok(c_str) = env::var("COOKIE_SECURE") {
|
||||
if let Ok(c) = c_str.parse::<bool>() { cookie_secure = c; }
|
||||
if let Ok(c) = c_str.parse::<bool>() {
|
||||
cookie_secure = c;
|
||||
}
|
||||
}
|
||||
if let Ok(ret_str) = env::var("DATA_RETENTION_DAYS") {
|
||||
if ret_str.eq_ignore_ascii_case("unlimited") {
|
||||
@@ -116,18 +161,31 @@ impl Config {
|
||||
}
|
||||
}
|
||||
if let Ok(lc_str) = env::var("LINK_CHECK_INTERVAL_MINS") {
|
||||
if let Ok(lc) = lc_str.parse::<u64>() { link_check_interval_mins = lc; }
|
||||
if let Ok(lc) = lc_str.parse::<u64>() {
|
||||
link_check_interval_mins = lc;
|
||||
}
|
||||
}
|
||||
if let Ok(ag_str) = env::var("AGGREGATION_INTERVAL_MINS") {
|
||||
if let Ok(ag) = ag_str.parse::<u64>() { aggregation_interval_mins = ag; }
|
||||
if let Ok(ag) = ag_str.parse::<u64>() {
|
||||
aggregation_interval_mins = ag;
|
||||
}
|
||||
}
|
||||
if let Ok(be_str) = env::var("BACKUP_ENABLED") {
|
||||
if let Ok(be) = be_str.parse::<bool>() { backup_enabled = be; }
|
||||
if let Ok(be) = be_str.parse::<bool>() {
|
||||
backup_enabled = be;
|
||||
}
|
||||
}
|
||||
if let Ok(bi_str) = env::var("BACKUP_INTERVAL_MINS") {
|
||||
if let Ok(bi) = bi_str.parse::<u64>() { backup_interval_mins = bi; }
|
||||
if let Ok(bi) = bi_str.parse::<u64>() {
|
||||
backup_interval_mins = bi;
|
||||
}
|
||||
}
|
||||
if let Ok(bo_str) = env::var("BACKUP_DIR") {
|
||||
backup_dir = PathBuf::from(bo_str);
|
||||
}
|
||||
if let Ok(bu) = env::var("BASE_URL") {
|
||||
base_url = Some(bu);
|
||||
}
|
||||
if let Ok(bo_str) = env::var("BACKUP_DIR") { backup_dir = PathBuf::from(bo_str); }
|
||||
|
||||
Self {
|
||||
host,
|
||||
@@ -143,6 +201,7 @@ impl Config {
|
||||
backup_enabled,
|
||||
backup_interval_mins,
|
||||
backup_dir,
|
||||
base_url,
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
-25
@@ -1,17 +1,21 @@
|
||||
use rusqlite::{Connection, params};
|
||||
use uuid::Uuid;
|
||||
use crate::models::{ApiKey, AuditLog, Session, User};
|
||||
use chrono::Utc;
|
||||
use crate::models::{User, Session, ApiKey, AuditLog};
|
||||
use rusqlite::{params, Connection};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub fn create_user(conn: &Connection, username: &str, password_hash: &str) -> rusqlite::Result<User> {
|
||||
pub fn create_user(
|
||||
conn: &Connection,
|
||||
username: &str,
|
||||
password_hash: &str,
|
||||
) -> rusqlite::Result<User> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let created_at = Utc::now().to_rfc3339();
|
||||
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, username, password_hash, created_at) VALUES (?1, ?2, ?3, ?4);",
|
||||
params![id, username, password_hash, created_at],
|
||||
)?;
|
||||
|
||||
|
||||
Ok(User {
|
||||
id,
|
||||
username: username.to_string(),
|
||||
@@ -21,9 +25,11 @@ pub fn create_user(conn: &Connection, username: &str, password_hash: &str) -> ru
|
||||
}
|
||||
|
||||
pub fn get_user_by_username(conn: &Connection, username: &str) -> rusqlite::Result<Option<User>> {
|
||||
let mut stmt = conn.prepare("SELECT id, username, password_hash, created_at FROM users WHERE username = ?1;")?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, username, password_hash, created_at FROM users WHERE username = ?1;",
|
||||
)?;
|
||||
let mut rows = stmt.query(params![username])?;
|
||||
|
||||
|
||||
if let Some(row) = rows.next()? {
|
||||
Ok(Some(User {
|
||||
id: row.get(0)?,
|
||||
@@ -37,9 +43,10 @@ pub fn get_user_by_username(conn: &Connection, username: &str) -> rusqlite::Resu
|
||||
}
|
||||
|
||||
pub fn get_user_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Option<User>> {
|
||||
let mut stmt = conn.prepare("SELECT id, username, password_hash, created_at FROM users WHERE id = ?1;")?;
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT id, username, password_hash, created_at FROM users WHERE id = ?1;")?;
|
||||
let mut rows = stmt.query(params![id])?;
|
||||
|
||||
|
||||
if let Some(row) = rows.next()? {
|
||||
Ok(Some(User {
|
||||
id: row.get(0)?,
|
||||
@@ -63,12 +70,12 @@ pub fn create_session(
|
||||
expires_at_rfc3339: &str,
|
||||
) -> rusqlite::Result<Session> {
|
||||
let created_at = Utc::now().to_rfc3339();
|
||||
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);",
|
||||
params![session_id, user_id, expires_at_rfc3339, created_at],
|
||||
)?;
|
||||
|
||||
|
||||
Ok(Session {
|
||||
id: session_id.to_string(),
|
||||
user_id: user_id.to_string(),
|
||||
@@ -78,9 +85,10 @@ pub fn create_session(
|
||||
}
|
||||
|
||||
pub fn get_session(conn: &Connection, session_id: &str) -> rusqlite::Result<Option<Session>> {
|
||||
let mut stmt = conn.prepare("SELECT id, user_id, expires_at, created_at FROM sessions WHERE id = ?1;")?;
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT id, user_id, expires_at, created_at FROM sessions WHERE id = ?1;")?;
|
||||
let mut rows = stmt.query(params![session_id])?;
|
||||
|
||||
|
||||
if let Some(row) = rows.next()? {
|
||||
Ok(Some(Session {
|
||||
id: row.get(0)?,
|
||||
@@ -112,12 +120,12 @@ pub fn create_api_key(
|
||||
) -> rusqlite::Result<ApiKey> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let created_at = Utc::now().to_rfc3339();
|
||||
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO api_keys (id, user_id, key_hash, name, created_at) VALUES (?1, ?2, ?3, ?4, ?5);",
|
||||
params![id, user_id, key_hash, name, created_at],
|
||||
)?;
|
||||
|
||||
|
||||
Ok(ApiKey {
|
||||
id,
|
||||
user_id: user_id.to_string(),
|
||||
@@ -133,7 +141,7 @@ pub fn get_api_key_by_hash(conn: &Connection, key_hash: &str) -> rusqlite::Resul
|
||||
"SELECT id, user_id, key_hash, name, created_at, last_used_at FROM api_keys WHERE key_hash = ?1;"
|
||||
)?;
|
||||
let mut rows = stmt.query(params![key_hash])?;
|
||||
|
||||
|
||||
if let Some(row) = rows.next()? {
|
||||
Ok(Some(ApiKey {
|
||||
id: row.get(0)?,
|
||||
@@ -162,7 +170,7 @@ pub fn list_api_keys(conn: &Connection, user_id: &str) -> rusqlite::Result<Vec<A
|
||||
last_used_at: row.get(5)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
|
||||
let mut keys = Vec::new();
|
||||
for key in rows {
|
||||
keys.push(key?);
|
||||
@@ -177,7 +185,10 @@ pub fn delete_api_key(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
|
||||
pub fn update_api_key_last_used(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
conn.execute("UPDATE api_keys SET last_used_at = ?1 WHERE id = ?2;", params![now, id])?;
|
||||
conn.execute(
|
||||
"UPDATE api_keys SET last_used_at = ?1 WHERE id = ?2;",
|
||||
params![now, id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -192,13 +203,13 @@ pub fn write_audit_log(
|
||||
) -> rusqlite::Result<AuditLog> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let timestamp = Utc::now().to_rfc3339();
|
||||
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO audit_logs (id, timestamp, username, action, object_type, object_id, ip_address, user_agent)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8);",
|
||||
params![id, timestamp, username, action, object_type, object_id, ip_address, user_agent],
|
||||
)?;
|
||||
|
||||
|
||||
Ok(AuditLog {
|
||||
id,
|
||||
timestamp,
|
||||
@@ -211,10 +222,14 @@ pub fn write_audit_log(
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_audit_logs(conn: &Connection, limit: i64, offset: i64) -> rusqlite::Result<Vec<AuditLog>> {
|
||||
pub fn list_audit_logs(
|
||||
conn: &Connection,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> rusqlite::Result<Vec<AuditLog>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, timestamp, username, action, object_type, object_id, ip_address, user_agent
|
||||
FROM audit_logs ORDER BY timestamp DESC LIMIT ?1 OFFSET ?2;"
|
||||
FROM audit_logs ORDER BY timestamp DESC LIMIT ?1 OFFSET ?2;",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![limit, offset], |row| {
|
||||
Ok(AuditLog {
|
||||
@@ -228,7 +243,7 @@ pub fn list_audit_logs(conn: &Connection, limit: i64, offset: i64) -> rusqlite::
|
||||
user_agent: row.get(7)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
|
||||
let mut logs = Vec::new();
|
||||
for log in rows {
|
||||
logs.push(log?);
|
||||
@@ -247,7 +262,7 @@ pub fn set_config(conn: &Connection, key: &str, value: &str) -> rusqlite::Result
|
||||
pub fn get_config(conn: &Connection, key: &str) -> rusqlite::Result<Option<String>> {
|
||||
let mut stmt = conn.prepare("SELECT value FROM config WHERE key = ?1;")?;
|
||||
let mut rows = stmt.query(params![key])?;
|
||||
|
||||
|
||||
if let Some(row) = rows.next()? {
|
||||
let val: String = row.get(0)?;
|
||||
Ok(Some(val))
|
||||
|
||||
+167
-68
@@ -1,11 +1,11 @@
|
||||
use rusqlite::{Connection, params};
|
||||
use std::collections::HashMap;
|
||||
use crate::models::VisitRecord;
|
||||
use rusqlite::{params, Connection};
|
||||
use std::collections::HashMap;
|
||||
|
||||
// Custom User-Agent parser to avoid bloated dependencies
|
||||
pub fn parse_ua(ua: &str) -> (String, String, String) {
|
||||
let ua_lower = ua.to_lowercase();
|
||||
|
||||
|
||||
let os = if ua_lower.contains("windows") {
|
||||
"Windows".to_string()
|
||||
} else if ua_lower.contains("macintosh") || ua_lower.contains("mac os x") {
|
||||
@@ -18,7 +18,8 @@ pub fn parse_ua(ua: &str) -> (String, String, String) {
|
||||
"Android".to_string()
|
||||
} else if ua_lower.contains("linux") {
|
||||
"Linux".to_string()
|
||||
} else if ua_lower.contains("iphone") || ua_lower.contains("ipad") || ua_lower.contains("ipod") {
|
||||
} else if ua_lower.contains("iphone") || ua_lower.contains("ipad") || ua_lower.contains("ipod")
|
||||
{
|
||||
"iOS".to_string()
|
||||
} else {
|
||||
"Other".to_string()
|
||||
@@ -38,7 +39,11 @@ pub fn parse_ua(ua: &str) -> (String, String, String) {
|
||||
"Other".to_string()
|
||||
};
|
||||
|
||||
let device = if ua_lower.contains("mobile") || ua_lower.contains("android") || ua_lower.contains("iphone") || ua_lower.contains("ipod") {
|
||||
let device = if ua_lower.contains("mobile")
|
||||
|| ua_lower.contains("android")
|
||||
|| ua_lower.contains("iphone")
|
||||
|| ua_lower.contains("ipod")
|
||||
{
|
||||
"Mobile".to_string()
|
||||
} else if ua_lower.contains("ipad") || ua_lower.contains("tablet") {
|
||||
"Tablet".to_string()
|
||||
@@ -54,15 +59,17 @@ pub fn clean_referrer(referer: &str) -> String {
|
||||
if referer.is_empty() || referer == "direct" {
|
||||
return "Direct".to_string();
|
||||
}
|
||||
|
||||
|
||||
if let Ok(url) = reqwest::Url::parse(referer) {
|
||||
if let Some(host) = url.host_str() {
|
||||
return host.trim_start_matches("www.").to_string();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Fallback if not a valid URL
|
||||
let cleaned = referer.trim_start_matches("https://").trim_start_matches("http://");
|
||||
let cleaned = referer
|
||||
.trim_start_matches("https://")
|
||||
.trim_start_matches("http://");
|
||||
let cleaned = cleaned.split('/').next().unwrap_or("Direct");
|
||||
if cleaned.is_empty() {
|
||||
"Direct".to_string()
|
||||
@@ -78,7 +85,7 @@ pub fn insert_visits_batch(conn: &mut Connection, records: &[VisitRecord]) -> ru
|
||||
"INSERT INTO visits (id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10);"
|
||||
)?;
|
||||
|
||||
|
||||
for r in records {
|
||||
stmt.execute(params![
|
||||
r.id,
|
||||
@@ -99,16 +106,25 @@ pub fn insert_visits_batch(conn: &mut Connection, records: &[VisitRecord]) -> ru
|
||||
}
|
||||
|
||||
pub fn get_total_clicks(conn: &Connection) -> rusqlite::Result<i64> {
|
||||
conn.query_row("SELECT COUNT(*) FROM visits WHERE target_type = 'url';", [], |row| row.get(0))
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM visits WHERE target_type = 'url';",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_total_page_views(conn: &Connection) -> rusqlite::Result<i64> {
|
||||
conn.query_row("SELECT COUNT(*) FROM visits WHERE target_type = 'page';", [], |row| row.get(0))
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM visits WHERE target_type = 'page';",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
}
|
||||
|
||||
// Get the date range of visits in the DB
|
||||
pub fn get_visits_date_range(conn: &Connection) -> rusqlite::Result<Option<(String, String)>> {
|
||||
let mut stmt = conn.prepare("SELECT MIN(date(timestamp)), MAX(date(timestamp)) FROM visits;")?;
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT MIN(date(timestamp)), MAX(date(timestamp)) FROM visits;")?;
|
||||
let mut rows = stmt.query([])?;
|
||||
if let Some(row) = rows.next()? {
|
||||
let min_date: Option<String> = row.get(0)?;
|
||||
@@ -128,7 +144,7 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT target_type, target_id, user_agent, referer, country, status_code FROM visits WHERE date(timestamp) = ?1;"
|
||||
)?;
|
||||
|
||||
|
||||
struct RawVisit {
|
||||
target_type: String,
|
||||
target_id: String,
|
||||
@@ -136,7 +152,7 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
|
||||
referer: String,
|
||||
country: String,
|
||||
}
|
||||
|
||||
|
||||
let rows = stmt.query_map(params![date], |row| {
|
||||
Ok(RawVisit {
|
||||
target_type: row.get(0)?,
|
||||
@@ -146,7 +162,7 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
|
||||
country: row.get(4)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
|
||||
for r in rows {
|
||||
visits.push(r?);
|
||||
}
|
||||
@@ -156,7 +172,6 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
|
||||
// 2. Compute metrics in-memory
|
||||
// Key structure: (target_type, target_id, metric_type, metric_key) -> count
|
||||
let mut aggregates: HashMap<(String, String, String, String), i64> = HashMap::new();
|
||||
@@ -165,7 +180,11 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
|
||||
for v in visits {
|
||||
let (browser, os, device) = parse_ua(&v.user_agent);
|
||||
let referrer = clean_referrer(&v.referer);
|
||||
let country = if v.country.is_empty() { "Unknown".to_string() } else { v.country.clone() };
|
||||
let country = if v.country.is_empty() {
|
||||
"Unknown".to_string()
|
||||
} else {
|
||||
v.country.clone()
|
||||
};
|
||||
|
||||
let targets = vec![
|
||||
(v.target_type.clone(), v.target_id.clone()),
|
||||
@@ -174,22 +193,59 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
|
||||
|
||||
for (t_type, t_id) in targets {
|
||||
// Clicks
|
||||
*aggregates.entry((t_type.clone(), t_id.clone(), "clicks".to_string(), "".to_string())).or_insert(0) += 1;
|
||||
|
||||
*aggregates
|
||||
.entry((
|
||||
t_type.clone(),
|
||||
t_id.clone(),
|
||||
"clicks".to_string(),
|
||||
"".to_string(),
|
||||
))
|
||||
.or_insert(0) += 1;
|
||||
|
||||
// Country
|
||||
*aggregates.entry((t_type.clone(), t_id.clone(), "country".to_string(), country.clone())).or_insert(0) += 1;
|
||||
*aggregates
|
||||
.entry((
|
||||
t_type.clone(),
|
||||
t_id.clone(),
|
||||
"country".to_string(),
|
||||
country.clone(),
|
||||
))
|
||||
.or_insert(0) += 1;
|
||||
|
||||
// Browser
|
||||
*aggregates.entry((t_type.clone(), t_id.clone(), "browser".to_string(), browser.clone())).or_insert(0) += 1;
|
||||
*aggregates
|
||||
.entry((
|
||||
t_type.clone(),
|
||||
t_id.clone(),
|
||||
"browser".to_string(),
|
||||
browser.clone(),
|
||||
))
|
||||
.or_insert(0) += 1;
|
||||
|
||||
// OS
|
||||
*aggregates.entry((t_type.clone(), t_id.clone(), "os".to_string(), os.clone())).or_insert(0) += 1;
|
||||
*aggregates
|
||||
.entry((t_type.clone(), t_id.clone(), "os".to_string(), os.clone()))
|
||||
.or_insert(0) += 1;
|
||||
|
||||
// Device
|
||||
*aggregates.entry((t_type.clone(), t_id.clone(), "device".to_string(), device.clone())).or_insert(0) += 1;
|
||||
*aggregates
|
||||
.entry((
|
||||
t_type.clone(),
|
||||
t_id.clone(),
|
||||
"device".to_string(),
|
||||
device.clone(),
|
||||
))
|
||||
.or_insert(0) += 1;
|
||||
|
||||
// Referrer
|
||||
*aggregates.entry((t_type.clone(), t_id.clone(), "referrer".to_string(), referrer.clone())).or_insert(0) += 1;
|
||||
*aggregates
|
||||
.entry((
|
||||
t_type.clone(),
|
||||
t_id.clone(),
|
||||
"referrer".to_string(),
|
||||
referrer.clone(),
|
||||
))
|
||||
.or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +253,10 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
// Delete old aggregates for this day
|
||||
tx.execute("DELETE FROM daily_summaries WHERE date = ?1;", params![date])?;
|
||||
tx.execute(
|
||||
"DELETE FROM daily_summaries WHERE date = ?1;",
|
||||
params![date],
|
||||
)?;
|
||||
|
||||
let mut insert_stmt = tx.prepare(
|
||||
"INSERT INTO daily_summaries (date, target_type, target_id, metric_type, metric_key, metric_value)
|
||||
@@ -205,14 +264,7 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
|
||||
)?;
|
||||
|
||||
for ((t_type, t_id, m_type, m_key), value) in aggregates {
|
||||
insert_stmt.execute(params![
|
||||
date,
|
||||
t_type,
|
||||
t_id,
|
||||
m_type,
|
||||
m_key,
|
||||
value
|
||||
])?;
|
||||
insert_stmt.execute(params![date, t_type, t_id, m_type, m_key, value])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
@@ -227,7 +279,10 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
|
||||
fn aggregate_month_from_daily(conn: &mut Connection, year_month: &str) -> rusqlite::Result<()> {
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
tx.execute("DELETE FROM monthly_summaries WHERE year_month = ?1;", params![year_month])?;
|
||||
tx.execute(
|
||||
"DELETE FROM monthly_summaries WHERE year_month = ?1;",
|
||||
params![year_month],
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT INTO monthly_summaries (year_month, target_type, target_id, metric_type, metric_key, metric_value)
|
||||
SELECT ?1, target_type, target_id, metric_type, metric_key, SUM(metric_value)
|
||||
@@ -244,7 +299,10 @@ fn aggregate_month_from_daily(conn: &mut Connection, year_month: &str) -> rusqli
|
||||
fn aggregate_year_from_daily(conn: &mut Connection, year: &str) -> rusqlite::Result<()> {
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
tx.execute("DELETE FROM yearly_summaries WHERE year = ?1;", params![year])?;
|
||||
tx.execute(
|
||||
"DELETE FROM yearly_summaries WHERE year = ?1;",
|
||||
params![year],
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT INTO yearly_summaries (year, target_type, target_id, metric_type, metric_key, metric_value)
|
||||
SELECT ?1, target_type, target_id, metric_type, metric_key, SUM(metric_value)
|
||||
@@ -262,7 +320,10 @@ fn aggregate_year_from_daily(conn: &mut Connection, year: &str) -> rusqlite::Res
|
||||
pub fn retention_cleanup(conn: &Connection, retention_days: i64) -> rusqlite::Result<usize> {
|
||||
let limit_date = chrono::Utc::now() - chrono::Duration::days(retention_days);
|
||||
let limit_str = limit_date.to_rfc3339();
|
||||
let count = conn.execute("DELETE FROM visits WHERE timestamp < ?1;", params![limit_str])?;
|
||||
let count = conn.execute(
|
||||
"DELETE FROM visits WHERE timestamp < ?1;",
|
||||
params![limit_str],
|
||||
)?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
@@ -274,18 +335,20 @@ pub fn get_clicks_trend(
|
||||
target_id: &str,
|
||||
limit_days: i64,
|
||||
) -> rusqlite::Result<Vec<(String, i64)>> {
|
||||
let limit_date = (chrono::Utc::now() - chrono::Duration::days(limit_days)).format("%Y-%m-%d").to_string();
|
||||
|
||||
let limit_date = (chrono::Utc::now() - chrono::Duration::days(limit_days))
|
||||
.format("%Y-%m-%d")
|
||||
.to_string();
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT date, SUM(metric_value) FROM daily_summaries
|
||||
WHERE target_type = ?1 AND target_id = ?2 AND metric_type = 'clicks' AND date >= ?3
|
||||
GROUP BY date ORDER BY date ASC;"
|
||||
GROUP BY date ORDER BY date ASC;",
|
||||
)?;
|
||||
|
||||
|
||||
let rows = stmt.query_map(params![target_type, target_id, limit_date], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
|
||||
})?;
|
||||
|
||||
|
||||
let mut res = Vec::new();
|
||||
for r in rows {
|
||||
res.push(r?);
|
||||
@@ -301,17 +364,17 @@ pub fn get_clicks_trend_raw(
|
||||
limit_days: i64,
|
||||
) -> rusqlite::Result<Vec<(String, i64)>> {
|
||||
let limit_date = (chrono::Utc::now() - chrono::Duration::days(limit_days)).to_rfc3339();
|
||||
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT date(timestamp) as d, COUNT(*) FROM visits
|
||||
WHERE target_type = ?1 AND target_id = ?2 AND timestamp >= ?3
|
||||
GROUP BY d ORDER BY d ASC;"
|
||||
GROUP BY d ORDER BY d ASC;",
|
||||
)?;
|
||||
|
||||
|
||||
let rows = stmt.query_map(params![target_type, target_id, limit_date], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
|
||||
})?;
|
||||
|
||||
|
||||
let mut res = Vec::new();
|
||||
for r in rows {
|
||||
res.push(r?);
|
||||
@@ -326,18 +389,18 @@ pub fn get_hourly_trend_raw(
|
||||
limit_days: i64,
|
||||
) -> rusqlite::Result<Vec<(String, i64)>> {
|
||||
let limit_date = (chrono::Utc::now() - chrono::Duration::days(limit_days)).to_rfc3339();
|
||||
|
||||
|
||||
// SQLite strftime('%H', timestamp) extracts the hour
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT strftime('%H', timestamp) as h, COUNT(*) FROM visits
|
||||
WHERE target_type = ?1 AND target_id = ?2 AND timestamp >= ?3
|
||||
GROUP BY h ORDER BY h ASC;"
|
||||
GROUP BY h ORDER BY h ASC;",
|
||||
)?;
|
||||
|
||||
|
||||
let rows = stmt.query_map(params![target_type, target_id, limit_date], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
|
||||
})?;
|
||||
|
||||
|
||||
let mut res = Vec::new();
|
||||
for r in rows {
|
||||
res.push(r?);
|
||||
@@ -355,13 +418,13 @@ pub fn get_metric_rankings(
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT metric_key, SUM(metric_value) as val FROM daily_summaries
|
||||
WHERE target_type = ?1 AND target_id = ?2 AND metric_type = ?3
|
||||
GROUP BY metric_key ORDER BY val DESC LIMIT ?4;"
|
||||
GROUP BY metric_key ORDER BY val DESC LIMIT ?4;",
|
||||
)?;
|
||||
|
||||
|
||||
let rows = stmt.query_map(params![target_type, target_id, metric_type, limit], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
|
||||
})?;
|
||||
|
||||
|
||||
let mut res = Vec::new();
|
||||
for r in rows {
|
||||
res.push(r?);
|
||||
@@ -378,30 +441,32 @@ pub fn get_metric_rankings_raw(
|
||||
) -> rusqlite::Result<Vec<(String, i64)>> {
|
||||
// Falls back to direct query on visits
|
||||
let mut res = Vec::new();
|
||||
|
||||
|
||||
match metric_type {
|
||||
"country" => {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT country, COUNT(*) as c FROM visits
|
||||
WHERE target_type = ?1 AND target_id = ?2
|
||||
GROUP BY country ORDER BY c DESC LIMIT ?3;"
|
||||
GROUP BY country ORDER BY c DESC LIMIT ?3;",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![target_type, target_id, limit], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
|
||||
})?;
|
||||
for r in rows { res.push(r?); }
|
||||
for r in rows {
|
||||
res.push(r?);
|
||||
}
|
||||
}
|
||||
"referrer" => {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT referer, COUNT(*) as c FROM visits
|
||||
WHERE target_type = ?1 AND target_id = ?2
|
||||
GROUP BY referer ORDER BY c DESC LIMIT ?3;"
|
||||
GROUP BY referer ORDER BY c DESC LIMIT ?3;",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![target_type, target_id, limit], |row| {
|
||||
let raw_ref: String = row.get(0)?;
|
||||
Ok((clean_referrer(&raw_ref), row.get::<_, i64>(1)?))
|
||||
})?;
|
||||
|
||||
|
||||
// Re-aggregate because clean_referrer might group different referrers
|
||||
let mut grouped: HashMap<String, i64> = HashMap::new();
|
||||
for r in rows {
|
||||
@@ -416,12 +481,12 @@ pub fn get_metric_rankings_raw(
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT user_agent, COUNT(*) as c FROM visits
|
||||
WHERE target_type = ?1 AND target_id = ?2
|
||||
GROUP BY user_agent;"
|
||||
GROUP BY user_agent;",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![target_type, target_id], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
|
||||
})?;
|
||||
|
||||
|
||||
let mut grouped: HashMap<String, i64> = HashMap::new();
|
||||
for r in rows {
|
||||
let (ua, count) = r?;
|
||||
@@ -439,7 +504,7 @@ pub fn get_metric_rankings_raw(
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
@@ -449,24 +514,58 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_parse_ua_browsers() {
|
||||
let firefox_linux = "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/119.0";
|
||||
let firefox_linux =
|
||||
"Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/119.0";
|
||||
let chrome_win = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
||||
let safari_mac = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15";
|
||||
let android_phone = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36";
|
||||
|
||||
assert_eq!(parse_ua(firefox_linux), ("Firefox".to_string(), "Linux".to_string(), "Desktop".to_string()));
|
||||
assert_eq!(parse_ua(chrome_win), ("Chrome".to_string(), "Windows".to_string(), "Desktop".to_string()));
|
||||
assert_eq!(parse_ua(safari_mac), ("Safari".to_string(), "macOS".to_string(), "Desktop".to_string()));
|
||||
assert_eq!(parse_ua(android_phone), ("Chrome".to_string(), "Android".to_string(), "Mobile".to_string()));
|
||||
assert_eq!(
|
||||
parse_ua(firefox_linux),
|
||||
(
|
||||
"Firefox".to_string(),
|
||||
"Linux".to_string(),
|
||||
"Desktop".to_string()
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_ua(chrome_win),
|
||||
(
|
||||
"Chrome".to_string(),
|
||||
"Windows".to_string(),
|
||||
"Desktop".to_string()
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_ua(safari_mac),
|
||||
(
|
||||
"Safari".to_string(),
|
||||
"macOS".to_string(),
|
||||
"Desktop".to_string()
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_ua(android_phone),
|
||||
(
|
||||
"Chrome".to_string(),
|
||||
"Android".to_string(),
|
||||
"Mobile".to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_referrer() {
|
||||
assert_eq!(clean_referrer("direct"), "Direct");
|
||||
assert_eq!(clean_referrer(""), "Direct");
|
||||
assert_eq!(clean_referrer("https://github.com/rust-lang/rust"), "github.com");
|
||||
assert_eq!(clean_referrer("http://www.google.com/search?q=rust"), "google.com");
|
||||
assert_eq!(
|
||||
clean_referrer("https://github.com/rust-lang/rust"),
|
||||
"github.com"
|
||||
);
|
||||
assert_eq!(
|
||||
clean_referrer("http://www.google.com/search?q=rust"),
|
||||
"google.com"
|
||||
);
|
||||
assert_eq!(clean_referrer("reddit.com/r/rust"), "reddit.com");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
use crate::models::AuditEvent;
|
||||
use chrono::Utc;
|
||||
use rusqlite::{params, Connection};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Write an audit event to the system.db audit_events table.
|
||||
pub fn write_audit_event(
|
||||
conn: &Connection,
|
||||
actor: &str,
|
||||
action: &str,
|
||||
object_type: &str,
|
||||
object_id: &str,
|
||||
metadata: Option<&str>,
|
||||
) -> rusqlite::Result<()> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO audit_events (id, actor, action, object_type, object_id, timestamp, metadata)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
|
||||
params![id, actor, action, object_type, object_id, now, metadata],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List audit events with optional filtering by actor or action.
|
||||
pub fn list_audit_events(
|
||||
conn: &Connection,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
actor_filter: Option<&str>,
|
||||
action_filter: Option<&str>,
|
||||
) -> rusqlite::Result<Vec<AuditEvent>> {
|
||||
let mut events = Vec::new();
|
||||
|
||||
let (sql, params_vec): (String, Vec<Box<dyn rusqlite::types::ToSql>>) = match (actor_filter, action_filter) {
|
||||
(Some(actor), Some(action)) => (
|
||||
"SELECT id, actor, action, object_type, object_id, timestamp, metadata FROM audit_events WHERE actor = ?1 AND action = ?2 ORDER BY timestamp DESC LIMIT ?3 OFFSET ?4;".to_string(),
|
||||
vec![Box::new(actor.to_string()), Box::new(action.to_string()), Box::new(limit), Box::new(offset)],
|
||||
),
|
||||
(Some(actor), None) => (
|
||||
"SELECT id, actor, action, object_type, object_id, timestamp, metadata FROM audit_events WHERE actor = ?1 ORDER BY timestamp DESC LIMIT ?2 OFFSET ?3;".to_string(),
|
||||
vec![Box::new(actor.to_string()), Box::new(limit), Box::new(offset)],
|
||||
),
|
||||
(None, Some(action)) => (
|
||||
"SELECT id, actor, action, object_type, object_id, timestamp, metadata FROM audit_events WHERE action = ?1 ORDER BY timestamp DESC LIMIT ?2 OFFSET ?3;".to_string(),
|
||||
vec![Box::new(action.to_string()), Box::new(limit), Box::new(offset)],
|
||||
),
|
||||
(None, None) => (
|
||||
"SELECT id, actor, action, object_type, object_id, timestamp, metadata FROM audit_events ORDER BY timestamp DESC LIMIT ?1 OFFSET ?2;".to_string(),
|
||||
vec![Box::new(limit), Box::new(offset)],
|
||||
),
|
||||
};
|
||||
|
||||
let params_refs: Vec<&dyn rusqlite::types::ToSql> =
|
||||
params_vec.iter().map(|p| p.as_ref()).collect();
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(params_refs.as_slice(), |row| {
|
||||
Ok(AuditEvent {
|
||||
id: row.get(0)?,
|
||||
actor: row.get(1)?,
|
||||
action: row.get(2)?,
|
||||
object_type: row.get(3)?,
|
||||
object_id: row.get(4)?,
|
||||
timestamp: row.get(5)?,
|
||||
metadata: row.get(6)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
for event in rows {
|
||||
events.push(event?);
|
||||
}
|
||||
Ok(events)
|
||||
}
|
||||
+248
-94
@@ -1,7 +1,7 @@
|
||||
use rusqlite::{Connection, params};
|
||||
use uuid::Uuid;
|
||||
use crate::models::Url;
|
||||
use chrono::Utc;
|
||||
use crate::models::{Url, LandingPage};
|
||||
use rusqlite::{params, Connection};
|
||||
use uuid::Uuid;
|
||||
|
||||
// Helper: Associate tags with a URL
|
||||
fn associate_tags(conn: &Connection, url_id: &str, tags: &[String]) -> rusqlite::Result<()> {
|
||||
@@ -11,20 +11,20 @@ fn associate_tags(conn: &Connection, url_id: &str, tags: &[String]) -> rusqlite:
|
||||
if tag_name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
// Insert tag if it doesn't exist
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO tags (id, name) VALUES (?1, ?2);",
|
||||
params![Uuid::new_v4().to_string(), tag_name],
|
||||
)?;
|
||||
|
||||
|
||||
// Get tag id
|
||||
let tag_id: String = conn.query_row(
|
||||
"SELECT id FROM tags WHERE name = ?1;",
|
||||
params![tag_name],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
|
||||
|
||||
// Insert association
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO url_tags (url_id, tag_id) VALUES (?1, ?2);",
|
||||
@@ -47,6 +47,31 @@ pub fn get_tags_for_url(conn: &Connection, url_id: &str) -> rusqlite::Result<Vec
|
||||
Ok(tags)
|
||||
}
|
||||
|
||||
/// The full column list used in all URL SELECT queries.
|
||||
const URL_COLUMNS: &str = "id, code, destination, title, description, status, created_at, updated_at, expires_at, expired, password_hash, last_status, last_latency_ms, max_access_count, access_count";
|
||||
|
||||
/// Build a Url struct from a row containing URL_COLUMNS in order.
|
||||
fn url_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Url> {
|
||||
Ok(Url {
|
||||
id: row.get(0)?,
|
||||
code: row.get(1)?,
|
||||
destination: row.get(2)?,
|
||||
title: row.get(3)?,
|
||||
description: row.get(4)?,
|
||||
status: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
updated_at: row.get(7)?,
|
||||
expires_at: row.get(8)?,
|
||||
expired: row.get::<_, i32>(9).unwrap_or(0) != 0,
|
||||
password_hash: row.get(10)?,
|
||||
last_status: row.get(11)?,
|
||||
last_latency_ms: row.get(12)?,
|
||||
max_access_count: row.get(13)?,
|
||||
access_count: row.get::<_, i64>(14).unwrap_or(0),
|
||||
tags: Vec::new(), // filled after query
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_url(
|
||||
conn: &Connection,
|
||||
code: &str,
|
||||
@@ -77,54 +102,84 @@ pub fn create_url(
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
tags: tags.to_vec(),
|
||||
expires_at: None,
|
||||
expired: false,
|
||||
password_hash: None,
|
||||
last_status: None,
|
||||
last_latency_ms: None,
|
||||
max_access_count: None,
|
||||
access_count: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a URL with all extended options.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn create_url_extended(
|
||||
conn: &Connection,
|
||||
code: &str,
|
||||
destination: &str,
|
||||
title: Option<&str>,
|
||||
description: Option<&str>,
|
||||
tags: &[String],
|
||||
expires_at: Option<&str>,
|
||||
password_hash: Option<&str>,
|
||||
max_access_count: Option<i64>,
|
||||
) -> rusqlite::Result<Url> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let status = "healthy".to_string();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO urls (id, code, destination, title, description, status, created_at, updated_at, expires_at, password_hash, max_access_count)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11);",
|
||||
params![id, code, destination, title, description, status, now, now, expires_at, password_hash, max_access_count],
|
||||
)?;
|
||||
|
||||
associate_tags(conn, &id, tags)?;
|
||||
|
||||
Ok(Url {
|
||||
id,
|
||||
code: code.to_string(),
|
||||
destination: destination.to_string(),
|
||||
title: title.map(|s| s.to_string()),
|
||||
description: description.map(|s| s.to_string()),
|
||||
status,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
tags: tags.to_vec(),
|
||||
expires_at: expires_at.map(|s| s.to_string()),
|
||||
expired: false,
|
||||
password_hash: password_hash.map(|s| s.to_string()),
|
||||
last_status: None,
|
||||
last_latency_ms: None,
|
||||
max_access_count,
|
||||
access_count: 0,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_url_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Option<Url>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, code, destination, title, description, status, created_at, updated_at FROM urls WHERE id = ?1;"
|
||||
)?;
|
||||
let sql = format!("SELECT {} FROM urls WHERE id = ?1;", URL_COLUMNS);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let mut rows = stmt.query(params![id])?;
|
||||
|
||||
if let Some(row) = rows.next()? {
|
||||
let url_id: String = row.get(0)?;
|
||||
let tags = get_tags_for_url(conn, &url_id)?;
|
||||
Ok(Some(Url {
|
||||
id: url_id,
|
||||
code: row.get(1)?,
|
||||
destination: row.get(2)?,
|
||||
title: row.get(3)?,
|
||||
description: row.get(4)?,
|
||||
status: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
updated_at: row.get(7)?,
|
||||
tags,
|
||||
}))
|
||||
let mut url = url_from_row(row)?;
|
||||
url.tags = get_tags_for_url(conn, &url.id)?;
|
||||
Ok(Some(url))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_url_by_code(conn: &Connection, code: &str) -> rusqlite::Result<Option<Url>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, code, destination, title, description, status, created_at, updated_at FROM urls WHERE code = ?1;"
|
||||
)?;
|
||||
let sql = format!("SELECT {} FROM urls WHERE code = ?1;", URL_COLUMNS);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let mut rows = stmt.query(params![code])?;
|
||||
|
||||
if let Some(row) = rows.next()? {
|
||||
let url_id: String = row.get(0)?;
|
||||
let tags = get_tags_for_url(conn, &url_id)?;
|
||||
Ok(Some(Url {
|
||||
id: url_id,
|
||||
code: row.get(1)?,
|
||||
destination: row.get(2)?,
|
||||
title: row.get(3)?,
|
||||
description: row.get(4)?,
|
||||
status: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
updated_at: row.get(7)?,
|
||||
tags,
|
||||
}))
|
||||
let mut url = url_from_row(row)?;
|
||||
url.tags = get_tags_for_url(conn, &url.id)?;
|
||||
Ok(Some(url))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
@@ -167,69 +222,60 @@ pub fn list_urls(
|
||||
tag_filter: Option<&str>,
|
||||
) -> rusqlite::Result<Vec<Url>> {
|
||||
let mut urls = Vec::new();
|
||||
|
||||
|
||||
if let Some(tag) = tag_filter {
|
||||
let tag_name = tag.trim().to_lowercase();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT u.id, u.code, u.destination, u.title, u.description, u.status, u.created_at, u.updated_at
|
||||
FROM urls u
|
||||
JOIN url_tags ut ON u.id = ut.url_id
|
||||
JOIN tags t ON ut.tag_id = t.id
|
||||
let sql = format!(
|
||||
"SELECT u.{} FROM urls u
|
||||
JOIN url_tags ut ON u.id = ut.url_id
|
||||
JOIN tags t ON ut.tag_id = t.id
|
||||
WHERE t.name = ?1
|
||||
ORDER BY u.created_at DESC LIMIT ?2 OFFSET ?3;"
|
||||
)?;
|
||||
let rows = stmt.query_map(params![tag_name, limit, offset], |row| {
|
||||
let url_id: String = row.get(0)?;
|
||||
Ok((url_id, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?, row.get(6)?, row.get(7)?))
|
||||
})?;
|
||||
|
||||
ORDER BY u.created_at DESC LIMIT ?2 OFFSET ?3;",
|
||||
URL_COLUMNS
|
||||
.replace("id,", "u.id,")
|
||||
.replace(", code", ", u.code")
|
||||
.replace(", destination", ", u.destination")
|
||||
.replace(", title", ", u.title")
|
||||
.replace(", description", ", u.description")
|
||||
.replace(", status", ", u.status")
|
||||
.replace(", created_at", ", u.created_at")
|
||||
.replace(", updated_at", ", u.updated_at")
|
||||
.replace(", expires_at", ", u.expires_at")
|
||||
.replace(", expired", ", u.expired")
|
||||
.replace(", password_hash", ", u.password_hash")
|
||||
.replace(", last_status", ", u.last_status")
|
||||
.replace(", last_latency_ms", ", u.last_latency_ms")
|
||||
.replace(", max_access_count", ", u.max_access_count")
|
||||
.replace(", access_count", ", u.access_count")
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(params![tag_name, limit, offset], url_from_row)?;
|
||||
|
||||
for r in rows {
|
||||
let (url_id, code, destination, title, description, status, created_at, updated_at) = r?;
|
||||
let tags = get_tags_for_url(conn, &url_id)?;
|
||||
urls.push(Url {
|
||||
id: url_id,
|
||||
code,
|
||||
destination,
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
created_at,
|
||||
updated_at,
|
||||
tags,
|
||||
});
|
||||
let mut url = r?;
|
||||
url.tags = get_tags_for_url(conn, &url.id)?;
|
||||
urls.push(url);
|
||||
}
|
||||
} else {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, code, destination, title, description, status, created_at, updated_at
|
||||
FROM urls ORDER BY created_at DESC LIMIT ?1 OFFSET ?2;"
|
||||
)?;
|
||||
let rows = stmt.query_map(params![limit, offset], |row| {
|
||||
let url_id: String = row.get(0)?;
|
||||
Ok((url_id, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?, row.get(6)?, row.get(7)?))
|
||||
})?;
|
||||
|
||||
let sql = format!(
|
||||
"SELECT {} FROM urls ORDER BY created_at DESC LIMIT ?1 OFFSET ?2;",
|
||||
URL_COLUMNS
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(params![limit, offset], url_from_row)?;
|
||||
|
||||
for r in rows {
|
||||
let (url_id, code, destination, title, description, status, created_at, updated_at) = r?;
|
||||
let tags = get_tags_for_url(conn, &url_id)?;
|
||||
urls.push(Url {
|
||||
id: url_id,
|
||||
code,
|
||||
destination,
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
created_at,
|
||||
updated_at,
|
||||
tags,
|
||||
});
|
||||
let mut url = r?;
|
||||
url.tags = get_tags_for_url(conn, &url.id)?;
|
||||
urls.push(url);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Ok(urls)
|
||||
}
|
||||
|
||||
pub fn list_urls_for_health_check(conn: &Connection) -> rusqlite::Result<Vec<(String, String)>> {
|
||||
let mut stmt = conn.prepare("SELECT id, destination FROM urls;")?;
|
||||
let mut stmt = conn.prepare("SELECT id, destination FROM urls WHERE expired = 0;")?;
|
||||
let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
|
||||
let mut res = Vec::new();
|
||||
for r in rows {
|
||||
@@ -247,13 +293,111 @@ pub fn update_url_health(conn: &Connection, id: &str, status: &str) -> rusqlite:
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update URL health with extended status and latency information.
|
||||
pub fn update_url_health_extended(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
status: &str,
|
||||
last_status: &str,
|
||||
latency_ms: Option<i64>,
|
||||
) -> rusqlite::Result<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
conn.execute(
|
||||
"UPDATE urls SET status = ?1, last_status = ?2, last_latency_ms = ?3, updated_at = ?4 WHERE id = ?5;",
|
||||
params![status, last_status, latency_ms, now, id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_url_counts(conn: &Connection) -> rusqlite::Result<(i64, i64, i64)> {
|
||||
let total: i64 = conn.query_row("SELECT COUNT(*) FROM urls;", [], |row| row.get(0))?;
|
||||
let active: i64 = conn.query_row("SELECT COUNT(*) FROM urls WHERE status IN ('healthy', 'suspect');", [], |row| row.get(0))?;
|
||||
let dead: i64 = conn.query_row("SELECT COUNT(*) FROM urls WHERE status = 'dead';", [], |row| row.get(0))?;
|
||||
let active: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM urls WHERE status IN ('healthy', 'suspect') AND expired = 0;",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
let dead: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM urls WHERE status = 'dead' OR expired = 1;",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
Ok((total, active, dead))
|
||||
}
|
||||
|
||||
/// Mark all URLs with expires_at < now as expired.
|
||||
pub fn expire_urls(conn: &Connection) -> rusqlite::Result<usize> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let count = conn.execute(
|
||||
"UPDATE urls SET expired = 1, updated_at = ?1 WHERE expires_at IS NOT NULL AND expires_at < ?1 AND expired = 0;",
|
||||
params![now],
|
||||
)?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Set a password hash on a URL.
|
||||
pub fn set_url_password(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
password_hash: &str,
|
||||
) -> rusqlite::Result<bool> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let count = conn.execute(
|
||||
"UPDATE urls SET password_hash = ?1, updated_at = ?2 WHERE id = ?3;",
|
||||
params![password_hash, now, id],
|
||||
)?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
/// Remove the password from a URL.
|
||||
pub fn remove_url_password(conn: &Connection, id: &str) -> rusqlite::Result<bool> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let count = conn.execute(
|
||||
"UPDATE urls SET password_hash = NULL, updated_at = ?1 WHERE id = ?2;",
|
||||
params![now, id],
|
||||
)?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
/// Atomically increment the access count and return the new value.
|
||||
pub fn increment_access_count(conn: &Connection, id: &str) -> rusqlite::Result<i64> {
|
||||
conn.execute(
|
||||
"UPDATE urls SET access_count = access_count + 1 WHERE id = ?1;",
|
||||
params![id],
|
||||
)?;
|
||||
conn.query_row(
|
||||
"SELECT access_count FROM urls WHERE id = ?1;",
|
||||
params![id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
}
|
||||
|
||||
/// Set expiry on a URL.
|
||||
pub fn set_url_expiry(conn: &Connection, id: &str, expires_at: &str) -> rusqlite::Result<bool> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let count = conn.execute(
|
||||
"UPDATE urls SET expires_at = ?1, updated_at = ?2 WHERE id = ?3;",
|
||||
params![expires_at, now, id],
|
||||
)?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
/// Get health status summary across all URLs.
|
||||
pub fn get_health_summary(conn: &Connection) -> rusqlite::Result<Vec<(String, i64)>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT COALESCE(last_status, status) AS health, COUNT(*) FROM urls GROUP BY health ORDER BY COUNT(*) DESC;"
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
|
||||
let mut res = Vec::new();
|
||||
for r in rows {
|
||||
res.push(r?);
|
||||
}
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
// --- Landing Page Operations (unchanged) ---
|
||||
|
||||
use crate::models::LandingPage;
|
||||
|
||||
pub fn create_landing_page(
|
||||
conn: &Connection,
|
||||
code: &str,
|
||||
@@ -283,7 +427,10 @@ pub fn create_landing_page(
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_landing_page_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Option<LandingPage>> {
|
||||
pub fn get_landing_page_by_id(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
) -> rusqlite::Result<Option<LandingPage>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, code, slug, title, html_content, state, created_at, updated_at FROM landing_pages WHERE id = ?1;"
|
||||
)?;
|
||||
@@ -305,7 +452,10 @@ pub fn get_landing_page_by_id(conn: &Connection, id: &str) -> rusqlite::Result<O
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_landing_page_by_code(conn: &Connection, code: &str) -> rusqlite::Result<Option<LandingPage>> {
|
||||
pub fn get_landing_page_by_code(
|
||||
conn: &Connection,
|
||||
code: &str,
|
||||
) -> rusqlite::Result<Option<LandingPage>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, code, slug, title, html_content, state, created_at, updated_at FROM landing_pages WHERE code = ?1;"
|
||||
)?;
|
||||
@@ -354,10 +504,14 @@ pub fn delete_landing_page(conn: &Connection, id: &str) -> rusqlite::Result<bool
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
pub fn list_landing_pages(conn: &Connection, limit: i64, offset: i64) -> rusqlite::Result<Vec<LandingPage>> {
|
||||
pub fn list_landing_pages(
|
||||
conn: &Connection,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> rusqlite::Result<Vec<LandingPage>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, code, slug, title, html_content, state, created_at, updated_at
|
||||
FROM landing_pages ORDER BY created_at DESC LIMIT ?1 OFFSET ?2;"
|
||||
FROM landing_pages ORDER BY created_at DESC LIMIT ?1 OFFSET ?2;",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![limit, offset], |row| {
|
||||
Ok(LandingPage {
|
||||
|
||||
+104
-11
@@ -27,7 +27,12 @@ pub fn run_migrations(
|
||||
|
||||
if current_version < target_version {
|
||||
for m in migrations.iter().filter(|m| m.version > current_version) {
|
||||
info!(database = db_name, version = m.version, name = m.name, "Applying migration");
|
||||
info!(
|
||||
database = db_name,
|
||||
version = m.version,
|
||||
name = m.name,
|
||||
"Applying migration"
|
||||
);
|
||||
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute_batch(m.sql)?;
|
||||
@@ -35,7 +40,12 @@ pub fn run_migrations(
|
||||
|
||||
crate::db::sqlite::set_user_version(conn, m.version as i32)?;
|
||||
|
||||
info!(database = db_name, version = m.version, name = m.name, "Migration completed");
|
||||
info!(
|
||||
database = db_name,
|
||||
version = m.version,
|
||||
name = m.name,
|
||||
"Migration completed"
|
||||
);
|
||||
|
||||
// Write audit record to system.db.migrations
|
||||
if let Some(sys_db_mutex) = system_db_opt {
|
||||
@@ -58,7 +68,11 @@ pub fn run_migrations(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
info!(database = db_name, version = current_version, "Database up to date");
|
||||
info!(
|
||||
database = db_name,
|
||||
version = current_version,
|
||||
"Database up to date"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -77,7 +91,10 @@ pub fn print_migration_plan(
|
||||
println!(" Current version: {current_version}");
|
||||
println!(" Target version: {target_version}");
|
||||
|
||||
let pending: Vec<&Migration> = migrations.iter().filter(|m| m.version > current_version).collect();
|
||||
let pending: Vec<&Migration> = migrations
|
||||
.iter()
|
||||
.filter(|m| m.version > current_version)
|
||||
.collect();
|
||||
|
||||
if pending.is_empty() {
|
||||
println!(" Status: up to date");
|
||||
@@ -94,11 +111,10 @@ pub fn print_migration_plan(
|
||||
// Migration definitions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub const ADMIN_MIGRATIONS: &[Migration] = &[
|
||||
Migration {
|
||||
version: 1,
|
||||
name: "initial_schema",
|
||||
sql: r#"
|
||||
pub const ADMIN_MIGRATIONS: &[Migration] = &[Migration {
|
||||
version: 1,
|
||||
name: "initial_schema",
|
||||
sql: r#"
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
@@ -140,8 +156,7 @@ pub const ADMIN_MIGRATIONS: &[Migration] = &[
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
"#,
|
||||
},
|
||||
];
|
||||
}];
|
||||
|
||||
pub const CONTENT_MIGRATIONS: &[Migration] = &[
|
||||
Migration {
|
||||
@@ -187,6 +202,49 @@ pub const CONTENT_MIGRATIONS: &[Migration] = &[
|
||||
CREATE INDEX IF NOT EXISTS idx_pages_code ON landing_pages(code);
|
||||
"#,
|
||||
},
|
||||
Migration {
|
||||
version: 2,
|
||||
name: "features_expansion",
|
||||
sql: r#"
|
||||
-- Expiring Links
|
||||
ALTER TABLE urls ADD COLUMN expires_at TEXT NULL;
|
||||
ALTER TABLE urls ADD COLUMN expired INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- Password Protected Links
|
||||
ALTER TABLE urls ADD COLUMN password_hash TEXT NULL;
|
||||
|
||||
-- Link Health Dashboard (extended columns)
|
||||
ALTER TABLE urls ADD COLUMN last_status TEXT;
|
||||
ALTER TABLE urls ADD COLUMN last_latency_ms INTEGER;
|
||||
|
||||
-- One-Time Links
|
||||
ALTER TABLE urls ADD COLUMN max_access_count INTEGER NULL;
|
||||
ALTER TABLE urls ADD COLUMN access_count INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- Smart Landing Pages / Link Preview
|
||||
CREATE TABLE IF NOT EXISTS link_preview (
|
||||
id TEXT PRIMARY KEY,
|
||||
url_id TEXT NOT NULL UNIQUE,
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
logo_url TEXT,
|
||||
button_text TEXT DEFAULT 'Continue',
|
||||
FOREIGN KEY(url_id) REFERENCES urls(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- QR Code style metadata
|
||||
CREATE TABLE IF NOT EXISTS qr_codes (
|
||||
id TEXT PRIMARY KEY,
|
||||
url_id TEXT NOT NULL,
|
||||
style TEXT NOT NULL DEFAULT 'default',
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(url_id) REFERENCES urls(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_urls_expired ON urls(expired);
|
||||
CREATE INDEX IF NOT EXISTS idx_urls_expires_at ON urls(expires_at);
|
||||
"#,
|
||||
},
|
||||
];
|
||||
|
||||
pub const ANALYTICS_MIGRATIONS: &[Migration] = &[
|
||||
@@ -241,6 +299,22 @@ pub const ANALYTICS_MIGRATIONS: &[Migration] = &[
|
||||
CREATE INDEX IF NOT EXISTS idx_visits_target ON visits(target_type, target_id);
|
||||
"#,
|
||||
},
|
||||
Migration {
|
||||
version: 2,
|
||||
name: "qr_access_log",
|
||||
sql: r#"
|
||||
CREATE TABLE IF NOT EXISTS qr_access_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
url_id TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
ip TEXT,
|
||||
user_agent TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_qr_access_url ON qr_access_log(url_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_qr_access_ts ON qr_access_log(timestamp);
|
||||
"#,
|
||||
},
|
||||
];
|
||||
|
||||
pub const SYSTEM_MIGRATIONS: &[Migration] = &[
|
||||
@@ -291,4 +365,23 @@ pub const SYSTEM_MIGRATIONS: &[Migration] = &[
|
||||
);
|
||||
"#,
|
||||
},
|
||||
Migration {
|
||||
version: 2,
|
||||
name: "audit_events",
|
||||
sql: r#"
|
||||
CREATE TABLE IF NOT EXISTS audit_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
actor TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
object_type TEXT NOT NULL,
|
||||
object_id TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
metadata TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_events(actor);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_events(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_events(action);
|
||||
"#,
|
||||
},
|
||||
];
|
||||
+48
-16
@@ -1,15 +1,20 @@
|
||||
use crate::config::Config;
|
||||
use crate::db::migrations::{
|
||||
run_migrations, ADMIN_MIGRATIONS, ANALYTICS_MIGRATIONS, CONTENT_MIGRATIONS, SYSTEM_MIGRATIONS,
|
||||
};
|
||||
use crate::db::sqlite::{enable_foreign_keys, enable_wal};
|
||||
use rusqlite::Connection;
|
||||
use std::fs;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use rusqlite::Connection;
|
||||
use crate::config::Config;
|
||||
use crate::db::migrations::{run_migrations, ADMIN_MIGRATIONS, CONTENT_MIGRATIONS, ANALYTICS_MIGRATIONS, SYSTEM_MIGRATIONS};
|
||||
use crate::db::sqlite::{enable_foreign_keys, enable_wal};
|
||||
|
||||
pub mod migrations;
|
||||
pub mod sqlite;
|
||||
pub mod admin;
|
||||
pub mod content;
|
||||
pub mod analytics;
|
||||
pub mod audit_events;
|
||||
pub mod content;
|
||||
pub mod migrations;
|
||||
pub mod preview;
|
||||
pub mod qr;
|
||||
pub mod sqlite;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Db {
|
||||
@@ -53,13 +58,25 @@ impl Db {
|
||||
enable_wal(&system_conn, "system")?;
|
||||
|
||||
// Enable foreign key support
|
||||
info!(database = "admin", "Enabling foreign key enforcement on admin.db");
|
||||
info!(
|
||||
database = "admin",
|
||||
"Enabling foreign key enforcement on admin.db"
|
||||
);
|
||||
enable_foreign_keys(&admin_conn, "admin")?;
|
||||
info!(database = "content", "Enabling foreign key enforcement on content.db");
|
||||
info!(
|
||||
database = "content",
|
||||
"Enabling foreign key enforcement on content.db"
|
||||
);
|
||||
enable_foreign_keys(&content_conn, "content")?;
|
||||
info!(database = "analytics", "Enabling foreign key enforcement on analytics.db");
|
||||
info!(
|
||||
database = "analytics",
|
||||
"Enabling foreign key enforcement on analytics.db"
|
||||
);
|
||||
enable_foreign_keys(&analytics_conn, "analytics")?;
|
||||
info!(database = "system", "Enabling foreign key enforcement on system.db");
|
||||
info!(
|
||||
database = "system",
|
||||
"Enabling foreign key enforcement on system.db"
|
||||
);
|
||||
enable_foreign_keys(&system_conn, "system")?;
|
||||
|
||||
// 1. Run migrations for system.db first, as it receives secondary audit records
|
||||
@@ -70,11 +87,26 @@ impl Db {
|
||||
|
||||
// 2. Run migrations for other databases with system.db logging
|
||||
info!("Running admin migrations");
|
||||
run_migrations(&mut admin_conn, "admin", ADMIN_MIGRATIONS, Some(&system_arc))?;
|
||||
run_migrations(
|
||||
&mut admin_conn,
|
||||
"admin",
|
||||
ADMIN_MIGRATIONS,
|
||||
Some(&system_arc),
|
||||
)?;
|
||||
info!("Running content migrations");
|
||||
run_migrations(&mut content_conn, "content", CONTENT_MIGRATIONS, Some(&system_arc))?;
|
||||
run_migrations(
|
||||
&mut content_conn,
|
||||
"content",
|
||||
CONTENT_MIGRATIONS,
|
||||
Some(&system_arc),
|
||||
)?;
|
||||
info!("Running analytics migrations");
|
||||
run_migrations(&mut analytics_conn, "analytics", ANALYTICS_MIGRATIONS, Some(&system_arc))?;
|
||||
run_migrations(
|
||||
&mut analytics_conn,
|
||||
"analytics",
|
||||
ANALYTICS_MIGRATIONS,
|
||||
Some(&system_arc),
|
||||
)?;
|
||||
|
||||
Ok(Self {
|
||||
admin: Arc::new(Mutex::new(admin_conn)),
|
||||
@@ -115,12 +147,12 @@ mod db_init_tests {
|
||||
let mut config = Config::load();
|
||||
config.data_dir = temp_dir.clone();
|
||||
let db = Db::init(&config);
|
||||
|
||||
|
||||
// Cleanup
|
||||
if temp_dir.exists() {
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
|
||||
assert!(db.is_ok(), "Failed to init DB: {:?}", db.err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
use crate::models::LinkPreview;
|
||||
use rusqlite::{params, Connection};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Insert or update a link preview for a URL.
|
||||
pub fn upsert_preview(
|
||||
conn: &Connection,
|
||||
url_id: &str,
|
||||
title: Option<&str>,
|
||||
description: Option<&str>,
|
||||
logo_url: Option<&str>,
|
||||
button_text: Option<&str>,
|
||||
) -> rusqlite::Result<LinkPreview> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let btn = button_text.unwrap_or("Continue");
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO link_preview (id, url_id, title, description, logo_url, button_text)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||
ON CONFLICT(url_id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
description = excluded.description,
|
||||
logo_url = excluded.logo_url,
|
||||
button_text = excluded.button_text;",
|
||||
params![id, url_id, title, description, logo_url, btn],
|
||||
)?;
|
||||
|
||||
// Return the current state (may have been an update with a different id)
|
||||
get_preview(conn, url_id)?.ok_or(rusqlite::Error::QueryReturnedNoRows)
|
||||
}
|
||||
|
||||
/// Get the link preview for a URL.
|
||||
pub fn get_preview(conn: &Connection, url_id: &str) -> rusqlite::Result<Option<LinkPreview>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, url_id, title, description, logo_url, button_text FROM link_preview WHERE url_id = ?1;"
|
||||
)?;
|
||||
let mut rows = stmt.query(params![url_id])?;
|
||||
|
||||
if let Some(row) = rows.next()? {
|
||||
Ok(Some(LinkPreview {
|
||||
id: row.get(0)?,
|
||||
url_id: row.get(1)?,
|
||||
title: row.get(2)?,
|
||||
description: row.get(3)?,
|
||||
logo_url: row.get(4)?,
|
||||
button_text: row
|
||||
.get::<_, Option<String>>(5)?
|
||||
.unwrap_or_else(|| "Continue".to_string()),
|
||||
}))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the link preview for a URL.
|
||||
pub fn delete_preview(conn: &Connection, url_id: &str) -> rusqlite::Result<bool> {
|
||||
let count = conn.execute(
|
||||
"DELETE FROM link_preview WHERE url_id = ?1;",
|
||||
params![url_id],
|
||||
)?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
use chrono::Utc;
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Log a QR code access event to the analytics database.
|
||||
pub fn log_qr_access(
|
||||
conn: &Connection,
|
||||
url_id: &str,
|
||||
ip: Option<&str>,
|
||||
user_agent: Option<&str>,
|
||||
) -> rusqlite::Result<()> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO qr_access_log (id, url_id, timestamp, ip, user_agent)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5);",
|
||||
params![id, url_id, now, ip, user_agent],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get QR scan count for a URL.
|
||||
pub fn get_qr_scan_count(conn: &Connection, url_id: &str) -> rusqlite::Result<i64> {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM qr_access_log WHERE url_id = ?1;",
|
||||
params![url_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
}
|
||||
|
||||
/// Get QR scan count for a URL by its code (joins with content.db — must be called on analytics db after lookup).
|
||||
pub fn get_qr_stats_for_url(
|
||||
conn: &Connection,
|
||||
url_id: &str,
|
||||
) -> rusqlite::Result<Vec<(String, String)>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT timestamp, ip FROM qr_access_log WHERE url_id = ?1 ORDER BY timestamp DESC LIMIT 100;"
|
||||
)?;
|
||||
let rows = stmt.query_map(params![url_id], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, Option<String>>(1)?.unwrap_or_default(),
|
||||
))
|
||||
})?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r?);
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Create or update a QR code style registration in the content database.
|
||||
pub fn upsert_qr_code(conn: &Connection, url_id: &str, style: &str) -> rusqlite::Result<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
// Check if entry already exists
|
||||
let existing_id: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT id FROM qr_codes WHERE url_id = ?1;",
|
||||
params![url_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
|
||||
if let Some(id) = existing_id {
|
||||
conn.execute(
|
||||
"UPDATE qr_codes SET style = ?1 WHERE id = ?2;",
|
||||
params![style, id],
|
||||
)?;
|
||||
} else {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
conn.execute(
|
||||
"INSERT INTO qr_codes (id, url_id, style, created_at) VALUES (?1, ?2, ?3, ?4);",
|
||||
params![id, url_id, style, now],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the style configured for a QR code.
|
||||
pub fn get_qr_code_style(conn: &Connection, url_id: &str) -> rusqlite::Result<String> {
|
||||
let style: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT style FROM qr_codes WHERE url_id = ?1;",
|
||||
params![url_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
Ok(style.unwrap_or_else(|| "default".to_string()))
|
||||
}
|
||||
+9
-5
@@ -14,8 +14,9 @@ use tracing::info;
|
||||
/// Uses `query_row` with `PRAGMA journal_mode=WAL` which both sets and returns
|
||||
/// the actual mode. Returns an error if the database does not confirm WAL mode.
|
||||
pub fn enable_wal(conn: &Connection, db_name: &str) -> Result<(), rusqlite::Error> {
|
||||
let actual_mode: String =
|
||||
conn.query_row("PRAGMA journal_mode=WAL;", [], |row| row.get::<_, String>(0))?;
|
||||
let actual_mode: String = conn.query_row("PRAGMA journal_mode=WAL;", [], |row| {
|
||||
row.get::<_, String>(0)
|
||||
})?;
|
||||
|
||||
info!(database = db_name, mode = %actual_mode, "WAL mode configured");
|
||||
|
||||
@@ -36,7 +37,11 @@ pub fn enable_foreign_keys(conn: &Connection, db_name: &str) -> Result<(), rusql
|
||||
let enabled: bool =
|
||||
conn.pragma_query_value(None, "foreign_keys", |row| row.get::<_, bool>(0))?;
|
||||
|
||||
info!(database = db_name, foreign_keys = enabled, "Foreign key enforcement configured");
|
||||
info!(
|
||||
database = db_name,
|
||||
foreign_keys = enabled,
|
||||
"Foreign key enforcement configured"
|
||||
);
|
||||
|
||||
if !enabled {
|
||||
return Err(rusqlite::Error::QueryReturnedNoRows);
|
||||
@@ -183,8 +188,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_collect_health_report() {
|
||||
let conn = memory_conn();
|
||||
let report =
|
||||
collect_health_report(&conn, "test").expect("Failed to collect health report");
|
||||
let report = collect_health_report(&conn, "test").expect("Failed to collect health report");
|
||||
assert_eq!(report.database, "test");
|
||||
assert!(report.integrity_ok);
|
||||
}
|
||||
|
||||
+60
-13
@@ -1,6 +1,6 @@
|
||||
use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use std::fmt;
|
||||
use std::path::PathBuf;
|
||||
@@ -12,17 +12,36 @@ use std::path::PathBuf;
|
||||
#[derive(Debug)]
|
||||
pub enum DatabaseInitError {
|
||||
/// Data directory could not be created or accessed
|
||||
DataDirCreate { path: PathBuf, source: std::io::Error },
|
||||
DataDirCreate {
|
||||
path: PathBuf,
|
||||
source: std::io::Error,
|
||||
},
|
||||
/// SQLite connection could not be opened
|
||||
ConnectionOpen { database: String, path: PathBuf, source: rusqlite::Error },
|
||||
ConnectionOpen {
|
||||
database: String,
|
||||
path: PathBuf,
|
||||
source: rusqlite::Error,
|
||||
},
|
||||
/// PRAGMA configuration failed (WAL, foreign_keys, etc.)
|
||||
PragmaConfig { database: String, pragma: String, source: rusqlite::Error },
|
||||
PragmaConfig {
|
||||
database: String,
|
||||
pragma: String,
|
||||
source: rusqlite::Error,
|
||||
},
|
||||
/// Migration execution failed
|
||||
MigrationFailed { database: String, version: u32, name: String, source: Box<dyn std::error::Error + Send + Sync> },
|
||||
MigrationFailed {
|
||||
database: String,
|
||||
version: u32,
|
||||
name: String,
|
||||
source: Box<dyn std::error::Error + Send + Sync>,
|
||||
},
|
||||
/// Database integrity check failed
|
||||
IntegrityCheckFailed { database: String, message: String },
|
||||
/// WAL mode could not be enabled (returned unexpected mode)
|
||||
WalModeFailed { database: String, actual_mode: String },
|
||||
WalModeFailed {
|
||||
database: String,
|
||||
actual_mode: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl fmt::Display for DatabaseInitError {
|
||||
@@ -31,20 +50,48 @@ impl fmt::Display for DatabaseInitError {
|
||||
Self::DataDirCreate { path, source } => {
|
||||
write!(f, "Failed to create data directory {:?}: {}", path, source)
|
||||
}
|
||||
Self::ConnectionOpen { database, path, source } => {
|
||||
write!(f, "Failed to open {}.db at {:?}: {}", database, path, source)
|
||||
Self::ConnectionOpen {
|
||||
database,
|
||||
path,
|
||||
source,
|
||||
} => {
|
||||
write!(
|
||||
f,
|
||||
"Failed to open {}.db at {:?}: {}",
|
||||
database, path, source
|
||||
)
|
||||
}
|
||||
Self::PragmaConfig { database, pragma, source } => {
|
||||
Self::PragmaConfig {
|
||||
database,
|
||||
pragma,
|
||||
source,
|
||||
} => {
|
||||
write!(f, "PRAGMA {} failed on {}.db: {}", pragma, database, source)
|
||||
}
|
||||
Self::MigrationFailed { database, version, name, source } => {
|
||||
write!(f, "Migration v{} ({}) failed on {}.db: {}", version, name, database, source)
|
||||
Self::MigrationFailed {
|
||||
database,
|
||||
version,
|
||||
name,
|
||||
source,
|
||||
} => {
|
||||
write!(
|
||||
f,
|
||||
"Migration v{} ({}) failed on {}.db: {}",
|
||||
version, name, database, source
|
||||
)
|
||||
}
|
||||
Self::IntegrityCheckFailed { database, message } => {
|
||||
write!(f, "Integrity check failed on {}.db: {}", database, message)
|
||||
}
|
||||
Self::WalModeFailed { database, actual_mode } => {
|
||||
write!(f, "WAL mode not enabled on {}.db (got '{}')", database, actual_mode)
|
||||
Self::WalModeFailed {
|
||||
database,
|
||||
actual_mode,
|
||||
} => {
|
||||
write!(
|
||||
f,
|
||||
"WAL mode not enabled on {}.db (got '{}')",
|
||||
database, actual_mode
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
use std::time::Duration;
|
||||
use tracing::{info, error};
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::db::Db;
|
||||
use super::{log_job_end, log_job_start};
|
||||
use crate::analytics::aggregate_day;
|
||||
use super::{log_job_start, log_job_end};
|
||||
use crate::db::Db;
|
||||
|
||||
pub async fn run_aggregator(db: Db, interval_mins: u64) {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(interval_mins * 60)).await;
|
||||
info!("Running background analytics aggregator...");
|
||||
|
||||
|
||||
let job_id = log_job_start(&db.system, "analytics_aggregator");
|
||||
match perform_aggregation(&db).await {
|
||||
Ok(_) => log_job_end(&db.system, &job_id, "success", None),
|
||||
|
||||
+17
-11
@@ -1,8 +1,8 @@
|
||||
use std::time::Duration;
|
||||
use tracing::{info, error};
|
||||
use crate::db::Db;
|
||||
use super::{log_job_end, log_job_start};
|
||||
use crate::config::Config;
|
||||
use super::{log_job_start, log_job_end};
|
||||
use crate::db::Db;
|
||||
use std::time::Duration;
|
||||
use tracing::{error, info};
|
||||
|
||||
pub async fn run_backup_scheduler(db: Db, config: Config) {
|
||||
if !config.backup_enabled {
|
||||
@@ -10,12 +10,15 @@ pub async fn run_backup_scheduler(db: Db, config: Config) {
|
||||
return;
|
||||
}
|
||||
|
||||
info!("Starting background backup scheduler (interval: {} mins)...", config.backup_interval_mins);
|
||||
info!(
|
||||
"Starting background backup scheduler (interval: {} mins)...",
|
||||
config.backup_interval_mins
|
||||
);
|
||||
loop {
|
||||
// Run backup every configured interval
|
||||
tokio::time::sleep(Duration::from_secs(config.backup_interval_mins * 60)).await;
|
||||
info!("Running background database backup...");
|
||||
|
||||
|
||||
let job_id = log_job_start(&db.system, "database_backup");
|
||||
match perform_backup(&db, &config).await {
|
||||
Ok(path) => {
|
||||
@@ -31,13 +34,16 @@ pub async fn run_backup_scheduler(db: Db, config: Config) {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn perform_backup(db: &Db, config: &Config) -> Result<String, Box<dyn std::error::Error>> {
|
||||
use std::fs::File;
|
||||
pub async fn perform_backup(
|
||||
db: &Db,
|
||||
config: &Config,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
use chrono::Utc;
|
||||
use flate2::write::GzEncoder;
|
||||
use flate2::Compression;
|
||||
use tar::Builder;
|
||||
use chrono::Utc;
|
||||
use rusqlite::params;
|
||||
use std::fs::File;
|
||||
use tar::Builder;
|
||||
use uuid::Uuid;
|
||||
|
||||
let out_dir = config.backup_dir.clone();
|
||||
@@ -60,7 +66,7 @@ pub async fn perform_backup(db: &Db, config: &Config) -> Result<String, Box<dyn
|
||||
tar.append_path_with_name(&db_file, f)?;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
tar.into_inner()?.finish()?;
|
||||
let size_bytes = std::fs::metadata(&tar_path)?.len();
|
||||
let path_str = tar_path.to_string_lossy().to_string();
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
use crate::db::Db;
|
||||
use std::time::Duration;
|
||||
use tracing::info;
|
||||
|
||||
/// Background job that marks expired URLs.
|
||||
///
|
||||
/// Runs every 60 seconds. Any URL with `expires_at < NOW()` and `expired = 0`
|
||||
/// gets flipped to `expired = 1`.
|
||||
pub async fn run_expiry_checker(db: Db) {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(60)).await;
|
||||
|
||||
let count = {
|
||||
let conn = db.content.lock().unwrap();
|
||||
crate::db::content::expire_urls(&conn).unwrap_or(0)
|
||||
};
|
||||
|
||||
if count > 0 {
|
||||
info!(expired_count = count, "Expired URLs marked");
|
||||
}
|
||||
}
|
||||
}
|
||||
+80
-23
@@ -1,17 +1,18 @@
|
||||
use reqwest::Client;
|
||||
use std::time::Duration;
|
||||
use tracing::{info, error};
|
||||
use uuid::Uuid;
|
||||
use chrono::Utc;
|
||||
use reqwest::Client;
|
||||
use rusqlite::params;
|
||||
use std::time::{Duration, Instant};
|
||||
use tracing::{error, info};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{log_job_end, log_job_start};
|
||||
use crate::db::Db;
|
||||
use super::{log_job_start, log_job_end};
|
||||
|
||||
pub async fn run_link_checker(db: Db, interval_mins: u64) {
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.user_agent("bzod-link-checker/0.1")
|
||||
.redirect(reqwest::redirect::Policy::limited(10))
|
||||
.build()
|
||||
.unwrap_or_default();
|
||||
|
||||
@@ -19,7 +20,7 @@ pub async fn run_link_checker(db: Db, interval_mins: u64) {
|
||||
// Sleep first to give server time to start up
|
||||
tokio::time::sleep(Duration::from_secs(interval_mins * 60)).await;
|
||||
info!("Running background link health check...");
|
||||
|
||||
|
||||
let job_id = log_job_start(&db.system, "link_checker");
|
||||
match perform_link_check(&db, &client).await {
|
||||
Ok(_) => log_job_end(&db.system, &job_id, "success", None),
|
||||
@@ -32,19 +33,29 @@ pub async fn run_link_checker(db: Db, interval_mins: u64) {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn perform_link_check(db: &Db, client: &Client) -> Result<(), Box<dyn std::error::Error>> {
|
||||
pub async fn perform_link_check(
|
||||
db: &Db,
|
||||
client: &Client,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let urls = {
|
||||
let conn = db.content.lock().unwrap();
|
||||
crate::db::content::list_urls_for_health_check(&conn)?
|
||||
};
|
||||
|
||||
for (id, dest) in urls {
|
||||
let (status, status_code, err_msg) = check_url_health(client, &dest).await;
|
||||
let (status, detail_status, status_code, latency_ms, err_msg) =
|
||||
check_url_health(client, &dest).await;
|
||||
{
|
||||
let conn = db.content.lock().unwrap();
|
||||
crate::db::content::update_url_health(&conn, &id, &status)?;
|
||||
crate::db::content::update_url_health_extended(
|
||||
&conn,
|
||||
&id,
|
||||
&status,
|
||||
&detail_status,
|
||||
Some(latency_ms),
|
||||
)?;
|
||||
}
|
||||
|
||||
|
||||
// Log to system.db.health_checks
|
||||
{
|
||||
let conn = db.system.lock().unwrap();
|
||||
@@ -64,31 +75,77 @@ pub async fn perform_link_check(db: &Db, client: &Client) -> Result<(), Box<dyn
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn check_url_health(client: &Client, url: &str) -> (String, Option<u16>, Option<String>) {
|
||||
let res = client.get(url)
|
||||
.timeout(Duration::from_secs(5))
|
||||
.send()
|
||||
.await;
|
||||
/// Check URL health with detailed classification and latency measurement.
|
||||
///
|
||||
/// Returns: (general_status, detail_status, status_code, latency_ms, error_message)
|
||||
async fn check_url_health(
|
||||
client: &Client,
|
||||
url: &str,
|
||||
) -> (String, String, Option<u16>, i64, Option<String>) {
|
||||
let start = Instant::now();
|
||||
let res = client.get(url).timeout(Duration::from_secs(5)).send().await;
|
||||
let latency_ms = start.elapsed().as_millis() as i64;
|
||||
|
||||
match res {
|
||||
Ok(response) => {
|
||||
let status = response.status();
|
||||
let code = status.as_u16();
|
||||
if status.is_success() || status.is_redirection() {
|
||||
("healthy".to_string(), Some(code), None)
|
||||
} else if status == reqwest::StatusCode::NOT_FOUND || status == reqwest::StatusCode::GONE {
|
||||
("dead".to_string(), Some(code), Some(format!("HTTP {}", code)))
|
||||
(
|
||||
"healthy".to_string(),
|
||||
"healthy".to_string(),
|
||||
Some(code),
|
||||
latency_ms,
|
||||
None,
|
||||
)
|
||||
} else if status == reqwest::StatusCode::NOT_FOUND
|
||||
|| status == reqwest::StatusCode::GONE
|
||||
{
|
||||
(
|
||||
"dead".to_string(),
|
||||
"dead".to_string(),
|
||||
Some(code),
|
||||
latency_ms,
|
||||
Some(format!("HTTP {}", code)),
|
||||
)
|
||||
} else {
|
||||
("suspect".to_string(), Some(code), Some(format!("HTTP {}", code)))
|
||||
(
|
||||
"suspect".to_string(),
|
||||
format!("http_{}", code),
|
||||
Some(code),
|
||||
latency_ms,
|
||||
Some(format!("HTTP {}", code)),
|
||||
)
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
let err_str = err.to_string();
|
||||
if err.is_timeout() || err.is_connect() {
|
||||
("suspect".to_string(), None, Some(err_str))
|
||||
let detail = if err.is_timeout() {
|
||||
"timeout".to_string()
|
||||
} else if err.is_connect() {
|
||||
if err_str.contains("dns") || err_str.contains("resolve") {
|
||||
"dns_failure".to_string()
|
||||
} else if err_str.contains("tls")
|
||||
|| err_str.contains("ssl")
|
||||
|| err_str.contains("certificate")
|
||||
{
|
||||
"tls_error".to_string()
|
||||
} else {
|
||||
"connection_refused".to_string()
|
||||
}
|
||||
} else if err.is_redirect() {
|
||||
"redirect_loop".to_string()
|
||||
} else {
|
||||
("dead".to_string(), None, Some(err_str))
|
||||
}
|
||||
"unknown_error".to_string()
|
||||
};
|
||||
|
||||
let general = if err.is_timeout() || err.is_connect() {
|
||||
"suspect"
|
||||
} else {
|
||||
"dead"
|
||||
};
|
||||
|
||||
(general.to_string(), detail, None, latency_ms, Some(err_str))
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-7
@@ -1,16 +1,18 @@
|
||||
use std::sync::Mutex;
|
||||
use rusqlite::{Connection, params};
|
||||
use uuid::Uuid;
|
||||
use chrono::Utc;
|
||||
use rusqlite::{params, Connection};
|
||||
use std::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub mod healthcheck;
|
||||
pub mod retention;
|
||||
pub mod aggregate;
|
||||
pub mod backup;
|
||||
pub mod expiry;
|
||||
pub mod healthcheck;
|
||||
pub mod retention;
|
||||
|
||||
pub use healthcheck::{run_link_checker, perform_link_check};
|
||||
pub use aggregate::{perform_aggregation, run_aggregator};
|
||||
pub use expiry::run_expiry_checker;
|
||||
pub use healthcheck::{perform_link_check, run_link_checker};
|
||||
pub use retention::run_retention_cleaner;
|
||||
pub use aggregate::{run_aggregator, perform_aggregation};
|
||||
|
||||
pub fn log_job_start(conn: &Mutex<Connection>, job_name: &str) -> String {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use std::time::Duration;
|
||||
use tracing::{info, error};
|
||||
use tracing::{error, info};
|
||||
|
||||
use super::{log_job_end, log_job_start};
|
||||
use crate::db::Db;
|
||||
use super::{log_job_start, log_job_end};
|
||||
|
||||
pub async fn run_retention_cleaner(db: Db, retention_days_opt: Option<i64>) {
|
||||
let retention_days = match retention_days_opt {
|
||||
@@ -14,7 +14,7 @@ pub async fn run_retention_cleaner(db: Db, retention_days_opt: Option<i64>) {
|
||||
// Check once every 24 hours
|
||||
tokio::time::sleep(Duration::from_secs(24 * 3600)).await;
|
||||
info!("Running background data retention cleanup...");
|
||||
|
||||
|
||||
let job_id = log_job_start(&db.system, "retention_cleaner");
|
||||
let conn = db.analytics.lock().unwrap();
|
||||
match crate::db::analytics::retention_cleanup(&conn, retention_days) {
|
||||
|
||||
+10
-10
@@ -1,14 +1,14 @@
|
||||
pub mod analytics;
|
||||
pub mod auth;
|
||||
pub mod charts;
|
||||
pub mod cli;
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod auth;
|
||||
pub mod analytics;
|
||||
pub mod jobs;
|
||||
pub mod services;
|
||||
pub mod utils;
|
||||
pub mod models;
|
||||
pub mod templates;
|
||||
pub mod charts;
|
||||
pub mod state;
|
||||
pub mod error;
|
||||
pub mod jobs;
|
||||
pub mod models;
|
||||
pub mod services;
|
||||
pub mod state;
|
||||
pub mod templates;
|
||||
pub mod utils;
|
||||
pub mod web;
|
||||
pub mod cli;
|
||||
+10
-4
@@ -1,20 +1,26 @@
|
||||
use bzod::cli::{Cli, Commands};
|
||||
use bzod::config::Config;
|
||||
use clap::Parser;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use bzod::config::Config;
|
||||
use bzod::cli::{Cli, Commands};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Set up tracing subscriber
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
|
||||
.with_env_filter(
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
|
||||
)
|
||||
.init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
let config = Config::load();
|
||||
|
||||
match cli.command {
|
||||
Commands::Serve { host, port, data_dir } => {
|
||||
Commands::Serve {
|
||||
host,
|
||||
port,
|
||||
data_dir,
|
||||
} => {
|
||||
bzod::cli::serve::run(host, port, data_dir, config).await?;
|
||||
}
|
||||
Commands::Backup { out, data_dir } => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct ApiKey {
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct AuditLog {
|
||||
|
||||
+8
-8
@@ -1,13 +1,13 @@
|
||||
pub mod user;
|
||||
pub mod url;
|
||||
pub mod page;
|
||||
pub mod visit;
|
||||
pub mod api_key;
|
||||
pub mod audit;
|
||||
pub mod page;
|
||||
pub mod url;
|
||||
pub mod user;
|
||||
pub mod visit;
|
||||
|
||||
pub use user::{User, Session};
|
||||
pub use url::Url;
|
||||
pub use page::LandingPage;
|
||||
pub use visit::{VisitRecord, SummaryEntry};
|
||||
pub use api_key::ApiKey;
|
||||
pub use audit::AuditLog;
|
||||
pub use page::LandingPage;
|
||||
pub use url::{AuditEvent, LinkPreview, QrCode, Url};
|
||||
pub use user::{Session, User};
|
||||
pub use visit::{SummaryEntry, VisitRecord};
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct LandingPage {
|
||||
|
||||
+64
-1
@@ -1,4 +1,4 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct Url {
|
||||
@@ -11,4 +11,67 @@ pub struct Url {
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub tags: Vec<String>,
|
||||
// --- Feature Expansion Fields ---
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expires_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub expired: bool,
|
||||
#[serde(skip_serializing)]
|
||||
pub password_hash: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_status: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_latency_ms: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_access_count: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub access_count: i64,
|
||||
}
|
||||
|
||||
impl Url {
|
||||
/// Returns true if this URL has a password set.
|
||||
pub fn is_password_protected(&self) -> bool {
|
||||
self.password_hash.is_some()
|
||||
}
|
||||
|
||||
/// Returns true if this URL has reached its access limit.
|
||||
pub fn is_access_exhausted(&self) -> bool {
|
||||
if let Some(max) = self.max_access_count {
|
||||
self.access_count >= max
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Link preview metadata for smart landing pages.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct LinkPreview {
|
||||
pub id: String,
|
||||
pub url_id: String,
|
||||
pub title: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub logo_url: Option<String>,
|
||||
pub button_text: String,
|
||||
}
|
||||
|
||||
/// QR code metadata record.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct QrCode {
|
||||
pub id: String,
|
||||
pub url_id: String,
|
||||
pub style: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// Audit event record for the enhanced audit trail.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct AuditEvent {
|
||||
pub id: String,
|
||||
pub actor: String,
|
||||
pub action: String,
|
||||
pub object_type: String,
|
||||
pub object_id: String,
|
||||
pub timestamp: String,
|
||||
pub metadata: Option<String>,
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct User {
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct VisitRecord {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::db::Db;
|
||||
use crate::models::ApiKey;
|
||||
use crate::error::AppError;
|
||||
use crate::models::ApiKey;
|
||||
|
||||
pub fn create_api_key(
|
||||
db: &Db,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::db::Db;
|
||||
use crate::models::AuditLog;
|
||||
use crate::error::AppError;
|
||||
use crate::models::AuditLog;
|
||||
|
||||
pub fn log_action(
|
||||
db: &Db,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
use crate::models::Url;
|
||||
use crate::services::qr::{generate_qr_png, generate_qr_svg};
|
||||
use std::io::Write;
|
||||
use zip::write::FileOptions;
|
||||
use zip::ZipWriter;
|
||||
|
||||
/// Export QR codes for the given URLs as a ZIP file.
|
||||
/// `format` can be "png" or "svg".
|
||||
/// `base_url` is used to build the full short URL encoded in the QR.
|
||||
pub fn export_qr_zip(
|
||||
urls: &[Url],
|
||||
format: &str,
|
||||
base_url: &str,
|
||||
) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut zip = ZipWriter::new(std::io::Cursor::new(&mut buf));
|
||||
let options =
|
||||
FileOptions::<()>::default().compression_method(zip::CompressionMethod::Deflated);
|
||||
|
||||
let mut csv_content = String::from("code,destination,qr_filename\n");
|
||||
|
||||
for url in urls {
|
||||
let full_url = format!("{}/{}", base_url.trim_end_matches('/'), url.code);
|
||||
let ext = if format == "svg" { "svg" } else { "png" };
|
||||
let filename = format!("{}.{}", url.code, ext);
|
||||
|
||||
let qr_data = if format == "svg" {
|
||||
generate_qr_svg(&full_url)?.into_bytes()
|
||||
} else {
|
||||
generate_qr_png(&full_url, 256)?
|
||||
};
|
||||
|
||||
zip.start_file(&filename, options)?;
|
||||
zip.write_all(&qr_data)?;
|
||||
|
||||
// Escape quotes in destination for CSV formatting
|
||||
let escaped_dest = url.destination.replace('"', "\"\"");
|
||||
csv_content.push_str(&format!("{},\"{}\",{}\n", url.code, escaped_dest, filename));
|
||||
}
|
||||
|
||||
zip.start_file("manifest.csv", options)?;
|
||||
zip.write_all(csv_content.as_bytes())?;
|
||||
|
||||
zip.finish()?;
|
||||
}
|
||||
|
||||
Ok(buf)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::db::Db;
|
||||
use crate::models::LandingPage;
|
||||
use crate::error::AppError;
|
||||
use crate::models::LandingPage;
|
||||
|
||||
pub fn create_landing_page(
|
||||
db: &Db,
|
||||
@@ -11,7 +11,8 @@ pub fn create_landing_page(
|
||||
state: &str,
|
||||
) -> Result<LandingPage, AppError> {
|
||||
let conn = db.content.lock().unwrap();
|
||||
let page = crate::db::content::create_landing_page(&conn, code, slug, title, html_content, state)?;
|
||||
let page =
|
||||
crate::db::content::create_landing_page(&conn, code, slug, title, html_content, state)?;
|
||||
Ok(page)
|
||||
}
|
||||
|
||||
|
||||
+4
-2
@@ -1,4 +1,6 @@
|
||||
pub mod shortener;
|
||||
pub mod landing_pages;
|
||||
pub mod api_keys;
|
||||
pub mod audit;
|
||||
pub mod bulk;
|
||||
pub mod landing_pages;
|
||||
pub mod qr;
|
||||
pub mod shortener;
|
||||
@@ -0,0 +1,58 @@
|
||||
//! QR code generation service.
|
||||
//!
|
||||
//! Generates QR codes on-demand as PNG or SVG. No files are stored on disk.
|
||||
|
||||
use image::Luma;
|
||||
use qrcode::QrCode;
|
||||
use std::io::Cursor;
|
||||
|
||||
/// Generate a QR code as a PNG byte vector.
|
||||
///
|
||||
/// The `url` is encoded into the QR matrix. The `size` parameter controls
|
||||
/// the pixel dimensions of the output image (default: 256).
|
||||
pub fn generate_qr_png(url: &str, size: u32) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
|
||||
let code = QrCode::new(url.as_bytes())?;
|
||||
let image = code.render::<Luma<u8>>().min_dimensions(size, size).build();
|
||||
|
||||
let mut buf = Vec::new();
|
||||
let mut cursor = Cursor::new(&mut buf);
|
||||
image.write_to(&mut cursor, image::ImageFormat::Png)?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Generate a QR code as an SVG string.
|
||||
pub fn generate_qr_svg(url: &str) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let code = QrCode::new(url.as_bytes())?;
|
||||
let svg = code
|
||||
.render::<qrcode::render::svg::Color>()
|
||||
.min_dimensions(256, 256)
|
||||
.build();
|
||||
Ok(svg)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_qr_png_generation() {
|
||||
let png = generate_qr_png("https://bzo.in/abc123", 256).unwrap();
|
||||
assert!(!png.is_empty());
|
||||
// PNG magic bytes
|
||||
assert_eq!(&png[..4], &[0x89, b'P', b'N', b'G']);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_qr_svg_generation() {
|
||||
let svg = generate_qr_svg("https://bzo.in/abc123").unwrap();
|
||||
assert!(svg.contains("<svg"));
|
||||
assert!(svg.contains("</svg>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_qr_long_url() {
|
||||
let long_url = format!("https://example.com/{}", "a".repeat(500));
|
||||
let png = generate_qr_png(&long_url, 512).unwrap();
|
||||
assert!(!png.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::db::Db;
|
||||
use crate::models::Url;
|
||||
use crate::error::AppError;
|
||||
use crate::models::Url;
|
||||
|
||||
pub fn create_url(
|
||||
db: &Db,
|
||||
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
use crate::analytics::queue::AnalyticsQueue;
|
||||
use crate::config::Config;
|
||||
use crate::db::Db;
|
||||
use rusqlite::Connection;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
use rusqlite::Connection;
|
||||
use crate::config::Config;
|
||||
use crate::analytics::queue::AnalyticsQueue;
|
||||
use crate::db::Db;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
response::{IntoResponse, Response, Html},
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
|
||||
#[derive(Template)]
|
||||
@@ -23,7 +23,11 @@ impl IntoResponse for DashboardTemplate {
|
||||
fn into_response(self) -> Response {
|
||||
match self.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Render error: {}", e)).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Render error: {}", e),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
-6
@@ -1,19 +1,19 @@
|
||||
pub mod dashboard;
|
||||
pub mod urls;
|
||||
pub mod pages;
|
||||
pub mod stats;
|
||||
pub mod settings;
|
||||
pub mod stats;
|
||||
pub mod urls;
|
||||
|
||||
pub use dashboard::DashboardTemplate;
|
||||
pub use urls::UrlsTemplate;
|
||||
pub use pages::PagesTemplate;
|
||||
pub use stats::{StatusTemplate, AuditTemplate};
|
||||
pub use settings::SettingsTemplate;
|
||||
pub use stats::{AuditTemplate, StatusTemplate};
|
||||
pub use urls::UrlsTemplate;
|
||||
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
response::{IntoResponse, Response, Html},
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
|
||||
#[derive(Template)]
|
||||
@@ -27,7 +27,55 @@ impl IntoResponse for LoginTemplate {
|
||||
fn into_response(self) -> Response {
|
||||
match self.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Render error: {}", e)).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Render error: {}", e),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "gate.html")]
|
||||
pub struct GateTemplate {
|
||||
pub code: String,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl IntoResponse for GateTemplate {
|
||||
fn into_response(self) -> Response {
|
||||
match self.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Render error: {}", e),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "preview.html")]
|
||||
pub struct PreviewTemplate {
|
||||
pub code: String,
|
||||
pub title: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub logo_url: Option<String>,
|
||||
pub button_text: String,
|
||||
pub destination: String,
|
||||
}
|
||||
|
||||
impl IntoResponse for PreviewTemplate {
|
||||
fn into_response(self) -> Response {
|
||||
match self.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Render error: {}", e),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
use crate::models::LandingPage;
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
response::{IntoResponse, Response, Html},
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
use crate::models::LandingPage;
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "pages.html")]
|
||||
@@ -18,7 +18,11 @@ impl IntoResponse for PagesTemplate {
|
||||
fn into_response(self) -> Response {
|
||||
match self.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Render error: {}", e)).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Render error: {}", e),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
use crate::models::ApiKey;
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
response::{IntoResponse, Response, Html},
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
use crate::models::ApiKey;
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "settings.html")]
|
||||
@@ -20,7 +20,11 @@ impl IntoResponse for SettingsTemplate {
|
||||
fn into_response(self) -> Response {
|
||||
match self.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Render error: {}", e)).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Render error: {}", e),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
-4
@@ -1,9 +1,9 @@
|
||||
use crate::models::AuditLog;
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
response::{IntoResponse, Response, Html},
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
use crate::models::AuditLog;
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "status_ui.html")]
|
||||
@@ -16,6 +16,7 @@ pub struct StatusTemplate {
|
||||
pub uptime: String,
|
||||
pub version: &'static str,
|
||||
pub git_commit: &'static str,
|
||||
pub urls: Vec<crate::models::Url>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
@@ -29,7 +30,11 @@ impl IntoResponse for StatusTemplate {
|
||||
fn into_response(self) -> Response {
|
||||
match self.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Render error: {}", e)).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Render error: {}", e),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,7 +43,11 @@ impl IntoResponse for AuditTemplate {
|
||||
fn into_response(self) -> Response {
|
||||
match self.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Render error: {}", e)).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Render error: {}", e),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
use crate::models::Url;
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
response::{IntoResponse, Response, Html},
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
use crate::models::Url;
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "urls.html")]
|
||||
@@ -13,13 +13,18 @@ pub struct UrlsTemplate {
|
||||
pub csrf_token: String,
|
||||
pub error: Option<String>,
|
||||
pub tag_filter: Option<String>,
|
||||
pub base_url: String,
|
||||
}
|
||||
|
||||
impl IntoResponse for UrlsTemplate {
|
||||
fn into_response(self) -> Response {
|
||||
match self.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Render error: {}", e)).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Render error: {}", e),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use sha2::{Sha256, Digest};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
// General SHA-256 hash helper
|
||||
pub fn sha256_hash(data: &str) -> String {
|
||||
|
||||
+4
-4
@@ -1,11 +1,11 @@
|
||||
pub mod time;
|
||||
pub mod system;
|
||||
pub mod hashing;
|
||||
pub mod network;
|
||||
pub mod random;
|
||||
pub mod system;
|
||||
pub mod time;
|
||||
|
||||
pub use time::format_duration;
|
||||
pub use system::{get_memory_usage, get_db_file_info};
|
||||
pub use hashing::sha256_hash;
|
||||
pub use network::get_client_ip;
|
||||
pub use random::generate_token;
|
||||
pub use system::{get_db_file_info, get_memory_usage};
|
||||
pub use time::format_duration;
|
||||
@@ -1,12 +1,12 @@
|
||||
use axum::{extract::ConnectInfo, http::HeaderMap};
|
||||
use std::net::SocketAddr;
|
||||
use axum::{
|
||||
extract::ConnectInfo,
|
||||
http::HeaderMap,
|
||||
};
|
||||
|
||||
// Extract client IP address from proxy headers or connection info
|
||||
pub fn get_client_ip(headers: &HeaderMap, connect_info: Option<ConnectInfo<SocketAddr>>) -> String {
|
||||
if let Some(ip) = headers.get("cf-connecting-ip").and_then(|h| h.to_str().ok()) {
|
||||
if let Some(ip) = headers
|
||||
.get("cf-connecting-ip")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
{
|
||||
return ip.to_string();
|
||||
}
|
||||
if let Some(ip) = headers.get("x-real-ip").and_then(|h| h.to_str().ok()) {
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
use rand::{RngCore, thread_rng};
|
||||
use rand::{thread_rng, RngCore};
|
||||
|
||||
// Generate a secure random token (hex-encoded)
|
||||
pub fn generate_token(bytes_len: usize) -> String {
|
||||
|
||||
+6
-1
@@ -30,7 +30,12 @@ pub fn get_db_file_info(data_dir: &Path) -> String {
|
||||
if path.exists() {
|
||||
if let Ok(metadata) = std::fs::metadata(&path) {
|
||||
let size = metadata.len();
|
||||
stats.push_str(&format!("{}: {} bytes ({:.2} MB)\n", name, size, size as f64 / 1_048_576.0));
|
||||
stats.push_str(&format!(
|
||||
"{}: {} bytes ({:.2} MB)\n",
|
||||
name,
|
||||
size,
|
||||
size as f64 / 1_048_576.0
|
||||
));
|
||||
}
|
||||
} else {
|
||||
stats.push_str(&format!("{}: File not created yet\n", name));
|
||||
|
||||
+499
-107
@@ -1,38 +1,77 @@
|
||||
use axum::{
|
||||
extract::{Path, State, Query, ConnectInfo},
|
||||
extract::{ConnectInfo, Path, Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{Redirect, Response, IntoResponse},
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
Form,
|
||||
};
|
||||
use rusqlite::params;
|
||||
use axum_extra::extract::CookieJar;
|
||||
use axum_extra::extract::cookie::Cookie;
|
||||
use serde::Deserialize;
|
||||
use std::net::SocketAddr;
|
||||
use axum_extra::extract::CookieJar;
|
||||
use chrono::Utc;
|
||||
use tar::Builder;
|
||||
use flate2::write::GzEncoder;
|
||||
use flate2::Compression;
|
||||
use rusqlite::params;
|
||||
use serde::Deserialize;
|
||||
use std::net::SocketAddr;
|
||||
use tar::Builder;
|
||||
|
||||
use crate::db::admin::{
|
||||
create_user, get_user_count, get_user_by_username, create_session, delete_session,
|
||||
write_audit_log, list_audit_logs, list_api_keys, create_api_key, delete_api_key, set_config, get_config
|
||||
create_api_key, create_session, create_user, delete_api_key, delete_session, get_config,
|
||||
get_user_by_username, get_user_count, list_api_keys, set_config,
|
||||
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<crate::models::AuditLog> {
|
||||
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_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::{
|
||||
get_clicks_trend, get_clicks_trend_raw, get_metric_rankings, get_metric_rankings_raw,
|
||||
get_total_clicks,
|
||||
};
|
||||
use crate::db::content::{
|
||||
list_urls, create_url, delete_url, get_url_counts, get_landing_page_count,
|
||||
list_landing_pages, create_landing_page, delete_landing_page
|
||||
create_landing_page, delete_landing_page, delete_url, get_landing_page_count, get_url_counts,
|
||||
list_landing_pages, list_urls,
|
||||
};
|
||||
use crate::db::analytics::{
|
||||
get_total_clicks, get_clicks_trend, get_clicks_trend_raw, get_metric_rankings, get_metric_rankings_raw
|
||||
};
|
||||
use crate::auth::{
|
||||
authenticate_session, verify_password, verify_sha256, hash_password, generate_token,
|
||||
generate_csrf_token, verify_csrf
|
||||
};
|
||||
use crate::charts::{generate_line_chart, generate_bar_chart};
|
||||
use crate::state::AppState;
|
||||
use crate::models::User;
|
||||
use crate::utils::{get_client_ip, get_memory_usage, get_db_file_info};
|
||||
use crate::state::AppState;
|
||||
use crate::utils::{get_client_ip, get_db_file_info, get_memory_usage};
|
||||
|
||||
// Helper: Verify session and return user or redirect to login
|
||||
async fn require_auth(state: &AppState, jar: &CookieJar) -> Result<(User, String), Redirect> {
|
||||
@@ -44,10 +83,7 @@ async fn require_auth(state: &AppState, jar: &CookieJar) -> Result<(User, String
|
||||
}
|
||||
|
||||
// GET /admin
|
||||
pub async fn admin_index(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
) -> Response {
|
||||
pub async fn admin_index(State(state): State<AppState>, jar: CookieJar) -> Response {
|
||||
match require_auth(&state, &jar).await {
|
||||
Ok(_) => Redirect::to("/admin/dashboard").into_response(),
|
||||
Err(redir) => redir.into_response(),
|
||||
@@ -62,7 +98,7 @@ pub async fn login_get(
|
||||
) -> Response {
|
||||
let error = params.get("error").cloned();
|
||||
let csrf_token = generate_token(16);
|
||||
|
||||
|
||||
let mut new_jar = jar.clone();
|
||||
new_jar = new_jar.add(
|
||||
Cookie::build(("bzod_temp_csrf", csrf_token.clone()))
|
||||
@@ -70,7 +106,7 @@ pub async fn login_get(
|
||||
.secure(state.config.cookie_secure)
|
||||
.http_only(true)
|
||||
.same_site(axum_extra::extract::cookie::SameSite::Strict)
|
||||
.build()
|
||||
.build(),
|
||||
);
|
||||
|
||||
let template = crate::templates::LoginTemplate { error, csrf_token };
|
||||
@@ -92,7 +128,10 @@ pub async fn login_post(
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Form(form): Form<LoginForm>,
|
||||
) -> Response {
|
||||
let temp_csrf = jar.get("bzod_temp_csrf").map(|c| c.value().to_string()).unwrap_or_default();
|
||||
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();
|
||||
}
|
||||
@@ -106,16 +145,30 @@ pub async fn login_post(
|
||||
|
||||
let user_opt = if user_count == 0 {
|
||||
// Bootstrap Phase using BOOTSTRAP_PASSWORD_SHA256
|
||||
if form.username == state.config.admin_username && verify_sha256(&form.password, &state.config.bootstrap_password_sha256) {
|
||||
if form.username == state.config.admin_username
|
||||
&& verify_sha256(&form.password, &state.config.bootstrap_password_sha256)
|
||||
{
|
||||
let hash = match hash_password(&form.password) {
|
||||
Ok(h) => h,
|
||||
Err(_) => return Redirect::to("/admin/login?error=Internal hashing error").into_response(),
|
||||
Err(_) => {
|
||||
return Redirect::to("/admin/login?error=Internal hashing error")
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
match create_user(&conn, &form.username, &hash) {
|
||||
Ok(u) => {
|
||||
let _ = write_audit_log(&conn, &u.username, "BOOTSTRAP_USER_PROVISIONED", Some("user"), Some(&u.id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()));
|
||||
let _ = write_audit_log(
|
||||
&conn,
|
||||
&state,
|
||||
&u.username,
|
||||
"BOOTSTRAP_USER_PROVISIONED",
|
||||
Some("user"),
|
||||
Some(&u.id),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
Some(u)
|
||||
}
|
||||
Err(_) => None,
|
||||
@@ -142,11 +195,20 @@ pub async fn login_post(
|
||||
Some(user) => {
|
||||
let session_token = generate_token(32);
|
||||
let expires = (Utc::now() + chrono::Duration::days(30)).to_rfc3339();
|
||||
|
||||
|
||||
{
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
let _ = create_session(&conn, &session_token, &user.id, &expires);
|
||||
let _ = write_audit_log(&conn, &user.username, "USER_LOGIN", Some("session"), Some(&session_token), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()));
|
||||
let _ = write_audit_log(
|
||||
&conn,
|
||||
&state,
|
||||
&user.username,
|
||||
"USER_LOGIN",
|
||||
Some("session"),
|
||||
Some(&session_token),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
}
|
||||
|
||||
let cookie = Cookie::build(("bzod_session", session_token))
|
||||
@@ -170,7 +232,16 @@ pub async fn login_post(
|
||||
None => {
|
||||
{
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(&conn, "anonymous", "LOGIN_FAILED", None, None, Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()));
|
||||
let _ = write_audit_log(
|
||||
&conn,
|
||||
&state,
|
||||
"anonymous",
|
||||
"LOGIN_FAILED",
|
||||
None,
|
||||
None,
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
}
|
||||
Redirect::to("/admin/login?error=Invalid username or password").into_response()
|
||||
}
|
||||
@@ -178,10 +249,7 @@ pub async fn login_post(
|
||||
}
|
||||
|
||||
// GET /admin/logout
|
||||
pub async fn logout(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
) -> Response {
|
||||
pub async fn logout(State(state): State<AppState>, jar: CookieJar) -> Response {
|
||||
if let Ok((_, session_id)) = require_auth(&state, &jar).await {
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
let _ = delete_session(&conn, &session_id);
|
||||
@@ -199,10 +267,7 @@ pub async fn logout(
|
||||
}
|
||||
|
||||
// GET /admin/dashboard
|
||||
pub async fn dashboard_get(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
) -> Response {
|
||||
pub async fn dashboard_get(State(state): State<AppState>, jar: CookieJar) -> Response {
|
||||
let (user, _) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
@@ -229,10 +294,12 @@ pub async fn dashboard_get(
|
||||
.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();
|
||||
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 {
|
||||
@@ -277,7 +344,7 @@ pub async fn dashboard_get(
|
||||
browsers_chart,
|
||||
referrers_chart,
|
||||
};
|
||||
|
||||
|
||||
template.into_response()
|
||||
}
|
||||
|
||||
@@ -305,12 +372,24 @@ pub async fn urls_get(
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
template.into_response()
|
||||
@@ -324,6 +403,9 @@ pub struct CreateUrlForm {
|
||||
pub description: String,
|
||||
pub tags: String,
|
||||
pub csrf_token: String,
|
||||
pub expires_at: String,
|
||||
pub password: String,
|
||||
pub max_access_count: String,
|
||||
}
|
||||
|
||||
// POST /admin/urls/create
|
||||
@@ -349,38 +431,97 @@ pub async fn urls_create(
|
||||
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();
|
||||
return Redirect::to("/admin/urls?error=Custom code must be exactly 6 hex characters")
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let tags_list: Vec<String> = form.tags
|
||||
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::<i64>() {
|
||||
Ok(c) => Some(c),
|
||||
Err(_) => {
|
||||
return Redirect::to("/admin/urls?error=Invalid max access count").into_response()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let tags_list: Vec<String> = 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 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 = state.content_db.lock().unwrap();
|
||||
create_url(&conn, &code, &form.destination, title_opt, desc_opt, &tags_list)
|
||||
crate::db::content::create_url_extended(
|
||||
&conn,
|
||||
&code,
|
||||
&form.destination,
|
||||
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 conn = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(&conn, &user.username, "URL_CREATION", Some("url"), Some(&url.id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()));
|
||||
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(rusqlite::Error::SqliteFailure(err, _)) if err.code == rusqlite::ErrorCode::ConstraintViolation => {
|
||||
Err(rusqlite::Error::SqliteFailure(err, _))
|
||||
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
|
||||
{
|
||||
Redirect::to("/admin/urls?error=Short code already exists").into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
Redirect::to(&format!("/admin/urls?error=Database error: {}", e)).into_response()
|
||||
}
|
||||
Err(e) => Redirect::to(&format!("/admin/urls?error=Database error: {}", e)).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,11 +551,22 @@ pub async fn urls_delete(
|
||||
Ok(_) => {
|
||||
{
|
||||
let conn_admin = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(&conn_admin, &user.username, "URL_DELETION", Some("url"), Some(&id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()));
|
||||
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(),
|
||||
Err(e) => {
|
||||
Redirect::to(&format!("/admin/urls?error=Failed to delete link: {}", e)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -484,7 +636,8 @@ pub async fn pages_create(
|
||||
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();
|
||||
return Redirect::to("/admin/pages?error=Custom code must be exactly 4 hex characters")
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,18 +648,36 @@ pub async fn pages_create(
|
||||
|
||||
let res = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
create_landing_page(&conn, &code, &clean_slug, &form.title, &form.html_content, &form.state)
|
||||
create_landing_page(
|
||||
&conn,
|
||||
&code,
|
||||
&clean_slug,
|
||||
&form.title,
|
||||
&form.html_content,
|
||||
&form.state,
|
||||
)
|
||||
};
|
||||
|
||||
match res {
|
||||
Ok(page) => {
|
||||
{
|
||||
let conn_admin = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(&conn_admin, &user.username, "PAGE_CREATION", Some("page"), Some(&page.id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()));
|
||||
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(rusqlite::Error::SqliteFailure(err, _)) if err.code == rusqlite::ErrorCode::ConstraintViolation => {
|
||||
Err(rusqlite::Error::SqliteFailure(err, _))
|
||||
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
|
||||
{
|
||||
Redirect::to("/admin/pages?error=Short code already exists").into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -541,11 +712,21 @@ pub async fn pages_delete(
|
||||
Ok(_) => {
|
||||
{
|
||||
let conn_admin = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(&conn_admin, &user.username, "PAGE_DELETION", Some("page"), Some(&id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()));
|
||||
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(),
|
||||
Err(e) => Redirect::to(&format!("/admin/pages?error=Failed to delete page: {}", e))
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -575,7 +756,13 @@ pub async fn settings_get(
|
||||
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()))
|
||||
.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);
|
||||
@@ -620,7 +807,16 @@ pub async fn change_password_post(
|
||||
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
if !verify_password(&form.current_password, &user.password_hash) {
|
||||
let _ = write_audit_log(&conn, &user.username, "PASSWORD_CHANGE_FAIL", Some("user"), Some(&user.id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()));
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -629,15 +825,29 @@ pub async fn change_password_post(
|
||||
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]);
|
||||
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, &user.username, "PASSWORD_CHANGE_SUCCESS", Some("user"), Some(&user.id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().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()
|
||||
}
|
||||
Err(e) => Redirect::to(&format!(
|
||||
"/admin/settings?error=Failed to update password: {}",
|
||||
e
|
||||
))
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -669,10 +879,21 @@ pub async fn change_retention_post(
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
match set_config(&conn, "retention_days", &form.retention) {
|
||||
Ok(_) => {
|
||||
let _ = write_audit_log(&conn, &user.username, "RETENTION_POLICY_CHANGED", Some("config"), Some("retention_days"), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().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(),
|
||||
Err(e) => {
|
||||
Redirect::to(&format!("/admin/settings?error=Database error: {}", e)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -693,10 +914,22 @@ pub async fn compact_db_post(
|
||||
match state.db_compact() {
|
||||
Ok(_) => {
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(&conn, &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()
|
||||
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()
|
||||
}
|
||||
Err(e) => Redirect::to(&format!("/admin/settings?error=Failed to compact: {}", e)).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -719,7 +952,7 @@ pub async fn download_backup(
|
||||
let res = {
|
||||
let enc = GzEncoder::new(&mut buffer, Compression::default());
|
||||
let mut tar = Builder::new(enc);
|
||||
|
||||
|
||||
let files = vec!["admin.db", "content.db", "analytics.db", "system.db"];
|
||||
let mut add_err = None;
|
||||
for f in files {
|
||||
@@ -731,15 +964,13 @@ pub async fn download_backup(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
match add_err {
|
||||
Some(e) => Err(e),
|
||||
None => {
|
||||
match tar.into_inner().and_then(|encoder| encoder.finish()) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
None => match tar.into_inner().and_then(|encoder| encoder.finish()) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(e),
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -747,7 +978,16 @@ pub async fn download_backup(
|
||||
Ok(_) => {
|
||||
{
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(&conn, &user.username, "DATABASE_BACKUP", Some("system"), Some("tarball"), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()));
|
||||
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();
|
||||
@@ -757,10 +997,14 @@ pub async fn download_backup(
|
||||
StatusCode::OK,
|
||||
[
|
||||
("Content-Type", "application/gzip"),
|
||||
("Content-Disposition", &format!("attachment; filename=\"{}\"", filename)),
|
||||
(
|
||||
"Content-Disposition",
|
||||
&format!("attachment; filename=\"{}\"", filename),
|
||||
),
|
||||
],
|
||||
buffer,
|
||||
).into_response()
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
Redirect::to(&format!("/admin/settings?error=Backup failed: {}", e)).into_response()
|
||||
@@ -793,8 +1037,8 @@ pub async fn create_api_key_post(
|
||||
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let key_secret = format!("bzo_{}", generate_token(16));
|
||||
|
||||
use sha2::{Sha256, Digest};
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(key_secret.as_bytes());
|
||||
let hashed_key = hex::encode(hasher.finalize());
|
||||
@@ -802,13 +1046,24 @@ pub async fn create_api_key_post(
|
||||
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, &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()));
|
||||
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(),
|
||||
Err(e) => {
|
||||
Redirect::to(&format!("/admin/settings?error=Database error: {}", e)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -836,26 +1091,157 @@ pub async fn revoke_api_key_post(
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
match delete_api_key(&conn, &id) {
|
||||
Ok(_) => {
|
||||
let _ = write_audit_log(&conn, &user.username, "API_KEY_REVOKED", Some("api_key"), Some(&id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().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(),
|
||||
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<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Form(form): Form<BulkQrExportForm>,
|
||||
) -> 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<AppState>,
|
||||
jar: CookieJar,
|
||||
) -> Response {
|
||||
pub async fn audit_get(State(state): State<AppState>, 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.admin_db.lock().unwrap();
|
||||
list_audit_logs(&conn, 100, 0).unwrap_or_default()
|
||||
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 {
|
||||
@@ -867,35 +1253,40 @@ pub async fn audit_get(
|
||||
}
|
||||
|
||||
// GET /admin/status
|
||||
pub async fn status_get(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
) -> Response {
|
||||
pub async fn status_get(State(state): State<AppState>, 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))
|
||||
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 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,
|
||||
@@ -905,6 +1296,7 @@ pub async fn status_get(
|
||||
uptime,
|
||||
version: "0.1.0",
|
||||
git_commit: "unknown",
|
||||
urls,
|
||||
};
|
||||
|
||||
template.into_response()
|
||||
|
||||
+808
-57
File diff suppressed because it is too large.
Load diff
+288
@@ -0,0 +1,288 @@
|
||||
use crate::auth::generate_token;
|
||||
use crate::auth::password::hash_password;
|
||||
use crate::auth::ApiUser;
|
||||
use crate::state::AppState;
|
||||
use crate::utils::get_client_ip;
|
||||
use axum::{
|
||||
extract::{ConnectInfo, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Json, Response},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::SocketAddr;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct BulkQrRequest {
|
||||
pub ids: Vec<String>,
|
||||
pub format: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct BulkUrlItem {
|
||||
pub destination: String,
|
||||
pub code: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
pub expires_at: Option<String>,
|
||||
pub password: Option<String>,
|
||||
pub max_access_count: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct BulkErrorResponse {
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
// POST /api/v1/bulk/qr
|
||||
pub async fn api_bulk_qr(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
user: ApiUser,
|
||||
Json(payload): Json<BulkQrRequest>,
|
||||
) -> Response {
|
||||
if payload.ids.len() > 500 {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(BulkErrorResponse {
|
||||
error: "Maximum 500 QR codes allowed per bulk request".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let format = payload
|
||||
.format
|
||||
.unwrap_or_else(|| "png".to_string())
|
||||
.to_lowercase();
|
||||
if format != "png" && format != "svg" {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(BulkErrorResponse {
|
||||
error: "Invalid format. Supported: png, svg".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Retrieve URLs from database
|
||||
let mut urls = Vec::new();
|
||||
{
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
for id in &payload.ids {
|
||||
match crate::db::content::get_url_by_id(&conn, id) {
|
||||
Ok(Some(url)) => urls.push(url),
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(BulkErrorResponse {
|
||||
error: format!("Database error fetching URL {}: {}", id, e),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if urls.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(BulkErrorResponse {
|
||||
error: "No valid URLs found for the provided IDs".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Base URL configuration check
|
||||
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));
|
||||
|
||||
// Generate ZIP
|
||||
match crate::services::bulk::export_qr_zip(&urls, &format, &base_url) {
|
||||
Ok(zip_data) => {
|
||||
// Write Audit Log
|
||||
{
|
||||
let system_conn = state.db.system.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.0.username,
|
||||
"BULK_QR_EXPORT",
|
||||
"bulk",
|
||||
"qr",
|
||||
Some(&format!("Count: {}, Format: {}", urls.len(), format)),
|
||||
);
|
||||
}
|
||||
|
||||
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) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(BulkErrorResponse {
|
||||
error: format!("Error generating ZIP: {}", e),
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/v1/bulk/url
|
||||
pub async fn api_bulk_url(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
user: ApiUser,
|
||||
Json(payload): Json<Vec<BulkUrlItem>>,
|
||||
) -> Response {
|
||||
if payload.len() > 500 {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(BulkErrorResponse {
|
||||
error: "Maximum 500 URLs allowed per bulk creation".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let mut conn = state.content_db.lock().unwrap();
|
||||
let tx = match conn.transaction() {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(BulkErrorResponse {
|
||||
error: format!("Failed to start database transaction: {}", e),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let mut created_urls = Vec::new();
|
||||
|
||||
for item in payload {
|
||||
let mut code = item.code.unwrap_or_default().trim().to_lowercase();
|
||||
if code.is_empty() {
|
||||
code = generate_token(3); // 6 hex
|
||||
} else {
|
||||
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
let _ = tx.rollback();
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(BulkErrorResponse {
|
||||
error: format!("Short code '{}' must be 6 hex characters", code),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let password_hash = if let Some(ref pwd) = item.password {
|
||||
match hash_password(pwd) {
|
||||
Ok(h) => Some(h),
|
||||
Err(e) => {
|
||||
let _ = tx.rollback();
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(BulkErrorResponse {
|
||||
error: format!("Password hashing error: {}", e),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let tags = item.tags.unwrap_or_default();
|
||||
match crate::db::content::create_url_extended(
|
||||
&tx,
|
||||
&code,
|
||||
&item.destination,
|
||||
item.title.as_deref(),
|
||||
item.description.as_deref(),
|
||||
&tags,
|
||||
item.expires_at.as_deref(),
|
||||
password_hash.as_deref(),
|
||||
item.max_access_count,
|
||||
) {
|
||||
Ok(url) => created_urls.push(url),
|
||||
Err(rusqlite::Error::SqliteFailure(err, _))
|
||||
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
|
||||
{
|
||||
let _ = tx.rollback();
|
||||
return (
|
||||
StatusCode::CONFLICT,
|
||||
Json(BulkErrorResponse {
|
||||
error: format!("Short code '{}' already exists", code),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.rollback();
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(BulkErrorResponse {
|
||||
error: format!("Database insert error: {}", e),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = tx.commit() {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(BulkErrorResponse {
|
||||
error: format!("Failed to commit transaction: {}", e),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Write Audit Log for the entire batch
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
|
||||
{
|
||||
let system_conn = state.db.system.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.0.username,
|
||||
"BULK_URL_CREATION",
|
||||
"bulk",
|
||||
"url",
|
||||
Some(&format!(
|
||||
"Count: {}, IP: {:?}, User-Agent: {:?}",
|
||||
created_urls.len(),
|
||||
ip,
|
||||
user_agent
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
(StatusCode::CREATED, Json(created_urls)).into_response()
|
||||
}
|
||||
+7
-4
@@ -1,9 +1,12 @@
|
||||
pub mod routes;
|
||||
pub mod middleware;
|
||||
pub mod redirect;
|
||||
pub mod pages;
|
||||
pub mod admin;
|
||||
pub mod api;
|
||||
pub mod bulk;
|
||||
pub mod middleware;
|
||||
pub mod pages;
|
||||
pub mod password_gate;
|
||||
pub mod qr;
|
||||
pub mod redirect;
|
||||
pub mod routes;
|
||||
pub mod system;
|
||||
|
||||
pub use routes::create_router;
|
||||
+12
-9
@@ -1,17 +1,17 @@
|
||||
use axum::{
|
||||
extract::{Path, State, ConnectInfo},
|
||||
extract::{ConnectInfo, Path, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{Response, Html, IntoResponse},
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use std::net::SocketAddr;
|
||||
use uuid::Uuid;
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::state::AppState;
|
||||
use crate::models::VisitRecord;
|
||||
use crate::utils::get_client_ip;
|
||||
use crate::analytics::get_client_country;
|
||||
use crate::models::VisitRecord;
|
||||
use crate::services::landing_pages::get_landing_page_by_code;
|
||||
use crate::state::AppState;
|
||||
use crate::utils::get_client_ip;
|
||||
|
||||
// GET /p/:code and GET /p/:code/*slug
|
||||
// Resolve and render landing page
|
||||
@@ -40,15 +40,18 @@ pub async fn resolve_page(
|
||||
// Record view analytics
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let country = get_client_country(&headers);
|
||||
let user_agent = headers.get("user-agent")
|
||||
let user_agent = headers
|
||||
.get("user-agent")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or("Unknown")
|
||||
.to_string();
|
||||
let referer = headers.get("referer")
|
||||
let referer = headers
|
||||
.get("referer")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or("Direct")
|
||||
.to_string();
|
||||
let accept_language = headers.get("accept-language")
|
||||
let accept_language = headers
|
||||
.get("accept-language")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or("Unknown")
|
||||
.to_string();
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
Form,
|
||||
};
|
||||
use axum_extra::extract::{cookie::Cookie, CookieJar};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::auth::password::verify_password;
|
||||
use crate::services::shortener::get_url_by_code;
|
||||
use crate::state::AppState;
|
||||
use crate::templates::GateTemplate;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PasswordGateForm {
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
// GET /gate/:code
|
||||
pub async fn gate_get(Path(code): Path<String>) -> impl IntoResponse {
|
||||
GateTemplate { code, error: None }
|
||||
}
|
||||
|
||||
// POST /gate/:code
|
||||
pub async fn gate_post(
|
||||
State(state): State<AppState>,
|
||||
Path(code): Path<String>,
|
||||
jar: CookieJar,
|
||||
Form(form): Form<PasswordGateForm>,
|
||||
) -> Response {
|
||||
let url_opt = match get_url_by_code(&state.db, &code) {
|
||||
Ok(url) => url,
|
||||
Err(_) => {
|
||||
return (
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Database error",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let url = match url_opt {
|
||||
Some(u) => u,
|
||||
None => return (axum::http::StatusCode::NOT_FOUND, "Url not found").into_response(),
|
||||
};
|
||||
|
||||
let password_hash = match url.password_hash {
|
||||
Some(ref h) => h,
|
||||
None => {
|
||||
// Not password protected, redirect to resolution
|
||||
return Redirect::temporary(&format!("/{}", code)).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if verify_password(&form.password, password_hash) {
|
||||
// Correct password - set 15 min temporary cookie
|
||||
let cookie_name = format!("bzod_gate_{}", code);
|
||||
let cookie = Cookie::build((cookie_name, "authorized"))
|
||||
.secure(state.config.cookie_secure)
|
||||
.same_site(axum_extra::extract::cookie::SameSite::Strict)
|
||||
.http_only(true)
|
||||
.path("/")
|
||||
.max_age(time::Duration::minutes(15));
|
||||
|
||||
let updated_jar = jar.add(cookie);
|
||||
(updated_jar, Redirect::temporary(&format!("/{}", code))).into_response()
|
||||
} else {
|
||||
// Invalid password
|
||||
GateTemplate {
|
||||
code,
|
||||
error: Some("Invalid password".to_string()),
|
||||
}
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
use crate::services::qr::{generate_qr_png, generate_qr_svg};
|
||||
use crate::services::shortener::get_url_by_code;
|
||||
use crate::state::AppState;
|
||||
use crate::utils::get_client_ip;
|
||||
use axum::{
|
||||
extract::{ConnectInfo, Path, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
// GET /api/qr/:file (e.g. /api/qr/abcdef.png or /api/qr/abcdef.svg or JSON stats /api/qr/abcdef)
|
||||
pub async fn qr_handler(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Path(file): Path<String>,
|
||||
) -> Response {
|
||||
let parts: Vec<&str> = file.split('.').collect();
|
||||
if parts.len() != 2 {
|
||||
// No extension: this is a JSON stats request!
|
||||
let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok());
|
||||
|
||||
let authenticated = if let Some(auth) = auth_header {
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
matches!(
|
||||
crate::auth::session::authenticate_api_key(&conn, auth),
|
||||
Ok(Some(_user))
|
||||
)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if !authenticated {
|
||||
return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
|
||||
}
|
||||
|
||||
let url_opt = match get_url_by_code(&state.db, &file) {
|
||||
Ok(u) => u,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
let url = match url_opt {
|
||||
Some(u) => u,
|
||||
None => return (StatusCode::NOT_FOUND, "URL not found").into_response(),
|
||||
};
|
||||
|
||||
let qr_scans = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
crate::db::qr::get_qr_scan_count(&conn, &url.id).unwrap_or(0)
|
||||
};
|
||||
|
||||
let direct_clicks = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM visits WHERE target_type = 'url' AND target_id = ?1;",
|
||||
rusqlite::params![url.id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
|
||||
return axum::response::Json(json!({
|
||||
"direct_clicks": direct_clicks,
|
||||
"qr_scans": qr_scans
|
||||
}))
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let code = parts[0];
|
||||
let ext = parts[1].to_lowercase();
|
||||
|
||||
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return (StatusCode::NOT_FOUND, "Not Found").into_response();
|
||||
}
|
||||
|
||||
let url_opt = match get_url_by_code(&state.db, code) {
|
||||
Ok(u) => u,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
let url = match url_opt {
|
||||
Some(u) => u,
|
||||
None => return (StatusCode::NOT_FOUND, "Url not found").into_response(),
|
||||
};
|
||||
|
||||
// Construct public base URL
|
||||
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));
|
||||
|
||||
let full_url = format!("{}/{}", base_url.trim_end_matches('/'), code);
|
||||
|
||||
// Generate QR code based on format
|
||||
let (body, content_type) = if ext == "svg" {
|
||||
match generate_qr_svg(&full_url) {
|
||||
Ok(svg) => (svg.into_bytes(), "image/svg+xml"),
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("QR generation error: {}", e),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
} else if ext == "png" {
|
||||
match generate_qr_png(&full_url, 256) {
|
||||
Ok(png) => (png, "image/png"),
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("QR generation error: {}", e),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Unsupported format. Use .png or .svg",
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
|
||||
// Log the QR access event
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let user_agent = headers
|
||||
.get("user-agent")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
{
|
||||
let analytics_conn = state.db.analytics.lock().unwrap();
|
||||
let _ = crate::db::qr::log_qr_access(
|
||||
&analytics_conn,
|
||||
&url.id,
|
||||
Some(ip.as_str()),
|
||||
user_agent.as_deref(),
|
||||
);
|
||||
}
|
||||
|
||||
Response::builder()
|
||||
.header("content-type", content_type)
|
||||
.header("cache-control", "public, max-age=86400") // cache for 1 day
|
||||
.body(axum::body::Body::from(body))
|
||||
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
|
||||
}
|
||||
+113
-41
@@ -1,22 +1,25 @@
|
||||
use axum::{
|
||||
extract::{Path, State, ConnectInfo},
|
||||
extract::{ConnectInfo, Path, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{Redirect, Response, IntoResponse},
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use chrono::Utc;
|
||||
use std::net::SocketAddr;
|
||||
use uuid::Uuid;
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::state::AppState;
|
||||
use crate::models::VisitRecord;
|
||||
use crate::utils::get_client_ip;
|
||||
use crate::analytics::get_client_country;
|
||||
use crate::models::VisitRecord;
|
||||
use crate::services::shortener::get_url_by_code;
|
||||
use crate::state::AppState;
|
||||
use crate::templates::PreviewTemplate;
|
||||
use crate::utils::get_client_ip;
|
||||
|
||||
// GET /:code
|
||||
// Resolve and redirect
|
||||
pub async fn resolve_redirect(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Path(code): Path<String>,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
@@ -31,43 +34,112 @@ pub async fn resolve_redirect(
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
match url_opt {
|
||||
Some(url) => {
|
||||
// Asynchronously record analytics
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let country = get_client_country(&headers);
|
||||
let user_agent = headers.get("user-agent")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or("Unknown")
|
||||
.to_string();
|
||||
let referer = headers.get("referer")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or("Direct")
|
||||
.to_string();
|
||||
let accept_language = headers.get("accept-language")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or("Unknown")
|
||||
.to_string();
|
||||
let url = match url_opt {
|
||||
Some(u) => u,
|
||||
None => return (StatusCode::NOT_FOUND, "Short code not found").into_response(),
|
||||
};
|
||||
|
||||
let record = VisitRecord {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
target_type: "url".to_string(),
|
||||
target_id: url.id.clone(),
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
ip_address: ip,
|
||||
user_agent,
|
||||
referer,
|
||||
accept_language,
|
||||
country,
|
||||
status_code: 302,
|
||||
};
|
||||
// 1. Expiration check
|
||||
if url.expired {
|
||||
return (StatusCode::GONE, "This link has expired").into_response();
|
||||
}
|
||||
|
||||
// Push to memory queue (non-blocking)
|
||||
state.analytics_queue.push(record);
|
||||
|
||||
// Perform redirect
|
||||
Redirect::temporary(&url.destination).into_response()
|
||||
if let Some(ref expires_at_str) = url.expires_at {
|
||||
if let Ok(expires_at) = chrono::DateTime::parse_from_rfc3339(expires_at_str) {
|
||||
if expires_at.with_timezone(&Utc) < Utc::now() {
|
||||
// Mark as expired in DB asynchronously/immediately
|
||||
{
|
||||
let conn = state.db.content.lock().unwrap();
|
||||
let _ = conn.execute(
|
||||
"UPDATE urls SET expired = 1 WHERE id = ?1;",
|
||||
[url.id.clone()],
|
||||
);
|
||||
}
|
||||
return (StatusCode::GONE, "This link has expired").into_response();
|
||||
}
|
||||
}
|
||||
None => (StatusCode::NOT_FOUND, "Short code not found").into_response(),
|
||||
}
|
||||
|
||||
// 2. Access limit check
|
||||
if url.is_access_exhausted() {
|
||||
return (
|
||||
StatusCode::GONE,
|
||||
"This link has reached its maximum access limit",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// 3. Password protection check
|
||||
if url.is_password_protected() {
|
||||
let cookie_name = format!("bzod_gate_{}", code);
|
||||
let authorized = jar
|
||||
.get(&cookie_name)
|
||||
.map(|c| c.value() == "authorized")
|
||||
.unwrap_or(false);
|
||||
|
||||
if !authorized {
|
||||
return Redirect::temporary(&format!("/gate/{}", code)).into_response();
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Increment access count & retrieve preview config
|
||||
let _new_access_count = {
|
||||
let conn = state.db.content.lock().unwrap();
|
||||
crate::db::content::increment_access_count(&conn, &url.id).unwrap_or(url.access_count + 1)
|
||||
};
|
||||
|
||||
let preview_opt = {
|
||||
let conn = state.db.content.lock().unwrap();
|
||||
crate::db::preview::get_preview(&conn, &url.id).unwrap_or(None)
|
||||
};
|
||||
|
||||
// Asynchronously record analytics
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let country = get_client_country(&headers);
|
||||
let user_agent = headers
|
||||
.get("user-agent")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or("Unknown")
|
||||
.to_string();
|
||||
let referer = headers
|
||||
.get("referer")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or("Direct")
|
||||
.to_string();
|
||||
let accept_language = headers
|
||||
.get("accept-language")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or("Unknown")
|
||||
.to_string();
|
||||
|
||||
let record = VisitRecord {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
target_type: "url".to_string(),
|
||||
target_id: url.id.clone(),
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
ip_address: ip,
|
||||
user_agent,
|
||||
referer,
|
||||
accept_language,
|
||||
country,
|
||||
status_code: if preview_opt.is_some() { 200 } else { 302 },
|
||||
};
|
||||
|
||||
// Push to memory queue (non-blocking)
|
||||
state.analytics_queue.push(record);
|
||||
|
||||
// 5. Render Preview or Redirect
|
||||
if let Some(preview) = preview_opt {
|
||||
PreviewTemplate {
|
||||
code,
|
||||
title: preview.title,
|
||||
description: preview.description,
|
||||
logo_url: preview.logo_url,
|
||||
button_text: preview.button_text,
|
||||
destination: url.destination,
|
||||
}
|
||||
.into_response()
|
||||
} else {
|
||||
Redirect::temporary(&url.destination).into_response()
|
||||
}
|
||||
}
|
||||
+77
-21
@@ -1,9 +1,9 @@
|
||||
use axum::{
|
||||
Router,
|
||||
routing::{get, post},
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use crate::web::{redirect, pages, admin, api, system};
|
||||
use crate::web::{admin, api, bulk, pages, password_gate, qr, redirect, system};
|
||||
use axum::{
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
|
||||
pub fn create_router(state: AppState) -> Router {
|
||||
Router::new()
|
||||
@@ -11,16 +11,22 @@ pub fn create_router(state: AppState) -> Router {
|
||||
.route("/:code", get(redirect::resolve_redirect))
|
||||
.route("/p/:code", get(pages::resolve_page))
|
||||
.route("/p/:code/*slug", get(pages::resolve_page))
|
||||
|
||||
.route(
|
||||
"/gate/:code",
|
||||
get(password_gate::gate_get).post(password_gate::gate_post),
|
||||
)
|
||||
// --- QR Code Serving ---
|
||||
.route("/api/qr/:file", get(qr::qr_handler))
|
||||
// --- System Health & Diagnostics ---
|
||||
.route("/status", get(system::status_endpoint))
|
||||
.route("/metrics", get(system::metrics_endpoint))
|
||||
|
||||
// --- Admin UI Login/Logout ---
|
||||
.route("/admin", get(admin::admin_index))
|
||||
.route("/admin/login", get(admin::login_get).post(admin::login_post))
|
||||
.route(
|
||||
"/admin/login",
|
||||
get(admin::login_get).post(admin::login_post),
|
||||
)
|
||||
.route("/admin/logout", get(admin::logout))
|
||||
|
||||
// --- Admin UI Pages ---
|
||||
.route("/admin/dashboard", get(admin::dashboard_get))
|
||||
.route("/admin/urls", get(admin::urls_get))
|
||||
@@ -30,26 +36,76 @@ pub fn create_router(state: AppState) -> Router {
|
||||
.route("/admin/pages/create", post(admin::pages_create))
|
||||
.route("/admin/pages/delete/:id", post(admin::pages_delete))
|
||||
.route("/admin/settings", get(admin::settings_get))
|
||||
.route("/admin/settings/password", post(admin::change_password_post))
|
||||
.route("/admin/settings/retention", post(admin::change_retention_post))
|
||||
.route(
|
||||
"/admin/settings/password",
|
||||
post(admin::change_password_post),
|
||||
)
|
||||
.route(
|
||||
"/admin/settings/retention",
|
||||
post(admin::change_retention_post),
|
||||
)
|
||||
.route("/admin/settings/compact", post(admin::compact_db_post))
|
||||
.route("/admin/settings/backup", get(admin::download_backup))
|
||||
.route("/admin/settings/api-keys/create", post(admin::create_api_key_post))
|
||||
.route("/admin/settings/api-keys/revoke/:id", post(admin::revoke_api_key_post))
|
||||
.route("/admin/settings/bulk-qr", post(admin::bulk_qr_export_post))
|
||||
.route(
|
||||
"/admin/settings/api-keys/create",
|
||||
post(admin::create_api_key_post),
|
||||
)
|
||||
.route(
|
||||
"/admin/settings/api-keys/revoke/:id",
|
||||
post(admin::revoke_api_key_post),
|
||||
)
|
||||
.route("/admin/audit", get(admin::audit_get))
|
||||
.route("/admin/status", get(admin::status_get))
|
||||
|
||||
// --- REST API v1 JSON Endpoints ---
|
||||
.route("/api/v1/urls", post(api::api_create_url).get(api::api_list_urls))
|
||||
.route("/api/v1/urls/:uuid", get(api::api_get_url).put(api::api_update_url).delete(api::api_delete_url))
|
||||
.route("/api/v1/pages", post(api::api_create_page).get(api::api_list_pages))
|
||||
.route("/api/v1/pages/:uuid", get(api::api_get_page).put(api::api_update_page).delete(api::api_delete_page))
|
||||
.route(
|
||||
"/api/v1/urls",
|
||||
post(api::api_create_url).get(api::api_list_urls),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/urls/:uuid",
|
||||
get(api::api_get_url)
|
||||
.put(api::api_update_url)
|
||||
.delete(api::api_delete_url),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/pages",
|
||||
post(api::api_create_page).get(api::api_list_pages),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/pages/:uuid",
|
||||
get(api::api_get_page)
|
||||
.put(api::api_update_page)
|
||||
.delete(api::api_delete_page),
|
||||
)
|
||||
.route("/api/v1/stats", get(api::api_overall_stats))
|
||||
.route("/api/v1/stats/url/:uuid", get(api::api_url_stats))
|
||||
.route("/api/v1/stats/page/:uuid", get(api::api_page_stats))
|
||||
|
||||
// --- Feature Expansion REST API Endpoints ---
|
||||
.route("/api/v1/qr/:code", get(api::api_get_qr_stats))
|
||||
.route("/api/v1/bulk/qr", post(bulk::api_bulk_qr))
|
||||
.route("/api/v1/bulk/url", post(bulk::api_bulk_url))
|
||||
.route("/api/v1/audit", get(api::api_list_audit))
|
||||
// --- Feature 10 Non-Versioned API Extensions ---
|
||||
.route("/api/qr", post(api::api_create_qr))
|
||||
.route("/api/bulk/qr", post(bulk::api_bulk_qr))
|
||||
.route("/api/bulk/url", post(bulk::api_bulk_url))
|
||||
.route("/api/stats", get(api::api_overall_stats))
|
||||
.route("/api/audit", get(api::api_list_audit))
|
||||
.route(
|
||||
"/api/v1/urls/:uuid/preview",
|
||||
post(api::api_set_preview)
|
||||
.get(api::api_get_preview)
|
||||
.delete(api::api_delete_preview),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/urls/:uuid/password",
|
||||
post(api::api_set_password).delete(api::api_remove_password),
|
||||
)
|
||||
// --- Static Asset Stub ---
|
||||
.route("/static/style.css", get(|| async { ([(axum::http::header::CONTENT_TYPE, "text/css")], "") }))
|
||||
|
||||
.route(
|
||||
"/static/style.css",
|
||||
get(|| async { ([(axum::http::header::CONTENT_TYPE, "text/css")], "") }),
|
||||
)
|
||||
.with_state(state)
|
||||
}
|
||||
+16
-10
@@ -1,15 +1,15 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response, Json},
|
||||
response::{IntoResponse, Json, Response},
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::auth::{authenticate_api_key, authenticate_session};
|
||||
use crate::db::admin::get_user_count;
|
||||
use crate::state::AppState;
|
||||
use crate::utils::{get_memory_usage, get_db_file_info};
|
||||
use crate::auth::{authenticate_session, authenticate_api_key};
|
||||
use crate::utils::{get_db_file_info, get_memory_usage};
|
||||
|
||||
// Helper: authenticate system request via header or session cookie
|
||||
fn authenticate_request(state: &AppState, jar: &CookieJar, headers: &HeaderMap) -> bool {
|
||||
@@ -51,8 +51,9 @@ pub async fn status_endpoint(
|
||||
// Return public basic status for container/load-balancer health checks
|
||||
return (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({ "application": "Healthy" }))
|
||||
).into_response();
|
||||
Json(serde_json::json!({ "application": "Healthy" })),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let is_db_ok = {
|
||||
@@ -61,7 +62,10 @@ pub async fn status_endpoint(
|
||||
};
|
||||
|
||||
let db_status = if is_db_ok {
|
||||
format!("Connected (WAL Mode enabled). Files Info:\n{}", get_db_file_info(&state.config.data_dir))
|
||||
format!(
|
||||
"Connected (WAL Mode enabled). Files Info:\n{}",
|
||||
get_db_file_info(&state.config.data_dir)
|
||||
)
|
||||
} else {
|
||||
"Disconnected".to_string()
|
||||
};
|
||||
@@ -71,12 +75,13 @@ pub async fn status_endpoint(
|
||||
Json(StatusResponse {
|
||||
application: "Healthy",
|
||||
database: db_status,
|
||||
queue_size: 0,
|
||||
queue_size: 0,
|
||||
memory_usage: get_memory_usage(),
|
||||
uptime_seconds: uptime,
|
||||
version: "0.1.0",
|
||||
git_commit: "unknown",
|
||||
}).into_response()
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
// GET /metrics
|
||||
@@ -104,7 +109,7 @@ pub async fn metrics_endpoint(
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
(
|
||||
crate::db::analytics::get_total_clicks(&conn).unwrap_or(0),
|
||||
crate::db::analytics::get_total_page_views(&conn).unwrap_or(0)
|
||||
crate::db::analytics::get_total_page_views(&conn).unwrap_or(0),
|
||||
)
|
||||
};
|
||||
|
||||
@@ -168,5 +173,6 @@ bzod_uptime_seconds {uptime}
|
||||
StatusCode::OK,
|
||||
[("Content-Type", "text/plain; version=0.0.4; charset=utf-8")],
|
||||
metrics_text,
|
||||
).into_response()
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Reference in new issue
Block a user