shared/models/
user_session.rs

1use crate::{
2    models::{
3        InsertQueryBuilder,
4        user::{AuthMethod, GetAuthMethod},
5    },
6    prelude::*,
7};
8use garde::Validate;
9use rand::distr::SampleString;
10use serde::{Deserialize, Serialize};
11use sha2::Digest;
12use sqlx::{Row, postgres::PgRow};
13use std::{
14    collections::BTreeMap,
15    sync::{Arc, LazyLock},
16};
17use utoipa::ToSchema;
18
19#[derive(Serialize, Deserialize, Clone)]
20pub struct UserSession {
21    pub uuid: uuid::Uuid,
22
23    pub ip: sqlx::types::ipnetwork::IpNetwork,
24    pub user_agent: String,
25
26    pub last_used: chrono::NaiveDateTime,
27    pub created: chrono::NaiveDateTime,
28}
29
30impl BaseModel for UserSession {
31    const NAME: &'static str = "user_session";
32
33    #[inline]
34    fn columns(prefix: Option<&str>) -> BTreeMap<&'static str, compact_str::CompactString> {
35        let prefix = prefix.unwrap_or_default();
36
37        BTreeMap::from([
38            (
39                "user_sessions.uuid",
40                compact_str::format_compact!("{prefix}uuid"),
41            ),
42            (
43                "user_sessions.ip",
44                compact_str::format_compact!("{prefix}ip"),
45            ),
46            (
47                "user_sessions.user_agent",
48                compact_str::format_compact!("{prefix}user_agent"),
49            ),
50            (
51                "user_sessions.last_used",
52                compact_str::format_compact!("{prefix}last_used"),
53            ),
54            (
55                "user_sessions.created",
56                compact_str::format_compact!("{prefix}created"),
57            ),
58        ])
59    }
60
61    #[inline]
62    fn map(prefix: Option<&str>, row: &PgRow) -> Result<Self, crate::database::DatabaseError> {
63        let prefix = prefix.unwrap_or_default();
64
65        Ok(Self {
66            uuid: row.try_get(compact_str::format_compact!("{prefix}uuid").as_str())?,
67            ip: row.try_get(compact_str::format_compact!("{prefix}ip").as_str())?,
68            user_agent: row.try_get(compact_str::format_compact!("{prefix}user_agent").as_str())?,
69            last_used: row.try_get(compact_str::format_compact!("{prefix}last_used").as_str())?,
70            created: row.try_get(compact_str::format_compact!("{prefix}created").as_str())?,
71        })
72    }
73}
74
75impl UserSession {
76    pub async fn by_user_uuid_uuid(
77        database: &crate::database::Database,
78        user_uuid: uuid::Uuid,
79        uuid: uuid::Uuid,
80    ) -> Result<Option<Self>, crate::database::DatabaseError> {
81        let row = sqlx::query(&format!(
82            r#"
83            SELECT {}
84            FROM user_sessions
85            WHERE user_sessions.user_uuid = $1 AND user_sessions.uuid = $2
86            "#,
87            Self::columns_sql(None)
88        ))
89        .bind(user_uuid)
90        .bind(uuid)
91        .fetch_optional(database.read())
92        .await?;
93
94        row.try_map(|row| Self::map(None, &row))
95    }
96
97    pub async fn by_user_uuid_with_pagination(
98        database: &crate::database::Database,
99        user_uuid: uuid::Uuid,
100        page: i64,
101        per_page: i64,
102        search: Option<&str>,
103    ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
104        let offset = (page - 1) * per_page;
105
106        let rows = sqlx::query(&format!(
107            r#"
108            SELECT {}, COUNT(*) OVER() AS total_count
109            FROM user_sessions
110            WHERE user_sessions.user_uuid = $1 AND ($2 IS NULL OR user_sessions.user_agent ILIKE '%' || $2 || '%')
111            ORDER BY user_sessions.created DESC
112            LIMIT $3 OFFSET $4
113            "#,
114            Self::columns_sql(None)
115        ))
116        .bind(user_uuid)
117        .bind(search)
118        .bind(per_page)
119        .bind(offset)
120        .fetch_all(database.read())
121        .await?;
122
123        Ok(super::Pagination {
124            total: rows
125                .first()
126                .map_or(Ok(0), |row| row.try_get("total_count"))?,
127            per_page,
128            page,
129            data: rows
130                .into_iter()
131                .map(|row| Self::map(None, &row))
132                .try_collect_vec()?,
133        })
134    }
135
136    pub async fn delete_unused(database: &crate::database::Database) -> Result<u64, sqlx::Error> {
137        Ok(sqlx::query(
138            r#"
139            DELETE FROM user_sessions
140            WHERE user_sessions.last_used < NOW() - INTERVAL '30 days'
141            "#,
142        )
143        .execute(database.write())
144        .await?
145        .rows_affected())
146    }
147
148    pub async fn update_last_used(
149        &self,
150        database: &Arc<crate::database::Database>,
151        ip: impl Into<sqlx::types::ipnetwork::IpNetwork>,
152        user_agent: &str,
153    ) {
154        let uuid = self.uuid;
155        let now = chrono::Utc::now().naive_utc();
156        let user_agent = crate::utils::slice_up_to(user_agent, 255).to_string();
157        let ip = ip.into();
158
159        database
160            .batch_action("update_user_session", uuid, {
161                let database = database.clone();
162
163                async move {
164                    sqlx::query!(
165                        "UPDATE user_sessions
166		                    SET ip = $2, user_agent = $3, last_used = $4
167		                    WHERE user_sessions.uuid = $1",
168                        uuid,
169                        ip,
170                        user_agent,
171                        now
172                    )
173                    .execute(database.write())
174                    .await?;
175
176                    Ok(())
177                }
178            })
179            .await;
180    }
181
182    #[inline]
183    pub fn into_api_object(self, auth: &GetAuthMethod) -> ApiUserSession {
184        ApiUserSession {
185            uuid: self.uuid,
186            ip: self.ip.ip().to_string(),
187            user_agent: self.user_agent,
188            is_using: match &**auth {
189                AuthMethod::Session(session) => session.uuid == self.uuid,
190                _ => false,
191            },
192            last_used: self.last_used.and_utc(),
193            created: self.created.and_utc(),
194        }
195    }
196}
197
198#[derive(ToSchema, Deserialize, Validate)]
199pub struct CreateUserSessionOptions {
200    #[garde(skip)]
201    pub user_uuid: uuid::Uuid,
202    #[garde(skip)]
203    #[schema(value_type = String)]
204    pub ip: sqlx::types::ipnetwork::IpNetwork,
205    #[garde(length(chars, min = 1, max = 1024))]
206    #[schema(min_length = 1, max_length = 1024)]
207    pub user_agent: compact_str::CompactString,
208}
209
210#[async_trait::async_trait]
211impl CreatableModel for UserSession {
212    type CreateOptions<'a> = CreateUserSessionOptions;
213    type CreateResult = String;
214
215    fn get_create_handlers() -> &'static LazyLock<CreateListenerList<Self>> {
216        static CREATE_LISTENERS: LazyLock<CreateListenerList<UserSession>> =
217            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
218
219        &CREATE_LISTENERS
220    }
221
222    async fn create(
223        state: &crate::State,
224        mut options: Self::CreateOptions<'_>,
225    ) -> Result<Self::CreateResult, crate::database::DatabaseError> {
226        options.validate()?;
227
228        let mut transaction = state.database.write().begin().await?;
229
230        let mut query_builder = InsertQueryBuilder::new("user_sessions");
231
232        Self::run_create_handlers(&mut options, &mut query_builder, state, &mut transaction)
233            .await?;
234
235        let key_id = rand::distr::Alphanumeric.sample_string(&mut rand::rng(), 16);
236
237        let mut hash = sha2::Sha256::new();
238        hash.update(chrono::Utc::now().timestamp().to_le_bytes());
239        hash.update(options.user_uuid.to_bytes_le());
240        let hash = format!("{:x}", hash.finalize());
241
242        query_builder
243            .set("user_uuid", options.user_uuid)
244            .set("key_id", key_id.clone())
245            .set_expr("key", "crypt($1, gen_salt('xdes', 321))", vec![&hash])
246            .set("ip", options.ip)
247            .set("user_agent", &options.user_agent);
248
249        query_builder.execute(&mut *transaction).await?;
250
251        transaction.commit().await?;
252
253        Ok(format!("{key_id}:{hash}"))
254    }
255}
256
257#[async_trait::async_trait]
258impl DeletableModel for UserSession {
259    type DeleteOptions = ();
260
261    fn get_delete_handlers() -> &'static LazyLock<DeleteListenerList<Self>> {
262        static DELETE_LISTENERS: LazyLock<DeleteListenerList<UserSession>> =
263            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
264
265        &DELETE_LISTENERS
266    }
267
268    async fn delete(
269        &self,
270        state: &crate::State,
271        options: Self::DeleteOptions,
272    ) -> Result<(), anyhow::Error> {
273        let mut transaction = state.database.write().begin().await?;
274
275        self.run_delete_handlers(&options, state, &mut transaction)
276            .await?;
277
278        sqlx::query(
279            r#"
280            DELETE FROM user_sessions
281            WHERE user_sessions.uuid = $1
282            "#,
283        )
284        .bind(self.uuid)
285        .execute(&mut *transaction)
286        .await?;
287
288        transaction.commit().await?;
289
290        Ok(())
291    }
292}
293
294#[derive(ToSchema, Serialize, Deserialize)]
295#[schema(title = "UserSession")]
296pub struct ApiUserSession {
297    pub uuid: uuid::Uuid,
298
299    pub ip: String,
300    pub user_agent: String,
301
302    pub is_using: bool,
303
304    pub last_used: chrono::DateTime<chrono::Utc>,
305    pub created: chrono::DateTime<chrono::Utc>,
306}