Skip to main content

ferro_rs/session/driver/
database.rs

1//! Database-backed session storage driver
2
3use async_trait::async_trait;
4use sea_orm::entity::prelude::*;
5use sea_orm::{Condition, QueryFilter, Set};
6use std::collections::HashMap;
7use std::time::Duration;
8
9use crate::database::DB;
10use crate::error::FrameworkError;
11use crate::session::store::{SessionData, SessionStore};
12
13/// Database session driver using SeaORM
14///
15/// Stores sessions in a `sessions` table with dual timeout enforcement:
16/// - Idle timeout: expires after inactivity (based on `last_activity`)
17/// - Absolute timeout: expires after creation time (based on `created_at`)
18///
19/// Both timeouts are checked on every `read()` and enforced during `gc()`.
20pub struct DatabaseSessionDriver {
21    idle_lifetime: Duration,
22    absolute_lifetime: Duration,
23}
24
25impl DatabaseSessionDriver {
26    /// Create a new database session driver with dual timeout configuration
27    pub fn new(idle_lifetime: Duration, absolute_lifetime: Duration) -> Self {
28        Self {
29            idle_lifetime,
30            absolute_lifetime,
31        }
32    }
33}
34
35#[async_trait]
36impl SessionStore for DatabaseSessionDriver {
37    async fn read(&self, id: &str) -> Result<Option<SessionData>, FrameworkError> {
38        let db = DB::connection()?;
39
40        let result = sessions::Entity::find_by_id(id)
41            .one(db.inner())
42            .await
43            .map_err(|e| FrameworkError::database(e.to_string()))?;
44
45        if let Some(session) = result {
46            let now = chrono::Utc::now();
47
48            // Check idle timeout
49            let idle_expiry = session.last_activity
50                + chrono::Duration::seconds(self.idle_lifetime.as_secs() as i64);
51            if now > idle_expiry {
52                let _ = self.destroy(id).await;
53                return Ok(None);
54            }
55
56            // Check absolute timeout (skip if created_at is NULL for backward compat)
57            if let Some(created) = session.created_at {
58                let absolute_expiry =
59                    created + chrono::Duration::seconds(self.absolute_lifetime.as_secs() as i64);
60                if now > absolute_expiry {
61                    let _ = self.destroy(id).await;
62                    return Ok(None);
63                }
64            }
65
66            // Parse the payload
67            let data: HashMap<String, serde_json::Value> =
68                serde_json::from_str(&session.payload).unwrap_or_default();
69
70            Ok(Some(SessionData {
71                id: session.id,
72                data,
73                user_id: session.user_id,
74                csrf_token: session.csrf_token,
75                dirty: false,
76            }))
77        } else {
78            Ok(None)
79        }
80    }
81
82    async fn write(&self, session: &SessionData) -> Result<(), FrameworkError> {
83        let db = DB::connection()?;
84
85        let payload = serde_json::to_string(&session.data)
86            .map_err(|e| FrameworkError::internal(format!("Session serialize error: {e}")))?;
87
88        let now = chrono::Utc::now();
89
90        // Check if session exists
91        let existing = sessions::Entity::find_by_id(&session.id)
92            .one(db.inner())
93            .await
94            .map_err(|e| FrameworkError::database(e.to_string()))?;
95
96        if existing.is_some() {
97            // Update existing session — preserve original created_at
98            let update = sessions::ActiveModel {
99                id: Set(session.id.clone()),
100                user_id: Set(session.user_id),
101                payload: Set(payload),
102                csrf_token: Set(session.csrf_token.clone()),
103                created_at: sea_orm::NotSet,
104                last_activity: Set(now),
105            };
106
107            sessions::Entity::update(update)
108                .exec(db.inner())
109                .await
110                .map_err(|e| FrameworkError::database(e.to_string()))?;
111        } else {
112            // Insert new session with created_at set to now
113            let model = sessions::ActiveModel {
114                id: Set(session.id.clone()),
115                user_id: Set(session.user_id),
116                payload: Set(payload),
117                csrf_token: Set(session.csrf_token.clone()),
118                created_at: Set(Some(now)),
119                last_activity: Set(now),
120            };
121
122            sessions::Entity::insert(model)
123                .exec(db.inner())
124                .await
125                .map_err(|e| FrameworkError::database(e.to_string()))?;
126        }
127
128        Ok(())
129    }
130
131    async fn destroy(&self, id: &str) -> Result<(), FrameworkError> {
132        let db = DB::connection()?;
133
134        sessions::Entity::delete_by_id(id)
135            .exec(db.inner())
136            .await
137            .map_err(|e| FrameworkError::database(e.to_string()))?;
138
139        Ok(())
140    }
141
142    async fn gc(&self) -> Result<u64, FrameworkError> {
143        let db = DB::connection()?;
144
145        let now = chrono::Utc::now();
146        let idle_threshold = now - chrono::Duration::seconds(self.idle_lifetime.as_secs() as i64);
147        let absolute_threshold =
148            now - chrono::Duration::seconds(self.absolute_lifetime.as_secs() as i64);
149
150        // Delete sessions expired by idle OR absolute timeout
151        let condition = Condition::any()
152            .add(sessions::Column::LastActivity.lt(idle_threshold))
153            .add(
154                Condition::all()
155                    .add(sessions::Column::CreatedAt.is_not_null())
156                    .add(sessions::Column::CreatedAt.lt(absolute_threshold)),
157            );
158
159        let result = sessions::Entity::delete_many()
160            .filter(condition)
161            .exec(db.inner())
162            .await
163            .map_err(|e| FrameworkError::database(e.to_string()))?;
164
165        Ok(result.rows_affected)
166    }
167
168    async fn destroy_for_user(
169        &self,
170        user_id: i64,
171        except_session_id: Option<&str>,
172    ) -> Result<u64, FrameworkError> {
173        let db = DB::connection()?;
174
175        let mut condition = Condition::all().add(sessions::Column::UserId.eq(user_id));
176        if let Some(except_id) = except_session_id {
177            condition = condition.add(sessions::Column::Id.ne(except_id));
178        }
179
180        let result = sessions::Entity::delete_many()
181            .filter(condition)
182            .exec(db.inner())
183            .await
184            .map_err(|e| FrameworkError::database(e.to_string()))?;
185
186        Ok(result.rows_affected)
187    }
188}
189
190/// Sessions table entity for SeaORM
191pub mod sessions {
192    use sea_orm::entity::prelude::*;
193
194    #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
195    #[sea_orm(table_name = "sessions")]
196    pub struct Model {
197        #[sea_orm(primary_key, auto_increment = false)]
198        pub id: String,
199        pub user_id: Option<i64>,
200        #[sea_orm(column_type = "Text")]
201        pub payload: String,
202        pub csrf_token: String,
203        pub created_at: Option<chrono::DateTime<chrono::Utc>>,
204        pub last_activity: chrono::DateTime<chrono::Utc>,
205    }
206
207    #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
208    pub enum Relation {}
209
210    impl ActiveModelBehavior for ActiveModel {}
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use std::time::Duration;
217
218    #[test]
219    fn new_stores_both_lifetimes() {
220        let idle = Duration::from_secs(7200);
221        let absolute = Duration::from_secs(2_592_000);
222        let driver = DatabaseSessionDriver::new(idle, absolute);
223        assert_eq!(driver.idle_lifetime, idle);
224        assert_eq!(driver.absolute_lifetime, absolute);
225    }
226
227    #[test]
228    fn sessions_model_has_created_at() {
229        // Compile-time verification: created_at field exists on Model
230        let model = sessions::Model {
231            id: "test".to_string(),
232            user_id: None,
233            payload: "{}".to_string(),
234            csrf_token: "token".to_string(),
235            created_at: Some(chrono::Utc::now()),
236            last_activity: chrono::Utc::now(),
237        };
238        assert!(model.created_at.is_some());
239    }
240
241    #[test]
242    fn sessions_model_created_at_nullable() {
243        let model = sessions::Model {
244            id: "test".to_string(),
245            user_id: None,
246            payload: "{}".to_string(),
247            csrf_token: "token".to_string(),
248            created_at: None,
249            last_activity: chrono::Utc::now(),
250        };
251        assert!(model.created_at.is_none());
252    }
253}