Skip to main content

shared/models/user/
mod.rs

1use crate::{
2    models::{InsertQueryBuilder, UpdateQueryBuilder},
3    prelude::*,
4    storage::StorageUrlRetriever,
5};
6use garde::Validate;
7use serde::{Deserialize, Serialize};
8use sha2::Digest;
9use sqlx::{Row, postgres::PgRow, prelude::Type};
10use std::{
11    collections::BTreeMap,
12    sync::{Arc, LazyLock},
13};
14use utoipa::ToSchema;
15use webauthn_rs::prelude::CredentialID;
16
17mod auth;
18pub use auth::*;
19
20#[derive(ToSchema, Serialize, Deserialize, Type, PartialEq, Eq, Hash, Clone, Copy)]
21#[serde(rename_all = "snake_case")]
22#[sqlx(type_name = "user_toast_position", rename_all = "SCREAMING_SNAKE_CASE")]
23pub enum UserToastPosition {
24    TopLeft,
25    TopCenter,
26    TopRight,
27    BottomLeft,
28    BottomCenter,
29    BottomRight,
30}
31
32#[derive(Serialize, Deserialize, Clone)]
33pub struct User {
34    pub uuid: uuid::Uuid,
35    pub role: Option<super::role::Role>,
36    pub external_id: Option<compact_str::CompactString>,
37
38    pub avatar: Option<String>,
39    pub username: compact_str::CompactString,
40    pub email: compact_str::CompactString,
41
42    pub name_first: compact_str::CompactString,
43    pub name_last: compact_str::CompactString,
44
45    pub admin: bool,
46    pub frozen: bool,
47    pub suspended: bool,
48
49    pub totp_enabled: bool,
50    pub totp_last_used: Option<chrono::NaiveDateTime>,
51    pub totp_secret: Option<String>,
52
53    pub language: compact_str::CompactString,
54    pub toast_position: UserToastPosition,
55    pub start_on_grouped_servers: bool,
56
57    pub has_password: bool,
58
59    pub created: chrono::NaiveDateTime,
60
61    extension_data: super::ModelExtensionData,
62}
63
64impl BaseModel for User {
65    const NAME: &'static str = "user";
66
67    fn get_extension_list() -> &'static super::ModelExtensionList {
68        static EXTENSIONS: LazyLock<super::ModelExtensionList> =
69            LazyLock::new(|| parking_lot::RwLock::new(Vec::new()));
70
71        &EXTENSIONS
72    }
73
74    fn get_extension_data(&self) -> &super::ModelExtensionData {
75        &self.extension_data
76    }
77
78    #[inline]
79    fn base_columns(prefix: Option<&str>) -> BTreeMap<&'static str, compact_str::CompactString> {
80        let prefix = prefix.unwrap_or_default();
81
82        let mut columns = BTreeMap::from([
83            ("users.uuid", compact_str::format_compact!("{prefix}uuid")),
84            (
85                "users.external_id",
86                compact_str::format_compact!("{prefix}external_id"),
87            ),
88            (
89                "users.avatar",
90                compact_str::format_compact!("{prefix}avatar"),
91            ),
92            (
93                "users.username",
94                compact_str::format_compact!("{prefix}username"),
95            ),
96            ("users.email", compact_str::format_compact!("{prefix}email")),
97            (
98                "users.name_first",
99                compact_str::format_compact!("{prefix}name_first"),
100            ),
101            (
102                "users.name_last",
103                compact_str::format_compact!("{prefix}name_last"),
104            ),
105            ("users.admin", compact_str::format_compact!("{prefix}admin")),
106            (
107                "users.frozen",
108                compact_str::format_compact!("{prefix}frozen"),
109            ),
110            (
111                "users.suspended",
112                compact_str::format_compact!("{prefix}suspended"),
113            ),
114            (
115                "users.totp_enabled",
116                compact_str::format_compact!("{prefix}totp_enabled"),
117            ),
118            (
119                "users.totp_last_used",
120                compact_str::format_compact!("{prefix}totp_last_used"),
121            ),
122            (
123                "users.totp_secret",
124                compact_str::format_compact!("{prefix}totp_secret"),
125            ),
126            (
127                "users.language",
128                compact_str::format_compact!("{prefix}language"),
129            ),
130            (
131                "users.toast_position",
132                compact_str::format_compact!("{prefix}toast_position"),
133            ),
134            (
135                "users.start_on_grouped_servers",
136                compact_str::format_compact!("{prefix}start_on_grouped_servers"),
137            ),
138            (
139                "(users.password IS NOT NULL)",
140                compact_str::format_compact!("{prefix}has_password"),
141            ),
142            (
143                "users.created",
144                compact_str::format_compact!("{prefix}created"),
145            ),
146        ]);
147
148        columns.extend(super::role::Role::base_columns(Some("role_")));
149
150        columns
151    }
152
153    #[inline]
154    fn map(prefix: Option<&str>, row: &PgRow) -> Result<Self, crate::database::DatabaseError> {
155        let prefix = prefix.unwrap_or_default();
156
157        Ok(Self {
158            uuid: row.try_get(compact_str::format_compact!("{prefix}uuid").as_str())?,
159            role: if row
160                .try_get::<uuid::Uuid, _>(
161                    compact_str::format_compact!("{prefix}role_uuid").as_str(),
162                )
163                .is_ok()
164            {
165                Some(super::role::Role::map(Some("role_"), row)?)
166            } else {
167                None
168            },
169            external_id: row
170                .try_get(compact_str::format_compact!("{prefix}external_id").as_str())?,
171            avatar: row.try_get(compact_str::format_compact!("{prefix}avatar").as_str())?,
172            username: row.try_get(compact_str::format_compact!("{prefix}username").as_str())?,
173            email: row.try_get(compact_str::format_compact!("{prefix}email").as_str())?,
174            name_first: row.try_get(compact_str::format_compact!("{prefix}name_first").as_str())?,
175            name_last: row.try_get(compact_str::format_compact!("{prefix}name_last").as_str())?,
176            admin: row.try_get(compact_str::format_compact!("{prefix}admin").as_str())?,
177            frozen: row.try_get(compact_str::format_compact!("{prefix}frozen").as_str())?,
178            suspended: row.try_get(compact_str::format_compact!("{prefix}suspended").as_str())?,
179            totp_enabled: row
180                .try_get(compact_str::format_compact!("{prefix}totp_enabled").as_str())?,
181            totp_last_used: row
182                .try_get(compact_str::format_compact!("{prefix}totp_last_used").as_str())?,
183            totp_secret: row
184                .try_get(compact_str::format_compact!("{prefix}totp_secret").as_str())?,
185            language: row.try_get(compact_str::format_compact!("{prefix}language").as_str())?,
186            toast_position: row
187                .try_get(compact_str::format_compact!("{prefix}toast_position").as_str())?,
188            start_on_grouped_servers: row.try_get(
189                compact_str::format_compact!("{prefix}start_on_grouped_servers").as_str(),
190            )?,
191            has_password: row
192                .try_get(compact_str::format_compact!("{prefix}has_password").as_str())?,
193            created: row.try_get(compact_str::format_compact!("{prefix}created").as_str())?,
194            extension_data: Self::map_extensions(prefix, row)?,
195        })
196    }
197}
198
199impl User {
200    pub async fn create_automatic_admin(
201        database: &crate::database::Database,
202        username: &str,
203        email: &str,
204        name_first: &str,
205        name_last: &str,
206        password: &str,
207    ) -> Result<uuid::Uuid, crate::database::DatabaseError> {
208        let row = sqlx::query(
209            r#"
210            INSERT INTO users (username, email, name_first, name_last, password, admin)
211            VALUES ($1, $2, $3, $4, crypt($5, gen_salt('bf', 12)), (SELECT COUNT(*) = 0 FROM users))
212            RETURNING users.uuid
213            "#,
214        )
215        .bind(username)
216        .bind(email)
217        .bind(name_first)
218        .bind(name_last)
219        .bind(password)
220        .fetch_one(database.write())
221        .await?;
222
223        Ok(row.try_get("uuid")?)
224    }
225
226    pub async fn by_external_id(
227        database: &crate::database::Database,
228        external_id: &str,
229    ) -> Result<Option<Self>, crate::database::DatabaseError> {
230        let row = sqlx::query(sqlx::AssertSqlSafe(format!(
231            r#"
232            SELECT {}
233            FROM users
234            LEFT JOIN roles ON roles.uuid = users.role_uuid
235            WHERE users.external_id = $1
236            "#,
237            Self::columns_sql(None)
238        )))
239        .bind(external_id)
240        .fetch_optional(database.read())
241        .await?;
242
243        row.try_map(|row| Self::map(None, &row))
244    }
245
246    /// Returns the user and session associated with the given session string, if valid.
247    ///
248    /// Cached for 5 seconds.
249    pub async fn by_session_cached(
250        database: &crate::database::Database,
251        session: &str,
252    ) -> Result<Option<(Self, super::user_session::UserSession)>, anyhow::Error> {
253        let (key_id, key) = match session.split_once(':') {
254            Some((key_id, key)) => (key_id, key),
255            None => return Ok(None),
256        };
257
258        database
259            .cache
260            .cached(
261                &format!(
262                    "user::session::{}",
263                    hex::encode(sha2::Sha256::digest(session.as_bytes()))
264                ),
265                5,
266                || async {
267                    let row = sqlx::query(sqlx::AssertSqlSafe(format!(
268                        r#"
269                        WITH user_sessions AS MATERIALIZED (
270                            SELECT * FROM user_sessions WHERE key_id = $1
271                        )
272                        SELECT {}, {}
273                        FROM users
274                        LEFT JOIN roles ON roles.uuid = users.role_uuid
275                        JOIN user_sessions ON user_sessions.user_uuid = users.uuid
276                        WHERE user_sessions.key = crypt($2, user_sessions.key)
277                        "#,
278                        Self::columns_sql(None),
279                        super::user_session::UserSession::columns_sql(Some("session_"))
280                    )))
281                    .bind(key_id)
282                    .bind(key)
283                    .fetch_optional(database.read())
284                    .await?;
285
286                    row.try_map(|row| {
287                        Ok::<_, anyhow::Error>((
288                            Self::map(None, &row)?,
289                            super::user_session::UserSession::map(Some("session_"), &row)?,
290                        ))
291                    })
292                },
293            )
294            .await
295    }
296
297    /// Returns the user and API key associated with the given API key string, if valid.
298    ///
299    /// Cached for 5 seconds.
300    pub async fn by_api_key_cached(
301        database: &crate::database::Database,
302        key: &str,
303    ) -> Result<Option<(Self, super::user_api_key::UserApiKey)>, anyhow::Error> {
304        database
305            .cache
306            .cached(
307                &format!(
308                    "user::api_key::{}",
309                    hex::encode(sha2::Sha256::digest(key.as_bytes()))
310                ),
311                5,
312                || async {
313                    let Some(key_start) = key.get(0..16) else {
314                        return Ok(None);
315                    };
316
317                    let row = sqlx::query(sqlx::AssertSqlSafe(format!(
318                        r#"
319                        WITH user_api_keys AS MATERIALIZED (
320                            SELECT * FROM user_api_keys 
321                            WHERE user_api_keys.key_start = $1 
322                            AND (user_api_keys.expires IS NULL OR user_api_keys.expires > NOW())
323                        )
324                        SELECT {}, {}
325                        FROM users
326                        LEFT JOIN roles ON roles.uuid = users.role_uuid
327                        JOIN user_api_keys ON user_api_keys.user_uuid = users.uuid
328                        WHERE user_api_keys.key = crypt($2, user_api_keys.key)
329                        "#,
330                        Self::columns_sql(None),
331                        super::user_api_key::UserApiKey::columns_sql(Some("api_key_"))
332                    )))
333                    .bind(key_start)
334                    .bind(key)
335                    .fetch_optional(database.read())
336                    .await?;
337
338                    row.try_map(|row| {
339                        Ok::<_, anyhow::Error>((
340                            Self::map(None, &row)?,
341                            super::user_api_key::UserApiKey::map(Some("api_key_"), &row)?,
342                        ))
343                    })
344                },
345            )
346            .await
347    }
348
349    pub async fn by_credential_id(
350        database: &crate::database::Database,
351        credential_id: &CredentialID,
352    ) -> Result<
353        Option<(Self, super::user_security_key::UserSecurityKey)>,
354        crate::database::DatabaseError,
355    > {
356        let row = sqlx::query(sqlx::AssertSqlSafe(format!(
357            r#"
358            SELECT {}, {}
359            FROM users
360            LEFT JOIN roles ON roles.uuid = users.role_uuid
361            JOIN user_security_keys ON user_security_keys.user_uuid = users.uuid
362            WHERE user_security_keys.credential_id = $1
363            "#,
364            Self::columns_sql(None),
365            super::user_security_key::UserSecurityKey::columns_sql(Some("security_key_"))
366        )))
367        .bind(credential_id.to_vec())
368        .fetch_optional(database.read())
369        .await?;
370
371        row.try_map(|row| {
372            Ok((
373                Self::map(None, &row)?,
374                super::user_security_key::UserSecurityKey::map(Some("security_key_"), &row)?,
375            ))
376        })
377    }
378
379    pub async fn by_email(
380        database: &crate::database::Database,
381        email: &str,
382    ) -> Result<Option<Self>, crate::database::DatabaseError> {
383        let row = sqlx::query(sqlx::AssertSqlSafe(format!(
384            r#"
385            SELECT {}
386            FROM users
387            LEFT JOIN roles ON roles.uuid = users.role_uuid
388            WHERE lower(users.email) = lower($1)
389            "#,
390            Self::columns_sql(None)
391        )))
392        .bind(email)
393        .fetch_optional(database.read())
394        .await?;
395
396        row.try_map(|row| Self::map(None, &row))
397    }
398
399    pub async fn by_email_password(
400        database: &crate::database::Database,
401        email: &str,
402        password: &str,
403    ) -> Result<Option<Self>, crate::database::DatabaseError> {
404        let row = sqlx::query(sqlx::AssertSqlSafe(format!(
405            r#"
406            SELECT {}
407            FROM users
408            LEFT JOIN roles ON roles.uuid = users.role_uuid
409            WHERE lower(users.email) = lower($1) AND users.password IS NOT NULL AND users.password = crypt($2, users.password)
410            "#,
411            Self::columns_sql(None)
412        )))
413        .bind(email)
414        .bind(password)
415        .fetch_optional(database.read())
416        .await?;
417
418        row.try_map(|row| Self::map(None, &row))
419    }
420
421    pub async fn by_username(
422        database: &crate::database::Database,
423        username: &str,
424    ) -> Result<Option<Self>, crate::database::DatabaseError> {
425        let row = sqlx::query(sqlx::AssertSqlSafe(format!(
426            r#"
427            SELECT {}
428            FROM users
429            LEFT JOIN roles ON roles.uuid = users.role_uuid
430            WHERE lower(users.username) = lower($1)
431            "#,
432            Self::columns_sql(None)
433        )))
434        .bind(username)
435        .fetch_optional(database.read())
436        .await?;
437
438        row.try_map(|row| Self::map(None, &row))
439    }
440
441    pub async fn by_username_password(
442        database: &crate::database::Database,
443        username: &str,
444        password: &str,
445    ) -> Result<Option<Self>, crate::database::DatabaseError> {
446        let row = sqlx::query(sqlx::AssertSqlSafe(format!(
447            r#"
448            SELECT {}
449            FROM users
450            LEFT JOIN roles ON roles.uuid = users.role_uuid
451            WHERE lower(users.username) = lower($1) AND users.password IS NOT NULL AND users.password = crypt($2, users.password)
452            "#,
453            Self::columns_sql(None)
454        )))
455        .bind(username)
456        .bind(password)
457        .fetch_optional(database.read())
458        .await?;
459
460        row.try_map(|row| Self::map(None, &row))
461    }
462
463    pub async fn by_username_public_key(
464        database: &crate::database::Database,
465        username: &str,
466        public_key: russh::keys::PublicKey,
467    ) -> Result<Option<Self>, crate::database::DatabaseError> {
468        let row = sqlx::query(sqlx::AssertSqlSafe(format!(
469            r#"
470            SELECT {}
471            FROM users
472            LEFT JOIN roles ON roles.uuid = users.role_uuid
473            JOIN user_ssh_keys ON user_ssh_keys.user_uuid = users.uuid
474            WHERE lower(users.username) = lower($1) AND user_ssh_keys.fingerprint = $2
475            "#,
476            Self::columns_sql(None)
477        )))
478        .bind(username)
479        .bind(
480            public_key
481                .fingerprint(russh::keys::HashAlg::Sha256)
482                .to_string(),
483        )
484        .fetch_optional(database.read())
485        .await?;
486
487        row.try_map(|row| Self::map(None, &row))
488    }
489
490    pub async fn by_role_uuid_with_pagination(
491        database: &crate::database::Database,
492        role_uuid: uuid::Uuid,
493        page: i64,
494        per_page: i64,
495        search: Option<&str>,
496    ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
497        let offset = (page - 1) * per_page;
498
499        let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
500            r#"
501            SELECT {}, COUNT(*) OVER() AS total_count
502            FROM users
503            LEFT JOIN roles ON roles.uuid = users.role_uuid
504            WHERE users.role_uuid = $1 AND ($2 IS NULL OR users.username ILIKE '%' || $2 || '%' OR users.email ILIKE '%' || $2 || '%')
505            ORDER BY users.created
506            LIMIT $3 OFFSET $4
507            "#,
508            Self::columns_sql(None)
509        )))
510        .bind(role_uuid)
511        .bind(search)
512        .bind(per_page)
513        .bind(offset)
514        .fetch_all(database.read())
515        .await?;
516
517        Ok(super::Pagination {
518            total: rows
519                .first()
520                .map_or(Ok(0), |row| row.try_get("total_count"))?,
521            per_page,
522            page,
523            data: rows
524                .into_iter()
525                .map(|row| Self::map(None, &row))
526                .try_collect_vec()?,
527        })
528    }
529
530    pub async fn all_with_pagination(
531        database: &crate::database::Database,
532        page: i64,
533        per_page: i64,
534        search: Option<&str>,
535    ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
536        let offset = (page - 1) * per_page;
537
538        let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
539            r#"
540            SELECT {}, COUNT(*) OVER() AS total_count
541            FROM users
542            LEFT JOIN roles ON roles.uuid = users.role_uuid
543            WHERE $1 IS NULL OR users.username ILIKE '%' || $1 || '%' OR users.email ILIKE '%' || $1 || '%'
544            ORDER BY users.created
545            LIMIT $2 OFFSET $3
546            "#,
547            Self::columns_sql(None)
548        )))
549        .bind(search)
550        .bind(per_page)
551        .bind(offset)
552        .fetch_all(database.read())
553        .await?;
554
555        Ok(super::Pagination {
556            total: rows
557                .first()
558                .map_or(Ok(0), |row| row.try_get("total_count"))?,
559            per_page,
560            page,
561            data: rows
562                .into_iter()
563                .map(|row| Self::map(None, &row))
564                .try_collect_vec()?,
565        })
566    }
567
568    pub async fn count(database: &crate::database::Database) -> i64 {
569        sqlx::query_scalar(
570            r#"
571            SELECT COUNT(*)
572            FROM users
573            "#,
574        )
575        .fetch_one(database.read())
576        .await
577        .unwrap_or(0)
578    }
579
580    pub async fn validate_password(
581        &self,
582        database: &crate::database::Database,
583        password: &str,
584    ) -> Result<bool, crate::database::DatabaseError> {
585        if !self.has_password {
586            return Ok(true);
587        }
588
589        let row = sqlx::query(
590            r#"
591            SELECT 1
592            FROM users
593            WHERE users.uuid = $1 AND users.password = crypt($2, users.password)
594            "#,
595        )
596        .bind(self.uuid)
597        .bind(password)
598        .fetch_optional(database.read())
599        .await?;
600
601        Ok(row.is_some())
602    }
603
604    /// Update the User password, `None` will disallow password login and not require one when changing
605    pub async fn update_password(
606        &mut self,
607        database: &crate::database::Database,
608        password: Option<&str>,
609    ) -> Result<(), crate::database::DatabaseError> {
610        if let Some(password) = password {
611            sqlx::query(
612                r#"
613		            UPDATE users
614		            SET password = crypt($2, gen_salt('bf', 12))
615		            WHERE users.uuid = $1
616		            "#,
617            )
618            .bind(self.uuid)
619            .bind(password)
620            .execute(database.write())
621            .await?;
622
623            self.has_password = true;
624        } else {
625            sqlx::query(
626                r#"
627		            UPDATE users
628		            SET password = NULL
629		            WHERE users.uuid = $1
630		            "#,
631            )
632            .bind(self.uuid)
633            .bind(password)
634            .execute(database.write())
635            .await?;
636
637            self.has_password = false;
638        }
639
640        Ok(())
641    }
642
643    /// Update the User password, `None` will disallow password login and not require one when changing
644    pub async fn update_password_with_transaction(
645        &mut self,
646        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
647        password: Option<&str>,
648    ) -> Result<(), crate::database::DatabaseError> {
649        if let Some(password) = password {
650            sqlx::query(
651                r#"
652		            UPDATE users
653		            SET password = crypt($2, gen_salt('bf', 12))
654		            WHERE users.uuid = $1
655		            "#,
656            )
657            .bind(self.uuid)
658            .bind(password)
659            .execute(&mut **transaction)
660            .await?;
661
662            self.has_password = true;
663        } else {
664            sqlx::query(
665                r#"
666		            UPDATE users
667		            SET password = NULL
668		            WHERE users.uuid = $1
669		            "#,
670            )
671            .bind(self.uuid)
672            .bind(password)
673            .execute(&mut **transaction)
674            .await?;
675
676            self.has_password = false;
677        }
678
679        Ok(())
680    }
681
682    pub fn require_two_factor(&self, settings: &crate::settings::AppSettings) -> bool {
683        if let Some(role) = &self.role {
684            role.require_two_factor
685        } else {
686            match settings.app.two_factor_requirement {
687                crate::settings::app::TwoFactorRequirement::Admins => self.admin,
688                crate::settings::app::TwoFactorRequirement::AllUsers => true,
689                crate::settings::app::TwoFactorRequirement::None => false,
690            }
691        }
692    }
693
694    pub async fn into_api_full_object(
695        self,
696        state: &crate::State,
697        storage_url_retriever: &StorageUrlRetriever<'_>,
698    ) -> Result<ApiFullUser, crate::database::DatabaseError> {
699        let api_object = ApiFullUser::init_hooks(&self, state).await?;
700
701        let require_two_factor = self.require_two_factor(storage_url_retriever.get_settings());
702
703        let role = if let Some(r) = self.role {
704            Some(r.into_admin_api_object(state, ()).await?)
705        } else {
706            None
707        };
708
709        let api_object = finish_extendible!(
710            ApiFullUser {
711                uuid: self.uuid,
712                username: self.username,
713                role,
714                avatar: self
715                    .avatar
716                    .as_ref()
717                    .map(|a| storage_url_retriever.get_url(a)),
718                email: self.email,
719                name_first: self.name_first,
720                name_last: self.name_last,
721                admin: self.admin,
722                frozen: self.frozen,
723                suspended: self.suspended,
724                totp_enabled: self.totp_enabled,
725                totp_last_used: self.totp_last_used.map(|dt| dt.and_utc()),
726                require_two_factor,
727                language: self.language,
728                toast_position: self.toast_position,
729                start_on_grouped_servers: self.start_on_grouped_servers,
730                has_password: self.has_password,
731                created: self.created.and_utc(),
732            },
733            api_object,
734            state
735        )?;
736
737        Ok(api_object)
738    }
739}
740
741#[async_trait::async_trait]
742impl IntoApiObject for User {
743    type ApiObject = ApiUser;
744    type ExtraArgs<'a> = &'a crate::storage::StorageUrlRetriever<'a>;
745
746    async fn into_api_object<'a>(
747        self,
748        state: &crate::State,
749        storage_url_retriever: Self::ExtraArgs<'a>,
750    ) -> Result<Self::ApiObject, crate::database::DatabaseError> {
751        let api_object = ApiUser::init_hooks(&self, state).await?;
752
753        let api_object = finish_extendible!(
754            ApiUser {
755                uuid: self.uuid,
756                username: self.username,
757                avatar: self
758                    .avatar
759                    .as_ref()
760                    .map(|a| storage_url_retriever.get_url(a)),
761                totp_enabled: self.totp_enabled,
762                created: self.created.and_utc(),
763            },
764            api_object,
765            state
766        )?;
767
768        Ok(api_object)
769    }
770}
771
772#[async_trait::async_trait]
773impl IntoAdminApiObject for User {
774    type AdminApiObject = AdminApiUser;
775    type ExtraArgs<'a> = &'a crate::storage::StorageUrlRetriever<'a>;
776
777    async fn into_admin_api_object<'a>(
778        self,
779        state: &crate::State,
780        storage_url_retriever: Self::ExtraArgs<'a>,
781    ) -> Result<Self::AdminApiObject, crate::database::DatabaseError> {
782        let api_object = AdminApiUser::init_hooks(&self, state).await?;
783
784        let require_two_factor = self.require_two_factor(storage_url_retriever.get_settings());
785
786        let role = if let Some(r) = self.role {
787            Some(r.into_admin_api_object(state, ()).await?)
788        } else {
789            None
790        };
791
792        let api_object = finish_extendible!(
793            AdminApiUser {
794                uuid: self.uuid,
795                external_id: self.external_id,
796                username: self.username,
797                role,
798                avatar: self
799                    .avatar
800                    .as_ref()
801                    .map(|a| storage_url_retriever.get_url(a)),
802                email: self.email,
803                name_first: self.name_first,
804                name_last: self.name_last,
805                admin: self.admin,
806                frozen: self.frozen,
807                suspended: self.suspended,
808                totp_enabled: self.totp_enabled,
809                totp_last_used: self.totp_last_used.map(|dt| dt.and_utc()),
810                require_two_factor,
811                language: self.language,
812                toast_position: self.toast_position,
813                start_on_grouped_servers: self.start_on_grouped_servers,
814                has_password: self.has_password,
815                created: self.created.and_utc(),
816            },
817            api_object,
818            state
819        )?;
820
821        Ok(api_object)
822    }
823}
824
825#[derive(ToSchema, Deserialize, Validate)]
826pub struct CreateUserOptions {
827    #[garde(skip)]
828    pub role_uuid: Option<uuid::Uuid>,
829
830    #[garde(length(max = 255))]
831    #[schema(max_length = 255)]
832    pub external_id: Option<compact_str::CompactString>,
833
834    #[garde(length(chars, min = 3, max = 15), pattern("^[a-zA-Z0-9_]+$"))]
835    #[schema(min_length = 3, max_length = 15)]
836    #[schema(pattern = "^[a-zA-Z0-9_]+$")]
837    pub username: compact_str::CompactString,
838    #[garde(email, length(max = 255))]
839    #[schema(format = "email", max_length = 255)]
840    pub email: compact_str::CompactString,
841    #[garde(length(chars, min = 2, max = 255))]
842    #[schema(min_length = 2, max_length = 255)]
843    pub name_first: compact_str::CompactString,
844    #[garde(length(chars, min = 2, max = 255))]
845    #[schema(min_length = 2, max_length = 255)]
846    pub name_last: compact_str::CompactString,
847    #[garde(length(chars, min = 1, max = 512))]
848    #[schema(min_length = 1, max_length = 512)]
849    pub password: Option<String>,
850
851    #[garde(skip)]
852    pub admin: bool,
853    #[garde(skip)]
854    #[serde(default)]
855    pub frozen: bool,
856    #[garde(skip)]
857    #[serde(default)]
858    pub suspended: bool,
859    #[garde(skip)]
860    #[serde(default)]
861    pub send_email: bool,
862
863    #[garde(
864        length(chars, min = 2, max = 15),
865        custom(crate::utils::validate_language)
866    )]
867    #[schema(min_length = 2, max_length = 15)]
868    pub language: compact_str::CompactString,
869}
870
871#[async_trait::async_trait]
872impl CreatableModel for User {
873    type CreateOptions<'a> = CreateUserOptions;
874    type CreateResult = Self;
875
876    fn get_create_handlers() -> &'static LazyLock<CreateListenerList<Self>> {
877        static CREATE_LISTENERS: LazyLock<CreateListenerList<User>> =
878            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
879
880        &CREATE_LISTENERS
881    }
882
883    async fn create_with_transaction(
884        state: &crate::State,
885        mut options: Self::CreateOptions<'_>,
886        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
887    ) -> Result<Self, crate::database::DatabaseError> {
888        options.validate()?;
889
890        if let Some(role_uuid) = options.role_uuid {
891            super::role::Role::by_uuid_optional_cached(&state.database, role_uuid)
892                .await?
893                .ok_or(crate::database::InvalidRelationError("role"))?;
894        }
895
896        let mut query_builder = InsertQueryBuilder::new("users");
897
898        Self::run_create_handlers(&mut options, &mut query_builder, state, transaction).await?;
899
900        query_builder
901            .set("role_uuid", options.role_uuid)
902            .set("external_id", options.external_id.as_deref())
903            .set("username", &options.username)
904            .set("email", &options.email)
905            .set("name_first", &options.name_first)
906            .set("name_last", &options.name_last);
907
908        if let Some(password) = &options.password {
909            query_builder.set_expr("password", "crypt($1, gen_salt('bf', 12))", vec![password]);
910        }
911
912        query_builder
913            .set("admin", options.admin)
914            .set("frozen", options.frozen)
915            .set("suspended", options.suspended)
916            .set("language", &options.language);
917
918        let row = query_builder
919            .returning("uuid")
920            .fetch_one(&mut **transaction)
921            .await?;
922        let uuid: uuid::Uuid = row.get("uuid");
923
924        let mut result = Self::by_uuid_with_transaction(transaction, uuid).await?;
925
926        Self::run_after_create_handlers(&mut result, &options, state, transaction).await?;
927
928        if options.send_email {
929            match super::user_password_reset::UserPasswordReset::create_with_transaction(
930                transaction,
931                result.uuid,
932            )
933            .await
934            {
935                Ok(token) => {
936                    let settings = state.settings.get().await?;
937
938                    super::user_activity::UserActivity::create_with_transaction(
939                        state,
940                        super::user_activity::CreateUserActivityOptions {
941                            user_uuid: result.uuid,
942                            impersonator_uuid: None,
943                            api_key_uuid: None,
944                            event: "email:account-created".into(),
945                            ip: None,
946                            data: serde_json::json!({}),
947                            created: None,
948                        },
949                        transaction,
950                    )
951                    .await?;
952
953                    state
954                        .mail
955                        .send_template(
956                            state,
957                            "account_created",
958                            result.email.clone(),
959                            minijinja::context! {
960                                user => result,
961                                reset_link => format!(
962                                    "{}/auth/reset-password?token={}",
963                                    settings.app.url,
964                                    urlencoding::encode(&token),
965                                )
966                            },
967                        )
968                        .await;
969                }
970                Err(err) => {
971                    tracing::warn!(
972                        user = %result.uuid,
973                        "failed to create user password reset token: {:#?}",
974                        err
975                    );
976                }
977            };
978        }
979
980        Ok(result)
981    }
982}
983
984#[derive(Default, ToSchema, Serialize, Deserialize, Validate)]
985pub struct UpdateUserOptions {
986    #[garde(skip)]
987    #[serde(
988        default,
989        skip_serializing_if = "Option::is_none",
990        with = "::serde_with::rust::double_option"
991    )]
992    pub role_uuid: Option<Option<uuid::Uuid>>,
993
994    #[garde(length(chars, min = 1, max = 255))]
995    #[schema(min_length = 1, max_length = 255)]
996    #[serde(
997        default,
998        skip_serializing_if = "Option::is_none",
999        with = "::serde_with::rust::double_option"
1000    )]
1001    pub external_id: Option<Option<compact_str::CompactString>>,
1002
1003    #[garde(length(chars, min = 3, max = 15), pattern("^[a-zA-Z0-9_]+$"))]
1004    #[schema(min_length = 3, max_length = 15)]
1005    #[schema(pattern = "^[a-zA-Z0-9_]+$")]
1006    pub username: Option<compact_str::CompactString>,
1007    #[garde(email, length(max = 255))]
1008    #[schema(format = "email", max_length = 255)]
1009    pub email: Option<compact_str::CompactString>,
1010    #[garde(length(chars, min = 2, max = 255))]
1011    #[schema(min_length = 2, max_length = 255)]
1012    pub name_first: Option<compact_str::CompactString>,
1013    #[garde(length(chars, min = 2, max = 255))]
1014    #[schema(min_length = 2, max_length = 255)]
1015    pub name_last: Option<compact_str::CompactString>,
1016    #[garde(length(chars, min = 8, max = 512))]
1017    #[schema(min_length = 8, max_length = 512)]
1018    pub password: Option<Option<compact_str::CompactString>>,
1019
1020    #[garde(skip)]
1021    pub toast_position: Option<UserToastPosition>,
1022    #[garde(skip)]
1023    pub start_on_grouped_servers: Option<bool>,
1024
1025    #[garde(skip)]
1026    pub admin: Option<bool>,
1027    #[garde(skip)]
1028    pub frozen: Option<bool>,
1029    #[garde(skip)]
1030    pub suspended: Option<bool>,
1031
1032    #[garde(
1033        length(chars, min = 2, max = 15),
1034        inner(custom(crate::utils::validate_language))
1035    )]
1036    #[schema(min_length = 2, max_length = 15)]
1037    pub language: Option<compact_str::CompactString>,
1038}
1039
1040#[async_trait::async_trait]
1041impl UpdatableModel for User {
1042    type UpdateOptions = UpdateUserOptions;
1043
1044    fn get_update_handlers() -> &'static LazyLock<UpdateHandlerList<Self>> {
1045        static UPDATE_LISTENERS: LazyLock<UpdateHandlerList<User>> =
1046            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
1047
1048        &UPDATE_LISTENERS
1049    }
1050
1051    async fn update_with_transaction(
1052        &mut self,
1053        state: &crate::State,
1054        mut options: Self::UpdateOptions,
1055        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
1056    ) -> Result<(), crate::database::DatabaseError> {
1057        options.validate()?;
1058
1059        let role = if let Some(role_uuid) = options.role_uuid {
1060            if let Some(role_uuid) = role_uuid {
1061                Some(Some(
1062                    super::role::Role::by_uuid_optional_cached(&state.database, role_uuid)
1063                        .await?
1064                        .ok_or(crate::database::InvalidRelationError("role"))?,
1065                ))
1066            } else {
1067                Some(None)
1068            }
1069        } else {
1070            None
1071        };
1072
1073        let mut query_builder = UpdateQueryBuilder::new("users");
1074
1075        self.run_update_handlers(&mut options, &mut query_builder, state, transaction)
1076            .await?;
1077
1078        query_builder
1079            .set("role_uuid", options.role_uuid.as_ref())
1080            .set("external_id", options.external_id.as_ref())
1081            .set("username", options.username.as_ref())
1082            .set("email", options.email.as_ref())
1083            .set("name_first", options.name_first.as_ref())
1084            .set("name_last", options.name_last.as_ref())
1085            .set("admin", options.admin)
1086            .set("frozen", options.frozen)
1087            .set("suspended", options.suspended)
1088            .set("language", options.language.as_ref())
1089            .set("toast_position", options.toast_position.as_ref())
1090            .set("start_on_grouped_servers", options.start_on_grouped_servers)
1091            .where_eq("uuid", self.uuid);
1092
1093        query_builder.execute(&mut **transaction).await?;
1094
1095        if let Some(role) = role {
1096            self.role = role;
1097        }
1098        if let Some(external_id) = options.external_id {
1099            self.external_id = external_id;
1100        }
1101        if let Some(username) = options.username {
1102            self.username = username;
1103        }
1104        if let Some(email) = options.email {
1105            self.email = email;
1106        }
1107        if let Some(name_first) = options.name_first {
1108            self.name_first = name_first;
1109        }
1110        if let Some(name_last) = options.name_last {
1111            self.name_last = name_last;
1112        }
1113        if let Some(toast_position) = options.toast_position {
1114            self.toast_position = toast_position;
1115        }
1116        if let Some(start_on_grouped_servers) = options.start_on_grouped_servers {
1117            self.start_on_grouped_servers = start_on_grouped_servers;
1118        }
1119        if let Some(admin) = options.admin {
1120            self.admin = admin;
1121        }
1122        if let Some(frozen) = options.frozen {
1123            self.frozen = frozen;
1124        }
1125        if let Some(suspended) = options.suspended {
1126            self.suspended = suspended;
1127        }
1128        if let Some(language) = options.language {
1129            self.language = language;
1130        }
1131
1132        if let Some(password) = options.password {
1133            self.update_password_with_transaction(transaction, password.as_deref())
1134                .await?;
1135        }
1136
1137        self.run_after_update_handlers(state, transaction).await?;
1138
1139        Ok(())
1140    }
1141}
1142
1143#[async_trait::async_trait]
1144impl DeletableModel for User {
1145    type DeleteOptions = ();
1146
1147    fn get_delete_handlers() -> &'static LazyLock<DeleteHandlerList<Self>> {
1148        static DELETE_LISTENERS: LazyLock<DeleteHandlerList<User>> =
1149            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
1150
1151        &DELETE_LISTENERS
1152    }
1153
1154    async fn delete_with_transaction(
1155        &self,
1156        state: &crate::State,
1157        options: Self::DeleteOptions,
1158        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
1159    ) -> Result<(), anyhow::Error> {
1160        self.run_delete_handlers(&options, state, transaction)
1161            .await?;
1162
1163        sqlx::query(
1164            r#"
1165            DELETE FROM users
1166            WHERE users.uuid = $1
1167            "#,
1168        )
1169        .bind(self.uuid)
1170        .execute(&mut **transaction)
1171        .await?;
1172
1173        self.run_after_delete_handlers(&options, state, transaction)
1174            .await?;
1175
1176        Ok(())
1177    }
1178
1179    async fn delete(
1180        &self,
1181        state: &crate::State,
1182        options: Self::DeleteOptions,
1183    ) -> Result<(), anyhow::Error> {
1184        let mut transaction = state.database.write().begin().await?;
1185        self.delete_with_transaction(state, options, &mut transaction)
1186            .await?;
1187        transaction.commit().await?;
1188
1189        state.storage.remove(self.avatar.as_deref()).await?;
1190
1191        Ok(())
1192    }
1193}
1194
1195#[async_trait::async_trait]
1196impl ByUuid for User {
1197    async fn by_uuid(
1198        database: &crate::database::Database,
1199        uuid: uuid::Uuid,
1200    ) -> Result<Self, crate::database::DatabaseError> {
1201        let row = sqlx::query(sqlx::AssertSqlSafe(format!(
1202            r#"
1203            SELECT {}
1204            FROM users
1205            LEFT JOIN roles ON roles.uuid = users.role_uuid
1206            WHERE users.uuid = $1
1207            "#,
1208            Self::columns_sql(None)
1209        )))
1210        .bind(uuid)
1211        .fetch_one(database.read())
1212        .await?;
1213
1214        Self::map(None, &row)
1215    }
1216
1217    async fn by_uuid_with_transaction(
1218        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
1219        uuid: uuid::Uuid,
1220    ) -> Result<Self, crate::database::DatabaseError> {
1221        let row = sqlx::query(sqlx::AssertSqlSafe(format!(
1222            r#"
1223            SELECT {}
1224            FROM users
1225            LEFT JOIN roles ON roles.uuid = users.role_uuid
1226            WHERE users.uuid = $1
1227            "#,
1228            Self::columns_sql(None)
1229        )))
1230        .bind(uuid)
1231        .fetch_one(&mut **transaction)
1232        .await?;
1233
1234        Self::map(None, &row)
1235    }
1236}
1237
1238#[schema_extension_derive::extendible]
1239#[init_args(User, crate::State)]
1240#[hook_args(crate::State)]
1241#[derive(ToSchema, Serialize)]
1242#[schema(title = "User")]
1243pub struct ApiUser {
1244    pub uuid: uuid::Uuid,
1245
1246    pub username: compact_str::CompactString,
1247    pub avatar: Option<String>,
1248
1249    pub totp_enabled: bool,
1250
1251    pub created: chrono::DateTime<chrono::Utc>,
1252}
1253
1254#[schema_extension_derive::extendible]
1255#[init_args(User, crate::State)]
1256#[hook_args(crate::State)]
1257#[derive(ToSchema, Serialize)]
1258#[schema(title = "FullUser")]
1259pub struct ApiFullUser {
1260    pub uuid: uuid::Uuid,
1261
1262    pub username: compact_str::CompactString,
1263    pub role: Option<super::role::AdminApiRole>,
1264    pub avatar: Option<String>,
1265    pub email: compact_str::CompactString,
1266
1267    pub name_first: compact_str::CompactString,
1268    pub name_last: compact_str::CompactString,
1269
1270    pub admin: bool,
1271    pub frozen: bool,
1272    pub suspended: bool,
1273
1274    pub totp_enabled: bool,
1275    pub totp_last_used: Option<chrono::DateTime<chrono::Utc>>,
1276    pub require_two_factor: bool,
1277
1278    pub language: compact_str::CompactString,
1279    pub toast_position: UserToastPosition,
1280    pub start_on_grouped_servers: bool,
1281
1282    pub has_password: bool,
1283
1284    pub created: chrono::DateTime<chrono::Utc>,
1285}
1286
1287#[schema_extension_derive::extendible]
1288#[init_args(User, crate::State)]
1289#[hook_args(crate::State)]
1290#[derive(ToSchema, Serialize)]
1291#[schema(title = "AdminUser")]
1292pub struct AdminApiUser {
1293    pub uuid: uuid::Uuid,
1294    pub external_id: Option<compact_str::CompactString>,
1295
1296    pub username: compact_str::CompactString,
1297    pub role: Option<super::role::AdminApiRole>,
1298    pub avatar: Option<String>,
1299    pub email: compact_str::CompactString,
1300
1301    pub name_first: compact_str::CompactString,
1302    pub name_last: compact_str::CompactString,
1303
1304    pub admin: bool,
1305    pub frozen: bool,
1306    pub suspended: bool,
1307
1308    pub totp_enabled: bool,
1309    pub totp_last_used: Option<chrono::DateTime<chrono::Utc>>,
1310    pub require_two_factor: bool,
1311
1312    pub language: compact_str::CompactString,
1313    pub toast_position: UserToastPosition,
1314    pub start_on_grouped_servers: bool,
1315
1316    pub has_password: bool,
1317
1318    pub created: chrono::DateTime<chrono::Utc>,
1319}