472 lines
18 KiB
Rust
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}")),
|
|
}
|
|
}
|
|
}
|