1 parent
0421994fe6
commit
425ea7449b
138 files changed
+5705
-1012
No files matched your search
@@ -0,0 +1,105 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use aes_gcm::{Aes256Gcm, KeyInit, Nonce, aead::Aead};
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use rand::RngCore;
|
||||
|
||||
use crate::config::AppConfig;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SessionCrypto {
|
||||
key: [u8; 32],
|
||||
}
|
||||
|
||||
impl SessionCrypto {
|
||||
pub fn load_or_create(config: &AppConfig) -> Result<Self, String> {
|
||||
if let Some(raw) = config
|
||||
.session_encryption_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty())
|
||||
{
|
||||
let decoded = STANDARD
|
||||
.decode(raw)
|
||||
.map_err(|e| format!("SESSION_ENCRYPTION_KEY must be base64: {e}"))?;
|
||||
if decoded.len() != 32 {
|
||||
return Err("SESSION_ENCRYPTION_KEY must decode to exactly 32 bytes".to_string());
|
||||
}
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(&decoded);
|
||||
return Ok(Self { key });
|
||||
}
|
||||
|
||||
if config.cluster_mode
|
||||
|| config.database_backend != "sqlite"
|
||||
|| config.session_backend == "redis"
|
||||
{
|
||||
return Err(
|
||||
"SESSION_ENCRYPTION_KEY is required for shared/cluster session storage".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
fs::create_dir_all(&config.data_dir)
|
||||
.map_err(|e| format!("failed to create data_dir: {e}"))?;
|
||||
let path = Path::new(&config.data_dir).join("session.key");
|
||||
if path.is_file() {
|
||||
let raw = fs::read(&path).map_err(|e| format!("failed to read session key: {e}"))?;
|
||||
if raw.len() != 32 {
|
||||
return Err(format!("invalid session key length in {}", path.display()));
|
||||
}
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(&raw);
|
||||
return Ok(Self { key });
|
||||
}
|
||||
|
||||
let mut key = [0u8; 32];
|
||||
rand::rng().fill_bytes(&mut key);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.mode(0o600)
|
||||
.open(&path)
|
||||
.map_err(|e| format!("failed to create session key: {e}"))?;
|
||||
file.write_all(&key)
|
||||
.map_err(|e| format!("failed to write session key: {e}"))?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
fs::write(&path, key).map_err(|e| format!("failed to write session key: {e}"))?;
|
||||
Ok(Self { key })
|
||||
}
|
||||
|
||||
pub fn encrypt(&self, plaintext: &str) -> Result<String, String> {
|
||||
let cipher =
|
||||
Aes256Gcm::new_from_slice(&self.key).map_err(|_| "invalid session key".to_string())?;
|
||||
let mut nonce_bytes = [0u8; 12];
|
||||
rand::rng().fill_bytes(&mut nonce_bytes);
|
||||
let ciphertext = cipher
|
||||
.encrypt(Nonce::from_slice(&nonce_bytes), plaintext.as_bytes())
|
||||
.map_err(|_| "failed to encrypt session credential".to_string())?;
|
||||
let mut combined = nonce_bytes.to_vec();
|
||||
combined.extend_from_slice(&ciphertext);
|
||||
Ok(STANDARD.encode(combined))
|
||||
}
|
||||
|
||||
pub fn decrypt(&self, encoded: &str) -> Result<String, String> {
|
||||
let raw = STANDARD
|
||||
.decode(encoded)
|
||||
.map_err(|e| format!("invalid encrypted session credential: {e}"))?;
|
||||
if raw.len() < 13 {
|
||||
return Err("invalid encrypted session credential".to_string());
|
||||
}
|
||||
let (nonce, ciphertext) = raw.split_at(12);
|
||||
let cipher =
|
||||
Aes256Gcm::new_from_slice(&self.key).map_err(|_| "invalid session key".to_string())?;
|
||||
let plaintext = cipher
|
||||
.decrypt(Nonce::from_slice(nonce), ciphertext)
|
||||
.map_err(|_| "failed to decrypt session credential".to_string())?;
|
||||
String::from_utf8(plaintext)
|
||||
.map_err(|e| format!("invalid UTF-8 in session credential: {e}"))
|
||||
}
|
||||
}
|
||||
@@ -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}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
pub mod crypto;
|
||||
pub mod database;
|
||||
pub mod redis_store;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::AppConfig;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PersistedSession {
|
||||
pub token: String,
|
||||
pub account_id: String,
|
||||
pub browser_id: String,
|
||||
pub email: String,
|
||||
pub password_ciphertext: String,
|
||||
pub user_hash: String,
|
||||
pub imap_host: String,
|
||||
pub imap_port: u16,
|
||||
pub imap_tls: bool,
|
||||
pub imap_tls_verify: bool,
|
||||
pub smtp_host: String,
|
||||
pub smtp_port: u16,
|
||||
pub smtp_tls: bool,
|
||||
pub smtp_tls_verify: bool,
|
||||
pub timeout_secs: u64,
|
||||
pub created_at_unix: u64,
|
||||
pub last_accessed_unix: u64,
|
||||
pub user_agent: String,
|
||||
}
|
||||
|
||||
impl PersistedSession {
|
||||
pub fn expired_at(&self, now_unix: u64) -> bool {
|
||||
now_unix.saturating_sub(self.last_accessed_unix) > self.timeout_secs
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait SessionPersistence: Send + Sync {
|
||||
async fn save(&self, session: &PersistedSession) -> Result<(), String>;
|
||||
async fn load_token(&self, token: &str) -> Result<Option<PersistedSession>, String>;
|
||||
async fn load_browser(&self, browser_id: &str) -> Result<Vec<PersistedSession>, String>;
|
||||
async fn load_user(&self, user_hash: &str) -> Result<Vec<PersistedSession>, String>;
|
||||
async fn browser_exists(&self, browser_id: &str) -> Result<bool, String>;
|
||||
async fn delete_account(&self, account_id: &str) -> Result<bool, String>;
|
||||
async fn delete_browser(&self, browser_id: &str) -> Result<(), String>;
|
||||
async fn purge_expired(&self, now_unix: u64) -> Result<u64, String>;
|
||||
}
|
||||
|
||||
pub async fn from_config(
|
||||
config: &AppConfig,
|
||||
) -> Result<std::sync::Arc<dyn SessionPersistence>, String> {
|
||||
match config.session_backend.as_str() {
|
||||
"database" => Ok(std::sync::Arc::new(
|
||||
database::DatabaseSessionStore::connect(config).await?,
|
||||
)),
|
||||
"redis" => Ok(std::sync::Arc::new(
|
||||
redis_store::RedisSessionStore::connect(config).await?,
|
||||
)),
|
||||
other => Err(format!("unsupported session backend: {other}")),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
use async_trait::async_trait;
|
||||
use redis::AsyncCommands;
|
||||
|
||||
use super::{PersistedSession, SessionPersistence};
|
||||
use crate::config::AppConfig;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SentinelConfig {
|
||||
nodes: Vec<String>,
|
||||
master: String,
|
||||
master_password: Option<String>,
|
||||
sentinel_password: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum RedisMode {
|
||||
Direct(redis::Client),
|
||||
Sentinel(SentinelConfig),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RedisSessionStore {
|
||||
mode: RedisMode,
|
||||
}
|
||||
|
||||
impl RedisSessionStore {
|
||||
pub async fn connect(config: &AppConfig) -> Result<Self, String> {
|
||||
let mode = if let Some(url) = config
|
||||
.redis_url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty())
|
||||
{
|
||||
RedisMode::Direct(
|
||||
redis::Client::open(url.to_string())
|
||||
.map_err(|e| format!("invalid Redis URL: {e}"))?,
|
||||
)
|
||||
} else {
|
||||
RedisMode::Sentinel(SentinelConfig {
|
||||
nodes: config.redis_sentinel_nodes.clone(),
|
||||
master: config
|
||||
.redis_sentinel_master
|
||||
.clone()
|
||||
.ok_or("REDIS_SENTINEL_MASTER is required")?,
|
||||
master_password: config.redis_password.clone(),
|
||||
sentinel_password: config.redis_sentinel_password.clone(),
|
||||
})
|
||||
};
|
||||
let store = Self { mode };
|
||||
let mut con = store.connection().await?;
|
||||
let _: String = redis::cmd("PING")
|
||||
.query_async(&mut con)
|
||||
.await
|
||||
.map_err(|e| format!("Redis PING failed: {e}"))?;
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
fn auth_fragment(password: Option<&str>) -> String {
|
||||
password
|
||||
.map(|p| {
|
||||
format!(
|
||||
":{}@",
|
||||
url::form_urlencoded::byte_serialize(p.as_bytes()).collect::<String>()
|
||||
)
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn sentinel_master_url(config: &SentinelConfig) -> Result<String, String> {
|
||||
let mut last_error = String::new();
|
||||
for node in &config.nodes {
|
||||
let sentinel_url = if node.contains("://") {
|
||||
node.clone()
|
||||
} else {
|
||||
format!(
|
||||
"redis://{}{}",
|
||||
Self::auth_fragment(config.sentinel_password.as_deref()),
|
||||
node
|
||||
)
|
||||
};
|
||||
let sentinel_url = if sentinel_url.ends_with('/') {
|
||||
sentinel_url
|
||||
} else {
|
||||
format!("{sentinel_url}/")
|
||||
};
|
||||
match redis::Client::open(sentinel_url) {
|
||||
Ok(client) => match client.get_multiplexed_async_connection().await {
|
||||
Ok(mut con) => {
|
||||
let result: redis::RedisResult<Vec<String>> = redis::cmd("SENTINEL")
|
||||
.arg("get-master-addr-by-name")
|
||||
.arg(&config.master)
|
||||
.query_async(&mut con)
|
||||
.await;
|
||||
match result {
|
||||
Ok(parts) if parts.len() >= 2 => {
|
||||
let auth = Self::auth_fragment(config.master_password.as_deref());
|
||||
return Ok(format!("redis://{auth}{}:{}/", parts[0], parts[1]));
|
||||
}
|
||||
Ok(_) => last_error = format!("Sentinel {node} returned no master"),
|
||||
Err(e) => last_error = format!("Sentinel {node} query failed: {e}"),
|
||||
}
|
||||
}
|
||||
Err(e) => last_error = format!("Sentinel {node} connection failed: {e}"),
|
||||
},
|
||||
Err(e) => last_error = format!("invalid Sentinel URL {node}: {e}"),
|
||||
}
|
||||
}
|
||||
Err(if last_error.is_empty() {
|
||||
"no Redis Sentinel nodes configured".to_string()
|
||||
} else {
|
||||
last_error
|
||||
})
|
||||
}
|
||||
|
||||
fn session_key(token: &str) -> String {
|
||||
format!("rustbox:session:{token}")
|
||||
}
|
||||
fn account_key(account_id: &str) -> String {
|
||||
format!("rustbox:account:{account_id}")
|
||||
}
|
||||
fn browser_key(browser_id: &str) -> String {
|
||||
format!("rustbox:browser:{browser_id}")
|
||||
}
|
||||
fn user_key(user_hash: &str) -> String {
|
||||
format!("rustbox:user:{user_hash}")
|
||||
}
|
||||
|
||||
async fn connection(&self) -> Result<redis::aio::MultiplexedConnection, String> {
|
||||
let client = match &self.mode {
|
||||
RedisMode::Direct(client) => client.clone(),
|
||||
RedisMode::Sentinel(config) => {
|
||||
let url = Self::sentinel_master_url(config).await?;
|
||||
redis::Client::open(url).map_err(|e| format!("invalid Redis master URL: {e}"))?
|
||||
}
|
||||
};
|
||||
client
|
||||
.get_multiplexed_async_connection()
|
||||
.await
|
||||
.map_err(|e| format!("Redis connection failed: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SessionPersistence for RedisSessionStore {
|
||||
async fn save(&self, session: &PersistedSession) -> Result<(), String> {
|
||||
let mut con = self.connection().await?;
|
||||
let json = serde_json::to_string(session)
|
||||
.map_err(|e| format!("failed to encode Redis session: {e}"))?;
|
||||
let ttl = session.timeout_secs.max(1) as i64;
|
||||
let mut pipe = redis::pipe();
|
||||
pipe.atomic()
|
||||
.set(Self::session_key(&session.token), json)
|
||||
.ignore()
|
||||
.expire(Self::session_key(&session.token), ttl)
|
||||
.ignore()
|
||||
.set(Self::account_key(&session.account_id), &session.token)
|
||||
.ignore()
|
||||
.expire(Self::account_key(&session.account_id), ttl)
|
||||
.ignore()
|
||||
.sadd(Self::browser_key(&session.browser_id), &session.account_id)
|
||||
.ignore()
|
||||
.expire(Self::browser_key(&session.browser_id), ttl)
|
||||
.ignore()
|
||||
.sadd(Self::user_key(&session.user_hash), &session.account_id)
|
||||
.ignore()
|
||||
.expire(Self::user_key(&session.user_hash), ttl)
|
||||
.ignore();
|
||||
pipe.query_async::<()>(&mut con)
|
||||
.await
|
||||
.map_err(|e| format!("failed to persist Redis session: {e}"))
|
||||
}
|
||||
|
||||
async fn load_token(&self, token: &str) -> Result<Option<PersistedSession>, String> {
|
||||
let mut con = self.connection().await?;
|
||||
let raw: Option<String> = con
|
||||
.get(Self::session_key(token))
|
||||
.await
|
||||
.map_err(|e| format!("Redis GET failed: {e}"))?;
|
||||
raw.map(|json| {
|
||||
serde_json::from_str(&json).map_err(|e| format!("invalid Redis session payload: {e}"))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn load_browser(&self, browser_id: &str) -> Result<Vec<PersistedSession>, String> {
|
||||
let mut con = self.connection().await?;
|
||||
let accounts: Vec<String> = con
|
||||
.smembers(Self::browser_key(browser_id))
|
||||
.await
|
||||
.map_err(|e| format!("Redis SMEMBERS failed: {e}"))?;
|
||||
let mut result = Vec::new();
|
||||
for account_id in accounts {
|
||||
let token: Option<String> = con
|
||||
.get(Self::account_key(&account_id))
|
||||
.await
|
||||
.map_err(|e| format!("Redis GET failed: {e}"))?;
|
||||
if let Some(token) = token
|
||||
&& let Some(session) = self.load_token(&token).await?
|
||||
{
|
||||
result.push(session);
|
||||
}
|
||||
}
|
||||
result.sort_by_key(|s| s.created_at_unix);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn load_user(&self, user_hash: &str) -> Result<Vec<PersistedSession>, String> {
|
||||
let mut con = self.connection().await?;
|
||||
let accounts: Vec<String> = con
|
||||
.smembers(Self::user_key(user_hash))
|
||||
.await
|
||||
.map_err(|e| format!("Redis SMEMBERS failed: {e}"))?;
|
||||
let mut result = Vec::new();
|
||||
for account_id in accounts {
|
||||
let token: Option<String> = con
|
||||
.get(Self::account_key(&account_id))
|
||||
.await
|
||||
.map_err(|e| format!("Redis GET failed: {e}"))?;
|
||||
if let Some(token) = token
|
||||
&& let Some(session) = self.load_token(&token).await?
|
||||
{
|
||||
result.push(session);
|
||||
}
|
||||
}
|
||||
result.sort_by_key(|s| std::cmp::Reverse(s.last_accessed_unix));
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn browser_exists(&self, browser_id: &str) -> Result<bool, String> {
|
||||
let mut con = self.connection().await?;
|
||||
con.exists(Self::browser_key(browser_id))
|
||||
.await
|
||||
.map_err(|e| format!("Redis EXISTS failed: {e}"))
|
||||
}
|
||||
|
||||
async fn delete_account(&self, account_id: &str) -> Result<bool, String> {
|
||||
let mut con = self.connection().await?;
|
||||
let token: Option<String> = con
|
||||
.get(Self::account_key(account_id))
|
||||
.await
|
||||
.map_err(|e| format!("Redis GET failed: {e}"))?;
|
||||
let Some(token) = token else {
|
||||
return Ok(false);
|
||||
};
|
||||
let session = self.load_token(&token).await?;
|
||||
let mut pipe = redis::pipe();
|
||||
pipe.atomic()
|
||||
.del(Self::account_key(account_id))
|
||||
.ignore()
|
||||
.del(Self::session_key(&token))
|
||||
.ignore();
|
||||
if let Some(session) = session {
|
||||
pipe.srem(Self::browser_key(&session.browser_id), account_id)
|
||||
.ignore()
|
||||
.srem(Self::user_key(&session.user_hash), account_id)
|
||||
.ignore();
|
||||
}
|
||||
pipe.query_async::<()>(&mut con)
|
||||
.await
|
||||
.map_err(|e| format!("Redis delete failed: {e}"))?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn delete_browser(&self, browser_id: &str) -> Result<(), String> {
|
||||
let sessions = self.load_browser(browser_id).await?;
|
||||
for session in sessions {
|
||||
self.delete_account(&session.account_id).await?;
|
||||
}
|
||||
let mut con = self.connection().await?;
|
||||
let _: usize = con
|
||||
.del(Self::browser_key(browser_id))
|
||||
.await
|
||||
.map_err(|e| format!("Redis DEL failed: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn purge_expired(&self, _now_unix: u64) -> Result<u64, String> {
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user