// 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/. //! Activation and password-reset invites. //! //! The emailed token is never stored; only its SHA-256 (`token_hash`) is, and //! redemption looks the invite up by that hash. Invites are single-use //! (`used_at`) and time-limited (`expires_at`). use model::Invite; use sqlx::any::AnyRow; use sqlx::{AssertSqlSafe, Row}; use crate::{Store, StoreError, new_id, now_ms}; /// The columns of `invites` in a fixed order, shared by every `SELECT`. const INVITE_COLUMNS: &str = "id, user_id, token_hash, purpose, expires_at, used_at, created_at"; /// Fields needed to mint an invite. The caller generates the token and passes its /// SHA-256 here; the store assigns the id and creation time. #[derive(Debug, Clone)] pub struct NewInvite { /// The user the invite activates. pub user_id: String, /// SHA-256 (hex) of the emailed token. pub token_hash: String, /// `"activate"` or `"reset"`. pub purpose: String, /// Absolute expiry, epoch ms. pub expires_at: i64, } impl Store { /// Create an invite row. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn create_invite(&self, new: NewInvite) -> Result { let invite = Invite { id: new_id(), user_id: new.user_id, token_hash: new.token_hash, purpose: new.purpose, expires_at: new.expires_at, used_at: None, created_at: now_ms(), }; let sql = "INSERT INTO invites (id, user_id, token_hash, purpose, expires_at, used_at, created_at) \ VALUES ($1, $2, $3, $4, $5, $6, $7)"; sqlx::query(sql) .bind(invite.id.clone()) .bind(invite.user_id.clone()) .bind(invite.token_hash.clone()) .bind(invite.purpose.clone()) .bind(invite.expires_at) .bind(invite.used_at) .bind(invite.created_at) .execute(&self.pool) .await?; Ok(invite) } /// Look up an invite by its token hash, regardless of state. The caller checks /// `used_at`/`expires_at` so it can distinguish "already used" from "expired" /// from "unknown" for the redemption page. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn invite_by_token_hash( &self, token_hash: &str, ) -> Result, StoreError> { let sql = format!("SELECT {INVITE_COLUMNS} FROM invites WHERE token_hash = $1"); let row = sqlx::query(AssertSqlSafe(sql)) .bind(token_hash) .fetch_optional(&self.pool) .await?; Ok(row.as_ref().map(map_invite).transpose()?) } /// Mark an invite redeemed. Guards against a double-redeem with a /// `used_at IS NULL` predicate, so `true` means "this call redeemed it". /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn mark_invite_used(&self, invite_id: &str) -> Result { let updated = sqlx::query("UPDATE invites SET used_at = $1 WHERE id = $2 AND used_at IS NULL") .bind(now_ms()) .bind(invite_id) .execute(&self.pool) .await? .rows_affected(); Ok(updated > 0) } } /// Map an `invites` row (selected via [`INVITE_COLUMNS`]) to an [`Invite`]. fn map_invite(row: &AnyRow) -> Result { Ok(Invite { id: row.try_get("id")?, user_id: row.try_get("user_id")?, token_hash: row.try_get("token_hash")?, purpose: row.try_get("purpose")?, expires_at: row.try_get("expires_at")?, used_at: row.try_get("used_at")?, created_at: row.try_get("created_at")?, }) }