ferro_rs/session/driver/
database.rs1use 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
13pub struct DatabaseSessionDriver {
21 idle_lifetime: Duration,
22 absolute_lifetime: Duration,
23}
24
25impl DatabaseSessionDriver {
26 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 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 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 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 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 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 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 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
190pub 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 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}