Files
RustBox/src/session_store/database.rs
T
2026-10-31 14:36:07 +01:00

472 lines
18 KiB
Rust

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<DbSessionRow> for PersistedSession {
type Error = String;
fn try_from(row: DbSessionRow) -> Result<Self, Self::Error> {
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<PathBuf, String> {
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<DbSessionRow> {
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<T, F>(path: PathBuf, operation: F) -> Result<T, String>
where
T: Send + 'static,
F: FnOnce(Connection) -> Result<T, String> + 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<Self, String> {
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<Vec<PersistedSession>, String> {
let rows: Vec<DbSessionRow> = 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::<rusqlite::Result<Vec<_>>>()
.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<Option<PersistedSession>, 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<Vec<PersistedSession>, 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<Vec<PersistedSession>, 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<bool, String> {
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<bool, String> {
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<u64, String> {
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}")),
}
}
}