v0.1.0
RustBox CI / rustbox (push) Canceled after 0s

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-30 17:22:17 +01:00
1 parent 0421994fe6
commit 425ea7449b
138 files changed
+5705 -1012

No files matched your search

+105
View File
@@ -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}"))
}
}
+469
View File
@@ -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}")),
}
}
}
+62
View File
@@ -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}")),
}
}
+280
View File
@@ -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)
}
}