Initial public release

This commit is contained in:
thakares committed 2026-06-11 20:19:03 +05:30
commit c1107147b4
92 files changed
+10562

No files matched your search

+257
View File
@@ -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)
}
}
+472
View File
@@ -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");
}
}
+384
View File
@@ -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))
}
+294
View File
@@ -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
View File
@@ -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());
}
}
+191
View File
@@ -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);
}
}