use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::db::{ColumnMap, ConnExt, ConnQueryExt, Database, FromRow};
use crate::error::{Error, Result};
use super::cookie::CookieSessionsConfig;
use super::meta::SessionMeta;
use super::token::SessionToken;
const SESSION_COLUMNS: &str = "id, user_id, ip_address, user_agent, device_name, device_type, \
fingerprint, data, created_at, last_active_at, expires_at";
const TABLE: &str = "authenticated_sessions";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionData {
pub id: String,
pub user_id: String,
pub ip_address: String,
pub user_agent: String,
pub device_name: String,
pub device_type: String,
pub fingerprint: String,
pub data: serde_json::Value,
pub created_at: DateTime<Utc>,
pub last_active_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
}
#[derive(Clone)]
pub struct SessionStore {
db: Database,
config: CookieSessionsConfig,
}
impl SessionStore {
pub fn new(db: Database, config: CookieSessionsConfig) -> Self {
Self { db, config }
}
pub fn config(&self) -> &CookieSessionsConfig {
&self.config
}
pub async fn read_by_token(&self, token: &SessionToken) -> Result<Option<SessionData>> {
let hash = token.hash();
let now = Utc::now().to_rfc3339();
let row: Option<SessionRow> = self
.db
.conn()
.query_optional(
&format!(
"SELECT {SESSION_COLUMNS} FROM {TABLE} \
WHERE session_token_hash = ?1 AND expires_at > ?2"
),
libsql::params![hash, now],
)
.await?;
row.map(row_to_session_data).transpose()
}
pub async fn read(&self, id: &str) -> Result<Option<SessionData>> {
let row: Option<SessionRow> = self
.db
.conn()
.query_optional(
&format!("SELECT {SESSION_COLUMNS} FROM {TABLE} WHERE id = ?1"),
libsql::params![id],
)
.await?;
row.map(row_to_session_data).transpose()
}
pub async fn list_for_user(&self, user_id: &str) -> Result<Vec<SessionData>> {
let now = Utc::now().to_rfc3339();
let rows: Vec<SessionRow> = self
.db
.conn()
.query_all(
&format!(
"SELECT {SESSION_COLUMNS} FROM {TABLE} \
WHERE user_id = ?1 AND expires_at > ?2 \
ORDER BY last_active_at DESC"
),
libsql::params![user_id, now],
)
.await?;
rows.into_iter().map(row_to_session_data).collect()
}
pub async fn create(
&self,
meta: &SessionMeta,
user_id: &str,
data: Option<serde_json::Value>,
) -> Result<(SessionData, SessionToken)> {
let id = crate::id::ulid();
let token = SessionToken::generate();
let token_hash = token.hash();
let now = Utc::now();
let expires_at = now + chrono::Duration::seconds(self.config.session_ttl_secs as i64);
let data_json = data.unwrap_or(serde_json::json!({}));
let data_str = serde_json::to_string(&data_json)
.map_err(|e| Error::internal(format!("serialize session data: {e}")))?;
let now_str = now.to_rfc3339();
let expires_str = expires_at.to_rfc3339();
self.db
.conn()
.execute_raw(
&format!(
"INSERT INTO {TABLE} \
(id, session_token_hash, user_id, ip_address, user_agent, device_name, \
device_type, fingerprint, data, created_at, last_active_at, expires_at) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)"
),
libsql::params![
id.as_str(),
token_hash.as_str(),
user_id,
meta.ip_address.as_str(),
meta.user_agent.as_str(),
meta.device_name.as_str(),
meta.device_type.as_str(),
meta.fingerprint.as_str(),
data_str.as_str(),
now_str.as_str(),
now_str.as_str(),
expires_str.as_str()
],
)
.await
.map_err(|e| Error::internal(format!("insert session: {e}")))?;
let max = self.config.max_sessions_per_user as i64;
self.db
.conn()
.execute_raw(
&format!(
"DELETE FROM {TABLE} WHERE id IN (\
SELECT id FROM {TABLE} \
WHERE user_id = ?1 AND expires_at > ?2 \
ORDER BY last_active_at ASC \
LIMIT MAX(0, (SELECT COUNT(*) FROM {TABLE} \
WHERE user_id = ?3 AND expires_at > ?4) - ?5)\
)"
),
libsql::params![user_id, now_str.as_str(), user_id, now_str.as_str(), max],
)
.await
.map_err(|e| Error::internal(format!("evict excess sessions: {e}")))?;
let session_data = SessionData {
id,
user_id: user_id.to_string(),
ip_address: meta.ip_address.clone(),
user_agent: meta.user_agent.clone(),
device_name: meta.device_name.clone(),
device_type: meta.device_type.clone(),
fingerprint: meta.fingerprint.clone(),
data: data_json,
created_at: now,
last_active_at: now,
expires_at,
};
Ok((session_data, token))
}
pub async fn destroy(&self, id: &str) -> Result<()> {
self.db
.conn()
.execute_raw(
&format!("DELETE FROM {TABLE} WHERE id = ?1"),
libsql::params![id],
)
.await
.map_err(|e| Error::internal(format!("destroy session: {e}")))?;
Ok(())
}
pub async fn destroy_all_for_user(&self, user_id: &str) -> Result<()> {
self.db
.conn()
.execute_raw(
&format!("DELETE FROM {TABLE} WHERE user_id = ?1"),
libsql::params![user_id],
)
.await
.map_err(|e| Error::internal(format!("destroy all sessions for user: {e}")))?;
Ok(())
}
pub async fn destroy_all_except(&self, user_id: &str, keep_id: &str) -> Result<()> {
self.db
.conn()
.execute_raw(
&format!("DELETE FROM {TABLE} WHERE user_id = ?1 AND id != ?2"),
libsql::params![user_id, keep_id],
)
.await
.map_err(|e| Error::internal(format!("destroy all except: {e}")))?;
Ok(())
}
pub async fn read_by_token_hash(&self, hash: &str) -> Result<Option<SessionData>> {
let now = Utc::now().to_rfc3339();
let row: Option<SessionRow> = self
.db
.conn()
.query_optional(
&format!(
"SELECT {SESSION_COLUMNS} FROM {TABLE} \
WHERE session_token_hash = ?1 AND expires_at > ?2"
),
libsql::params![hash, now],
)
.await?;
row.map(row_to_session_data).transpose()
}
pub async fn rotate_token_to(&self, id: &str, new_token: &SessionToken) -> Result<()> {
let new_hash = new_token.hash();
let now = Utc::now().to_rfc3339();
self.db
.conn()
.execute_raw(
&format!(
"UPDATE {TABLE} SET session_token_hash = ?1, last_active_at = ?2 WHERE id = ?3"
),
libsql::params![new_hash, now, id],
)
.await
.map_err(|e| Error::internal(format!("rotate token to: {e}")))?;
Ok(())
}
pub async fn rotate_token(&self, id: &str) -> Result<SessionToken> {
let new_token = SessionToken::generate();
let new_hash = new_token.hash();
let now = Utc::now().to_rfc3339();
self.db
.conn()
.execute_raw(
&format!(
"UPDATE {TABLE} SET session_token_hash = ?1, last_active_at = ?2 WHERE id = ?3"
),
libsql::params![new_hash, now, id],
)
.await
.map_err(|e| Error::internal(format!("rotate token: {e}")))?;
Ok(new_token)
}
pub async fn flush(
&self,
id: &str,
data: &serde_json::Value,
now: DateTime<Utc>,
expires_at: DateTime<Utc>,
) -> Result<()> {
let data_str = serde_json::to_string(data)
.map_err(|e| Error::internal(format!("serialize session data: {e}")))?;
self.db
.conn()
.execute_raw(
&format!(
"UPDATE {TABLE} SET data = ?1, last_active_at = ?2, expires_at = ?3 \
WHERE id = ?4"
),
libsql::params![data_str, now.to_rfc3339(), expires_at.to_rfc3339(), id],
)
.await
.map_err(|e| Error::internal(format!("flush session: {e}")))?;
Ok(())
}
pub async fn touch(
&self,
id: &str,
now: DateTime<Utc>,
expires_at: DateTime<Utc>,
) -> Result<()> {
self.db
.conn()
.execute_raw(
&format!("UPDATE {TABLE} SET last_active_at = ?1, expires_at = ?2 WHERE id = ?3"),
libsql::params![now.to_rfc3339(), expires_at.to_rfc3339(), id],
)
.await
.map_err(|e| Error::internal(format!("touch session: {e}")))?;
Ok(())
}
#[allow(dead_code)]
pub async fn cleanup_expired(&self) -> Result<u64> {
let now = Utc::now().to_rfc3339();
let affected = self
.db
.conn()
.execute_raw(
&format!("DELETE FROM {TABLE} WHERE expires_at < ?1"),
libsql::params![now],
)
.await
.map_err(Error::from)?;
Ok(affected)
}
}
struct SessionRow {
id: String,
user_id: String,
ip_address: String,
user_agent: String,
device_name: String,
device_type: String,
fingerprint: String,
data: String,
created_at: String,
last_active_at: String,
expires_at: String,
}
impl FromRow for SessionRow {
fn from_row(row: &libsql::Row) -> Result<Self> {
let cols = ColumnMap::from_row(row);
Ok(Self {
id: cols.get(row, "id")?,
user_id: cols.get(row, "user_id")?,
ip_address: cols.get(row, "ip_address")?,
user_agent: cols.get(row, "user_agent")?,
device_name: cols.get(row, "device_name")?,
device_type: cols.get(row, "device_type")?,
fingerprint: cols.get(row, "fingerprint")?,
data: cols.get(row, "data")?,
created_at: cols.get(row, "created_at")?,
last_active_at: cols.get(row, "last_active_at")?,
expires_at: cols.get(row, "expires_at")?,
})
}
}
fn row_to_session_data(row: SessionRow) -> Result<SessionData> {
let data: serde_json::Value = serde_json::from_str(&row.data)
.map_err(|e| Error::internal(format!("deserialize session data: {e}")))?;
let created_at = DateTime::parse_from_rfc3339(&row.created_at)
.map_err(|e| Error::internal(format!("parse created_at: {e}")))?
.with_timezone(&Utc);
let last_active_at = DateTime::parse_from_rfc3339(&row.last_active_at)
.map_err(|e| Error::internal(format!("parse last_active_at: {e}")))?
.with_timezone(&Utc);
let expires_at = DateTime::parse_from_rfc3339(&row.expires_at)
.map_err(|e| Error::internal(format!("parse expires_at: {e}")))?
.with_timezone(&Utc);
Ok(SessionData {
id: row.id,
user_id: row.user_id,
ip_address: row.ip_address,
user_agent: row.user_agent,
device_name: row.device_name,
device_type: row.device_type,
fingerprint: row.fingerprint,
data,
created_at,
last_active_at,
expires_at,
})
}