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::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(|| std::sync::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(&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(&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_unused(
154        database: &crate::database::Database,
155        duration_seconds: i64,
156    ) -> Result<u64, sqlx::Error> {
157        Ok(sqlx::query(
158            r#"
159            DELETE FROM user_sessions
160            WHERE user_sessions.last_used < $1
161            "#,
162        )
163        .bind(chrono::Utc::now().naive_utc() - chrono::Duration::seconds(duration_seconds))
164        .execute(database.write())
165        .await?
166        .rows_affected())
167    }
168
169    pub async fn update_last_used(
170        &self,
171        database: &Arc<crate::database::Database>,
172        ip: impl Into<sqlx::types::ipnetwork::IpNetwork>,
173        user_agent: &str,
174    ) {
175        let uuid = self.uuid;
176        let now = chrono::Utc::now().naive_utc();
177        let user_agent = crate::utils::slice_up_to(user_agent, 255).to_string();
178        let ip = ip.into();
179
180        database
181            .batch_action("update_user_session", uuid, {
182                let database = database.clone();
183
184                async move {
185                    sqlx::query!(
186                        "UPDATE user_sessions
187                        SET ip = $2, user_agent = $3, last_used = $4
188                        WHERE user_sessions.uuid = $1",
189                        uuid,
190                        ip,
191                        user_agent,
192                        now
193                    )
194                    .execute(database.write())
195                    .await?;
196
197                    Ok(())
198                }
199            })
200            .await;
201    }
202
203    pub async fn get_cookie<'a>(
204        state: &crate::State,
205        key: impl Into<Cow<'a, str>>,
206    ) -> Result<Cookie<'a>, anyhow::Error> {
207        let settings = state.settings.get().await?;
208
209        Ok(Cookie::build((settings.app.session_cookie.clone(), key))
210            .http_only(true)
211            .same_site(tower_cookies::cookie::SameSite::Lax)
212            .secure(settings.app.url.starts_with("https://"))
213            .path("/")
214            .expires(
215                tower_cookies::cookie::time::OffsetDateTime::now_utc()
216                    + tower_cookies::cookie::time::Duration::seconds(
217                        settings.app.session_duration_seconds as i64,
218                    ),
219            )
220            .build())
221    }
222}
223
224#[async_trait::async_trait]
225impl IntoApiObject for UserSession {
226    type ApiObject = ApiUserSession;
227    type ExtraArgs<'a> = &'a GetAuthMethod;
228
229    async fn into_api_object<'a>(
230        self,
231        state: &crate::State,
232        auth: Self::ExtraArgs<'a>,
233    ) -> Result<Self::ApiObject, crate::database::DatabaseError> {
234        let api_object = ApiUserSession::init_hooks(&self, state).await?;
235
236        let api_object = finish_extendible!(
237            ApiUserSession {
238                uuid: self.uuid,
239                ip: self.ip.ip().to_compact_string(),
240                user_agent: self.user_agent,
241                is_using: match &**auth {
242                    AuthMethod::Session(session) => session.uuid == self.uuid,
243                    _ => false,
244                },
245                last_used: self.last_used.and_utc(),
246                created: self.created.and_utc(),
247            },
248            api_object,
249            state
250        )?;
251
252        Ok(api_object)
253    }
254}
255
256#[derive(ToSchema, Deserialize, Validate)]
257pub struct CreateUserSessionOptions {
258    #[garde(skip)]
259    pub user_uuid: uuid::Uuid,
260    #[garde(skip)]
261    #[schema(value_type = String)]
262    pub ip: sqlx::types::ipnetwork::IpNetwork,
263    #[garde(length(chars, min = 1, max = 1024))]
264    #[schema(min_length = 1, max_length = 1024)]
265    pub user_agent: compact_str::CompactString,
266}
267
268#[async_trait::async_trait]
269impl CreatableModel for UserSession {
270    type CreateOptions<'a> = CreateUserSessionOptions;
271    type CreateResult = String;
272
273    fn get_create_handlers() -> &'static LazyLock<CreateListenerList<Self>> {
274        static CREATE_LISTENERS: LazyLock<CreateListenerList<UserSession>> =
275            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
276
277        &CREATE_LISTENERS
278    }
279
280    async fn create_with_transaction(
281        state: &crate::State,
282        mut options: Self::CreateOptions<'_>,
283        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
284    ) -> Result<Self::CreateResult, crate::database::DatabaseError> {
285        options.validate()?;
286
287        let mut query_builder = InsertQueryBuilder::new("user_sessions");
288
289        Self::run_create_handlers(&mut options, &mut query_builder, state, transaction).await?;
290
291        let key_id = rand::distr::Alphanumeric.sample_string(&mut rand::rng(), 16);
292
293        let mut hash = sha2::Sha256::new();
294        hash.update(chrono::Utc::now().timestamp().to_le_bytes());
295        hash.update(options.user_uuid.to_bytes_le());
296        let hash = hex::encode(hash.finalize());
297
298        query_builder
299            .set("user_uuid", options.user_uuid)
300            .set("key_id", key_id.clone())
301            .set_expr("key", "crypt($1, gen_salt('bf', 12))", vec![&hash])
302            .set("ip", options.ip)
303            .set("user_agent", &options.user_agent);
304
305        query_builder.execute(&mut **transaction).await?;
306
307        let mut result = format!("{key_id}:{hash}");
308
309        Self::run_after_create_handlers(&mut result, &options, state, transaction).await?;
310
311        Ok(result)
312    }
313}
314
315#[async_trait::async_trait]
316impl DeletableModel for UserSession {
317    type DeleteOptions = ();
318
319    fn get_delete_handlers() -> &'static LazyLock<DeleteHandlerList<Self>> {
320        static DELETE_LISTENERS: LazyLock<DeleteHandlerList<UserSession>> =
321            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
322
323        &DELETE_LISTENERS
324    }
325
326    async fn delete_with_transaction(
327        &self,
328        state: &crate::State,
329        options: Self::DeleteOptions,
330        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
331    ) -> Result<(), anyhow::Error> {
332        self.run_delete_handlers(&options, state, transaction)
333            .await?;
334
335        sqlx::query(
336            r#"
337            DELETE FROM user_sessions
338            WHERE user_sessions.uuid = $1
339            "#,
340        )
341        .bind(self.uuid)
342        .execute(&mut **transaction)
343        .await?;
344
345        self.run_after_delete_handlers(&options, state, transaction)
346            .await?;
347
348        Ok(())
349    }
350}
351
352#[schema_extension_derive::extendible]
353#[init_args(UserSession, crate::State)]
354#[hook_args(crate::State)]
355#[derive(ToSchema, Serialize, Deserialize)]
356#[schema(title = "UserSession")]
357pub struct ApiUserSession {
358    pub uuid: uuid::Uuid,
359
360    pub ip: compact_str::CompactString,
361    pub user_agent: compact_str::CompactString,
362
363    pub is_using: bool,
364
365    pub last_used: chrono::DateTime<chrono::Utc>,
366    pub created: chrono::DateTime<chrono::Utc>,
367}