kegani 0.1.1

A developer-friendly, ergonomic, production-ready Rust web framework
Documentation
//! Session management for Kegani
//!
//! Provides in-memory and Redis-backed session stores.

use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use crate::Result as AppResult;

/// Session data
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
    /// Unique session ID
    pub id: String,
    /// Arbitrary session data
    pub data: HashMap<String, serde_json::Value>,
    /// Creation timestamp (Unix epoch seconds)
    pub created_at: u64,
    /// Last access timestamp
    pub accessed_at: u64,
    /// Expiration timestamp
    pub expires_at: u64,
}

impl Session {
    /// Create a new session with a generated ID
    pub fn new(ttl: Duration) -> Self {
        let id = uuid::Uuid::new_v4().to_string();
        Self::with_id(id, ttl)
    }

    /// Create a new session with a specific ID
    pub fn with_id(id: String, ttl: Duration) -> Self {
        let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
        Self {
            id,
            data: HashMap::new(),
            created_at: now,
            accessed_at: now,
            expires_at: now + ttl.as_secs(),
        }
    }

    /// Check if the session has expired
    pub fn is_expired(&self) -> bool {
        let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
        now > self.expires_at
    }

    /// Remaining TTL in seconds
    pub fn ttl_seconds(&self) -> i64 {
        let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
        self.expires_at as i64 - now as i64
    }

    /// Get a value
    pub fn get(&self, key: &str) -> Option<&serde_json::Value> {
        self.data.get(key)
    }

    /// Set a value and update accessed_at
    pub fn set(&mut self, key: &str, value: serde_json::Value) {
        let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
        self.accessed_at = now;
        self.data.insert(key.to_string(), value);
    }

    /// Remove a value
    pub fn remove(&mut self, key: &str) {
        self.data.remove(key);
    }

    /// Clear all data
    pub fn clear(&mut self) {
        self.data.clear();
    }
}

/// Session store trait — implement this for custom backends
#[async_trait::async_trait]
pub trait SessionStore: Send + Sync {
    /// Create or update a session
    async fn save(&self, session: &Session) -> AppResult<()>;

    /// Retrieve a session by ID, returns None if expired or missing
    async fn load(&self, id: &str) -> AppResult<Option<Session>>;

    /// Delete a session
    async fn destroy(&self, id: &str) -> AppResult<()>;

    /// Touch — update accessed_at timestamp
    async fn touch(&self, id: &str) -> AppResult<()>;
}

/// In-memory session store
pub struct MemorySessionStore {
    sessions: Mutex<HashMap<String, Session>>,
    config: SessionConfig,
}

impl MemorySessionStore {
    /// Create a new in-memory store
    pub fn new(config: SessionConfig) -> Self {
        Self {
            sessions: Mutex::new(HashMap::new()),
            config,
        }
    }

    /// Create a new session, save it, and return it
    pub async fn create(&self) -> AppResult<Session> {
        let session = Session::new(self.config.ttl);
        let s = session.clone();
        self.save(&s).await?;
        Ok(s)
    }
}

#[async_trait::async_trait]
impl SessionStore for MemorySessionStore {
    async fn save(&self, session: &Session) -> AppResult<()> {
        let mut sessions = self.sessions.lock().map_err(|_| {
            std::io::Error::new(std::io::ErrorKind::Other, "session lock poisoned")
        })?;
        sessions.insert(session.id.clone(), session.clone());
        Ok(())
    }

    async fn load(&self, id: &str) -> AppResult<Option<Session>> {
        let sessions = self.sessions.lock().map_err(|_| {
            std::io::Error::new(std::io::ErrorKind::Other, "session lock poisoned")
        })?;
        Ok(sessions.get(id).cloned().filter(|s: &Session| !s.is_expired()))
    }

    async fn destroy(&self, id: &str) -> AppResult<()> {
        let mut sessions = self.sessions.lock().map_err(|_| {
            std::io::Error::new(std::io::ErrorKind::Other, "session lock poisoned")
        })?;
        sessions.remove(id);
        Ok(())
    }

    async fn touch(&self, id: &str) -> AppResult<()> {
        let mut sessions = self.sessions.lock().map_err(|_| {
            std::io::Error::new(std::io::ErrorKind::Other, "session lock poisoned")
        })?;
        if let Some(s) = sessions.get_mut(id) {
            s.accessed_at = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
        }
        Ok(())
    }
}

/// Redis-backed session store
pub struct RedisSessionStore {
    redis: crate::cache::RedisCache,
    config: SessionConfig,
}

impl RedisSessionStore {
    /// Create a new Redis-backed store
    pub async fn new(redis: crate::cache::RedisCache, config: SessionConfig) -> AppResult<Self> {
        Ok(Self { redis, config })
    }

    /// Create a new session, save it, and return it
    pub async fn create(&self) -> AppResult<Session> {
        let session = Session::new(self.config.ttl);
        let s = session.clone();
        self.save(&s).await?;
        Ok(s)
    }

    fn session_key(&self, id: &str) -> String {
        format!("{}:{}", self.config.name, id)
    }
}

#[async_trait::async_trait]
impl SessionStore for RedisSessionStore {
    async fn save(&self, session: &Session) -> AppResult<()> {
        let key = self.session_key(&session.id);
        let ttl = Duration::from_secs(session.ttl_seconds().max(0) as u64);
        self.redis
            .set_json(&key, session, Some(ttl))
            .await
            .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
        Ok(())
    }

    async fn load(&self, id: &str) -> AppResult<Option<Session>> {
        let key = self.session_key(id);
        let session = self.redis
            .get_json::<Session>(&key)
            .await
            .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
        Ok(session.filter(|s| !s.is_expired()))
    }

    async fn destroy(&self, id: &str) -> AppResult<()> {
        let key = self.session_key(id);
        self.redis
            .delete(&key)
            .await
            .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
        Ok(())
    }

    async fn touch(&self, id: &str) -> AppResult<()> {
        if let Some(mut session) = self.load(id).await? {
            let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
            session.accessed_at = now;
            self.save(&session).await?;
        }
        Ok(())
    }
}

/// Session configuration
#[derive(Debug, Clone)]
pub struct SessionConfig {
    /// Cookie name
    pub name: String,
    /// Time-to-live
    pub ttl: Duration,
    /// Secure flag (HTTPS only)
    pub secure: bool,
    /// HTTP-only flag
    pub http_only: bool,
    /// SameSite attribute
    pub same_site: actix_web::cookie::SameSite,
    /// Path
    pub path: String,
}

impl Default for SessionConfig {
    fn default() -> Self {
        Self {
            name: "kegani_sid".to_string(),
            ttl: Duration::from_secs(3600 * 24),
            secure: false,
            http_only: true,
            same_site: actix_web::cookie::SameSite::Lax,
            path: "/".to_string(),
        }
    }
}

impl SessionConfig {
    /// Builder-style method to set secure flag
    pub fn secure(mut self, secure: bool) -> Self {
        self.secure = secure;
        self
    }

    /// Builder-style method to set HTTP-only flag
    pub fn http_only(mut self, http_only: bool) -> Self {
        self.http_only = http_only;
        self
    }
}

/// Session error
#[derive(Debug)]
pub enum SessionError {
    NotFound,
    Expired,
    LockFailed,
    Redis(String),
}

impl std::fmt::Display for SessionError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            SessionError::NotFound => write!(f, "Session not found"),
            SessionError::Expired => write!(f, "Session expired"),
            SessionError::LockFailed => write!(f, "Failed to acquire session lock"),
            SessionError::Redis(e) => write!(f, "Redis error: {}", e),
        }
    }
}

impl std::error::Error for SessionError {}