Skip to main content

shared/models/
user_session.rs

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}