use std::path::{Path, PathBuf}; use async_trait::async_trait; use rusqlite::{Connection, params}; use sqlx::{FromRow, MySqlPool, PgPool}; use super::{PersistedSession, SessionPersistence}; use crate::config::AppConfig; use crate::db::pool::{DatabaseBackend, global_schema_for_backend}; use crate::queries; #[derive(Clone)] enum Pool { Sqlite(PathBuf), Mysql(MySqlPool), Pgsql(PgPool), } #[derive(Debug, FromRow)] struct DbSessionRow { token: String, account_id: String, browser_id: String, email: String, password_ciphertext: String, user_hash: String, imap_host: String, imap_port: i64, imap_tls: i64, imap_tls_verify: i64, smtp_host: String, smtp_port: i64, smtp_tls: i64, smtp_tls_verify: i64, timeout_secs: i64, created_at_unix: i64, last_accessed_unix: i64, user_agent: String, } impl TryFrom for PersistedSession { type Error = String; fn try_from(row: DbSessionRow) -> Result { Ok(Self { token: row.token, account_id: row.account_id, browser_id: row.browser_id, email: row.email, password_ciphertext: row.password_ciphertext, user_hash: row.user_hash, imap_host: row.imap_host, imap_port: u16::try_from(row.imap_port).map_err(|_| "invalid imap_port in database")?, imap_tls: row.imap_tls != 0, imap_tls_verify: row.imap_tls_verify != 0, smtp_host: row.smtp_host, smtp_port: u16::try_from(row.smtp_port).map_err(|_| "invalid smtp_port in database")?, smtp_tls: row.smtp_tls != 0, smtp_tls_verify: row.smtp_tls_verify != 0, timeout_secs: u64::try_from(row.timeout_secs) .map_err(|_| "invalid timeout_secs in database")?, created_at_unix: u64::try_from(row.created_at_unix) .map_err(|_| "invalid created_at_unix in database")?, last_accessed_unix: u64::try_from(row.last_accessed_unix) .map_err(|_| "invalid last_accessed_unix in database")?, user_agent: row.user_agent, }) } } fn sqlite_path(config: &AppConfig) -> Result { let path = match config .database_url .as_deref() .map(str::trim) .filter(|v| !v.is_empty()) { Some(raw) => { let without_scheme = raw .strip_prefix("sqlite://") .or_else(|| raw.strip_prefix("sqlite:")) .unwrap_or(raw); let path = without_scheme.split('?').next().unwrap_or_default().trim(); if path.is_empty() { return Err("SQLite DATABASE_URL does not contain a database path".to_string()); } PathBuf::from(path) } None => PathBuf::from(&config.data_dir).join("rustbox.sqlite"), }; if path == Path::new(":memory:") { return Err( "SQLite session storage must use a file so sessions survive restart".to_string(), ); } if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) { std::fs::create_dir_all(parent) .map_err(|e| format!("failed to create SQLite database directory: {e}"))?; } Ok(path) } fn sqlite_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { Ok(DbSessionRow { token: row.get("token")?, account_id: row.get("account_id")?, browser_id: row.get("browser_id")?, email: row.get("email")?, password_ciphertext: row.get("password_ciphertext")?, user_hash: row.get("user_hash")?, imap_host: row.get("imap_host")?, imap_port: row.get("imap_port")?, imap_tls: row.get("imap_tls")?, imap_tls_verify: row.get("imap_tls_verify")?, smtp_host: row.get("smtp_host")?, smtp_port: row.get("smtp_port")?, smtp_tls: row.get("smtp_tls")?, smtp_tls_verify: row.get("smtp_tls_verify")?, timeout_secs: row.get("timeout_secs")?, created_at_unix: row.get("created_at_unix")?, last_accessed_unix: row.get("last_accessed_unix")?, user_agent: row.get("user_agent")?, }) } async fn sqlite_task(path: PathBuf, operation: F) -> Result where T: Send + 'static, F: FnOnce(Connection) -> Result + Send + 'static, { tokio::task::spawn_blocking(move || { let conn = Connection::open(path).map_err(|e| format!("failed to open SQLite database: {e}"))?; conn.busy_timeout(std::time::Duration::from_secs(5)) .map_err(|e| format!("failed to configure SQLite busy timeout: {e}"))?; operation(conn) }) .await .map_err(|e| format!("SQLite session task failed: {e}"))? } #[derive(Clone)] pub struct DatabaseSessionStore { pool: Pool, } impl DatabaseSessionStore { pub async fn connect(config: &AppConfig) -> Result { let pool = match config.database_backend.as_str() { "sqlite" => { let path = sqlite_path(config)?; let migration_path = path.clone(); let schema = global_schema_for_backend(DatabaseBackend::Sqlite)?; sqlite_task(migration_path, move |conn| { conn.execute_batch(&schema).map_err(|e| { format!("SQLite database schema initialization failed: {e}") })?; Ok(()) }) .await?; Pool::Sqlite(path) } "mysql" => { let url = config .database_url .as_deref() .ok_or("DATABASE_URL required for mysql")?; let pool = MySqlPool::connect(url) .await .map_err(|e| format!("failed to connect MySQL database: {e}"))?; let schema = global_schema_for_backend(DatabaseBackend::Mysql)?; sqlx::raw_sql(&schema) .execute(&pool) .await .map_err(|e| format!("MySQL database schema initialization failed: {e}"))?; Pool::Mysql(pool) } "pgsql" => { let url = config .database_url .as_deref() .ok_or("DATABASE_URL required for pgsql")?; let pool = PgPool::connect(url) .await .map_err(|e| format!("failed to connect PostgreSQL database: {e}"))?; let schema = global_schema_for_backend(DatabaseBackend::Pgsql)?; sqlx::raw_sql(&schema).execute(&pool).await.map_err(|e| { format!("PostgreSQL database schema initialization failed: {e}") })?; Pool::Pgsql(pool) } other => return Err(format!("unsupported database backend: {other}")), }; Ok(Self { pool }) } async fn rows( &self, sql_sqlite: &str, sql_mysql: &str, sql_pgsql: &str, value: &str, ) -> Result, String> { let rows: Vec = match &self.pool { Pool::Sqlite(path) => { let path = path.clone(); let sql = sql_sqlite.to_string(); let value = value.to_string(); sqlite_task(path, move |conn| { let mut stmt = conn .prepare(&sql) .map_err(|e| format!("failed to prepare SQLite session query: {e}"))?; let mapped = stmt .query_map(params![value], sqlite_row) .map_err(|e| format!("SQLite session query failed: {e}"))?; mapped .collect::>>() .map_err(|e| format!("failed to read SQLite session rows: {e}")) }) .await? } Pool::Mysql(pool) => sqlx::query_as(sql_mysql) .bind(value) .fetch_all(pool) .await .map_err(|e| format!("MySQL session query failed: {e}"))?, Pool::Pgsql(pool) => sqlx::query_as(sql_pgsql) .bind(value) .fetch_all(pool) .await .map_err(|e| format!("PostgreSQL session query failed: {e}"))?, }; rows.into_iter().map(TryInto::try_into).collect() } } macro_rules! bind_session { ($q:expr, $s:expr) => { $q.bind(&$s.token) .bind(&$s.account_id) .bind(&$s.browser_id) .bind(&$s.email) .bind(&$s.password_ciphertext) .bind(&$s.user_hash) .bind(&$s.imap_host) .bind(i64::from($s.imap_port)) .bind(if $s.imap_tls { 1_i64 } else { 0_i64 }) .bind(if $s.imap_tls_verify { 1_i64 } else { 0_i64 }) .bind(&$s.smtp_host) .bind(i64::from($s.smtp_port)) .bind(if $s.smtp_tls { 1_i64 } else { 0_i64 }) .bind(if $s.smtp_tls_verify { 1_i64 } else { 0_i64 }) .bind($s.timeout_secs as i64) .bind($s.created_at_unix as i64) .bind($s.last_accessed_unix as i64) .bind(&$s.user_agent) }; } #[async_trait] impl SessionPersistence for DatabaseSessionStore { async fn save(&self, s: &PersistedSession) -> Result<(), String> { match &self.pool { Pool::Sqlite(path) => { let path = path.clone(); let s = s.clone(); sqlite_task(path, move |conn| { conn.execute( queries::sqlite::queries::sessions::UPSERT, params![ s.token, s.account_id, s.browser_id, s.email, s.password_ciphertext, s.user_hash, s.imap_host, i64::from(s.imap_port), if s.imap_tls { 1_i64 } else { 0_i64 }, if s.imap_tls_verify { 1_i64 } else { 0_i64 }, s.smtp_host, i64::from(s.smtp_port), if s.smtp_tls { 1_i64 } else { 0_i64 }, if s.smtp_tls_verify { 1_i64 } else { 0_i64 }, s.timeout_secs as i64, s.created_at_unix as i64, s.last_accessed_unix as i64, s.user_agent, ], ) .map_err(|e| format!("failed to persist SQLite session: {e}"))?; Ok(()) }) .await } Pool::Mysql(pool) => { bind_session!(sqlx::query(queries::mysql::queries::sessions::UPSERT), s) .execute(pool) .await .map(|_| ()) .map_err(|e| format!("failed to persist MySQL session: {e}")) } Pool::Pgsql(pool) => { bind_session!(sqlx::query(queries::pgsql::queries::sessions::UPSERT), s) .execute(pool) .await .map(|_| ()) .map_err(|e| format!("failed to persist PostgreSQL session: {e}")) } } } async fn load_token(&self, token: &str) -> Result, String> { Ok(self .rows( queries::sqlite::queries::sessions::BY_TOKEN, queries::mysql::queries::sessions::BY_TOKEN, queries::pgsql::queries::sessions::BY_TOKEN, token, ) .await? .into_iter() .next()) } async fn load_browser(&self, browser_id: &str) -> Result, String> { self.rows( queries::sqlite::queries::sessions::BY_BROWSER, queries::mysql::queries::sessions::BY_BROWSER, queries::pgsql::queries::sessions::BY_BROWSER, browser_id, ) .await } async fn load_user(&self, user_hash: &str) -> Result, String> { self.rows( queries::sqlite::queries::sessions::BY_USER, queries::mysql::queries::sessions::BY_USER, queries::pgsql::queries::sessions::BY_USER, user_hash, ) .await } async fn browser_exists(&self, browser_id: &str) -> Result { let count = match &self.pool { Pool::Sqlite(path) => { let path = path.clone(); let browser_id = browser_id.to_string(); sqlite_task(path, move |conn| { conn.query_row( queries::sqlite::queries::sessions::BROWSER_EXISTS, params![browser_id], |row| row.get::<_, i64>(0), ) .map_err(|e| format!("failed to query SQLite browser session: {e}")) }) .await? } Pool::Mysql(pool) => { sqlx::query_scalar(queries::mysql::queries::sessions::BROWSER_EXISTS) .bind(browser_id) .fetch_one(pool) .await .map_err(|e| format!("failed to query MySQL browser session: {e}"))? } Pool::Pgsql(pool) => { sqlx::query_scalar(queries::pgsql::queries::sessions::BROWSER_EXISTS) .bind(browser_id) .fetch_one(pool) .await .map_err(|e| format!("failed to query PostgreSQL browser session: {e}"))? } }; Ok(count > 0) } async fn delete_account(&self, account_id: &str) -> Result { let affected = match &self.pool { Pool::Sqlite(path) => { let path = path.clone(); let account_id = account_id.to_string(); sqlite_task(path, move |conn| { conn.execute( queries::sqlite::queries::sessions::DELETE_ACCOUNT, params![account_id], ) .map(|count| count as u64) .map_err(|e| format!("failed to delete SQLite session account: {e}")) }) .await? } Pool::Mysql(pool) => sqlx::query(queries::mysql::queries::sessions::DELETE_ACCOUNT) .bind(account_id) .execute(pool) .await .map(|r| r.rows_affected()) .map_err(|e| format!("failed to delete MySQL session account: {e}"))?, Pool::Pgsql(pool) => sqlx::query(queries::pgsql::queries::sessions::DELETE_ACCOUNT) .bind(account_id) .execute(pool) .await .map(|r| r.rows_affected()) .map_err(|e| format!("failed to delete PostgreSQL session account: {e}"))?, }; Ok(affected > 0) } async fn delete_browser(&self, browser_id: &str) -> Result<(), String> { match &self.pool { Pool::Sqlite(path) => { let path = path.clone(); let browser_id = browser_id.to_string(); sqlite_task(path, move |conn| { conn.execute( queries::sqlite::queries::sessions::DELETE_BROWSER, params![browser_id], ) .map_err(|e| format!("failed to delete SQLite browser sessions: {e}"))?; Ok(()) }) .await } Pool::Mysql(pool) => sqlx::query(queries::mysql::queries::sessions::DELETE_BROWSER) .bind(browser_id) .execute(pool) .await .map(|_| ()) .map_err(|e| format!("failed to delete MySQL browser sessions: {e}")), Pool::Pgsql(pool) => sqlx::query(queries::pgsql::queries::sessions::DELETE_BROWSER) .bind(browser_id) .execute(pool) .await .map(|_| ()) .map_err(|e| format!("failed to delete PostgreSQL browser sessions: {e}")), } } async fn purge_expired(&self, now_unix: u64) -> Result { match &self.pool { Pool::Sqlite(path) => { let path = path.clone(); sqlite_task(path, move |conn| { conn.execute( queries::sqlite::queries::sessions::DELETE_EXPIRED, params![now_unix as i64], ) .map(|count| count as u64) .map_err(|e| format!("failed to purge expired SQLite sessions: {e}")) }) .await } Pool::Mysql(pool) => sqlx::query(queries::mysql::queries::sessions::DELETE_EXPIRED) .bind(now_unix as i64) .execute(pool) .await .map(|r| r.rows_affected()) .map_err(|e| format!("failed to purge expired MySQL sessions: {e}")), Pool::Pgsql(pool) => sqlx::query(queries::pgsql::queries::sessions::DELETE_EXPIRED) .bind(now_unix as i64) .execute(pool) .await .map(|r| r.rows_affected()) .map_err(|e| format!("failed to purge expired PostgreSQL sessions: {e}")), } } }