shared/models/
user_password_reset.rs1use crate::prelude::*;
2use rand::distr::SampleString;
3use serde::{Deserialize, Serialize};
4use sqlx::{Row, postgres::PgRow};
5use std::{collections::BTreeMap, sync::LazyLock};
6
7#[derive(Serialize, Deserialize)]
8pub struct UserPasswordReset {
9 pub uuid: uuid::Uuid,
10 pub user: super::user::User,
11
12 pub token: String,
13
14 pub created: chrono::NaiveDateTime,
15
16 extension_data: super::ModelExtensionData,
17}
18
19impl BaseModel for UserPasswordReset {
20 const NAME: &'static str = "user_password_reset";
21
22 fn get_extension_list() -> &'static super::ModelExtensionList {
23 static EXTENSIONS: LazyLock<super::ModelExtensionList> =
24 LazyLock::new(|| parking_lot::RwLock::new(Vec::new()));
25
26 &EXTENSIONS
27 }
28
29 fn get_extension_data(&self) -> &super::ModelExtensionData {
30 &self.extension_data
31 }
32
33 #[inline]
34 fn base_columns(prefix: Option<&str>) -> BTreeMap<&'static str, compact_str::CompactString> {
35 let prefix = prefix.unwrap_or_default();
36
37 let mut columns = BTreeMap::from([
38 (
39 "user_password_resets.uuid",
40 compact_str::format_compact!("{prefix}uuid"),
41 ),
42 (
43 "user_password_resets.token",
44 compact_str::format_compact!("{prefix}token"),
45 ),
46 (
47 "user_password_resets.created",
48 compact_str::format_compact!("{prefix}created"),
49 ),
50 ]);
51
52 columns.extend(super::user::User::base_columns(Some("user_")));
53
54 columns
55 }
56
57 #[inline]
58 fn map(prefix: Option<&str>, row: &PgRow) -> Result<Self, crate::database::DatabaseError> {
59 let prefix = prefix.unwrap_or_default();
60
61 Ok(Self {
62 uuid: row.try_get(compact_str::format_compact!("{prefix}uuid").as_str())?,
63 user: super::user::User::map(Some("user_"), row)?,
64 token: row.try_get(compact_str::format_compact!("{prefix}token").as_str())?,
65 created: row.try_get(compact_str::format_compact!("{prefix}created").as_str())?,
66 extension_data: Self::map_extensions(prefix, row)?,
67 })
68 }
69}
70
71impl UserPasswordReset {
72 pub async fn create(
73 database: &crate::database::Database,
74 user_uuid: uuid::Uuid,
75 ) -> Result<String, anyhow::Error> {
76 let existing = sqlx::query(
77 r#"
78 SELECT COUNT(*)
79 FROM user_password_resets
80 WHERE user_password_resets.user_uuid = $1 AND user_password_resets.created > NOW() - INTERVAL '20 minutes'
81 "#,
82 )
83 .bind(user_uuid)
84 .fetch_optional(database.read())
85 .await?;
86
87 if let Some(row) = existing
88 && row.get::<i64, _>(0) > 0
89 {
90 return Err(anyhow::anyhow!(
91 "a password reset was already requested recently"
92 ));
93 }
94
95 let token = rand::distr::Alphanumeric.sample_string(&mut rand::rng(), 96);
96
97 sqlx::query(
98 r#"
99 INSERT INTO user_password_resets (user_uuid, token_start, token, created)
100 VALUES ($1, $2, crypt($3, gen_salt('bf', 12)), NOW())
101 "#,
102 )
103 .bind(user_uuid)
104 .bind(&token[0..16])
105 .bind(&token)
106 .execute(database.write())
107 .await?;
108
109 Ok(token)
110 }
111
112 pub async fn create_with_transaction(
113 transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
114 user_uuid: uuid::Uuid,
115 ) -> Result<String, anyhow::Error> {
116 let existing = sqlx::query(
117 r#"
118 SELECT COUNT(*)
119 FROM user_password_resets
120 WHERE user_password_resets.user_uuid = $1 AND user_password_resets.created > NOW() - INTERVAL '20 minutes'
121 "#,
122 )
123 .bind(user_uuid)
124 .fetch_optional(&mut **transaction)
125 .await?;
126
127 if let Some(row) = existing
128 && row.get::<i64, _>(0) > 0
129 {
130 return Err(anyhow::anyhow!(
131 "a password reset was already requested recently"
132 ));
133 }
134
135 let token = rand::distr::Alphanumeric.sample_string(&mut rand::rng(), 96);
136
137 sqlx::query(
138 r#"
139 INSERT INTO user_password_resets (user_uuid, token_start, token, created)
140 VALUES ($1, $2, crypt($3, gen_salt('bf', 12)), NOW())
141 "#,
142 )
143 .bind(user_uuid)
144 .bind(&token[0..16])
145 .bind(&token)
146 .execute(&mut **transaction)
147 .await?;
148
149 Ok(token)
150 }
151
152 pub async fn delete_by_token(
153 database: &crate::database::Database,
154 token: &str,
155 ) -> Result<Option<Self>, crate::database::DatabaseError> {
156 let Some(token_start) = token.get(0..16) else {
157 return Ok(None);
158 };
159
160 let row = sqlx::query(sqlx::AssertSqlSafe(format!(
161 r#"
162 WITH user_password_resets AS MATERIALIZED (
163 SELECT * FROM user_password_resets
164 WHERE user_password_resets.token_start = $1
165 AND user_password_resets.created > NOW() - INTERVAL '20 minutes'
166 )
167 SELECT {}, {} FROM user_password_resets
168 JOIN users ON users.uuid = user_password_resets.user_uuid
169 LEFT JOIN roles ON roles.uuid = users.role_uuid
170 WHERE user_password_resets.token = crypt($2, user_password_resets.token)
171 "#,
172 Self::columns_sql(None),
173 super::user::User::columns_sql(Some("user_"))
174 )))
175 .bind(token_start)
176 .bind(token)
177 .fetch_optional(database.read())
178 .await?;
179
180 let row = match row {
181 Some(row) => row,
182 None => return Ok(None),
183 };
184
185 sqlx::query(
186 r#"
187 DELETE FROM user_password_resets
188 WHERE user_password_resets.uuid = $1
189 "#,
190 )
191 .bind(row.get::<uuid::Uuid, _>("uuid"))
192 .execute(database.write())
193 .await?;
194
195 Ok(Some(Self::map(None, &row)?))
196 }
197
198 pub async fn delete_by_user_uuid(
199 database: &crate::database::Database,
200 user_uuid: uuid::Uuid,
201 ) -> Result<(), crate::database::DatabaseError> {
202 sqlx::query(
203 r#"
204 DELETE FROM user_password_resets
205 WHERE user_password_resets.user_uuid = $1
206 "#,
207 )
208 .bind(user_uuid)
209 .execute(database.write())
210 .await?;
211
212 Ok(())
213 }
214
215 pub async fn delete_expired(database: &crate::database::Database) -> Result<u64, sqlx::Error> {
216 Ok(sqlx::query(
217 r#"
218 DELETE FROM user_password_resets
219 WHERE user_password_resets.created < NOW() - INTERVAL '20 minutes'
220 "#,
221 )
222 .execute(database.write())
223 .await?
224 .rows_affected())
225 }
226}