// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! User accounts: creation and lookup. use model::User; use sqlx::any::AnyRow; use sqlx::{AssertSqlSafe, Row}; use crate::{Store, StoreError, map_conflict, new_id, now_ms}; /// The columns of `users` in a fixed order, shared by every `SELECT` so the /// row-mapping in [`Store::map_user`] can rely on names. pub(crate) const USER_COLUMNS: &str = "id, username, email, email_public, display_name, pronouns, \ bio, location, links, avatar_mime, password_hash, \ must_change_password, is_admin, disabled_at, created_at, updated_at"; /// The editable profile fields, set together by the settings page. Each `None` /// clears the column. #[derive(Debug, Clone, Default)] pub struct ProfileUpdate { /// Display name. pub display_name: Option, /// Pronouns. pub pronouns: Option, /// Free-text bio. pub bio: Option, /// Location. pub location: Option, /// Up to five arbitrary profile links. pub links: Vec, } /// Encode profile links for storage: trimmed, non-empty, capped at five, joined /// by newlines, or `None` when the list is empty. fn encode_links(links: &[String]) -> Option { let joined = links .iter() .map(|s| s.trim()) .filter(|s| !s.is_empty()) .take(5) .collect::>() .join("\n"); (!joined.is_empty()).then_some(joined) } /// Decode the stored newline-separated links column into a bounded list. fn decode_links(raw: Option<&str>) -> Vec { raw.map(|s| { s.lines() .map(str::trim) .filter(|l| !l.is_empty()) .take(5) .map(str::to_string) .collect() }) .unwrap_or_default() } /// Fields needed to create a user. The server fills in the id, timestamps, and /// the normalized `*_lower` uniqueness keys. #[derive(Debug, Clone)] pub struct NewUser { /// Login name; validated with [`model::validate_name`] before insert. pub username: String, /// Primary email address. pub email: String, /// Optional display name. pub display_name: Option, /// Argon2id PHC hash, or `None` for an account awaiting invite completion. pub password_hash: Option, /// Whether the account is a site administrator. pub is_admin: bool, /// Whether the user must change their password on next login. pub must_change_password: bool, } impl Store { /// Create a user, returning the persisted row. /// /// The username is validated and case-folded for uniqueness; the email is /// likewise unique case-insensitively. /// /// # Errors /// /// * [`StoreError::Name`] if the username is invalid. /// * [`StoreError::Conflict`] if the username or email is already taken. /// * [`StoreError::Query`] for any other database failure. pub async fn create_user(&self, new: NewUser) -> Result { model::validate_name(&new.username)?; let now = now_ms(); let user = User { id: new_id(), username: new.username, email: new.email, email_public: false, display_name: new.display_name, pronouns: None, bio: None, location: None, links: Vec::new(), avatar_mime: None, password_hash: new.password_hash, must_change_password: new.must_change_password, is_admin: new.is_admin, disabled_at: None, created_at: now, updated_at: now, }; let mut tx = self.pool.begin().await?; let sql = "INSERT INTO users \ (id, username, username_lower, email, email_lower, display_name, \ password_hash, must_change_password, is_admin, disabled_at, created_at, updated_at) \ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)"; sqlx::query(sql) .bind(user.id.clone()) .bind(user.username.clone()) .bind(user.username.to_lowercase()) .bind(user.email.clone()) .bind(user.email.to_lowercase()) .bind(user.display_name.clone()) .bind(user.password_hash.clone()) .bind(user.must_change_password) .bind(user.is_admin) .bind(user.disabled_at) .bind(user.created_at) .bind(user.updated_at) .execute(&mut *tx) .await .map_err(|e| { map_conflict(e, "user", &[("email", "email"), ("username", "username")]) })?; // Seed the primary email row so `user_emails` holds every address. It is // created verified — admin/CLI/invite accounts are trusted; the // self-registration path explicitly re-opens verification afterwards. sqlx::query( "INSERT INTO user_emails \ (id, user_id, email, email_lower, is_primary, verified_at, created_at) \ VALUES ($1, $2, $3, $4, $5, $6, $7)", ) .bind(new_id()) .bind(user.id.clone()) .bind(user.email.clone()) .bind(user.email.to_lowercase()) .bind(true) .bind(user.created_at) .bind(user.created_at) .execute(&mut *tx) .await .map_err(|e| map_conflict(e, "user", &[("email_lower", "email")]))?; tx.commit().await?; Ok(user) } /// Fetch a user by id, or `None` if there is no such user. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn user_by_id(&self, id: &str) -> Result, StoreError> { let sql = format!("SELECT {USER_COLUMNS} FROM users WHERE id = $1"); let row = sqlx::query(AssertSqlSafe(sql)) .bind(id) .fetch_optional(&self.pool) .await?; Ok(row.as_ref().map(|r| self.map_user(r)).transpose()?) } /// Fetch a user by username, case-insensitively, or `None` if absent. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn user_by_username(&self, username: &str) -> Result, StoreError> { let sql = format!("SELECT {USER_COLUMNS} FROM users WHERE username_lower = $1"); let row = sqlx::query(AssertSqlSafe(sql)) .bind(username.to_lowercase()) .fetch_optional(&self.pool) .await?; Ok(row.as_ref().map(|r| self.map_user(r)).transpose()?) } /// List all users, ordered case-insensitively by username. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn list_users(&self) -> Result, StoreError> { let sql = format!("SELECT {USER_COLUMNS} FROM users ORDER BY username_lower ASC"); let rows = sqlx::query(AssertSqlSafe(sql)) .fetch_all(&self.pool) .await?; rows.iter() .map(|r| self.map_user(r)) .collect::>() .map_err(Into::into) } /// One page of users, ordered case-insensitively by username. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn list_users_paged(&self, limit: i64, offset: i64) -> Result, StoreError> { let sql = format!( "SELECT {USER_COLUMNS} FROM users ORDER BY username_lower ASC LIMIT $1 OFFSET $2" ); let rows = sqlx::query(AssertSqlSafe(sql)) .bind(limit) .bind(offset) .fetch_all(&self.pool) .await?; rows.iter() .map(|r| self.map_user(r)) .collect::>() .map_err(Into::into) } /// Change a user's username (case-preserved, case-insensitively unique). /// /// # Errors /// /// * [`StoreError::Name`] if the username is invalid. /// * [`StoreError::Conflict`] if the username is already taken. /// * [`StoreError::Query`] for any other database failure. pub async fn update_username( &self, user_id: &str, new_username: &str, ) -> Result { model::validate_name(new_username)?; let updated = sqlx::query( "UPDATE users SET username = $1, username_lower = $2, updated_at = $3 WHERE id = $4", ) .bind(new_username) .bind(new_username.to_lowercase()) .bind(now_ms()) .bind(user_id) .execute(&self.pool) .await .map_err(|e| map_conflict(e, "user", &[("username", "username")]))? .rows_affected(); Ok(updated > 0) } /// Change a user's email (case-insensitively unique). /// /// # Errors /// /// * [`StoreError::Conflict`] if the email is already taken. /// * [`StoreError::Query`] for any other database failure. pub async fn update_email(&self, user_id: &str, new_email: &str) -> Result { let updated = sqlx::query( "UPDATE users SET email = $1, email_lower = $2, updated_at = $3 WHERE id = $4", ) .bind(new_email) .bind(new_email.to_lowercase()) .bind(now_ms()) .bind(user_id) .execute(&self.pool) .await .map_err(|e| map_conflict(e, "user", &[("email", "email")]))? .rows_affected(); Ok(updated > 0) } /// List a user's email addresses, primary first. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn emails_by_user(&self, user_id: &str) -> Result, StoreError> { let rows = sqlx::query( "SELECT id, user_id, email, is_primary, verified_at, created_at FROM user_emails \ WHERE user_id = $1 ORDER BY is_primary DESC, created_at ASC", ) .bind(user_id) .fetch_all(&self.pool) .await?; rows.iter() .map(|r| { Ok(model::UserEmail { id: r.try_get("id")?, user_id: r.try_get("user_id")?, email: r.try_get("email")?, is_primary: self.get_bool(r, "is_primary")?, verified_at: r.try_get("verified_at")?, created_at: r.try_get("created_at")?, }) }) .collect::>() .map_err(Into::into) } /// Add a secondary email address (case-insensitively unique across all users). /// /// # Errors /// /// * [`StoreError::Conflict`] if the address is already registered. /// * [`StoreError::Query`] for any other database failure. pub async fn add_email( &self, user_id: &str, email: &str, ) -> Result { let row = model::UserEmail { id: new_id(), user_id: user_id.to_string(), email: email.to_string(), is_primary: false, verified_at: None, created_at: now_ms(), }; sqlx::query( "INSERT INTO user_emails (id, user_id, email, email_lower, is_primary, created_at) \ VALUES ($1, $2, $3, $4, $5, $6)", ) .bind(&row.id) .bind(&row.user_id) .bind(&row.email) .bind(email.to_lowercase()) .bind(false) .bind(row.created_at) .execute(&self.pool) .await .map_err(|e| map_conflict(e, "email", &[("email_lower", "email")]))?; Ok(row) } /// Remove one of a user's non-primary email addresses. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn delete_email(&self, user_id: &str, email_id: &str) -> Result { let deleted = sqlx::query( "DELETE FROM user_emails WHERE id = $1 AND user_id = $2 AND is_primary = $3", ) .bind(email_id) .bind(user_id) .bind(false) .execute(&self.pool) .await? .rows_affected(); Ok(deleted > 0) } /// Make one of a user's **verified** addresses primary, mirroring it into /// `users.email`. Returns `false` if the address is missing or unverified. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn set_primary_email( &self, user_id: &str, email_id: &str, ) -> Result { // Confirm the address belongs to the user, is verified, and capture it. let row = sqlx::query( "SELECT email FROM user_emails \ WHERE id = $1 AND user_id = $2 AND verified_at IS NOT NULL", ) .bind(email_id) .bind(user_id) .fetch_optional(&self.pool) .await?; let Some(row) = row else { return Ok(false); }; let email: String = row.try_get("email")?; let mut tx = self.pool.begin().await?; sqlx::query("UPDATE user_emails SET is_primary = $1 WHERE user_id = $2") .bind(false) .bind(user_id) .execute(&mut *tx) .await?; sqlx::query("UPDATE user_emails SET is_primary = $1 WHERE id = $2") .bind(true) .bind(email_id) .execute(&mut *tx) .await?; sqlx::query("UPDATE users SET email = $1, email_lower = $2, updated_at = $3 WHERE id = $4") .bind(&email) .bind(email.to_lowercase()) .bind(now_ms()) .bind(user_id) .execute(&mut *tx) .await?; tx.commit().await?; Ok(true) } /// Set whether the user's primary email is shown on their public profile. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn set_email_public(&self, user_id: &str, public: bool) -> Result<(), StoreError> { sqlx::query("UPDATE users SET email_public = $1, updated_at = $2 WHERE id = $3") .bind(public) .bind(now_ms()) .bind(user_id) .execute(&self.pool) .await?; Ok(()) } /// Begin (or restart) verification of an email address: mark it unverified, /// drop any outstanding tokens, and store a fresh `token` valid for 24 hours. /// The caller generates the random token and emails the link. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn begin_email_verification( &self, email_id: &str, token: &str, ) -> Result<(), StoreError> { let now = now_ms(); let mut tx = self.pool.begin().await?; sqlx::query("UPDATE user_emails SET verified_at = NULL WHERE id = $1") .bind(email_id) .execute(&mut *tx) .await?; sqlx::query("DELETE FROM email_tokens WHERE email_id = $1") .bind(email_id) .execute(&mut *tx) .await?; sqlx::query( "INSERT INTO email_tokens (token, email_id, expires_at, created_at) \ VALUES ($1, $2, $3, $4)", ) .bind(token) .bind(email_id) .bind(now + 24 * 60 * 60 * 1000) .bind(now) .execute(&mut *tx) .await?; tx.commit().await?; Ok(()) } /// Redeem a verification token: mark its address verified and consume the /// token. Returns the owning user id, or `None` if the token is unknown or /// expired. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn verify_email_token(&self, token: &str) -> Result, StoreError> { let now = now_ms(); let row = sqlx::query( "SELECT t.email_id, e.user_id FROM email_tokens t \ JOIN user_emails e ON e.id = t.email_id \ WHERE t.token = $1 AND t.expires_at > $2", ) .bind(token) .bind(now) .fetch_optional(&self.pool) .await?; let Some(row) = row else { return Ok(None); }; let email_id: String = row.try_get("email_id")?; let user_id: String = row.try_get("user_id")?; let mut tx = self.pool.begin().await?; sqlx::query("UPDATE user_emails SET verified_at = $1 WHERE id = $2") .bind(now) .bind(&email_id) .execute(&mut *tx) .await?; sqlx::query("DELETE FROM email_tokens WHERE token = $1") .bind(token) .execute(&mut *tx) .await?; tx.commit().await?; Ok(Some(user_id)) } /// Look up an email row id owned by `user_id`, for resending verification. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn email_owned_by( &self, user_id: &str, email_id: &str, ) -> Result, StoreError> { Ok(self .emails_by_user(user_id) .await? .into_iter() .find(|e| e.id == email_id)) } /// Mark the user's primary email verified (the manual `user verify` path, /// used when SMTP is unavailable). /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn verify_primary_email(&self, user_id: &str) -> Result { let updated = sqlx::query( "UPDATE user_emails SET verified_at = $1 WHERE user_id = $2 AND is_primary = $3", ) .bind(now_ms()) .bind(user_id) .bind(true) .execute(&self.pool) .await? .rows_affected(); Ok(updated > 0) } /// Whether a user's primary email address is verified. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn primary_email_verified(&self, user_id: &str) -> Result { let row = sqlx::query( "SELECT verified_at FROM user_emails WHERE user_id = $1 AND is_primary = $2", ) .bind(user_id) .bind(true) .fetch_optional(&self.pool) .await?; let verified = row .and_then(|r| r.try_get::, _>("verified_at").ok().flatten()) .is_some(); Ok(verified) } /// Set (or clear) a user's password hash. /// /// Passing `Some(hash)` sets the credential and clears the /// `must_change_password` flag — the operator has just chosen a known /// password. Passing `None` returns the account to the invite-pending state. /// Either way, **all of the user's sessions are deleted**, as required on any /// password change, so a rotated credential immediately invalidates existing /// logins. /// /// Returns `true` if a user with `user_id` existed and was updated. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn set_password( &self, user_id: &str, password_hash: Option<&str>, ) -> Result { let updated = sqlx::query( "UPDATE users SET password_hash = $1, must_change_password = $2, updated_at = $3 \ WHERE id = $4", ) .bind(password_hash) .bind(false) .bind(now_ms()) .bind(user_id) .execute(&self.pool) .await? .rows_affected(); // Invalidate every existing session for the user (best-effort — the row // may simply not have existed). sqlx::query("DELETE FROM sessions WHERE user_id = $1") .bind(user_id) .execute(&self.pool) .await?; Ok(updated > 0) } /// Enable or disable a user. Disabling stamps `disabled_at` with the current /// time and deletes the user's sessions; enabling clears `disabled_at`. /// /// Returns `true` if a user with `user_id` existed and was updated. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn set_disabled(&self, user_id: &str, disabled: bool) -> Result { let now = now_ms(); let disabled_at = disabled.then_some(now); let updated = sqlx::query("UPDATE users SET disabled_at = $1, updated_at = $2 WHERE id = $3") .bind(disabled_at) .bind(now) .bind(user_id) .execute(&self.pool) .await? .rows_affected(); if disabled { sqlx::query("DELETE FROM sessions WHERE user_id = $1") .bind(user_id) .execute(&self.pool) .await?; } Ok(updated > 0) } /// Grant or revoke a user's site-administrator flag. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn set_admin(&self, user_id: &str, is_admin: bool) -> Result { let updated = sqlx::query("UPDATE users SET is_admin = $1, updated_at = $2 WHERE id = $3") .bind(is_admin) .bind(now_ms()) .bind(user_id) .execute(&self.pool) .await? .rows_affected(); Ok(updated > 0) } /// Delete a user by id, cascading to their repositories, keys, tokens, and /// sessions (via `ON DELETE CASCADE`). The caller is responsible for any /// policy that should *prevent* the delete (e.g. refusing when repos exist /// and `--purge` was not given). /// /// Returns `true` if a user was deleted. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn delete_user(&self, user_id: &str) -> Result { let deleted = sqlx::query("DELETE FROM users WHERE id = $1") .bind(user_id) .execute(&self.pool) .await? .rows_affected(); Ok(deleted > 0) } /// Overwrite a user's profile fields (each `None` clears the column). /// /// Returns `true` if a user with `user_id` existed and was updated. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn update_profile( &self, user_id: &str, profile: ProfileUpdate, ) -> Result { let sql = "UPDATE users SET display_name = $1, pronouns = $2, bio = $3, \ location = $4, links = $5, updated_at = $6 WHERE id = $7"; let updated = sqlx::query(sql) .bind(profile.display_name) .bind(profile.pronouns) .bind(profile.bio) .bind(profile.location) .bind(encode_links(&profile.links)) .bind(now_ms()) .bind(user_id) .execute(&self.pool) .await? .rows_affected(); Ok(updated > 0) } /// Set (or clear) the content type of a user's uploaded avatar. `None` /// returns the user to the generated-identicon fallback. /// /// Returns `true` if a user with `user_id` existed and was updated. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn set_avatar_mime( &self, user_id: &str, mime: Option<&str>, ) -> Result { let updated = sqlx::query("UPDATE users SET avatar_mime = $1, updated_at = $2 WHERE id = $3") .bind(mime) .bind(now_ms()) .bind(user_id) .execute(&self.pool) .await? .rows_affected(); Ok(updated > 0) } /// Map a `users` row (selected via [`USER_COLUMNS`]) to a [`User`]. pub(crate) fn map_user(&self, row: &AnyRow) -> Result { Ok(User { id: row.try_get("id")?, username: row.try_get("username")?, email: row.try_get("email")?, email_public: self.get_bool(row, "email_public")?, display_name: row.try_get("display_name")?, pronouns: row.try_get("pronouns")?, bio: row.try_get("bio")?, location: row.try_get("location")?, links: decode_links(row.try_get::, _>("links")?.as_deref()), avatar_mime: row.try_get("avatar_mime")?, password_hash: row.try_get("password_hash")?, must_change_password: self.get_bool(row, "must_change_password")?, is_admin: self.get_bool(row, "is_admin")?, disabled_at: row.try_get("disabled_at")?, created_at: row.try_get("created_at")?, updated_at: row.try_get("updated_at")?, }) } }