1use crate::{
2 models::{
3 InsertQueryBuilder,
4 user::{AuthMethod, GetAuthMethod},
5 },
6 prelude::*,
7};
8use compact_str::ToCompactString;
9use garde::Validate;
10use rand::{RngExt, distr::SampleString};
11use serde::{Deserialize, Serialize};
12use sha2::Digest;
13use sqlx::{Row, postgres::PgRow};
14use std::{
15 borrow::Cow,
16 collections::BTreeMap,
17 sync::{Arc, LazyLock},
18};
19use tower_cookies::Cookie;
20use utoipa::ToSchema;
21
22#[derive(Serialize, Deserialize, Clone)]
23pub struct UserSession {
24 pub uuid: uuid::Uuid,
25
26 pub ip: sqlx::types::ipnetwork::IpNetwork,
27 pub user_agent: compact_str::CompactString,
28
29 pub last_used: chrono::NaiveDateTime,
30 pub created: chrono::NaiveDateTime,
31
32 extension_data: super::ModelExtensionData,
33}
34
35impl BaseModel for UserSession {
36 const NAME: &'static str = "user_session";
37
38 fn get_extension_list() -> &'static super::ModelExtensionList {
39 static EXTENSIONS: LazyLock<super::ModelExtensionList> =
40 LazyLock::new(|| parking_lot::RwLock::new(Vec::new()));
41
42 &EXTENSIONS
43 }
44
45 fn get_extension_data(&self) -> &super::ModelExtensionData {
46 &self.extension_data
47 }
48
49 #[inline]
50 fn base_columns(prefix: Option<&str>) -> BTreeMap<&'static str, compact_str::CompactString> {
51 let prefix = prefix.unwrap_or_default();
52
53 BTreeMap::from([
54 (
55 "user_sessions.uuid",
56 compact_str::format_compact!("{prefix}uuid"),
57 ),
58 (
59 "user_sessions.ip",
60 compact_str::format_compact!("{prefix}ip"),
61 ),
62 (
63 "user_sessions.user_agent",
64 compact_str::format_compact!("{prefix}user_agent"),
65 ),
66 (
67 "user_sessions.last_used",
68 compact_str::format_compact!("{prefix}last_used"),
69 ),
70 (
71 "user_sessions.created",
72 compact_str::format_compact!("{prefix}created"),
73 ),
74 ])
75 }
76
77 #[inline]
78 fn map(prefix: Option<&str>, row: &PgRow) -> Result<Self, crate::database::DatabaseError> {
79 let prefix = prefix.unwrap_or_default();
80
81 Ok(Self {
82 uuid: row.try_get(compact_str::format_compact!("{prefix}uuid").as_str())?,
83 ip: row.try_get(compact_str::format_compact!("{prefix}ip").as_str())?,
84 user_agent: row.try_get(compact_str::format_compact!("{prefix}user_agent").as_str())?,
85 last_used: row.try_get(compact_str::format_compact!("{prefix}last_used").as_str())?,
86 created: row.try_get(compact_str::format_compact!("{prefix}created").as_str())?,
87 extension_data: Self::map_extensions(prefix, row)?,
88 })
89 }
90}
91
92impl UserSession {
93 pub async fn by_user_uuid_uuid(
94 database: &crate::database::Database,
95 user_uuid: uuid::Uuid,
96 uuid: uuid::Uuid,
97 ) -> Result<Option<Self>, crate::database::DatabaseError> {
98 let row = sqlx::query(sqlx::AssertSqlSafe(format!(
99 r#"
100 SELECT {}
101 FROM user_sessions
102 WHERE user_sessions.user_uuid = $1 AND user_sessions.uuid = $2
103 "#,
104 Self::columns_sql(None)
105 )))
106 .bind(user_uuid)
107 .bind(uuid)
108 .fetch_optional(database.read())
109 .await?;
110
111 row.try_map(|row| Self::map(None, &row))
112 }
113
114 pub async fn by_user_uuid_with_pagination(
115 database: &crate::database::Database,
116 user_uuid: uuid::Uuid,
117 page: i64,
118 per_page: i64,
119 search: Option<&str>,
120 ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
121 let offset = (page - 1) * per_page;
122
123 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
124 r#"
125 SELECT {}, COUNT(*) OVER() AS total_count
126 FROM user_sessions
127 WHERE user_sessions.user_uuid = $1 AND ($2 IS NULL OR user_sessions.user_agent ILIKE '%' || $2 || '%')
128 ORDER BY user_sessions.created DESC
129 LIMIT $3 OFFSET $4
130 "#,
131 Self::columns_sql(None)
132 )))
133 .bind(user_uuid)
134 .bind(search)
135 .bind(per_page)
136 .bind(offset)
137 .fetch_all(database.read())
138 .await?;
139
140 Ok(super::Pagination {
141 total: rows
142 .first()
143 .map_or(Ok(0), |row| row.try_get("total_count"))?,
144 per_page,
145 page,
146 data: rows
147 .into_iter()
148 .map(|row| Self::map(None, &row))
149 .try_collect_vec()?,
150 })
151 }
152
153 pub async fn delete_by_user_uuid_except(
154 database: &crate::database::Database,
155 user_uuid: uuid::Uuid,
156 except: Option<uuid::Uuid>,
157 ) -> Result<u64, sqlx::Error> {
158 Ok(sqlx::query(
159 r#"
160 DELETE FROM user_sessions
161 WHERE user_sessions.user_uuid = $1 AND ($2 IS NULL OR user_sessions.uuid != $2)
162 "#,
163 )
164 .bind(user_uuid)
165 .bind(except)
166 .execute(database.write())
167 .await?
168 .rows_affected())
169 }
170
171 pub async fn delete_unused(
172 database: &crate::database::Database,
173 duration_seconds: i64,
174 ) -> Result<u64, sqlx::Error> {
175 Ok(sqlx::query(
176 r#"
177 DELETE FROM user_sessions
178 WHERE user_sessions.last_used < $1
179 "#,
180 )
181 .bind(chrono::Utc::now().naive_utc() - chrono::Duration::seconds(duration_seconds))
182 .execute(database.write())
183 .await?
184 .rows_affected())
185 }
186
187 pub async fn update_last_used(
188 &self,
189 database: &Arc<crate::database::Database>,
190 ip: impl Into<sqlx::types::ipnetwork::IpNetwork>,
191 user_agent: &str,
192 ) {
193 let uuid = self.uuid;
194 let now = chrono::Utc::now().naive_utc();
195 let user_agent = crate::utils::slice_up_to(user_agent, 255).to_string();
196 let ip = ip.into();
197
198 database
199 .batch_action("update_user_session", uuid, {
200 let database = database.clone();
201
202 async move {
203 sqlx::query!(
204 "UPDATE user_sessions
205 SET ip = $2, user_agent = $3, last_used = $4
206 WHERE user_sessions.uuid = $1",
207 uuid,
208 ip,
209 user_agent,
210 now
211 )
212 .execute(database.write())
213 .await?;
214
215 Ok(())
216 }
217 })
218 .await;
219 }
220
221 pub async fn get_cookie<'a>(
222 state: &crate::State,
223 key: impl Into<Cow<'a, str>>,
224 ) -> Result<Cookie<'a>, anyhow::Error> {
225 let settings = state.settings.get().await?;
226
227 Ok(Cookie::build((settings.app.session_cookie.clone(), key))
228 .http_only(true)
229 .same_site(tower_cookies::cookie::SameSite::Lax)
230 .secure(settings.app.url.starts_with("https://"))
231 .path("/")
232 .expires(
233 tower_cookies::cookie::time::OffsetDateTime::now_utc()
234 + tower_cookies::cookie::time::Duration::seconds(
235 settings.app.session_duration_seconds as i64,
236 ),
237 )
238 .build())
239 }
240}
241
242#[async_trait::async_trait]
243impl IntoApiObject for UserSession {
244 type ApiObject = ApiUserSession;
245 type ExtraArgs<'a> = &'a GetAuthMethod;
246
247 async fn into_api_object<'a>(
248 self,
249 state: &crate::State,
250 auth: Self::ExtraArgs<'a>,
251 ) -> Result<Self::ApiObject, crate::database::DatabaseError> {
252 let api_object = ApiUserSession::init_hooks(&self, state).await?;
253
254 let api_object = finish_extendible!(
255 ApiUserSession {
256 uuid: self.uuid,
257 ip: self.ip.ip().to_compact_string(),
258 user_agent: self.user_agent,
259 is_using: match &***auth {
260 AuthMethod::Session(session) => session.uuid == self.uuid,
261 _ => false,
262 },
263 last_used: self.last_used.and_utc(),
264 created: self.created.and_utc(),
265 },
266 api_object,
267 state
268 )?;
269
270 Ok(api_object)
271 }
272}
273
274#[derive(ToSchema, Deserialize, Validate)]
275pub struct CreateUserSessionOptions {
276 #[garde(skip)]
277 pub user_uuid: uuid::Uuid,
278 #[garde(skip)]
279 #[schema(value_type = String)]
280 pub ip: sqlx::types::ipnetwork::IpNetwork,
281 #[garde(length(chars, min = 1, max = 1024))]
282 #[schema(min_length = 1, max_length = 1024)]
283 pub user_agent: compact_str::CompactString,
284}
285
286#[async_trait::async_trait]
287impl CreatableModel for UserSession {
288 type CreateOptions<'a> = CreateUserSessionOptions;
289 type CreateResult = String;
290
291 fn get_create_handlers() -> &'static LazyLock<CreateListenerList<Self>> {
292 static CREATE_LISTENERS: LazyLock<CreateListenerList<UserSession>> =
293 LazyLock::new(|| Arc::new(ModelHandlerList::default()));
294
295 &CREATE_LISTENERS
296 }
297
298 async fn create_with_transaction(
299 state: &crate::State,
300 mut options: Self::CreateOptions<'_>,
301 transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
302 ) -> Result<Self::CreateResult, crate::database::DatabaseError> {
303 options.validate()?;
304
305 let mut query_builder = InsertQueryBuilder::new("user_sessions");
306
307 Self::run_create_handlers(&mut options, &mut query_builder, state, transaction).await?;
308
309 let key_id = rand::distr::Alphanumeric.sample_string(&mut rand::rng(), 16);
310
311 let mut hash = sha2::Sha256::new();
312 hash.update(chrono::Utc::now().timestamp().to_le_bytes());
313 hash.update(options.user_uuid.to_bytes_le());
314 hash.update(rand::rng().random::<[u8; 32]>());
315 let hash = hex::encode(hash.finalize());
316
317 query_builder
318 .set("user_uuid", options.user_uuid)
319 .set("key_id", key_id.clone())
320 .set_expr("key", "crypt($1, gen_salt('bf', 12))", vec![&hash])
321 .set("ip", options.ip)
322 .set("user_agent", &options.user_agent);
323
324 query_builder.execute(&mut **transaction).await?;
325
326 let mut result = format!("{key_id}:{hash}");
327
328 Self::run_after_create_handlers(&mut result, &options, state, transaction).await?;
329
330 Ok(result)
331 }
332}
333
334#[async_trait::async_trait]
335impl DeletableModel for UserSession {
336 type DeleteOptions = ();
337
338 fn get_delete_handlers() -> &'static LazyLock<DeleteHandlerList<Self>> {
339 static DELETE_LISTENERS: LazyLock<DeleteHandlerList<UserSession>> =
340 LazyLock::new(|| Arc::new(ModelHandlerList::default()));
341
342 &DELETE_LISTENERS
343 }
344
345 async fn delete_with_transaction(
346 &self,
347 state: &crate::State,
348 options: Self::DeleteOptions,
349 transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
350 ) -> Result<(), anyhow::Error> {
351 self.run_delete_handlers(&options, state, transaction)
352 .await?;
353
354 sqlx::query(
355 r#"
356 DELETE FROM user_sessions
357 WHERE user_sessions.uuid = $1
358 "#,
359 )
360 .bind(self.uuid)
361 .execute(&mut **transaction)
362 .await?;
363
364 self.run_after_delete_handlers(&options, state, transaction)
365 .await?;
366
367 Ok(())
368 }
369}
370
371#[schema_extension_derive::extendible]
372#[init_args(UserSession, crate::State)]
373#[hook_args(crate::State)]
374#[derive(ToSchema, Serialize, Deserialize)]
375#[schema(title = "UserSession")]
376pub struct ApiUserSession {
377 pub uuid: uuid::Uuid,
378
379 pub ip: compact_str::CompactString,
380 pub user_agent: compact_str::CompactString,
381
382 pub is_using: bool,
383
384 pub last_used: chrono::DateTime<chrono::Utc>,
385 pub created: chrono::DateTime<chrono::Utc>,
386}