388 lines
11 KiB
Rust
388 lines
11 KiB
Rust
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);
|
|
"#,
|
|
},
|
|
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] = &[
|
|
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);
|
|
"#,
|
|
},
|
|
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] = &[
|
|
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
|
|
);
|
|
"#,
|
|
},
|
|
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);
|
|
"#,
|
|
},
|
|
];
|