Initial public release
This commit is contained in:
commit
c1107147b4
92 files changed
+10562
No files matched your search
+257
@@ -0,0 +1,257 @@
|
||||
use rusqlite::{Connection, params};
|
||||
use uuid::Uuid;
|
||||
use chrono::Utc;
|
||||
use crate::models::{User, Session, ApiKey, AuditLog};
|
||||
|
||||
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(),
|
||||
password_hash: password_hash.to_string(),
|
||||
created_at,
|
||||
})
|
||||
}
|
||||
|
||||
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 rows = stmt.query(params![username])?;
|
||||
|
||||
if let Some(row) = rows.next()? {
|
||||
Ok(Some(User {
|
||||
id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
}))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
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 rows = stmt.query(params![id])?;
|
||||
|
||||
if let Some(row) = rows.next()? {
|
||||
Ok(Some(User {
|
||||
id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
}))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_user_count(conn: &Connection) -> rusqlite::Result<i64> {
|
||||
conn.query_row("SELECT COUNT(*) FROM users;", [], |row| row.get(0))
|
||||
}
|
||||
|
||||
pub fn create_session(
|
||||
conn: &Connection,
|
||||
session_id: &str,
|
||||
user_id: &str,
|
||||
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(),
|
||||
expires_at: expires_at_rfc3339.to_string(),
|
||||
created_at,
|
||||
})
|
||||
}
|
||||
|
||||
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 rows = stmt.query(params![session_id])?;
|
||||
|
||||
if let Some(row) = rows.next()? {
|
||||
Ok(Some(Session {
|
||||
id: row.get(0)?,
|
||||
user_id: row.get(1)?,
|
||||
expires_at: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
}))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_session(conn: &Connection, session_id: &str) -> rusqlite::Result<()> {
|
||||
conn.execute("DELETE FROM sessions WHERE id = ?1;", params![session_id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cleanup_expired_sessions(conn: &Connection) -> rusqlite::Result<usize> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let count = conn.execute("DELETE FROM sessions WHERE expires_at < ?1;", params![now])?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
pub fn create_api_key(
|
||||
conn: &Connection,
|
||||
user_id: &str,
|
||||
name: &str,
|
||||
key_hash: &str,
|
||||
) -> 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(),
|
||||
key_hash: key_hash.to_string(),
|
||||
name: name.to_string(),
|
||||
created_at,
|
||||
last_used_at: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_api_key_by_hash(conn: &Connection, key_hash: &str) -> rusqlite::Result<Option<ApiKey>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"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)?,
|
||||
user_id: row.get(1)?,
|
||||
key_hash: row.get(2)?,
|
||||
name: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
last_used_at: row.get(5)?,
|
||||
}))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_api_keys(conn: &Connection, user_id: &str) -> rusqlite::Result<Vec<ApiKey>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, user_id, key_hash, name, created_at, last_used_at FROM api_keys WHERE user_id = ?1 ORDER BY created_at DESC;"
|
||||
)?;
|
||||
let rows = stmt.query_map(params![user_id], |row| {
|
||||
Ok(ApiKey {
|
||||
id: row.get(0)?,
|
||||
user_id: row.get(1)?,
|
||||
key_hash: row.get(2)?,
|
||||
name: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
last_used_at: row.get(5)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
let mut keys = Vec::new();
|
||||
for key in rows {
|
||||
keys.push(key?);
|
||||
}
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
pub fn delete_api_key(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
conn.execute("DELETE FROM api_keys WHERE id = ?1;", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn write_audit_log(
|
||||
conn: &Connection,
|
||||
username: &str,
|
||||
action: &str,
|
||||
object_type: Option<&str>,
|
||||
object_id: Option<&str>,
|
||||
ip_address: Option<&str>,
|
||||
user_agent: Option<&str>,
|
||||
) -> 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,
|
||||
username: username.to_string(),
|
||||
action: action.to_string(),
|
||||
object_type: object_type.map(|s| s.to_string()),
|
||||
object_id: object_id.map(|s| s.to_string()),
|
||||
ip_address: ip_address.map(|s| s.to_string()),
|
||||
user_agent: user_agent.map(|s| s.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
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;"
|
||||
)?;
|
||||
let rows = stmt.query_map(params![limit, offset], |row| {
|
||||
Ok(AuditLog {
|
||||
id: row.get(0)?,
|
||||
timestamp: row.get(1)?,
|
||||
username: row.get(2)?,
|
||||
action: row.get(3)?,
|
||||
object_type: row.get(4)?,
|
||||
object_id: row.get(5)?,
|
||||
ip_address: row.get(6)?,
|
||||
user_agent: row.get(7)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
let mut logs = Vec::new();
|
||||
for log in rows {
|
||||
logs.push(log?);
|
||||
}
|
||||
Ok(logs)
|
||||
}
|
||||
|
||||
pub fn set_config(conn: &Connection, key: &str, value: &str) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO config (key, value) VALUES (?1, ?2);",
|
||||
params![key, value],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
use rusqlite::{Connection, params};
|
||||
use std::collections::HashMap;
|
||||
use crate::models::VisitRecord;
|
||||
|
||||
// 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") {
|
||||
if ua_lower.contains("iphone") || ua_lower.contains("ipad") {
|
||||
"iOS".to_string()
|
||||
} else {
|
||||
"macOS".to_string()
|
||||
}
|
||||
} else if ua_lower.contains("android") {
|
||||
"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") {
|
||||
"iOS".to_string()
|
||||
} else {
|
||||
"Other".to_string()
|
||||
};
|
||||
|
||||
let browser = if ua_lower.contains("firefox") {
|
||||
"Firefox".to_string()
|
||||
} else if ua_lower.contains("opr/") || ua_lower.contains("opera") {
|
||||
"Opera".to_string()
|
||||
} else if ua_lower.contains("edg/") {
|
||||
"Edge".to_string()
|
||||
} else if ua_lower.contains("chrome") {
|
||||
"Chrome".to_string()
|
||||
} else if ua_lower.contains("safari") {
|
||||
"Safari".to_string()
|
||||
} else {
|
||||
"Other".to_string()
|
||||
};
|
||||
|
||||
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()
|
||||
} else {
|
||||
"Desktop".to_string()
|
||||
};
|
||||
|
||||
(browser, os, device)
|
||||
}
|
||||
|
||||
// Clean referer to domain
|
||||
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 = cleaned.split('/').next().unwrap_or("Direct");
|
||||
if cleaned.is_empty() {
|
||||
"Direct".to_string()
|
||||
} else {
|
||||
cleaned.trim_start_matches("www.").to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert_visits_batch(conn: &mut Connection, records: &[VisitRecord]) -> rusqlite::Result<()> {
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
let mut stmt = tx.prepare(
|
||||
"INSERT INTO visits (id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10);"
|
||||
)?;
|
||||
|
||||
for r in records {
|
||||
stmt.execute(params![
|
||||
r.id,
|
||||
r.target_type,
|
||||
r.target_id,
|
||||
r.timestamp,
|
||||
r.ip_address,
|
||||
r.user_agent,
|
||||
r.referer,
|
||||
r.accept_language,
|
||||
r.country,
|
||||
r.status_code
|
||||
])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
// 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 rows = stmt.query([])?;
|
||||
if let Some(row) = rows.next()? {
|
||||
let min_date: Option<String> = row.get(0)?;
|
||||
let max_date: Option<String> = row.get(1)?;
|
||||
if let (Some(min), Some(max)) = (min_date, max_date) {
|
||||
return Ok(Some((min, max)));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
// Run aggregation for a specific day
|
||||
pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()> {
|
||||
let mut visits = Vec::new();
|
||||
{
|
||||
// 1. Fetch all visits on that day
|
||||
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,
|
||||
user_agent: String,
|
||||
referer: String,
|
||||
country: String,
|
||||
}
|
||||
|
||||
let rows = stmt.query_map(params![date], |row| {
|
||||
Ok(RawVisit {
|
||||
target_type: row.get(0)?,
|
||||
target_id: row.get(1)?,
|
||||
user_agent: row.get(2)?,
|
||||
referer: row.get(3)?,
|
||||
country: row.get(4)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
for r in rows {
|
||||
visits.push(r?);
|
||||
}
|
||||
}
|
||||
|
||||
if visits.is_empty() {
|
||||
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();
|
||||
|
||||
// Also track total per day (all targets combined) using target_id = "all"
|
||||
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 targets = vec![
|
||||
(v.target_type.clone(), v.target_id.clone()),
|
||||
(v.target_type.clone(), "all".to_string()),
|
||||
];
|
||||
|
||||
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;
|
||||
|
||||
// Country
|
||||
*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;
|
||||
|
||||
// OS
|
||||
*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;
|
||||
|
||||
// Referrer
|
||||
*aggregates.entry((t_type.clone(), t_id.clone(), "referrer".to_string(), referrer.clone())).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Save to database in a transaction
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
// Delete old aggregates for this day
|
||||
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)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6);"
|
||||
)?;
|
||||
|
||||
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
|
||||
])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
|
||||
// Update monthly and yearly summaries using the daily summaries
|
||||
aggregate_month_from_daily(conn, &date[0..7])?;
|
||||
aggregate_year_from_daily(conn, &date[0..4])?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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(
|
||||
"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)
|
||||
FROM daily_summaries
|
||||
WHERE date LIKE ?2
|
||||
GROUP BY target_type, target_id, metric_type, metric_key;",
|
||||
params![year_month, format!("{}-%", year_month)],
|
||||
)?;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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(
|
||||
"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)
|
||||
FROM daily_summaries
|
||||
WHERE date LIKE ?2
|
||||
GROUP BY target_type, target_id, metric_type, metric_key;",
|
||||
params![year, format!("{}-%", year)],
|
||||
)?;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Clean old raw visit records
|
||||
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])?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
// --- Query functions for Dashboard & API ---
|
||||
|
||||
pub fn get_clicks_trend(
|
||||
conn: &Connection,
|
||||
target_type: &str,
|
||||
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 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;"
|
||||
)?;
|
||||
|
||||
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?);
|
||||
}
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
// Fallback to query raw visits table if daily summaries are not aggregated yet
|
||||
pub fn get_clicks_trend_raw(
|
||||
conn: &Connection,
|
||||
target_type: &str,
|
||||
target_id: &str,
|
||||
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;"
|
||||
)?;
|
||||
|
||||
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?);
|
||||
}
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
pub fn get_hourly_trend_raw(
|
||||
conn: &Connection,
|
||||
target_type: &str,
|
||||
target_id: &str,
|
||||
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;"
|
||||
)?;
|
||||
|
||||
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?);
|
||||
}
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
pub fn get_metric_rankings(
|
||||
conn: &Connection,
|
||||
target_type: &str,
|
||||
target_id: &str,
|
||||
metric_type: &str,
|
||||
limit: i64,
|
||||
) -> rusqlite::Result<Vec<(String, i64)>> {
|
||||
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;"
|
||||
)?;
|
||||
|
||||
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?);
|
||||
}
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
pub fn get_metric_rankings_raw(
|
||||
conn: &Connection,
|
||||
target_type: &str,
|
||||
target_id: &str,
|
||||
metric_type: &str,
|
||||
limit: i64,
|
||||
) -> 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;"
|
||||
)?;
|
||||
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?); }
|
||||
}
|
||||
"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;"
|
||||
)?;
|
||||
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 {
|
||||
let (k, v) = r?;
|
||||
*grouped.entry(k).or_insert(0) += v;
|
||||
}
|
||||
res = grouped.into_iter().collect();
|
||||
res.sort_by_key(|b| std::cmp::Reverse(b.1));
|
||||
res.truncate(limit as usize);
|
||||
}
|
||||
"browser" | "os" | "device" => {
|
||||
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;"
|
||||
)?;
|
||||
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?;
|
||||
let (b, o, d) = parse_ua(&ua);
|
||||
let key = match metric_type {
|
||||
"browser" => b,
|
||||
"os" => o,
|
||||
_ => d,
|
||||
};
|
||||
*grouped.entry(key).or_insert(0) += count;
|
||||
}
|
||||
res = grouped.into_iter().collect();
|
||||
res.sort_by_key(|b| std::cmp::Reverse(b.1));
|
||||
res.truncate(limit as usize);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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 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()));
|
||||
}
|
||||
|
||||
#[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("reddit.com/r/rust"), "reddit.com");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
use rusqlite::{Connection, params};
|
||||
use uuid::Uuid;
|
||||
use chrono::Utc;
|
||||
use crate::models::{Url, LandingPage};
|
||||
|
||||
// Helper: Associate tags with a URL
|
||||
fn associate_tags(conn: &Connection, url_id: &str, tags: &[String]) -> rusqlite::Result<()> {
|
||||
conn.execute("DELETE FROM url_tags WHERE url_id = ?1;", params![url_id])?;
|
||||
for tag_name in tags {
|
||||
let tag_name = tag_name.trim().to_lowercase();
|
||||
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);",
|
||||
params![url_id, tag_id],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Helper: Get tags for a URL
|
||||
pub fn get_tags_for_url(conn: &Connection, url_id: &str) -> rusqlite::Result<Vec<String>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT t.name FROM tags t JOIN url_tags ut ON t.id = ut.tag_id WHERE ut.url_id = ?1 ORDER BY t.name;"
|
||||
)?;
|
||||
let rows = stmt.query_map(params![url_id], |row| row.get::<_, String>(0))?;
|
||||
let mut tags = Vec::new();
|
||||
for tag in rows {
|
||||
tags.push(tag?);
|
||||
}
|
||||
Ok(tags)
|
||||
}
|
||||
|
||||
pub fn create_url(
|
||||
conn: &Connection,
|
||||
code: &str,
|
||||
destination: &str,
|
||||
title: Option<&str>,
|
||||
description: Option<&str>,
|
||||
tags: &[String],
|
||||
) -> 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)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8);",
|
||||
params![id, code, destination, title, description, status, now, now],
|
||||
)?;
|
||||
|
||||
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(),
|
||||
})
|
||||
}
|
||||
|
||||
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 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,
|
||||
}))
|
||||
} 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 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,
|
||||
}))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_url(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
destination: &str,
|
||||
title: Option<&str>,
|
||||
description: Option<&str>,
|
||||
status: &str,
|
||||
tags: &[String],
|
||||
) -> rusqlite::Result<Option<Url>> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let count = conn.execute(
|
||||
"UPDATE urls SET destination = ?1, title = ?2, description = ?3, status = ?4, updated_at = ?5 WHERE id = ?6;",
|
||||
params![destination, title, description, status, now, id],
|
||||
)?;
|
||||
|
||||
if count == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
associate_tags(conn, id, tags)?;
|
||||
|
||||
get_url_by_id(conn, id)
|
||||
}
|
||||
|
||||
pub fn delete_url(conn: &Connection, id: &str) -> rusqlite::Result<bool> {
|
||||
let count = conn.execute("DELETE FROM urls WHERE id = ?1;", params![id])?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
pub fn list_urls(
|
||||
conn: &Connection,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
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
|
||||
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)?))
|
||||
})?;
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
} 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)?))
|
||||
})?;
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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 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)
|
||||
}
|
||||
|
||||
pub fn update_url_health(conn: &Connection, id: &str, status: &str) -> rusqlite::Result<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
conn.execute(
|
||||
"UPDATE urls SET status = ?1, updated_at = ?2 WHERE id = ?3;",
|
||||
params![status, 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))?;
|
||||
Ok((total, active, dead))
|
||||
}
|
||||
|
||||
pub fn create_landing_page(
|
||||
conn: &Connection,
|
||||
code: &str,
|
||||
slug: &str,
|
||||
title: &str,
|
||||
html_content: &str,
|
||||
state: &str,
|
||||
) -> rusqlite::Result<LandingPage> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO landing_pages (id, code, slug, title, html_content, state, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8);",
|
||||
params![id, code, slug, title, html_content, state, now, now],
|
||||
)?;
|
||||
|
||||
Ok(LandingPage {
|
||||
id,
|
||||
code: code.to_string(),
|
||||
slug: slug.to_string(),
|
||||
title: title.to_string(),
|
||||
html_content: html_content.to_string(),
|
||||
state: state.to_string(),
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
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;"
|
||||
)?;
|
||||
let mut rows = stmt.query(params![id])?;
|
||||
|
||||
if let Some(row) = rows.next()? {
|
||||
Ok(Some(LandingPage {
|
||||
id: row.get(0)?,
|
||||
code: row.get(1)?,
|
||||
slug: row.get(2)?,
|
||||
title: row.get(3)?,
|
||||
html_content: row.get(4)?,
|
||||
state: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
updated_at: row.get(7)?,
|
||||
}))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
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;"
|
||||
)?;
|
||||
let mut rows = stmt.query(params![code])?;
|
||||
|
||||
if let Some(row) = rows.next()? {
|
||||
Ok(Some(LandingPage {
|
||||
id: row.get(0)?,
|
||||
code: row.get(1)?,
|
||||
slug: row.get(2)?,
|
||||
title: row.get(3)?,
|
||||
html_content: row.get(4)?,
|
||||
state: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
updated_at: row.get(7)?,
|
||||
}))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_landing_page(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
slug: &str,
|
||||
title: &str,
|
||||
html_content: &str,
|
||||
state: &str,
|
||||
) -> rusqlite::Result<Option<LandingPage>> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let count = conn.execute(
|
||||
"UPDATE landing_pages SET slug = ?1, title = ?2, html_content = ?3, state = ?4, updated_at = ?5 WHERE id = ?6;",
|
||||
params![slug, title, html_content, state, now, id],
|
||||
)?;
|
||||
|
||||
if count == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
get_landing_page_by_id(conn, id)
|
||||
}
|
||||
|
||||
pub fn delete_landing_page(conn: &Connection, id: &str) -> rusqlite::Result<bool> {
|
||||
let count = conn.execute("DELETE FROM landing_pages WHERE id = ?1;", params![id])?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
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;"
|
||||
)?;
|
||||
let rows = stmt.query_map(params![limit, offset], |row| {
|
||||
Ok(LandingPage {
|
||||
id: row.get(0)?,
|
||||
code: row.get(1)?,
|
||||
slug: row.get(2)?,
|
||||
title: row.get(3)?,
|
||||
html_content: row.get(4)?,
|
||||
state: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
updated_at: row.get(7)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
let mut pages = Vec::new();
|
||||
for page in rows {
|
||||
pages.push(page?);
|
||||
}
|
||||
Ok(pages)
|
||||
}
|
||||
|
||||
pub fn get_landing_page_count(conn: &Connection) -> rusqlite::Result<i64> {
|
||||
conn.query_row("SELECT COUNT(*) FROM landing_pages;", [], |row| row.get(0))
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
use std::sync::Mutex;
|
||||
|
||||
use chrono::Utc;
|
||||
use rusqlite::Connection;
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A single versioned migration with a human-readable name.
|
||||
pub struct Migration {
|
||||
pub version: u32,
|
||||
pub name: &'static str,
|
||||
pub sql: &'static str,
|
||||
}
|
||||
|
||||
/// Run all pending migrations against `conn`, recording audit entries in `system_db_opt`.
|
||||
///
|
||||
/// Migrations are applied in order. Each migration runs inside a transaction,
|
||||
/// and the schema version is bumped only after a successful commit.
|
||||
pub fn run_migrations(
|
||||
conn: &mut Connection,
|
||||
db_name: &str,
|
||||
migrations: &[Migration],
|
||||
system_db_opt: Option<&Mutex<Connection>>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let current_version = crate::db::sqlite::get_user_version(conn)?;
|
||||
let target_version = migrations.last().map_or(0, |m| m.version);
|
||||
|
||||
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");
|
||||
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute_batch(m.sql)?;
|
||||
tx.commit()?;
|
||||
|
||||
crate::db::sqlite::set_user_version(conn, m.version as i32)?;
|
||||
|
||||
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 {
|
||||
if let Ok(sys_db) = sys_db_mutex.lock() {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let _ = sys_db.execute(
|
||||
"INSERT INTO migrations (id, db_name, version, applied_at) VALUES (?1, ?2, ?3, ?4);",
|
||||
rusqlite::params![id, db_name, m.version as i32, now],
|
||||
);
|
||||
}
|
||||
} else if db_name == "system" {
|
||||
// If migrating system.db itself, write directly to its own migrations table
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let _ = conn.execute(
|
||||
"INSERT INTO migrations (id, db_name, version, applied_at) VALUES (?1, ?2, ?3, ?4);",
|
||||
rusqlite::params![id, db_name, m.version as i32, now],
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
info!(database = db_name, version = current_version, "Database up to date");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Print a dry-run migration plan to stdout without applying any changes.
|
||||
pub fn print_migration_plan(
|
||||
conn: &Connection,
|
||||
db_name: &str,
|
||||
migrations: &[Migration],
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let current_version = crate::db::sqlite::get_user_version(conn)?;
|
||||
let target_version = migrations.last().map_or(0, |m| m.version);
|
||||
|
||||
println!("Database: {db_name}");
|
||||
println!(" Current version: {current_version}");
|
||||
println!(" Target version: {target_version}");
|
||||
|
||||
let pending: Vec<&Migration> = migrations.iter().filter(|m| m.version > current_version).collect();
|
||||
|
||||
if pending.is_empty() {
|
||||
println!(" Status: up to date");
|
||||
} else {
|
||||
for m in pending {
|
||||
println!(" Would apply: v{} {}", m.version, m.name);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Migration definitions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
key_hash TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
last_used_at TEXT,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id TEXT PRIMARY KEY,
|
||||
timestamp TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
object_type TEXT,
|
||||
object_id TEXT,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
"#,
|
||||
},
|
||||
];
|
||||
|
||||
pub const CONTENT_MIGRATIONS: &[Migration] = &[
|
||||
Migration {
|
||||
version: 1,
|
||||
name: "initial_schema",
|
||||
sql: r#"
|
||||
CREATE TABLE IF NOT EXISTS urls (
|
||||
id TEXT PRIMARY KEY,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
destination TEXT NOT NULL,
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
status TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS landing_pages (
|
||||
id TEXT PRIMARY KEY,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
slug TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
html_content TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS url_tags (
|
||||
url_id TEXT NOT NULL,
|
||||
tag_id TEXT NOT NULL,
|
||||
PRIMARY KEY (url_id, tag_id),
|
||||
FOREIGN KEY(url_id) REFERENCES urls(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(tag_id) REFERENCES tags(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_urls_code ON urls(code);
|
||||
CREATE INDEX IF NOT EXISTS idx_pages_code ON landing_pages(code);
|
||||
"#,
|
||||
},
|
||||
];
|
||||
|
||||
pub const ANALYTICS_MIGRATIONS: &[Migration] = &[
|
||||
Migration {
|
||||
version: 1,
|
||||
name: "initial_schema",
|
||||
sql: r#"
|
||||
CREATE TABLE IF NOT EXISTS visits (
|
||||
id TEXT PRIMARY KEY,
|
||||
target_type TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
ip_address TEXT NOT NULL,
|
||||
user_agent TEXT NOT NULL,
|
||||
referer TEXT NOT NULL,
|
||||
accept_language TEXT NOT NULL,
|
||||
country TEXT NOT NULL,
|
||||
status_code INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS daily_summaries (
|
||||
date TEXT NOT NULL,
|
||||
target_type TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL,
|
||||
metric_type TEXT NOT NULL,
|
||||
metric_key TEXT NOT NULL,
|
||||
metric_value INTEGER NOT NULL,
|
||||
PRIMARY KEY (date, target_type, target_id, metric_type, metric_key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS monthly_summaries (
|
||||
year_month TEXT NOT NULL,
|
||||
target_type TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL,
|
||||
metric_type TEXT NOT NULL,
|
||||
metric_key TEXT NOT NULL,
|
||||
metric_value INTEGER NOT NULL,
|
||||
PRIMARY KEY (year_month, target_type, target_id, metric_type, metric_key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS yearly_summaries (
|
||||
year TEXT NOT NULL,
|
||||
target_type TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL,
|
||||
metric_type TEXT NOT NULL,
|
||||
metric_key TEXT NOT NULL,
|
||||
metric_value INTEGER NOT NULL,
|
||||
PRIMARY KEY (year, target_type, target_id, metric_type, metric_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_visits_timestamp ON visits(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_visits_target ON visits(target_type, target_id);
|
||||
"#,
|
||||
},
|
||||
];
|
||||
|
||||
pub const SYSTEM_MIGRATIONS: &[Migration] = &[
|
||||
Migration {
|
||||
version: 1,
|
||||
name: "initial_schema",
|
||||
sql: r#"
|
||||
CREATE TABLE IF NOT EXISTS migrations (
|
||||
id TEXT PRIMARY KEY,
|
||||
db_name TEXT NOT NULL,
|
||||
version INTEGER NOT NULL,
|
||||
applied_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS job_history (
|
||||
id TEXT PRIMARY KEY,
|
||||
job_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
error_message TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS health_checks (
|
||||
id TEXT PRIMARY KEY,
|
||||
object_type TEXT NOT NULL,
|
||||
object_id TEXT NOT NULL,
|
||||
checked_at TEXT NOT NULL,
|
||||
status_code INTEGER,
|
||||
error_message TEXT,
|
||||
is_healthy INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS backup_history (
|
||||
id TEXT PRIMARY KEY,
|
||||
backup_path TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
size_bytes INTEGER,
|
||||
error_message TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS system_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
event_type TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
details TEXT NOT NULL
|
||||
);
|
||||
"#,
|
||||
},
|
||||
];
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
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;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Db {
|
||||
pub admin: Arc<Mutex<Connection>>,
|
||||
pub content: Arc<Mutex<Connection>>,
|
||||
pub analytics: Arc<Mutex<Connection>>,
|
||||
pub system: Arc<Mutex<Connection>>,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub fn init(config: &Config) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
// Ensure data directory exists
|
||||
if !config.data_dir.exists() {
|
||||
fs::create_dir_all(&config.data_dir)?;
|
||||
}
|
||||
|
||||
let admin_path = config.data_dir.join("admin.db");
|
||||
let content_path = config.data_dir.join("content.db");
|
||||
let analytics_path = config.data_dir.join("analytics.db");
|
||||
let system_path = config.data_dir.join("system.db");
|
||||
|
||||
use tracing::info;
|
||||
|
||||
info!("Opening admin.db");
|
||||
let mut admin_conn = Connection::open(admin_path)?;
|
||||
info!("Opening content.db");
|
||||
let mut content_conn = Connection::open(content_path)?;
|
||||
info!("Opening analytics.db");
|
||||
let mut analytics_conn = Connection::open(analytics_path)?;
|
||||
info!("Opening system.db");
|
||||
let mut system_conn = Connection::open(system_path)?;
|
||||
|
||||
// Enable WAL mode for better concurrency and write performance
|
||||
info!(database = "admin", "Enabling WAL mode on admin.db");
|
||||
enable_wal(&admin_conn, "admin")?;
|
||||
info!(database = "content", "Enabling WAL mode on content.db");
|
||||
enable_wal(&content_conn, "content")?;
|
||||
info!(database = "analytics", "Enabling WAL mode on analytics.db");
|
||||
enable_wal(&analytics_conn, "analytics")?;
|
||||
info!(database = "system", "Enabling WAL mode on system.db");
|
||||
enable_wal(&system_conn, "system")?;
|
||||
|
||||
// Enable foreign key support
|
||||
info!(database = "admin", "Enabling foreign key enforcement on admin.db");
|
||||
enable_foreign_keys(&admin_conn, "admin")?;
|
||||
info!(database = "content", "Enabling foreign key enforcement on content.db");
|
||||
enable_foreign_keys(&content_conn, "content")?;
|
||||
info!(database = "analytics", "Enabling foreign key enforcement on analytics.db");
|
||||
enable_foreign_keys(&analytics_conn, "analytics")?;
|
||||
info!(database = "system", "Enabling foreign key enforcement on system.db");
|
||||
enable_foreign_keys(&system_conn, "system")?;
|
||||
|
||||
// 1. Run migrations for system.db first, as it receives secondary audit records
|
||||
info!("Running system migrations");
|
||||
run_migrations(&mut system_conn, "system", SYSTEM_MIGRATIONS, None)?;
|
||||
|
||||
let system_arc = Arc::new(Mutex::new(system_conn));
|
||||
|
||||
// 2. Run migrations for other databases with system.db logging
|
||||
info!("Running admin migrations");
|
||||
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))?;
|
||||
info!("Running analytics migrations");
|
||||
run_migrations(&mut analytics_conn, "analytics", ANALYTICS_MIGRATIONS, Some(&system_arc))?;
|
||||
|
||||
Ok(Self {
|
||||
admin: Arc::new(Mutex::new(admin_conn)),
|
||||
content: Arc::new(Mutex::new(content_conn)),
|
||||
analytics: Arc::new(Mutex::new(analytics_conn)),
|
||||
system: system_arc,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn compact(&self) -> Result<(), rusqlite::Error> {
|
||||
let admin = self.admin.lock().unwrap();
|
||||
admin.execute("VACUUM;", [])?;
|
||||
|
||||
let content = self.content.lock().unwrap();
|
||||
content.execute("VACUUM;", [])?;
|
||||
|
||||
let analytics = self.analytics.lock().unwrap();
|
||||
analytics.execute("VACUUM;", [])?;
|
||||
|
||||
let system = self.system.lock().unwrap();
|
||||
system.execute("VACUUM;", [])?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod db_init_tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn test_db_init() {
|
||||
let temp_dir = PathBuf::from("./temp_test_db_dir");
|
||||
if temp_dir.exists() {
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
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,191 @@
|
||||
//! Strongly-typed SQLite PRAGMA and configuration helpers.
|
||||
//!
|
||||
//! This module provides safe wrappers around common SQLite PRAGMAs using
|
||||
//! rusqlite's type-safe APIs (`pragma_update`, `pragma_query_value`, `query_row`)
|
||||
//! instead of raw `execute` calls. All functions use structured tracing for
|
||||
//! observability.
|
||||
|
||||
use rusqlite::Connection;
|
||||
use serde::Serialize;
|
||||
use tracing::info;
|
||||
|
||||
/// Enables WAL (Write-Ahead Logging) journal mode on the given connection.
|
||||
///
|
||||
/// 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))?;
|
||||
|
||||
info!(database = db_name, mode = %actual_mode, "WAL mode configured");
|
||||
|
||||
if actual_mode.to_lowercase() != "wal" {
|
||||
return Err(rusqlite::Error::QueryReturnedNoRows);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Enables foreign key constraint enforcement on the given connection.
|
||||
///
|
||||
/// Sets `foreign_keys` to ON via `pragma_update`, then verifies the setting
|
||||
/// was applied by reading it back with `pragma_query_value`.
|
||||
pub fn enable_foreign_keys(conn: &Connection, db_name: &str) -> Result<(), rusqlite::Error> {
|
||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||
|
||||
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");
|
||||
|
||||
if !enabled {
|
||||
return Err(rusqlite::Error::QueryReturnedNoRows);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sets the schema user_version on the given connection.
|
||||
///
|
||||
/// Uses `pragma_update` with the type-safe API — no `format!` string, no raw
|
||||
/// `execute`.
|
||||
pub fn set_user_version(conn: &Connection, version: i32) -> Result<(), rusqlite::Error> {
|
||||
conn.pragma_update(None, "user_version", version)
|
||||
}
|
||||
|
||||
/// Returns the current schema user_version from the given connection.
|
||||
pub fn get_user_version(conn: &Connection) -> Result<u32, rusqlite::Error> {
|
||||
conn.pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0))
|
||||
}
|
||||
|
||||
/// Runs `PRAGMA integrity_check` and returns `Ok(())` if the database reports "ok".
|
||||
///
|
||||
/// If the integrity check returns any other value, the function returns an error
|
||||
/// containing the integrity check message.
|
||||
pub fn integrity_check(conn: &Connection, db_name: &str) -> Result<(), rusqlite::Error> {
|
||||
let result: String =
|
||||
conn.query_row("PRAGMA integrity_check;", [], |row| row.get::<_, String>(0))?;
|
||||
|
||||
if result == "ok" {
|
||||
info!(database = db_name, "Integrity check passed");
|
||||
Ok(())
|
||||
} else {
|
||||
Err(rusqlite::Error::SqliteFailure(
|
||||
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CORRUPT),
|
||||
Some(format!("Integrity check failed for {db_name}: {result}")),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the current journal mode of the given connection.
|
||||
pub fn get_journal_mode(conn: &Connection) -> Result<String, rusqlite::Error> {
|
||||
conn.pragma_query_value(None, "journal_mode", |row| row.get::<_, String>(0))
|
||||
}
|
||||
|
||||
/// A snapshot of database health information collected from various PRAGMAs.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DatabaseHealthReport {
|
||||
/// Name of the database (e.g. "admin", "content").
|
||||
pub database: String,
|
||||
/// Current schema version (`user_version` PRAGMA).
|
||||
pub schema_version: u32,
|
||||
/// Active journal mode (e.g. "wal", "delete").
|
||||
pub journal_mode: String,
|
||||
/// Whether foreign key enforcement is enabled.
|
||||
pub foreign_keys_enabled: bool,
|
||||
/// Whether `PRAGMA integrity_check` returned "ok".
|
||||
pub integrity_ok: bool,
|
||||
}
|
||||
|
||||
/// Collects a [`DatabaseHealthReport`] by querying all relevant PRAGMAs.
|
||||
///
|
||||
/// This function queries `user_version`, `journal_mode`, `foreign_keys`, and
|
||||
/// `integrity_check` to build a comprehensive health snapshot. The report is
|
||||
/// logged at `info` level with structured fields.
|
||||
pub fn collect_health_report(
|
||||
conn: &Connection,
|
||||
db_name: &str,
|
||||
) -> Result<DatabaseHealthReport, rusqlite::Error> {
|
||||
let schema_version = get_user_version(conn)?;
|
||||
let journal_mode = get_journal_mode(conn)?;
|
||||
|
||||
let foreign_keys_enabled: bool =
|
||||
conn.pragma_query_value(None, "foreign_keys", |row| row.get::<_, bool>(0))?;
|
||||
|
||||
let integrity_result: String =
|
||||
conn.query_row("PRAGMA integrity_check;", [], |row| row.get::<_, String>(0))?;
|
||||
let integrity_ok = integrity_result == "ok";
|
||||
|
||||
let report = DatabaseHealthReport {
|
||||
database: db_name.to_owned(),
|
||||
schema_version,
|
||||
journal_mode,
|
||||
foreign_keys_enabled,
|
||||
integrity_ok,
|
||||
};
|
||||
|
||||
info!(
|
||||
database = %report.database,
|
||||
version = report.schema_version,
|
||||
journal_mode = %report.journal_mode,
|
||||
foreign_keys = report.foreign_keys_enabled,
|
||||
integrity = report.integrity_ok,
|
||||
"Database health report collected"
|
||||
);
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rusqlite::Connection;
|
||||
|
||||
fn memory_conn() -> Connection {
|
||||
Connection::open_in_memory().expect("Failed to open in-memory database")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_enable_wal() {
|
||||
let conn = memory_conn();
|
||||
// In-memory databases may not support WAL; we just verify no panic.
|
||||
// On-disk databases would return "wal".
|
||||
let _ = enable_wal(&conn, "test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_enable_foreign_keys() {
|
||||
let conn = memory_conn();
|
||||
enable_foreign_keys(&conn, "test").expect("Failed to enable foreign keys");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_user_version_roundtrip() {
|
||||
let conn = memory_conn();
|
||||
set_user_version(&conn, 42).expect("Failed to set user_version");
|
||||
let v = get_user_version(&conn).expect("Failed to get user_version");
|
||||
assert_eq!(v, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_journal_mode() {
|
||||
let conn = memory_conn();
|
||||
let mode = get_journal_mode(&conn).expect("Failed to get journal_mode");
|
||||
assert!(!mode.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_integrity_check() {
|
||||
let conn = memory_conn();
|
||||
integrity_check(&conn, "test").expect("Integrity check should pass on fresh db");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_health_report() {
|
||||
let conn = memory_conn();
|
||||
let report =
|
||||
collect_health_report(&conn, "test").expect("Failed to collect health report");
|
||||
assert_eq!(report.database, "test");
|
||||
assert!(report.integrity_ok);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user