Skip to main content

shared/models/
user_email_verification.rs

1use crate::prelude::*;
2use rand::distr::SampleString;
3use serde::{Deserialize, Serialize};
4use sqlx::{Row, postgres::PgRow};
5use std::{collections::BTreeMap, sync::LazyLock};
6
7pub const TOKEN_VALIDITY_HOURS: i32 = 24;
8pub const RESEND_COOLDOWN_SECONDS: f64 = 60.0;
9
10#[derive(Serialize, Deserialize)]
11pub struct UserEmailVerification {
12    pub uuid: uuid::Uuid,
13    pub user: super::user::User,
14
15    /// For an email change this is the pending address, not yet written to the user.
16    pub email: compact_str::CompactString,
17    pub token: String,
18
19    pub created: chrono::NaiveDateTime,
20
21    extension_data: super::ModelExtensionData,
22}
23
24impl BaseModel for UserEmailVerification {
25    const NAME: &'static str = "user_email_verification";
26
27    fn get_extension_list() -> &'static super::ModelExtensionList {
28        static EXTENSIONS: LazyLock<super::ModelExtensionList> =
29            LazyLock::new(|| parking_lot::RwLock::new(Vec::new()));
30
31        &EXTENSIONS
32    }
33
34    fn get_extension_data(&self) -> &super::ModelExtensionData {
35        &self.extension_data
36    }
37
38    #[inline]
39    fn base_columns(prefix: Option<&str>) -> BTreeMap<&'static str, compact_str::CompactString> {
40        let prefix = prefix.unwrap_or_default();
41
42        let mut columns = BTreeMap::from([
43            (
44                "user_email_verifications.uuid",
45                compact_str::format_compact!("{prefix}uuid"),
46            ),
47            (
48                "user_email_verifications.email",
49                compact_str::format_compact!("{prefix}email"),
50            ),
51            (
52                "user_email_verifications.token",
53                compact_str::format_compact!("{prefix}token"),
54            ),
55            (
56                "user_email_verifications.created",
57                compact_str::format_compact!("{prefix}created"),
58            ),
59        ]);
60
61        columns.extend(super::user::User::base_columns(Some("user_")));
62
63        columns
64    }
65
66    #[inline]
67    fn map(prefix: Option<&str>, row: &PgRow) -> Result<Self, crate::database::DatabaseError> {
68        let prefix = prefix.unwrap_or_default();
69
70        Ok(Self {
71            uuid: row.try_get(compact_str::format_compact!("{prefix}uuid").as_str())?,
72            user: super::user::User::map(Some("user_"), row)?,
73            email: row.try_get(compact_str::format_compact!("{prefix}email").as_str())?,
74            token: row.try_get(compact_str::format_compact!("{prefix}token").as_str())?,
75            created: row.try_get(compact_str::format_compact!("{prefix}created").as_str())?,
76            extension_data: Self::map_extensions(prefix, row)?,
77        })
78    }
79}
80
81impl UserEmailVerification {
82    pub async fn create(
83        database: &crate::database::Database,
84        user_uuid: uuid::Uuid,
85        email: &str,
86    ) -> Result<String, anyhow::Error> {
87        let mut transaction = database.write().begin().await?;
88        let token = Self::create_with_transaction(&mut transaction, user_uuid, email).await?;
89        transaction.commit().await?;
90
91        Ok(token)
92    }
93
94    /// Replaces any outstanding verification, so an older link cannot resurrect an abandoned
95    /// address.
96    pub async fn create_with_transaction(
97        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
98        user_uuid: uuid::Uuid,
99        email: &str,
100    ) -> Result<String, anyhow::Error> {
101        let existing = sqlx::query(
102            r#"
103            SELECT COUNT(*)
104            FROM user_email_verifications
105            WHERE user_email_verifications.user_uuid = $1
106            AND user_email_verifications.created > NOW() - make_interval(secs => $2)
107            "#,
108        )
109        .bind(user_uuid)
110        .bind(RESEND_COOLDOWN_SECONDS)
111        .fetch_optional(&mut **transaction)
112        .await?;
113
114        if let Some(row) = existing
115            && row.get::<i64, _>(0) > 0
116        {
117            return Err(anyhow::anyhow!(
118                "a verification email was already requested recently"
119            ));
120        }
121
122        sqlx::query(
123            r#"
124            DELETE FROM user_email_verifications
125            WHERE user_email_verifications.user_uuid = $1
126            "#,
127        )
128        .bind(user_uuid)
129        .execute(&mut **transaction)
130        .await?;
131
132        let token = rand::distr::Alphanumeric.sample_string(&mut rand::rng(), 96);
133
134        sqlx::query(
135            r#"
136            INSERT INTO user_email_verifications (user_uuid, email, token_start, token, created)
137            VALUES ($1, $2, $3, crypt($4, gen_salt('bf', 12)), NOW())
138            "#,
139        )
140        .bind(user_uuid)
141        .bind(email)
142        .bind(&token[0..16])
143        .bind(&token)
144        .execute(&mut **transaction)
145        .await?;
146
147        Ok(token)
148    }
149
150    pub async fn pending_email_by_user_uuid(
151        database: &crate::database::Database,
152        user_uuid: uuid::Uuid,
153    ) -> Result<Option<compact_str::CompactString>, crate::database::DatabaseError> {
154        let row = sqlx::query(
155            r#"
156            SELECT user_email_verifications.email
157            FROM user_email_verifications
158            WHERE user_email_verifications.user_uuid = $1
159            AND user_email_verifications.created > NOW() - make_interval(hours => $2)
160            ORDER BY user_email_verifications.created DESC
161            LIMIT 1
162            "#,
163        )
164        .bind(user_uuid)
165        .bind(TOKEN_VALIDITY_HOURS)
166        .fetch_optional(database.read())
167        .await?;
168
169        let row = match row {
170            Some(row) => row,
171            None => return Ok(None),
172        };
173
174        Ok(Some(row.try_get("email")?))
175    }
176
177    pub async fn send(
178        state: &crate::State,
179        user: &super::user::User,
180        email: &str,
181        token: &str,
182    ) -> Result<(), anyhow::Error> {
183        let verification_link = {
184            let settings = state.settings.get().await?;
185
186            format!(
187                "{}/auth/verify-email?token={}",
188                settings.app.url.trim_end_matches('/'),
189                urlencoding::encode(token),
190            )
191        };
192
193        state
194            .mail
195            .send_template_foreground(
196                state,
197                "email_verification",
198                email.into(),
199                minijinja::context! {
200                    user => user,
201                    email => email,
202                    verification_link => verification_link,
203                },
204            )
205            .await
206    }
207
208    pub async fn delete_by_token(
209        database: &crate::database::Database,
210        token: &str,
211    ) -> Result<Option<Self>, crate::database::DatabaseError> {
212        let Some(token_start) = token.get(0..16) else {
213            return Ok(None);
214        };
215
216        let row = sqlx::query(sqlx::AssertSqlSafe(format!(
217            r#"
218            WITH user_email_verifications AS MATERIALIZED (
219                SELECT * FROM user_email_verifications
220                WHERE user_email_verifications.token_start = $1
221                AND user_email_verifications.created > NOW() - make_interval(hours => $3)
222            )
223            SELECT {}, {} FROM user_email_verifications
224            JOIN users ON users.uuid = user_email_verifications.user_uuid
225            LEFT JOIN roles ON roles.uuid = users.role_uuid
226            WHERE user_email_verifications.token = crypt($2, user_email_verifications.token)
227            "#,
228            Self::columns_sql(None),
229            super::user::User::columns_sql(Some("user_"))
230        )))
231        .bind(token_start)
232        .bind(token)
233        .bind(TOKEN_VALIDITY_HOURS)
234        .fetch_optional(database.read())
235        .await?;
236
237        let row = match row {
238            Some(row) => row,
239            None => return Ok(None),
240        };
241
242        sqlx::query(
243            r#"
244            DELETE FROM user_email_verifications
245            WHERE user_email_verifications.uuid = $1
246            "#,
247        )
248        .bind(row.get::<uuid::Uuid, _>("uuid"))
249        .execute(database.write())
250        .await?;
251
252        Ok(Some(Self::map(None, &row)?))
253    }
254
255    pub async fn delete_by_user_uuid(
256        database: &crate::database::Database,
257        user_uuid: uuid::Uuid,
258    ) -> Result<(), crate::database::DatabaseError> {
259        sqlx::query(
260            r#"
261            DELETE FROM user_email_verifications
262            WHERE user_email_verifications.user_uuid = $1
263            "#,
264        )
265        .bind(user_uuid)
266        .execute(database.write())
267        .await?;
268
269        Ok(())
270    }
271
272    pub async fn delete_expired(database: &crate::database::Database) -> Result<u64, sqlx::Error> {
273        Ok(sqlx::query(
274            r#"
275            DELETE FROM user_email_verifications
276            WHERE user_email_verifications.created < NOW() - make_interval(hours => $1)
277            "#,
278        )
279        .bind(TOKEN_VALIDITY_HOURS)
280        .execute(database.write())
281        .await?
282        .rows_affected())
283    }
284}