fabrica

hanna/fabrica

25257 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! User accounts: creation and lookup.
6
7use model::User;
8use sqlx::any::AnyRow;
9use sqlx::{AssertSqlSafe, Row};
10
11use crate::{Store, StoreError, map_conflict, new_id, now_ms};
12
13/// The columns of `users` in a fixed order, shared by every `SELECT` so the
14/// row-mapping in [`Store::map_user`] can rely on names.
15pub(crate) const USER_COLUMNS: &str = "id, username, email, email_public, display_name, pronouns, \
16 bio, location, links, avatar_mime, password_hash, \
17 must_change_password, is_admin, disabled_at, created_at, updated_at";
18
19/// The editable profile fields, set together by the settings page. Each `None`
20/// clears the column.
21#[derive(Debug, Clone, Default)]
22pub struct ProfileUpdate {
23 /// Display name.
24 pub display_name: Option<String>,
25 /// Pronouns.
26 pub pronouns: Option<String>,
27 /// Free-text bio.
28 pub bio: Option<String>,
29 /// Location.
30 pub location: Option<String>,
31 /// Up to five arbitrary profile links.
32 pub links: Vec<String>,
33}
34
35/// Encode profile links for storage: trimmed, non-empty, capped at five, joined
36/// by newlines, or `None` when the list is empty.
37fn encode_links(links: &[String]) -> Option<String> {
38 let joined = links
39 .iter()
40 .map(|s| s.trim())
41 .filter(|s| !s.is_empty())
42 .take(5)
43 .collect::<Vec<_>>()
44 .join("\n");
45 (!joined.is_empty()).then_some(joined)
46}
47
48/// Decode the stored newline-separated links column into a bounded list.
49fn decode_links(raw: Option<&str>) -> Vec<String> {
50 raw.map(|s| {
51 s.lines()
52 .map(str::trim)
53 .filter(|l| !l.is_empty())
54 .take(5)
55 .map(str::to_string)
56 .collect()
57 })
58 .unwrap_or_default()
59}
60
61/// Fields needed to create a user. The server fills in the id, timestamps, and
62/// the normalized `*_lower` uniqueness keys.
63#[derive(Debug, Clone)]
64pub struct NewUser {
65 /// Login name; validated with [`model::validate_name`] before insert.
66 pub username: String,
67 /// Primary email address.
68 pub email: String,
69 /// Optional display name.
70 pub display_name: Option<String>,
71 /// Argon2id PHC hash, or `None` for an account awaiting invite completion.
72 pub password_hash: Option<String>,
73 /// Whether the account is a site administrator.
74 pub is_admin: bool,
75 /// Whether the user must change their password on next login.
76 pub must_change_password: bool,
77}
78
79impl Store {
80 /// Create a user, returning the persisted row.
81 ///
82 /// The username is validated and case-folded for uniqueness; the email is
83 /// likewise unique case-insensitively.
84 ///
85 /// # Errors
86 ///
87 /// * [`StoreError::Name`] if the username is invalid.
88 /// * [`StoreError::Conflict`] if the username or email is already taken.
89 /// * [`StoreError::Query`] for any other database failure.
90 pub async fn create_user(&self, new: NewUser) -> Result<User, StoreError> {
91 model::validate_name(&new.username)?;
92 let now = now_ms();
93 let user = User {
94 id: new_id(),
95 username: new.username,
96 email: new.email,
97 email_public: false,
98 display_name: new.display_name,
99 pronouns: None,
100 bio: None,
101 location: None,
102 links: Vec::new(),
103 avatar_mime: None,
104 password_hash: new.password_hash,
105 must_change_password: new.must_change_password,
106 is_admin: new.is_admin,
107 disabled_at: None,
108 created_at: now,
109 updated_at: now,
110 };
111
112 let mut tx = self.pool.begin().await?;
113 let sql = "INSERT INTO users \
114 (id, username, username_lower, email, email_lower, display_name, \
115 password_hash, must_change_password, is_admin, disabled_at, created_at, updated_at) \
116 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)";
117 sqlx::query(sql)
118 .bind(user.id.clone())
119 .bind(user.username.clone())
120 .bind(user.username.to_lowercase())
121 .bind(user.email.clone())
122 .bind(user.email.to_lowercase())
123 .bind(user.display_name.clone())
124 .bind(user.password_hash.clone())
125 .bind(user.must_change_password)
126 .bind(user.is_admin)
127 .bind(user.disabled_at)
128 .bind(user.created_at)
129 .bind(user.updated_at)
130 .execute(&mut *tx)
131 .await
132 .map_err(|e| {
133 map_conflict(e, "user", &[("email", "email"), ("username", "username")])
134 })?;
135 // Seed the primary email row so `user_emails` holds every address. It is
136 // created verified — admin/CLI/invite accounts are trusted; the
137 // self-registration path explicitly re-opens verification afterwards.
138 sqlx::query(
139 "INSERT INTO user_emails \
140 (id, user_id, email, email_lower, is_primary, verified_at, created_at) \
141 VALUES ($1, $2, $3, $4, $5, $6, $7)",
142 )
143 .bind(new_id())
144 .bind(user.id.clone())
145 .bind(user.email.clone())
146 .bind(user.email.to_lowercase())
147 .bind(true)
148 .bind(user.created_at)
149 .bind(user.created_at)
150 .execute(&mut *tx)
151 .await
152 .map_err(|e| map_conflict(e, "user", &[("email_lower", "email")]))?;
153 tx.commit().await?;
154
155 Ok(user)
156 }
157
158 /// Fetch a user by id, or `None` if there is no such user.
159 ///
160 /// # Errors
161 ///
162 /// Returns [`StoreError::Query`] on a database failure.
163 pub async fn user_by_id(&self, id: &str) -> Result<Option<User>, StoreError> {
164 let sql = format!("SELECT {USER_COLUMNS} FROM users WHERE id = $1");
165 let row = sqlx::query(AssertSqlSafe(sql))
166 .bind(id)
167 .fetch_optional(&self.pool)
168 .await?;
169 Ok(row.as_ref().map(|r| self.map_user(r)).transpose()?)
170 }
171
172 /// Fetch a user by username, case-insensitively, or `None` if absent.
173 ///
174 /// # Errors
175 ///
176 /// Returns [`StoreError::Query`] on a database failure.
177 pub async fn user_by_username(&self, username: &str) -> Result<Option<User>, StoreError> {
178 let sql = format!("SELECT {USER_COLUMNS} FROM users WHERE username_lower = $1");
179 let row = sqlx::query(AssertSqlSafe(sql))
180 .bind(username.to_lowercase())
181 .fetch_optional(&self.pool)
182 .await?;
183 Ok(row.as_ref().map(|r| self.map_user(r)).transpose()?)
184 }
185
186 /// List all users, ordered case-insensitively by username.
187 ///
188 /// # Errors
189 ///
190 /// Returns [`StoreError::Query`] on a database failure.
191 pub async fn list_users(&self) -> Result<Vec<User>, StoreError> {
192 let sql = format!("SELECT {USER_COLUMNS} FROM users ORDER BY username_lower ASC");
193 let rows = sqlx::query(AssertSqlSafe(sql))
194 .fetch_all(&self.pool)
195 .await?;
196 rows.iter()
197 .map(|r| self.map_user(r))
198 .collect::<Result<_, _>>()
199 .map_err(Into::into)
200 }
201
202 /// One page of users, ordered case-insensitively by username.
203 ///
204 /// # Errors
205 ///
206 /// Returns [`StoreError::Query`] on a database failure.
207 pub async fn list_users_paged(&self, limit: i64, offset: i64) -> Result<Vec<User>, StoreError> {
208 let sql = format!(
209 "SELECT {USER_COLUMNS} FROM users ORDER BY username_lower ASC LIMIT $1 OFFSET $2"
210 );
211 let rows = sqlx::query(AssertSqlSafe(sql))
212 .bind(limit)
213 .bind(offset)
214 .fetch_all(&self.pool)
215 .await?;
216 rows.iter()
217 .map(|r| self.map_user(r))
218 .collect::<Result<_, _>>()
219 .map_err(Into::into)
220 }
221
222 /// Change a user's username (case-preserved, case-insensitively unique).
223 ///
224 /// # Errors
225 ///
226 /// * [`StoreError::Name`] if the username is invalid.
227 /// * [`StoreError::Conflict`] if the username is already taken.
228 /// * [`StoreError::Query`] for any other database failure.
229 pub async fn update_username(
230 &self,
231 user_id: &str,
232 new_username: &str,
233 ) -> Result<bool, StoreError> {
234 model::validate_name(new_username)?;
235 let updated = sqlx::query(
236 "UPDATE users SET username = $1, username_lower = $2, updated_at = $3 WHERE id = $4",
237 )
238 .bind(new_username)
239 .bind(new_username.to_lowercase())
240 .bind(now_ms())
241 .bind(user_id)
242 .execute(&self.pool)
243 .await
244 .map_err(|e| map_conflict(e, "user", &[("username", "username")]))?
245 .rows_affected();
246 Ok(updated > 0)
247 }
248
249 /// Change a user's email (case-insensitively unique).
250 ///
251 /// # Errors
252 ///
253 /// * [`StoreError::Conflict`] if the email is already taken.
254 /// * [`StoreError::Query`] for any other database failure.
255 pub async fn update_email(&self, user_id: &str, new_email: &str) -> Result<bool, StoreError> {
256 let updated = sqlx::query(
257 "UPDATE users SET email = $1, email_lower = $2, updated_at = $3 WHERE id = $4",
258 )
259 .bind(new_email)
260 .bind(new_email.to_lowercase())
261 .bind(now_ms())
262 .bind(user_id)
263 .execute(&self.pool)
264 .await
265 .map_err(|e| map_conflict(e, "user", &[("email", "email")]))?
266 .rows_affected();
267 Ok(updated > 0)
268 }
269
270 /// List a user's email addresses, primary first.
271 ///
272 /// # Errors
273 ///
274 /// Returns [`StoreError::Query`] on a database failure.
275 pub async fn emails_by_user(&self, user_id: &str) -> Result<Vec<model::UserEmail>, StoreError> {
276 let rows = sqlx::query(
277 "SELECT id, user_id, email, is_primary, verified_at, created_at FROM user_emails \
278 WHERE user_id = $1 ORDER BY is_primary DESC, created_at ASC",
279 )
280 .bind(user_id)
281 .fetch_all(&self.pool)
282 .await?;
283 rows.iter()
284 .map(|r| {
285 Ok(model::UserEmail {
286 id: r.try_get("id")?,
287 user_id: r.try_get("user_id")?,
288 email: r.try_get("email")?,
289 is_primary: self.get_bool(r, "is_primary")?,
290 verified_at: r.try_get("verified_at")?,
291 created_at: r.try_get("created_at")?,
292 })
293 })
294 .collect::<Result<_, sqlx::Error>>()
295 .map_err(Into::into)
296 }
297
298 /// Add a secondary email address (case-insensitively unique across all users).
299 ///
300 /// # Errors
301 ///
302 /// * [`StoreError::Conflict`] if the address is already registered.
303 /// * [`StoreError::Query`] for any other database failure.
304 pub async fn add_email(
305 &self,
306 user_id: &str,
307 email: &str,
308 ) -> Result<model::UserEmail, StoreError> {
309 let row = model::UserEmail {
310 id: new_id(),
311 user_id: user_id.to_string(),
312 email: email.to_string(),
313 is_primary: false,
314 verified_at: None,
315 created_at: now_ms(),
316 };
317 sqlx::query(
318 "INSERT INTO user_emails (id, user_id, email, email_lower, is_primary, created_at) \
319 VALUES ($1, $2, $3, $4, $5, $6)",
320 )
321 .bind(&row.id)
322 .bind(&row.user_id)
323 .bind(&row.email)
324 .bind(email.to_lowercase())
325 .bind(false)
326 .bind(row.created_at)
327 .execute(&self.pool)
328 .await
329 .map_err(|e| map_conflict(e, "email", &[("email_lower", "email")]))?;
330 Ok(row)
331 }
332
333 /// Remove one of a user's non-primary email addresses.
334 ///
335 /// # Errors
336 ///
337 /// Returns [`StoreError::Query`] on a database failure.
338 pub async fn delete_email(&self, user_id: &str, email_id: &str) -> Result<bool, StoreError> {
339 let deleted = sqlx::query(
340 "DELETE FROM user_emails WHERE id = $1 AND user_id = $2 AND is_primary = $3",
341 )
342 .bind(email_id)
343 .bind(user_id)
344 .bind(false)
345 .execute(&self.pool)
346 .await?
347 .rows_affected();
348 Ok(deleted > 0)
349 }
350
351 /// Make one of a user's **verified** addresses primary, mirroring it into
352 /// `users.email`. Returns `false` if the address is missing or unverified.
353 ///
354 /// # Errors
355 ///
356 /// Returns [`StoreError::Query`] on a database failure.
357 pub async fn set_primary_email(
358 &self,
359 user_id: &str,
360 email_id: &str,
361 ) -> Result<bool, StoreError> {
362 // Confirm the address belongs to the user, is verified, and capture it.
363 let row = sqlx::query(
364 "SELECT email FROM user_emails \
365 WHERE id = $1 AND user_id = $2 AND verified_at IS NOT NULL",
366 )
367 .bind(email_id)
368 .bind(user_id)
369 .fetch_optional(&self.pool)
370 .await?;
371 let Some(row) = row else {
372 return Ok(false);
373 };
374 let email: String = row.try_get("email")?;
375
376 let mut tx = self.pool.begin().await?;
377 sqlx::query("UPDATE user_emails SET is_primary = $1 WHERE user_id = $2")
378 .bind(false)
379 .bind(user_id)
380 .execute(&mut *tx)
381 .await?;
382 sqlx::query("UPDATE user_emails SET is_primary = $1 WHERE id = $2")
383 .bind(true)
384 .bind(email_id)
385 .execute(&mut *tx)
386 .await?;
387 sqlx::query("UPDATE users SET email = $1, email_lower = $2, updated_at = $3 WHERE id = $4")
388 .bind(&email)
389 .bind(email.to_lowercase())
390 .bind(now_ms())
391 .bind(user_id)
392 .execute(&mut *tx)
393 .await?;
394 tx.commit().await?;
395 Ok(true)
396 }
397
398 /// Set whether the user's primary email is shown on their public profile.
399 ///
400 /// # Errors
401 ///
402 /// Returns [`StoreError::Query`] on a database failure.
403 pub async fn set_email_public(&self, user_id: &str, public: bool) -> Result<(), StoreError> {
404 sqlx::query("UPDATE users SET email_public = $1, updated_at = $2 WHERE id = $3")
405 .bind(public)
406 .bind(now_ms())
407 .bind(user_id)
408 .execute(&self.pool)
409 .await?;
410 Ok(())
411 }
412
413 /// Begin (or restart) verification of an email address: mark it unverified,
414 /// drop any outstanding tokens, and store a fresh `token` valid for 24 hours.
415 /// The caller generates the random token and emails the link.
416 ///
417 /// # Errors
418 ///
419 /// Returns [`StoreError::Query`] on a database failure.
420 pub async fn begin_email_verification(
421 &self,
422 email_id: &str,
423 token: &str,
424 ) -> Result<(), StoreError> {
425 let now = now_ms();
426 let mut tx = self.pool.begin().await?;
427 sqlx::query("UPDATE user_emails SET verified_at = NULL WHERE id = $1")
428 .bind(email_id)
429 .execute(&mut *tx)
430 .await?;
431 sqlx::query("DELETE FROM email_tokens WHERE email_id = $1")
432 .bind(email_id)
433 .execute(&mut *tx)
434 .await?;
435 sqlx::query(
436 "INSERT INTO email_tokens (token, email_id, expires_at, created_at) \
437 VALUES ($1, $2, $3, $4)",
438 )
439 .bind(token)
440 .bind(email_id)
441 .bind(now + 24 * 60 * 60 * 1000)
442 .bind(now)
443 .execute(&mut *tx)
444 .await?;
445 tx.commit().await?;
446 Ok(())
447 }
448
449 /// Redeem a verification token: mark its address verified and consume the
450 /// token. Returns the owning user id, or `None` if the token is unknown or
451 /// expired.
452 ///
453 /// # Errors
454 ///
455 /// Returns [`StoreError::Query`] on a database failure.
456 pub async fn verify_email_token(&self, token: &str) -> Result<Option<String>, StoreError> {
457 let now = now_ms();
458 let row = sqlx::query(
459 "SELECT t.email_id, e.user_id FROM email_tokens t \
460 JOIN user_emails e ON e.id = t.email_id \
461 WHERE t.token = $1 AND t.expires_at > $2",
462 )
463 .bind(token)
464 .bind(now)
465 .fetch_optional(&self.pool)
466 .await?;
467 let Some(row) = row else {
468 return Ok(None);
469 };
470 let email_id: String = row.try_get("email_id")?;
471 let user_id: String = row.try_get("user_id")?;
472
473 let mut tx = self.pool.begin().await?;
474 sqlx::query("UPDATE user_emails SET verified_at = $1 WHERE id = $2")
475 .bind(now)
476 .bind(&email_id)
477 .execute(&mut *tx)
478 .await?;
479 sqlx::query("DELETE FROM email_tokens WHERE token = $1")
480 .bind(token)
481 .execute(&mut *tx)
482 .await?;
483 tx.commit().await?;
484 Ok(Some(user_id))
485 }
486
487 /// Look up an email row id owned by `user_id`, for resending verification.
488 ///
489 /// # Errors
490 ///
491 /// Returns [`StoreError::Query`] on a database failure.
492 pub async fn email_owned_by(
493 &self,
494 user_id: &str,
495 email_id: &str,
496 ) -> Result<Option<model::UserEmail>, StoreError> {
497 Ok(self
498 .emails_by_user(user_id)
499 .await?
500 .into_iter()
501 .find(|e| e.id == email_id))
502 }
503
504 /// Mark the user's primary email verified (the manual `user verify` path,
505 /// used when SMTP is unavailable).
506 ///
507 /// # Errors
508 ///
509 /// Returns [`StoreError::Query`] on a database failure.
510 pub async fn verify_primary_email(&self, user_id: &str) -> Result<bool, StoreError> {
511 let updated = sqlx::query(
512 "UPDATE user_emails SET verified_at = $1 WHERE user_id = $2 AND is_primary = $3",
513 )
514 .bind(now_ms())
515 .bind(user_id)
516 .bind(true)
517 .execute(&self.pool)
518 .await?
519 .rows_affected();
520 Ok(updated > 0)
521 }
522
523 /// Whether a user's primary email address is verified.
524 ///
525 /// # Errors
526 ///
527 /// Returns [`StoreError::Query`] on a database failure.
528 pub async fn primary_email_verified(&self, user_id: &str) -> Result<bool, StoreError> {
529 let row = sqlx::query(
530 "SELECT verified_at FROM user_emails WHERE user_id = $1 AND is_primary = $2",
531 )
532 .bind(user_id)
533 .bind(true)
534 .fetch_optional(&self.pool)
535 .await?;
536 let verified = row
537 .and_then(|r| r.try_get::<Option<i64>, _>("verified_at").ok().flatten())
538 .is_some();
539 Ok(verified)
540 }
541
542 /// Set (or clear) a user's password hash.
543 ///
544 /// Passing `Some(hash)` sets the credential and clears the
545 /// `must_change_password` flag — the operator has just chosen a known
546 /// password. Passing `None` returns the account to the invite-pending state.
547 /// Either way, **all of the user's sessions are deleted**, as required on any
548 /// password change, so a rotated credential immediately invalidates existing
549 /// logins.
550 ///
551 /// Returns `true` if a user with `user_id` existed and was updated.
552 ///
553 /// # Errors
554 ///
555 /// Returns [`StoreError::Query`] on a database failure.
556 pub async fn set_password(
557 &self,
558 user_id: &str,
559 password_hash: Option<&str>,
560 ) -> Result<bool, StoreError> {
561 let updated = sqlx::query(
562 "UPDATE users SET password_hash = $1, must_change_password = $2, updated_at = $3 \
563 WHERE id = $4",
564 )
565 .bind(password_hash)
566 .bind(false)
567 .bind(now_ms())
568 .bind(user_id)
569 .execute(&self.pool)
570 .await?
571 .rows_affected();
572
573 // Invalidate every existing session for the user (best-effort — the row
574 // may simply not have existed).
575 sqlx::query("DELETE FROM sessions WHERE user_id = $1")
576 .bind(user_id)
577 .execute(&self.pool)
578 .await?;
579
580 Ok(updated > 0)
581 }
582
583 /// Enable or disable a user. Disabling stamps `disabled_at` with the current
584 /// time and deletes the user's sessions; enabling clears `disabled_at`.
585 ///
586 /// Returns `true` if a user with `user_id` existed and was updated.
587 ///
588 /// # Errors
589 ///
590 /// Returns [`StoreError::Query`] on a database failure.
591 pub async fn set_disabled(&self, user_id: &str, disabled: bool) -> Result<bool, StoreError> {
592 let now = now_ms();
593 let disabled_at = disabled.then_some(now);
594 let updated =
595 sqlx::query("UPDATE users SET disabled_at = $1, updated_at = $2 WHERE id = $3")
596 .bind(disabled_at)
597 .bind(now)
598 .bind(user_id)
599 .execute(&self.pool)
600 .await?
601 .rows_affected();
602
603 if disabled {
604 sqlx::query("DELETE FROM sessions WHERE user_id = $1")
605 .bind(user_id)
606 .execute(&self.pool)
607 .await?;
608 }
609
610 Ok(updated > 0)
611 }
612
613 /// Grant or revoke a user's site-administrator flag.
614 ///
615 /// # Errors
616 ///
617 /// Returns [`StoreError::Query`] on a database failure.
618 pub async fn set_admin(&self, user_id: &str, is_admin: bool) -> Result<bool, StoreError> {
619 let updated = sqlx::query("UPDATE users SET is_admin = $1, updated_at = $2 WHERE id = $3")
620 .bind(is_admin)
621 .bind(now_ms())
622 .bind(user_id)
623 .execute(&self.pool)
624 .await?
625 .rows_affected();
626 Ok(updated > 0)
627 }
628
629 /// Delete a user by id, cascading to their repositories, keys, tokens, and
630 /// sessions (via `ON DELETE CASCADE`). The caller is responsible for any
631 /// policy that should *prevent* the delete (e.g. refusing when repos exist
632 /// and `--purge` was not given).
633 ///
634 /// Returns `true` if a user was deleted.
635 ///
636 /// # Errors
637 ///
638 /// Returns [`StoreError::Query`] on a database failure.
639 pub async fn delete_user(&self, user_id: &str) -> Result<bool, StoreError> {
640 let deleted = sqlx::query("DELETE FROM users WHERE id = $1")
641 .bind(user_id)
642 .execute(&self.pool)
643 .await?
644 .rows_affected();
645 Ok(deleted > 0)
646 }
647
648 /// Overwrite a user's profile fields (each `None` clears the column).
649 ///
650 /// Returns `true` if a user with `user_id` existed and was updated.
651 ///
652 /// # Errors
653 ///
654 /// Returns [`StoreError::Query`] on a database failure.
655 pub async fn update_profile(
656 &self,
657 user_id: &str,
658 profile: ProfileUpdate,
659 ) -> Result<bool, StoreError> {
660 let sql = "UPDATE users SET display_name = $1, pronouns = $2, bio = $3, \
661 location = $4, links = $5, updated_at = $6 WHERE id = $7";
662 let updated = sqlx::query(sql)
663 .bind(profile.display_name)
664 .bind(profile.pronouns)
665 .bind(profile.bio)
666 .bind(profile.location)
667 .bind(encode_links(&profile.links))
668 .bind(now_ms())
669 .bind(user_id)
670 .execute(&self.pool)
671 .await?
672 .rows_affected();
673 Ok(updated > 0)
674 }
675
676 /// Set (or clear) the content type of a user's uploaded avatar. `None`
677 /// returns the user to the generated-identicon fallback.
678 ///
679 /// Returns `true` if a user with `user_id` existed and was updated.
680 ///
681 /// # Errors
682 ///
683 /// Returns [`StoreError::Query`] on a database failure.
684 pub async fn set_avatar_mime(
685 &self,
686 user_id: &str,
687 mime: Option<&str>,
688 ) -> Result<bool, StoreError> {
689 let updated =
690 sqlx::query("UPDATE users SET avatar_mime = $1, updated_at = $2 WHERE id = $3")
691 .bind(mime)
692 .bind(now_ms())
693 .bind(user_id)
694 .execute(&self.pool)
695 .await?
696 .rows_affected();
697 Ok(updated > 0)
698 }
699
700 /// Map a `users` row (selected via [`USER_COLUMNS`]) to a [`User`].
701 pub(crate) fn map_user(&self, row: &AnyRow) -> Result<User, sqlx::Error> {
702 Ok(User {
703 id: row.try_get("id")?,
704 username: row.try_get("username")?,
705 email: row.try_get("email")?,
706 email_public: self.get_bool(row, "email_public")?,
707 display_name: row.try_get("display_name")?,
708 pronouns: row.try_get("pronouns")?,
709 bio: row.try_get("bio")?,
710 location: row.try_get("location")?,
711 links: decode_links(row.try_get::<Option<String>, _>("links")?.as_deref()),
712 avatar_mime: row.try_get("avatar_mime")?,
713 password_hash: row.try_get("password_hash")?,
714 must_change_password: self.get_bool(row, "must_change_password")?,
715 is_admin: self.get_bool(row, "is_admin")?,
716 disabled_at: row.try_get("disabled_at")?,
717 created_at: row.try_get("created_at")?,
718 updated_at: row.try_get("updated_at")?,
719 })
720 }
721}