Add QR codes, link expiry, password protection, previews, audit trail and bulk operations

This commit is contained in:
thakares committed 2026-06-12 19:57:53 +05:30
1 parent 157aa81252
commit bcbcc90d98
88 files changed
+5354 -815

No files matched your search

+40 -25
View File
@@ -1,17 +1,21 @@
use rusqlite::{Connection, params};
use uuid::Uuid;
use crate::models::{ApiKey, AuditLog, Session, User};
use chrono::Utc;
use crate::models::{User, Session, ApiKey, AuditLog};
use rusqlite::{params, Connection};
use uuid::Uuid;
pub fn create_user(conn: &Connection, username: &str, password_hash: &str) -> rusqlite::Result<User> {
pub fn create_user(
conn: &Connection,
username: &str,
password_hash: &str,
) -> rusqlite::Result<User> {
let id = Uuid::new_v4().to_string();
let created_at = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO users (id, username, password_hash, created_at) VALUES (?1, ?2, ?3, ?4);",
params![id, username, password_hash, created_at],
)?;
Ok(User {
id,
username: username.to_string(),
@@ -21,9 +25,11 @@ pub fn create_user(conn: &Connection, username: &str, password_hash: &str) -> ru
}
pub fn get_user_by_username(conn: &Connection, username: &str) -> rusqlite::Result<Option<User>> {
let mut stmt = conn.prepare("SELECT id, username, password_hash, created_at FROM users WHERE username = ?1;")?;
let mut stmt = conn.prepare(
"SELECT id, username, password_hash, created_at FROM users WHERE username = ?1;",
)?;
let mut rows = stmt.query(params![username])?;
if let Some(row) = rows.next()? {
Ok(Some(User {
id: row.get(0)?,
@@ -37,9 +43,10 @@ pub fn get_user_by_username(conn: &Connection, username: &str) -> rusqlite::Resu
}
pub fn get_user_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Option<User>> {
let mut stmt = conn.prepare("SELECT id, username, password_hash, created_at FROM users WHERE id = ?1;")?;
let mut stmt =
conn.prepare("SELECT id, username, password_hash, created_at FROM users WHERE id = ?1;")?;
let mut rows = stmt.query(params![id])?;
if let Some(row) = rows.next()? {
Ok(Some(User {
id: row.get(0)?,
@@ -63,12 +70,12 @@ pub fn create_session(
expires_at_rfc3339: &str,
) -> rusqlite::Result<Session> {
let created_at = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);",
params![session_id, user_id, expires_at_rfc3339, created_at],
)?;
Ok(Session {
id: session_id.to_string(),
user_id: user_id.to_string(),
@@ -78,9 +85,10 @@ pub fn create_session(
}
pub fn get_session(conn: &Connection, session_id: &str) -> rusqlite::Result<Option<Session>> {
let mut stmt = conn.prepare("SELECT id, user_id, expires_at, created_at FROM sessions WHERE id = ?1;")?;
let mut stmt =
conn.prepare("SELECT id, user_id, expires_at, created_at FROM sessions WHERE id = ?1;")?;
let mut rows = stmt.query(params![session_id])?;
if let Some(row) = rows.next()? {
Ok(Some(Session {
id: row.get(0)?,
@@ -112,12 +120,12 @@ pub fn create_api_key(
) -> rusqlite::Result<ApiKey> {
let id = Uuid::new_v4().to_string();
let created_at = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO api_keys (id, user_id, key_hash, name, created_at) VALUES (?1, ?2, ?3, ?4, ?5);",
params![id, user_id, key_hash, name, created_at],
)?;
Ok(ApiKey {
id,
user_id: user_id.to_string(),
@@ -133,7 +141,7 @@ pub fn get_api_key_by_hash(conn: &Connection, key_hash: &str) -> rusqlite::Resul
"SELECT id, user_id, key_hash, name, created_at, last_used_at FROM api_keys WHERE key_hash = ?1;"
)?;
let mut rows = stmt.query(params![key_hash])?;
if let Some(row) = rows.next()? {
Ok(Some(ApiKey {
id: row.get(0)?,
@@ -162,7 +170,7 @@ pub fn list_api_keys(conn: &Connection, user_id: &str) -> rusqlite::Result<Vec<A
last_used_at: row.get(5)?,
})
})?;
let mut keys = Vec::new();
for key in rows {
keys.push(key?);
@@ -177,7 +185,10 @@ pub fn delete_api_key(conn: &Connection, id: &str) -> rusqlite::Result<()> {
pub fn update_api_key_last_used(conn: &Connection, id: &str) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
conn.execute("UPDATE api_keys SET last_used_at = ?1 WHERE id = ?2;", params![now, id])?;
conn.execute(
"UPDATE api_keys SET last_used_at = ?1 WHERE id = ?2;",
params![now, id],
)?;
Ok(())
}
@@ -192,13 +203,13 @@ pub fn write_audit_log(
) -> rusqlite::Result<AuditLog> {
let id = Uuid::new_v4().to_string();
let timestamp = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO audit_logs (id, timestamp, username, action, object_type, object_id, ip_address, user_agent)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8);",
params![id, timestamp, username, action, object_type, object_id, ip_address, user_agent],
)?;
Ok(AuditLog {
id,
timestamp,
@@ -211,10 +222,14 @@ pub fn write_audit_log(
})
}
pub fn list_audit_logs(conn: &Connection, limit: i64, offset: i64) -> rusqlite::Result<Vec<AuditLog>> {
pub fn list_audit_logs(
conn: &Connection,
limit: i64,
offset: i64,
) -> rusqlite::Result<Vec<AuditLog>> {
let mut stmt = conn.prepare(
"SELECT id, timestamp, username, action, object_type, object_id, ip_address, user_agent
FROM audit_logs ORDER BY timestamp DESC LIMIT ?1 OFFSET ?2;"
FROM audit_logs ORDER BY timestamp DESC LIMIT ?1 OFFSET ?2;",
)?;
let rows = stmt.query_map(params![limit, offset], |row| {
Ok(AuditLog {
@@ -228,7 +243,7 @@ pub fn list_audit_logs(conn: &Connection, limit: i64, offset: i64) -> rusqlite::
user_agent: row.get(7)?,
})
})?;
let mut logs = Vec::new();
for log in rows {
logs.push(log?);
@@ -247,7 +262,7 @@ pub fn set_config(conn: &Connection, key: &str, value: &str) -> rusqlite::Result
pub fn get_config(conn: &Connection, key: &str) -> rusqlite::Result<Option<String>> {
let mut stmt = conn.prepare("SELECT value FROM config WHERE key = ?1;")?;
let mut rows = stmt.query(params![key])?;
if let Some(row) = rows.next()? {
let val: String = row.get(0)?;
Ok(Some(val))
+167 -68
View File
@@ -1,11 +1,11 @@
use rusqlite::{Connection, params};
use std::collections::HashMap;
use crate::models::VisitRecord;
use rusqlite::{params, Connection};
use std::collections::HashMap;
// Custom User-Agent parser to avoid bloated dependencies
pub fn parse_ua(ua: &str) -> (String, String, String) {
let ua_lower = ua.to_lowercase();
let os = if ua_lower.contains("windows") {
"Windows".to_string()
} else if ua_lower.contains("macintosh") || ua_lower.contains("mac os x") {
@@ -18,7 +18,8 @@ pub fn parse_ua(ua: &str) -> (String, String, String) {
"Android".to_string()
} else if ua_lower.contains("linux") {
"Linux".to_string()
} else if ua_lower.contains("iphone") || ua_lower.contains("ipad") || ua_lower.contains("ipod") {
} else if ua_lower.contains("iphone") || ua_lower.contains("ipad") || ua_lower.contains("ipod")
{
"iOS".to_string()
} else {
"Other".to_string()
@@ -38,7 +39,11 @@ pub fn parse_ua(ua: &str) -> (String, String, String) {
"Other".to_string()
};
let device = if ua_lower.contains("mobile") || ua_lower.contains("android") || ua_lower.contains("iphone") || ua_lower.contains("ipod") {
let device = if ua_lower.contains("mobile")
|| ua_lower.contains("android")
|| ua_lower.contains("iphone")
|| ua_lower.contains("ipod")
{
"Mobile".to_string()
} else if ua_lower.contains("ipad") || ua_lower.contains("tablet") {
"Tablet".to_string()
@@ -54,15 +59,17 @@ pub fn clean_referrer(referer: &str) -> String {
if referer.is_empty() || referer == "direct" {
return "Direct".to_string();
}
if let Ok(url) = reqwest::Url::parse(referer) {
if let Some(host) = url.host_str() {
return host.trim_start_matches("www.").to_string();
}
}
// Fallback if not a valid URL
let cleaned = referer.trim_start_matches("https://").trim_start_matches("http://");
let cleaned = referer
.trim_start_matches("https://")
.trim_start_matches("http://");
let cleaned = cleaned.split('/').next().unwrap_or("Direct");
if cleaned.is_empty() {
"Direct".to_string()
@@ -78,7 +85,7 @@ pub fn insert_visits_batch(conn: &mut Connection, records: &[VisitRecord]) -> ru
"INSERT INTO visits (id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10);"
)?;
for r in records {
stmt.execute(params![
r.id,
@@ -99,16 +106,25 @@ pub fn insert_visits_batch(conn: &mut Connection, records: &[VisitRecord]) -> ru
}
pub fn get_total_clicks(conn: &Connection) -> rusqlite::Result<i64> {
conn.query_row("SELECT COUNT(*) FROM visits WHERE target_type = 'url';", [], |row| row.get(0))
conn.query_row(
"SELECT COUNT(*) FROM visits WHERE target_type = 'url';",
[],
|row| row.get(0),
)
}
pub fn get_total_page_views(conn: &Connection) -> rusqlite::Result<i64> {
conn.query_row("SELECT COUNT(*) FROM visits WHERE target_type = 'page';", [], |row| row.get(0))
conn.query_row(
"SELECT COUNT(*) FROM visits WHERE target_type = 'page';",
[],
|row| row.get(0),
)
}
// Get the date range of visits in the DB
pub fn get_visits_date_range(conn: &Connection) -> rusqlite::Result<Option<(String, String)>> {
let mut stmt = conn.prepare("SELECT MIN(date(timestamp)), MAX(date(timestamp)) FROM visits;")?;
let mut stmt =
conn.prepare("SELECT MIN(date(timestamp)), MAX(date(timestamp)) FROM visits;")?;
let mut rows = stmt.query([])?;
if let Some(row) = rows.next()? {
let min_date: Option<String> = row.get(0)?;
@@ -128,7 +144,7 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
let mut stmt = conn.prepare(
"SELECT target_type, target_id, user_agent, referer, country, status_code FROM visits WHERE date(timestamp) = ?1;"
)?;
struct RawVisit {
target_type: String,
target_id: String,
@@ -136,7 +152,7 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
referer: String,
country: String,
}
let rows = stmt.query_map(params![date], |row| {
Ok(RawVisit {
target_type: row.get(0)?,
@@ -146,7 +162,7 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
country: row.get(4)?,
})
})?;
for r in rows {
visits.push(r?);
}
@@ -156,7 +172,6 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
return Ok(());
}
// 2. Compute metrics in-memory
// Key structure: (target_type, target_id, metric_type, metric_key) -> count
let mut aggregates: HashMap<(String, String, String, String), i64> = HashMap::new();
@@ -165,7 +180,11 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
for v in visits {
let (browser, os, device) = parse_ua(&v.user_agent);
let referrer = clean_referrer(&v.referer);
let country = if v.country.is_empty() { "Unknown".to_string() } else { v.country.clone() };
let country = if v.country.is_empty() {
"Unknown".to_string()
} else {
v.country.clone()
};
let targets = vec![
(v.target_type.clone(), v.target_id.clone()),
@@ -174,22 +193,59 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
for (t_type, t_id) in targets {
// Clicks
*aggregates.entry((t_type.clone(), t_id.clone(), "clicks".to_string(), "".to_string())).or_insert(0) += 1;
*aggregates
.entry((
t_type.clone(),
t_id.clone(),
"clicks".to_string(),
"".to_string(),
))
.or_insert(0) += 1;
// Country
*aggregates.entry((t_type.clone(), t_id.clone(), "country".to_string(), country.clone())).or_insert(0) += 1;
*aggregates
.entry((
t_type.clone(),
t_id.clone(),
"country".to_string(),
country.clone(),
))
.or_insert(0) += 1;
// Browser
*aggregates.entry((t_type.clone(), t_id.clone(), "browser".to_string(), browser.clone())).or_insert(0) += 1;
*aggregates
.entry((
t_type.clone(),
t_id.clone(),
"browser".to_string(),
browser.clone(),
))
.or_insert(0) += 1;
// OS
*aggregates.entry((t_type.clone(), t_id.clone(), "os".to_string(), os.clone())).or_insert(0) += 1;
*aggregates
.entry((t_type.clone(), t_id.clone(), "os".to_string(), os.clone()))
.or_insert(0) += 1;
// Device
*aggregates.entry((t_type.clone(), t_id.clone(), "device".to_string(), device.clone())).or_insert(0) += 1;
*aggregates
.entry((
t_type.clone(),
t_id.clone(),
"device".to_string(),
device.clone(),
))
.or_insert(0) += 1;
// Referrer
*aggregates.entry((t_type.clone(), t_id.clone(), "referrer".to_string(), referrer.clone())).or_insert(0) += 1;
*aggregates
.entry((
t_type.clone(),
t_id.clone(),
"referrer".to_string(),
referrer.clone(),
))
.or_insert(0) += 1;
}
}
@@ -197,7 +253,10 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
let tx = conn.transaction()?;
{
// Delete old aggregates for this day
tx.execute("DELETE FROM daily_summaries WHERE date = ?1;", params![date])?;
tx.execute(
"DELETE FROM daily_summaries WHERE date = ?1;",
params![date],
)?;
let mut insert_stmt = tx.prepare(
"INSERT INTO daily_summaries (date, target_type, target_id, metric_type, metric_key, metric_value)
@@ -205,14 +264,7 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
)?;
for ((t_type, t_id, m_type, m_key), value) in aggregates {
insert_stmt.execute(params![
date,
t_type,
t_id,
m_type,
m_key,
value
])?;
insert_stmt.execute(params![date, t_type, t_id, m_type, m_key, value])?;
}
}
tx.commit()?;
@@ -227,7 +279,10 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
fn aggregate_month_from_daily(conn: &mut Connection, year_month: &str) -> rusqlite::Result<()> {
let tx = conn.transaction()?;
{
tx.execute("DELETE FROM monthly_summaries WHERE year_month = ?1;", params![year_month])?;
tx.execute(
"DELETE FROM monthly_summaries WHERE year_month = ?1;",
params![year_month],
)?;
tx.execute(
"INSERT INTO monthly_summaries (year_month, target_type, target_id, metric_type, metric_key, metric_value)
SELECT ?1, target_type, target_id, metric_type, metric_key, SUM(metric_value)
@@ -244,7 +299,10 @@ fn aggregate_month_from_daily(conn: &mut Connection, year_month: &str) -> rusqli
fn aggregate_year_from_daily(conn: &mut Connection, year: &str) -> rusqlite::Result<()> {
let tx = conn.transaction()?;
{
tx.execute("DELETE FROM yearly_summaries WHERE year = ?1;", params![year])?;
tx.execute(
"DELETE FROM yearly_summaries WHERE year = ?1;",
params![year],
)?;
tx.execute(
"INSERT INTO yearly_summaries (year, target_type, target_id, metric_type, metric_key, metric_value)
SELECT ?1, target_type, target_id, metric_type, metric_key, SUM(metric_value)
@@ -262,7 +320,10 @@ fn aggregate_year_from_daily(conn: &mut Connection, year: &str) -> rusqlite::Res
pub fn retention_cleanup(conn: &Connection, retention_days: i64) -> rusqlite::Result<usize> {
let limit_date = chrono::Utc::now() - chrono::Duration::days(retention_days);
let limit_str = limit_date.to_rfc3339();
let count = conn.execute("DELETE FROM visits WHERE timestamp < ?1;", params![limit_str])?;
let count = conn.execute(
"DELETE FROM visits WHERE timestamp < ?1;",
params![limit_str],
)?;
Ok(count)
}
@@ -274,18 +335,20 @@ pub fn get_clicks_trend(
target_id: &str,
limit_days: i64,
) -> rusqlite::Result<Vec<(String, i64)>> {
let limit_date = (chrono::Utc::now() - chrono::Duration::days(limit_days)).format("%Y-%m-%d").to_string();
let limit_date = (chrono::Utc::now() - chrono::Duration::days(limit_days))
.format("%Y-%m-%d")
.to_string();
let mut stmt = conn.prepare(
"SELECT date, SUM(metric_value) FROM daily_summaries
WHERE target_type = ?1 AND target_id = ?2 AND metric_type = 'clicks' AND date >= ?3
GROUP BY date ORDER BY date ASC;"
GROUP BY date ORDER BY date ASC;",
)?;
let rows = stmt.query_map(params![target_type, target_id, limit_date], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
let mut res = Vec::new();
for r in rows {
res.push(r?);
@@ -301,17 +364,17 @@ pub fn get_clicks_trend_raw(
limit_days: i64,
) -> rusqlite::Result<Vec<(String, i64)>> {
let limit_date = (chrono::Utc::now() - chrono::Duration::days(limit_days)).to_rfc3339();
let mut stmt = conn.prepare(
"SELECT date(timestamp) as d, COUNT(*) FROM visits
WHERE target_type = ?1 AND target_id = ?2 AND timestamp >= ?3
GROUP BY d ORDER BY d ASC;"
GROUP BY d ORDER BY d ASC;",
)?;
let rows = stmt.query_map(params![target_type, target_id, limit_date], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
let mut res = Vec::new();
for r in rows {
res.push(r?);
@@ -326,18 +389,18 @@ pub fn get_hourly_trend_raw(
limit_days: i64,
) -> rusqlite::Result<Vec<(String, i64)>> {
let limit_date = (chrono::Utc::now() - chrono::Duration::days(limit_days)).to_rfc3339();
// SQLite strftime('%H', timestamp) extracts the hour
let mut stmt = conn.prepare(
"SELECT strftime('%H', timestamp) as h, COUNT(*) FROM visits
WHERE target_type = ?1 AND target_id = ?2 AND timestamp >= ?3
GROUP BY h ORDER BY h ASC;"
GROUP BY h ORDER BY h ASC;",
)?;
let rows = stmt.query_map(params![target_type, target_id, limit_date], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
let mut res = Vec::new();
for r in rows {
res.push(r?);
@@ -355,13 +418,13 @@ pub fn get_metric_rankings(
let mut stmt = conn.prepare(
"SELECT metric_key, SUM(metric_value) as val FROM daily_summaries
WHERE target_type = ?1 AND target_id = ?2 AND metric_type = ?3
GROUP BY metric_key ORDER BY val DESC LIMIT ?4;"
GROUP BY metric_key ORDER BY val DESC LIMIT ?4;",
)?;
let rows = stmt.query_map(params![target_type, target_id, metric_type, limit], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
let mut res = Vec::new();
for r in rows {
res.push(r?);
@@ -378,30 +441,32 @@ pub fn get_metric_rankings_raw(
) -> rusqlite::Result<Vec<(String, i64)>> {
// Falls back to direct query on visits
let mut res = Vec::new();
match metric_type {
"country" => {
let mut stmt = conn.prepare(
"SELECT country, COUNT(*) as c FROM visits
WHERE target_type = ?1 AND target_id = ?2
GROUP BY country ORDER BY c DESC LIMIT ?3;"
GROUP BY country ORDER BY c DESC LIMIT ?3;",
)?;
let rows = stmt.query_map(params![target_type, target_id, limit], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
for r in rows { res.push(r?); }
for r in rows {
res.push(r?);
}
}
"referrer" => {
let mut stmt = conn.prepare(
"SELECT referer, COUNT(*) as c FROM visits
WHERE target_type = ?1 AND target_id = ?2
GROUP BY referer ORDER BY c DESC LIMIT ?3;"
GROUP BY referer ORDER BY c DESC LIMIT ?3;",
)?;
let rows = stmt.query_map(params![target_type, target_id, limit], |row| {
let raw_ref: String = row.get(0)?;
Ok((clean_referrer(&raw_ref), row.get::<_, i64>(1)?))
})?;
// Re-aggregate because clean_referrer might group different referrers
let mut grouped: HashMap<String, i64> = HashMap::new();
for r in rows {
@@ -416,12 +481,12 @@ pub fn get_metric_rankings_raw(
let mut stmt = conn.prepare(
"SELECT user_agent, COUNT(*) as c FROM visits
WHERE target_type = ?1 AND target_id = ?2
GROUP BY user_agent;"
GROUP BY user_agent;",
)?;
let rows = stmt.query_map(params![target_type, target_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
let mut grouped: HashMap<String, i64> = HashMap::new();
for r in rows {
let (ua, count) = r?;
@@ -439,7 +504,7 @@ pub fn get_metric_rankings_raw(
}
_ => {}
}
Ok(res)
}
@@ -449,24 +514,58 @@ mod tests {
#[test]
fn test_parse_ua_browsers() {
let firefox_linux = "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/119.0";
let firefox_linux =
"Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/119.0";
let chrome_win = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
let safari_mac = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15";
let android_phone = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36";
assert_eq!(parse_ua(firefox_linux), ("Firefox".to_string(), "Linux".to_string(), "Desktop".to_string()));
assert_eq!(parse_ua(chrome_win), ("Chrome".to_string(), "Windows".to_string(), "Desktop".to_string()));
assert_eq!(parse_ua(safari_mac), ("Safari".to_string(), "macOS".to_string(), "Desktop".to_string()));
assert_eq!(parse_ua(android_phone), ("Chrome".to_string(), "Android".to_string(), "Mobile".to_string()));
assert_eq!(
parse_ua(firefox_linux),
(
"Firefox".to_string(),
"Linux".to_string(),
"Desktop".to_string()
)
);
assert_eq!(
parse_ua(chrome_win),
(
"Chrome".to_string(),
"Windows".to_string(),
"Desktop".to_string()
)
);
assert_eq!(
parse_ua(safari_mac),
(
"Safari".to_string(),
"macOS".to_string(),
"Desktop".to_string()
)
);
assert_eq!(
parse_ua(android_phone),
(
"Chrome".to_string(),
"Android".to_string(),
"Mobile".to_string()
)
);
}
#[test]
fn test_clean_referrer() {
assert_eq!(clean_referrer("direct"), "Direct");
assert_eq!(clean_referrer(""), "Direct");
assert_eq!(clean_referrer("https://github.com/rust-lang/rust"), "github.com");
assert_eq!(clean_referrer("http://www.google.com/search?q=rust"), "google.com");
assert_eq!(
clean_referrer("https://github.com/rust-lang/rust"),
"github.com"
);
assert_eq!(
clean_referrer("http://www.google.com/search?q=rust"),
"google.com"
);
assert_eq!(clean_referrer("reddit.com/r/rust"), "reddit.com");
}
}
+74
View File
@@ -0,0 +1,74 @@
use crate::models::AuditEvent;
use chrono::Utc;
use rusqlite::{params, Connection};
use uuid::Uuid;
/// Write an audit event to the system.db audit_events table.
pub fn write_audit_event(
conn: &Connection,
actor: &str,
action: &str,
object_type: &str,
object_id: &str,
metadata: Option<&str>,
) -> rusqlite::Result<()> {
let id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO audit_events (id, actor, action, object_type, object_id, timestamp, metadata)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
params![id, actor, action, object_type, object_id, now, metadata],
)?;
Ok(())
}
/// List audit events with optional filtering by actor or action.
pub fn list_audit_events(
conn: &Connection,
limit: i64,
offset: i64,
actor_filter: Option<&str>,
action_filter: Option<&str>,
) -> rusqlite::Result<Vec<AuditEvent>> {
let mut events = Vec::new();
let (sql, params_vec): (String, Vec<Box<dyn rusqlite::types::ToSql>>) = match (actor_filter, action_filter) {
(Some(actor), Some(action)) => (
"SELECT id, actor, action, object_type, object_id, timestamp, metadata FROM audit_events WHERE actor = ?1 AND action = ?2 ORDER BY timestamp DESC LIMIT ?3 OFFSET ?4;".to_string(),
vec![Box::new(actor.to_string()), Box::new(action.to_string()), Box::new(limit), Box::new(offset)],
),
(Some(actor), None) => (
"SELECT id, actor, action, object_type, object_id, timestamp, metadata FROM audit_events WHERE actor = ?1 ORDER BY timestamp DESC LIMIT ?2 OFFSET ?3;".to_string(),
vec![Box::new(actor.to_string()), Box::new(limit), Box::new(offset)],
),
(None, Some(action)) => (
"SELECT id, actor, action, object_type, object_id, timestamp, metadata FROM audit_events WHERE action = ?1 ORDER BY timestamp DESC LIMIT ?2 OFFSET ?3;".to_string(),
vec![Box::new(action.to_string()), Box::new(limit), Box::new(offset)],
),
(None, None) => (
"SELECT id, actor, action, object_type, object_id, timestamp, metadata FROM audit_events ORDER BY timestamp DESC LIMIT ?1 OFFSET ?2;".to_string(),
vec![Box::new(limit), Box::new(offset)],
),
};
let params_refs: Vec<&dyn rusqlite::types::ToSql> =
params_vec.iter().map(|p| p.as_ref()).collect();
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(params_refs.as_slice(), |row| {
Ok(AuditEvent {
id: row.get(0)?,
actor: row.get(1)?,
action: row.get(2)?,
object_type: row.get(3)?,
object_id: row.get(4)?,
timestamp: row.get(5)?,
metadata: row.get(6)?,
})
})?;
for event in rows {
events.push(event?);
}
Ok(events)
}
+248 -94
View File
@@ -1,7 +1,7 @@
use rusqlite::{Connection, params};
use uuid::Uuid;
use crate::models::Url;
use chrono::Utc;
use crate::models::{Url, LandingPage};
use rusqlite::{params, Connection};
use uuid::Uuid;
// Helper: Associate tags with a URL
fn associate_tags(conn: &Connection, url_id: &str, tags: &[String]) -> rusqlite::Result<()> {
@@ -11,20 +11,20 @@ fn associate_tags(conn: &Connection, url_id: &str, tags: &[String]) -> rusqlite:
if tag_name.is_empty() {
continue;
}
// Insert tag if it doesn't exist
conn.execute(
"INSERT OR IGNORE INTO tags (id, name) VALUES (?1, ?2);",
params![Uuid::new_v4().to_string(), tag_name],
)?;
// Get tag id
let tag_id: String = conn.query_row(
"SELECT id FROM tags WHERE name = ?1;",
params![tag_name],
|row| row.get(0),
)?;
// Insert association
conn.execute(
"INSERT OR IGNORE INTO url_tags (url_id, tag_id) VALUES (?1, ?2);",
@@ -47,6 +47,31 @@ pub fn get_tags_for_url(conn: &Connection, url_id: &str) -> rusqlite::Result<Vec
Ok(tags)
}
/// The full column list used in all URL SELECT queries.
const URL_COLUMNS: &str = "id, code, destination, title, description, status, created_at, updated_at, expires_at, expired, password_hash, last_status, last_latency_ms, max_access_count, access_count";
/// Build a Url struct from a row containing URL_COLUMNS in order.
fn url_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Url> {
Ok(Url {
id: row.get(0)?,
code: row.get(1)?,
destination: row.get(2)?,
title: row.get(3)?,
description: row.get(4)?,
status: row.get(5)?,
created_at: row.get(6)?,
updated_at: row.get(7)?,
expires_at: row.get(8)?,
expired: row.get::<_, i32>(9).unwrap_or(0) != 0,
password_hash: row.get(10)?,
last_status: row.get(11)?,
last_latency_ms: row.get(12)?,
max_access_count: row.get(13)?,
access_count: row.get::<_, i64>(14).unwrap_or(0),
tags: Vec::new(), // filled after query
})
}
pub fn create_url(
conn: &Connection,
code: &str,
@@ -77,54 +102,84 @@ pub fn create_url(
created_at: now.clone(),
updated_at: now,
tags: tags.to_vec(),
expires_at: None,
expired: false,
password_hash: None,
last_status: None,
last_latency_ms: None,
max_access_count: None,
access_count: 0,
})
}
/// Create a URL with all extended options.
#[allow(clippy::too_many_arguments)]
pub fn create_url_extended(
conn: &Connection,
code: &str,
destination: &str,
title: Option<&str>,
description: Option<&str>,
tags: &[String],
expires_at: Option<&str>,
password_hash: Option<&str>,
max_access_count: Option<i64>,
) -> rusqlite::Result<Url> {
let id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
let status = "healthy".to_string();
conn.execute(
"INSERT INTO urls (id, code, destination, title, description, status, created_at, updated_at, expires_at, password_hash, max_access_count)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11);",
params![id, code, destination, title, description, status, now, now, expires_at, password_hash, max_access_count],
)?;
associate_tags(conn, &id, tags)?;
Ok(Url {
id,
code: code.to_string(),
destination: destination.to_string(),
title: title.map(|s| s.to_string()),
description: description.map(|s| s.to_string()),
status,
created_at: now.clone(),
updated_at: now,
tags: tags.to_vec(),
expires_at: expires_at.map(|s| s.to_string()),
expired: false,
password_hash: password_hash.map(|s| s.to_string()),
last_status: None,
last_latency_ms: None,
max_access_count,
access_count: 0,
})
}
pub fn get_url_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Option<Url>> {
let mut stmt = conn.prepare(
"SELECT id, code, destination, title, description, status, created_at, updated_at FROM urls WHERE id = ?1;"
)?;
let sql = format!("SELECT {} FROM urls WHERE id = ?1;", URL_COLUMNS);
let mut stmt = conn.prepare(&sql)?;
let mut rows = stmt.query(params![id])?;
if let Some(row) = rows.next()? {
let url_id: String = row.get(0)?;
let tags = get_tags_for_url(conn, &url_id)?;
Ok(Some(Url {
id: url_id,
code: row.get(1)?,
destination: row.get(2)?,
title: row.get(3)?,
description: row.get(4)?,
status: row.get(5)?,
created_at: row.get(6)?,
updated_at: row.get(7)?,
tags,
}))
let mut url = url_from_row(row)?;
url.tags = get_tags_for_url(conn, &url.id)?;
Ok(Some(url))
} else {
Ok(None)
}
}
pub fn get_url_by_code(conn: &Connection, code: &str) -> rusqlite::Result<Option<Url>> {
let mut stmt = conn.prepare(
"SELECT id, code, destination, title, description, status, created_at, updated_at FROM urls WHERE code = ?1;"
)?;
let sql = format!("SELECT {} FROM urls WHERE code = ?1;", URL_COLUMNS);
let mut stmt = conn.prepare(&sql)?;
let mut rows = stmt.query(params![code])?;
if let Some(row) = rows.next()? {
let url_id: String = row.get(0)?;
let tags = get_tags_for_url(conn, &url_id)?;
Ok(Some(Url {
id: url_id,
code: row.get(1)?,
destination: row.get(2)?,
title: row.get(3)?,
description: row.get(4)?,
status: row.get(5)?,
created_at: row.get(6)?,
updated_at: row.get(7)?,
tags,
}))
let mut url = url_from_row(row)?;
url.tags = get_tags_for_url(conn, &url.id)?;
Ok(Some(url))
} else {
Ok(None)
}
@@ -167,69 +222,60 @@ pub fn list_urls(
tag_filter: Option<&str>,
) -> rusqlite::Result<Vec<Url>> {
let mut urls = Vec::new();
if let Some(tag) = tag_filter {
let tag_name = tag.trim().to_lowercase();
let mut stmt = conn.prepare(
"SELECT u.id, u.code, u.destination, u.title, u.description, u.status, u.created_at, u.updated_at
FROM urls u
JOIN url_tags ut ON u.id = ut.url_id
JOIN tags t ON ut.tag_id = t.id
let sql = format!(
"SELECT u.{} FROM urls u
JOIN url_tags ut ON u.id = ut.url_id
JOIN tags t ON ut.tag_id = t.id
WHERE t.name = ?1
ORDER BY u.created_at DESC LIMIT ?2 OFFSET ?3;"
)?;
let rows = stmt.query_map(params![tag_name, limit, offset], |row| {
let url_id: String = row.get(0)?;
Ok((url_id, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?, row.get(6)?, row.get(7)?))
})?;
ORDER BY u.created_at DESC LIMIT ?2 OFFSET ?3;",
URL_COLUMNS
.replace("id,", "u.id,")
.replace(", code", ", u.code")
.replace(", destination", ", u.destination")
.replace(", title", ", u.title")
.replace(", description", ", u.description")
.replace(", status", ", u.status")
.replace(", created_at", ", u.created_at")
.replace(", updated_at", ", u.updated_at")
.replace(", expires_at", ", u.expires_at")
.replace(", expired", ", u.expired")
.replace(", password_hash", ", u.password_hash")
.replace(", last_status", ", u.last_status")
.replace(", last_latency_ms", ", u.last_latency_ms")
.replace(", max_access_count", ", u.max_access_count")
.replace(", access_count", ", u.access_count")
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(params![tag_name, limit, offset], url_from_row)?;
for r in rows {
let (url_id, code, destination, title, description, status, created_at, updated_at) = r?;
let tags = get_tags_for_url(conn, &url_id)?;
urls.push(Url {
id: url_id,
code,
destination,
title,
description,
status,
created_at,
updated_at,
tags,
});
let mut url = r?;
url.tags = get_tags_for_url(conn, &url.id)?;
urls.push(url);
}
} else {
let mut stmt = conn.prepare(
"SELECT id, code, destination, title, description, status, created_at, updated_at
FROM urls ORDER BY created_at DESC LIMIT ?1 OFFSET ?2;"
)?;
let rows = stmt.query_map(params![limit, offset], |row| {
let url_id: String = row.get(0)?;
Ok((url_id, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?, row.get(6)?, row.get(7)?))
})?;
let sql = format!(
"SELECT {} FROM urls ORDER BY created_at DESC LIMIT ?1 OFFSET ?2;",
URL_COLUMNS
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(params![limit, offset], url_from_row)?;
for r in rows {
let (url_id, code, destination, title, description, status, created_at, updated_at) = r?;
let tags = get_tags_for_url(conn, &url_id)?;
urls.push(Url {
id: url_id,
code,
destination,
title,
description,
status,
created_at,
updated_at,
tags,
});
let mut url = r?;
url.tags = get_tags_for_url(conn, &url.id)?;
urls.push(url);
}
}
Ok(urls)
}
pub fn list_urls_for_health_check(conn: &Connection) -> rusqlite::Result<Vec<(String, String)>> {
let mut stmt = conn.prepare("SELECT id, destination FROM urls;")?;
let mut stmt = conn.prepare("SELECT id, destination FROM urls WHERE expired = 0;")?;
let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
let mut res = Vec::new();
for r in rows {
@@ -247,13 +293,111 @@ pub fn update_url_health(conn: &Connection, id: &str, status: &str) -> rusqlite:
Ok(())
}
/// Update URL health with extended status and latency information.
pub fn update_url_health_extended(
conn: &Connection,
id: &str,
status: &str,
last_status: &str,
latency_ms: Option<i64>,
) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
conn.execute(
"UPDATE urls SET status = ?1, last_status = ?2, last_latency_ms = ?3, updated_at = ?4 WHERE id = ?5;",
params![status, last_status, latency_ms, now, id],
)?;
Ok(())
}
pub fn get_url_counts(conn: &Connection) -> rusqlite::Result<(i64, i64, i64)> {
let total: i64 = conn.query_row("SELECT COUNT(*) FROM urls;", [], |row| row.get(0))?;
let active: i64 = conn.query_row("SELECT COUNT(*) FROM urls WHERE status IN ('healthy', 'suspect');", [], |row| row.get(0))?;
let dead: i64 = conn.query_row("SELECT COUNT(*) FROM urls WHERE status = 'dead';", [], |row| row.get(0))?;
let active: i64 = conn.query_row(
"SELECT COUNT(*) FROM urls WHERE status IN ('healthy', 'suspect') AND expired = 0;",
[],
|row| row.get(0),
)?;
let dead: i64 = conn.query_row(
"SELECT COUNT(*) FROM urls WHERE status = 'dead' OR expired = 1;",
[],
|row| row.get(0),
)?;
Ok((total, active, dead))
}
/// Mark all URLs with expires_at < now as expired.
pub fn expire_urls(conn: &Connection) -> rusqlite::Result<usize> {
let now = Utc::now().to_rfc3339();
let count = conn.execute(
"UPDATE urls SET expired = 1, updated_at = ?1 WHERE expires_at IS NOT NULL AND expires_at < ?1 AND expired = 0;",
params![now],
)?;
Ok(count)
}
/// Set a password hash on a URL.
pub fn set_url_password(
conn: &Connection,
id: &str,
password_hash: &str,
) -> rusqlite::Result<bool> {
let now = Utc::now().to_rfc3339();
let count = conn.execute(
"UPDATE urls SET password_hash = ?1, updated_at = ?2 WHERE id = ?3;",
params![password_hash, now, id],
)?;
Ok(count > 0)
}
/// Remove the password from a URL.
pub fn remove_url_password(conn: &Connection, id: &str) -> rusqlite::Result<bool> {
let now = Utc::now().to_rfc3339();
let count = conn.execute(
"UPDATE urls SET password_hash = NULL, updated_at = ?1 WHERE id = ?2;",
params![now, id],
)?;
Ok(count > 0)
}
/// Atomically increment the access count and return the new value.
pub fn increment_access_count(conn: &Connection, id: &str) -> rusqlite::Result<i64> {
conn.execute(
"UPDATE urls SET access_count = access_count + 1 WHERE id = ?1;",
params![id],
)?;
conn.query_row(
"SELECT access_count FROM urls WHERE id = ?1;",
params![id],
|row| row.get(0),
)
}
/// Set expiry on a URL.
pub fn set_url_expiry(conn: &Connection, id: &str, expires_at: &str) -> rusqlite::Result<bool> {
let now = Utc::now().to_rfc3339();
let count = conn.execute(
"UPDATE urls SET expires_at = ?1, updated_at = ?2 WHERE id = ?3;",
params![expires_at, now, id],
)?;
Ok(count > 0)
}
/// Get health status summary across all URLs.
pub fn get_health_summary(conn: &Connection) -> rusqlite::Result<Vec<(String, i64)>> {
let mut stmt = conn.prepare(
"SELECT COALESCE(last_status, status) AS health, COUNT(*) FROM urls GROUP BY health ORDER BY COUNT(*) DESC;"
)?;
let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
let mut res = Vec::new();
for r in rows {
res.push(r?);
}
Ok(res)
}
// --- Landing Page Operations (unchanged) ---
use crate::models::LandingPage;
pub fn create_landing_page(
conn: &Connection,
code: &str,
@@ -283,7 +427,10 @@ pub fn create_landing_page(
})
}
pub fn get_landing_page_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Option<LandingPage>> {
pub fn get_landing_page_by_id(
conn: &Connection,
id: &str,
) -> rusqlite::Result<Option<LandingPage>> {
let mut stmt = conn.prepare(
"SELECT id, code, slug, title, html_content, state, created_at, updated_at FROM landing_pages WHERE id = ?1;"
)?;
@@ -305,7 +452,10 @@ pub fn get_landing_page_by_id(conn: &Connection, id: &str) -> rusqlite::Result<O
}
}
pub fn get_landing_page_by_code(conn: &Connection, code: &str) -> rusqlite::Result<Option<LandingPage>> {
pub fn get_landing_page_by_code(
conn: &Connection,
code: &str,
) -> rusqlite::Result<Option<LandingPage>> {
let mut stmt = conn.prepare(
"SELECT id, code, slug, title, html_content, state, created_at, updated_at FROM landing_pages WHERE code = ?1;"
)?;
@@ -354,10 +504,14 @@ pub fn delete_landing_page(conn: &Connection, id: &str) -> rusqlite::Result<bool
Ok(count > 0)
}
pub fn list_landing_pages(conn: &Connection, limit: i64, offset: i64) -> rusqlite::Result<Vec<LandingPage>> {
pub fn list_landing_pages(
conn: &Connection,
limit: i64,
offset: i64,
) -> rusqlite::Result<Vec<LandingPage>> {
let mut stmt = conn.prepare(
"SELECT id, code, slug, title, html_content, state, created_at, updated_at
FROM landing_pages ORDER BY created_at DESC LIMIT ?1 OFFSET ?2;"
FROM landing_pages ORDER BY created_at DESC LIMIT ?1 OFFSET ?2;",
)?;
let rows = stmt.query_map(params![limit, offset], |row| {
Ok(LandingPage {
+104 -11
View File
@@ -27,7 +27,12 @@ pub fn run_migrations(
if current_version < target_version {
for m in migrations.iter().filter(|m| m.version > current_version) {
info!(database = db_name, version = m.version, name = m.name, "Applying migration");
info!(
database = db_name,
version = m.version,
name = m.name,
"Applying migration"
);
let tx = conn.transaction()?;
tx.execute_batch(m.sql)?;
@@ -35,7 +40,12 @@ pub fn run_migrations(
crate::db::sqlite::set_user_version(conn, m.version as i32)?;
info!(database = db_name, version = m.version, name = m.name, "Migration completed");
info!(
database = db_name,
version = m.version,
name = m.name,
"Migration completed"
);
// Write audit record to system.db.migrations
if let Some(sys_db_mutex) = system_db_opt {
@@ -58,7 +68,11 @@ pub fn run_migrations(
}
}
} else {
info!(database = db_name, version = current_version, "Database up to date");
info!(
database = db_name,
version = current_version,
"Database up to date"
);
}
Ok(())
@@ -77,7 +91,10 @@ pub fn print_migration_plan(
println!(" Current version: {current_version}");
println!(" Target version: {target_version}");
let pending: Vec<&Migration> = migrations.iter().filter(|m| m.version > current_version).collect();
let pending: Vec<&Migration> = migrations
.iter()
.filter(|m| m.version > current_version)
.collect();
if pending.is_empty() {
println!(" Status: up to date");
@@ -94,11 +111,10 @@ pub fn print_migration_plan(
// Migration definitions
// ---------------------------------------------------------------------------
pub const ADMIN_MIGRATIONS: &[Migration] = &[
Migration {
version: 1,
name: "initial_schema",
sql: r#"
pub const ADMIN_MIGRATIONS: &[Migration] = &[Migration {
version: 1,
name: "initial_schema",
sql: r#"
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
@@ -140,8 +156,7 @@ pub const ADMIN_MIGRATIONS: &[Migration] = &[
value TEXT NOT NULL
);
"#,
},
];
}];
pub const CONTENT_MIGRATIONS: &[Migration] = &[
Migration {
@@ -187,6 +202,49 @@ pub const CONTENT_MIGRATIONS: &[Migration] = &[
CREATE INDEX IF NOT EXISTS idx_pages_code ON landing_pages(code);
"#,
},
Migration {
version: 2,
name: "features_expansion",
sql: r#"
-- Expiring Links
ALTER TABLE urls ADD COLUMN expires_at TEXT NULL;
ALTER TABLE urls ADD COLUMN expired INTEGER NOT NULL DEFAULT 0;
-- Password Protected Links
ALTER TABLE urls ADD COLUMN password_hash TEXT NULL;
-- Link Health Dashboard (extended columns)
ALTER TABLE urls ADD COLUMN last_status TEXT;
ALTER TABLE urls ADD COLUMN last_latency_ms INTEGER;
-- One-Time Links
ALTER TABLE urls ADD COLUMN max_access_count INTEGER NULL;
ALTER TABLE urls ADD COLUMN access_count INTEGER NOT NULL DEFAULT 0;
-- Smart Landing Pages / Link Preview
CREATE TABLE IF NOT EXISTS link_preview (
id TEXT PRIMARY KEY,
url_id TEXT NOT NULL UNIQUE,
title TEXT,
description TEXT,
logo_url TEXT,
button_text TEXT DEFAULT 'Continue',
FOREIGN KEY(url_id) REFERENCES urls(id) ON DELETE CASCADE
);
-- QR Code style metadata
CREATE TABLE IF NOT EXISTS qr_codes (
id TEXT PRIMARY KEY,
url_id TEXT NOT NULL,
style TEXT NOT NULL DEFAULT 'default',
created_at TEXT NOT NULL,
FOREIGN KEY(url_id) REFERENCES urls(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_urls_expired ON urls(expired);
CREATE INDEX IF NOT EXISTS idx_urls_expires_at ON urls(expires_at);
"#,
},
];
pub const ANALYTICS_MIGRATIONS: &[Migration] = &[
@@ -241,6 +299,22 @@ pub const ANALYTICS_MIGRATIONS: &[Migration] = &[
CREATE INDEX IF NOT EXISTS idx_visits_target ON visits(target_type, target_id);
"#,
},
Migration {
version: 2,
name: "qr_access_log",
sql: r#"
CREATE TABLE IF NOT EXISTS qr_access_log (
id TEXT PRIMARY KEY,
url_id TEXT NOT NULL,
timestamp TEXT NOT NULL,
ip TEXT,
user_agent TEXT
);
CREATE INDEX IF NOT EXISTS idx_qr_access_url ON qr_access_log(url_id);
CREATE INDEX IF NOT EXISTS idx_qr_access_ts ON qr_access_log(timestamp);
"#,
},
];
pub const SYSTEM_MIGRATIONS: &[Migration] = &[
@@ -291,4 +365,23 @@ pub const SYSTEM_MIGRATIONS: &[Migration] = &[
);
"#,
},
Migration {
version: 2,
name: "audit_events",
sql: r#"
CREATE TABLE IF NOT EXISTS audit_events (
id TEXT PRIMARY KEY,
actor TEXT NOT NULL,
action TEXT NOT NULL,
object_type TEXT NOT NULL,
object_id TEXT NOT NULL,
timestamp TEXT NOT NULL,
metadata TEXT
);
CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_events(actor);
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_events(timestamp);
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_events(action);
"#,
},
];
+48 -16
View File
@@ -1,15 +1,20 @@
use crate::config::Config;
use crate::db::migrations::{
run_migrations, ADMIN_MIGRATIONS, ANALYTICS_MIGRATIONS, CONTENT_MIGRATIONS, SYSTEM_MIGRATIONS,
};
use crate::db::sqlite::{enable_foreign_keys, enable_wal};
use rusqlite::Connection;
use std::fs;
use std::sync::{Arc, Mutex};
use rusqlite::Connection;
use crate::config::Config;
use crate::db::migrations::{run_migrations, ADMIN_MIGRATIONS, CONTENT_MIGRATIONS, ANALYTICS_MIGRATIONS, SYSTEM_MIGRATIONS};
use crate::db::sqlite::{enable_foreign_keys, enable_wal};
pub mod migrations;
pub mod sqlite;
pub mod admin;
pub mod content;
pub mod analytics;
pub mod audit_events;
pub mod content;
pub mod migrations;
pub mod preview;
pub mod qr;
pub mod sqlite;
#[derive(Clone)]
pub struct Db {
@@ -53,13 +58,25 @@ impl Db {
enable_wal(&system_conn, "system")?;
// Enable foreign key support
info!(database = "admin", "Enabling foreign key enforcement on admin.db");
info!(
database = "admin",
"Enabling foreign key enforcement on admin.db"
);
enable_foreign_keys(&admin_conn, "admin")?;
info!(database = "content", "Enabling foreign key enforcement on content.db");
info!(
database = "content",
"Enabling foreign key enforcement on content.db"
);
enable_foreign_keys(&content_conn, "content")?;
info!(database = "analytics", "Enabling foreign key enforcement on analytics.db");
info!(
database = "analytics",
"Enabling foreign key enforcement on analytics.db"
);
enable_foreign_keys(&analytics_conn, "analytics")?;
info!(database = "system", "Enabling foreign key enforcement on system.db");
info!(
database = "system",
"Enabling foreign key enforcement on system.db"
);
enable_foreign_keys(&system_conn, "system")?;
// 1. Run migrations for system.db first, as it receives secondary audit records
@@ -70,11 +87,26 @@ impl Db {
// 2. Run migrations for other databases with system.db logging
info!("Running admin migrations");
run_migrations(&mut admin_conn, "admin", ADMIN_MIGRATIONS, Some(&system_arc))?;
run_migrations(
&mut admin_conn,
"admin",
ADMIN_MIGRATIONS,
Some(&system_arc),
)?;
info!("Running content migrations");
run_migrations(&mut content_conn, "content", CONTENT_MIGRATIONS, Some(&system_arc))?;
run_migrations(
&mut content_conn,
"content",
CONTENT_MIGRATIONS,
Some(&system_arc),
)?;
info!("Running analytics migrations");
run_migrations(&mut analytics_conn, "analytics", ANALYTICS_MIGRATIONS, Some(&system_arc))?;
run_migrations(
&mut analytics_conn,
"analytics",
ANALYTICS_MIGRATIONS,
Some(&system_arc),
)?;
Ok(Self {
admin: Arc::new(Mutex::new(admin_conn)),
@@ -115,12 +147,12 @@ mod db_init_tests {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let db = Db::init(&config);
// Cleanup
if temp_dir.exists() {
let _ = std::fs::remove_dir_all(&temp_dir);
}
assert!(db.is_ok(), "Failed to init DB: {:?}", db.err());
}
}
+62
View File
@@ -0,0 +1,62 @@
use crate::models::LinkPreview;
use rusqlite::{params, Connection};
use uuid::Uuid;
/// Insert or update a link preview for a URL.
pub fn upsert_preview(
conn: &Connection,
url_id: &str,
title: Option<&str>,
description: Option<&str>,
logo_url: Option<&str>,
button_text: Option<&str>,
) -> rusqlite::Result<LinkPreview> {
let id = Uuid::new_v4().to_string();
let btn = button_text.unwrap_or("Continue");
conn.execute(
"INSERT INTO link_preview (id, url_id, title, description, logo_url, button_text)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(url_id) DO UPDATE SET
title = excluded.title,
description = excluded.description,
logo_url = excluded.logo_url,
button_text = excluded.button_text;",
params![id, url_id, title, description, logo_url, btn],
)?;
// Return the current state (may have been an update with a different id)
get_preview(conn, url_id)?.ok_or(rusqlite::Error::QueryReturnedNoRows)
}
/// Get the link preview for a URL.
pub fn get_preview(conn: &Connection, url_id: &str) -> rusqlite::Result<Option<LinkPreview>> {
let mut stmt = conn.prepare(
"SELECT id, url_id, title, description, logo_url, button_text FROM link_preview WHERE url_id = ?1;"
)?;
let mut rows = stmt.query(params![url_id])?;
if let Some(row) = rows.next()? {
Ok(Some(LinkPreview {
id: row.get(0)?,
url_id: row.get(1)?,
title: row.get(2)?,
description: row.get(3)?,
logo_url: row.get(4)?,
button_text: row
.get::<_, Option<String>>(5)?
.unwrap_or_else(|| "Continue".to_string()),
}))
} else {
Ok(None)
}
}
/// Delete the link preview for a URL.
pub fn delete_preview(conn: &Connection, url_id: &str) -> rusqlite::Result<bool> {
let count = conn.execute(
"DELETE FROM link_preview WHERE url_id = ?1;",
params![url_id],
)?;
Ok(count > 0)
}
+90
View File
@@ -0,0 +1,90 @@
use chrono::Utc;
use rusqlite::{params, Connection, OptionalExtension};
use uuid::Uuid;
/// Log a QR code access event to the analytics database.
pub fn log_qr_access(
conn: &Connection,
url_id: &str,
ip: Option<&str>,
user_agent: Option<&str>,
) -> rusqlite::Result<()> {
let id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO qr_access_log (id, url_id, timestamp, ip, user_agent)
VALUES (?1, ?2, ?3, ?4, ?5);",
params![id, url_id, now, ip, user_agent],
)?;
Ok(())
}
/// Get QR scan count for a URL.
pub fn get_qr_scan_count(conn: &Connection, url_id: &str) -> rusqlite::Result<i64> {
conn.query_row(
"SELECT COUNT(*) FROM qr_access_log WHERE url_id = ?1;",
params![url_id],
|row| row.get(0),
)
}
/// Get QR scan count for a URL by its code (joins with content.db — must be called on analytics db after lookup).
pub fn get_qr_stats_for_url(
conn: &Connection,
url_id: &str,
) -> rusqlite::Result<Vec<(String, String)>> {
let mut stmt = conn.prepare(
"SELECT timestamp, ip FROM qr_access_log WHERE url_id = ?1 ORDER BY timestamp DESC LIMIT 100;"
)?;
let rows = stmt.query_map(params![url_id], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, Option<String>>(1)?.unwrap_or_default(),
))
})?;
let mut results = Vec::new();
for r in rows {
results.push(r?);
}
Ok(results)
}
/// Create or update a QR code style registration in the content database.
pub fn upsert_qr_code(conn: &Connection, url_id: &str, style: &str) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
// Check if entry already exists
let existing_id: Option<String> = conn
.query_row(
"SELECT id FROM qr_codes WHERE url_id = ?1;",
params![url_id],
|row| row.get(0),
)
.optional()?;
if let Some(id) = existing_id {
conn.execute(
"UPDATE qr_codes SET style = ?1 WHERE id = ?2;",
params![style, id],
)?;
} else {
let id = Uuid::new_v4().to_string();
conn.execute(
"INSERT INTO qr_codes (id, url_id, style, created_at) VALUES (?1, ?2, ?3, ?4);",
params![id, url_id, style, now],
)?;
}
Ok(())
}
/// Get the style configured for a QR code.
pub fn get_qr_code_style(conn: &Connection, url_id: &str) -> rusqlite::Result<String> {
let style: Option<String> = conn
.query_row(
"SELECT style FROM qr_codes WHERE url_id = ?1;",
params![url_id],
|row| row.get(0),
)
.optional()?;
Ok(style.unwrap_or_else(|| "default".to_string()))
}
+9 -5
View File
@@ -14,8 +14,9 @@ use tracing::info;
/// Uses `query_row` with `PRAGMA journal_mode=WAL` which both sets and returns
/// the actual mode. Returns an error if the database does not confirm WAL mode.
pub fn enable_wal(conn: &Connection, db_name: &str) -> Result<(), rusqlite::Error> {
let actual_mode: String =
conn.query_row("PRAGMA journal_mode=WAL;", [], |row| row.get::<_, String>(0))?;
let actual_mode: String = conn.query_row("PRAGMA journal_mode=WAL;", [], |row| {
row.get::<_, String>(0)
})?;
info!(database = db_name, mode = %actual_mode, "WAL mode configured");
@@ -36,7 +37,11 @@ pub fn enable_foreign_keys(conn: &Connection, db_name: &str) -> Result<(), rusql
let enabled: bool =
conn.pragma_query_value(None, "foreign_keys", |row| row.get::<_, bool>(0))?;
info!(database = db_name, foreign_keys = enabled, "Foreign key enforcement configured");
info!(
database = db_name,
foreign_keys = enabled,
"Foreign key enforcement configured"
);
if !enabled {
return Err(rusqlite::Error::QueryReturnedNoRows);
@@ -183,8 +188,7 @@ mod tests {
#[test]
fn test_collect_health_report() {
let conn = memory_conn();
let report =
collect_health_report(&conn, "test").expect("Failed to collect health report");
let report = collect_health_report(&conn, "test").expect("Failed to collect health report");
assert_eq!(report.database, "test");
assert!(report.integrity_ok);
}