1 parent
0421994fe6
commit
425ea7449b
138 files changed
+5705
-1012
No files matched your search
@@ -0,0 +1,469 @@
|
||||
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::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 session 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 session 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 session database")?,
|
||||
created_at_unix: u64::try_from(row.created_at_unix)
|
||||
.map_err(|_| "invalid created_at_unix in session database")?,
|
||||
last_accessed_unix: u64::try_from(row.last_accessed_unix)
|
||||
.map_err(|_| "invalid last_accessed_unix in session 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 session 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();
|
||||
sqlite_task(migration_path, |conn| {
|
||||
conn.execute_batch(include_str!("../../migrations/sqlite/V047__sessions.sql"))
|
||||
.map_err(|e| format!("SQLite session migration 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 session database: {e}"))?;
|
||||
sqlx::raw_sql(include_str!("../../migrations/mysql/V047__sessions.sql"))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| format!("MySQL session migration failed: {e}"))?;
|
||||
Pool::Mysql(pool)
|
||||
}
|
||||
"pgsql" | "postgres" | "postgresql" => {
|
||||
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 session database: {e}"))?;
|
||||
sqlx::raw_sql(include_str!("../../migrations/pgsql/V047__sessions.sql"))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| format!("PostgreSQL session migration 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}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user