From 812f1cafe3f8a68bf45b677fade7417c30d92eac Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Fri, 11 Oct 2024 22:40:03 -0400 Subject: Create APIs for inviting users. --- src/invite/app.rs | 106 ++++++++++++++++++++++++++++++++++++++++++++ src/invite/id.rs | 24 ++++++++++ src/invite/mod.rs | 24 ++++++++++ src/invite/repo.rs | 121 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/invite/routes.rs | 94 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 369 insertions(+) create mode 100644 src/invite/app.rs create mode 100644 src/invite/id.rs create mode 100644 src/invite/mod.rs create mode 100644 src/invite/repo.rs create mode 100644 src/invite/routes.rs (limited to 'src/invite') diff --git a/src/invite/app.rs b/src/invite/app.rs new file mode 100644 index 0000000..998b4f1 --- /dev/null +++ b/src/invite/app.rs @@ -0,0 +1,106 @@ +use chrono::TimeDelta; +use sqlx::sqlite::SqlitePool; + +use super::{repo::Provider as _, Id, Invite, Summary}; +use crate::{ + clock::DateTime, + db::NotFound as _, + event::repo::Provider as _, + login::{repo::Provider as _, Login, Password}, + token::{repo::Provider as _, Secret}, +}; + +pub struct Invites<'a> { + db: &'a SqlitePool, +} + +impl<'a> Invites<'a> { + pub const fn new(db: &'a SqlitePool) -> Self { + Self { db } + } + + pub async fn create( + &self, + issuer: &Login, + issued_at: &DateTime, + ) -> Result { + let mut tx = self.db.begin().await?; + let invite = tx.invites().create(issuer, issued_at).await?; + tx.commit().await?; + + Ok(invite) + } + + pub async fn get(&self, invite: &Id) -> Result { + let mut tx = self.db.begin().await?; + let invite = tx + .invites() + .summary(invite) + .await + .not_found(|| Error::NotFound(invite.clone()))?; + tx.commit().await?; + + Ok(invite) + } + + pub async fn accept( + &self, + invite: &Id, + name: &str, + password: &Password, + accepted_at: &DateTime, + ) -> Result { + let mut tx = self.db.begin().await?; + let invite = tx + .invites() + .by_id(invite) + .await + .not_found(|| AcceptError::NotFound(invite.clone()))?; + // Split the tx here so we don't block writes while we deal with the password, + // and don't deal with the password until we're fairly confident we can accept + // the invite. Final validation is in the next tx. + tx.commit().await?; + + let password_hash = password.hash()?; + + let mut tx = self.db.begin().await?; + // If the invite has been deleted or accepted in the interim, this step will + // catch it. + tx.invites().accept(&invite).await?; + let created = tx.sequence().next(accepted_at).await?; + let login = tx.logins().create(name, &password_hash, &created).await?; + let secret = tx.tokens().issue(&login, accepted_at).await?; + tx.commit().await?; + + Ok(secret) + } + + pub async fn expire(&self, relative_to: &DateTime) -> Result<(), sqlx::Error> { + // Somewhat arbitrarily, expire after one day. + let expire_at = relative_to.to_owned() - TimeDelta::days(1); + + let mut tx = self.db.begin().await?; + tx.invites().expire(&expire_at).await?; + tx.commit().await?; + + Ok(()) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("invite not found: {0}")] + NotFound(Id), + #[error(transparent)] + Database(#[from] sqlx::Error), +} + +#[derive(Debug, thiserror::Error)] +pub enum AcceptError { + #[error("invite not found: {0}")] + NotFound(Id), + #[error(transparent)] + Database(#[from] sqlx::Error), + #[error(transparent)] + PasswordHash(#[from] password_hash::Error), +} diff --git a/src/invite/id.rs b/src/invite/id.rs new file mode 100644 index 0000000..bd53f1f --- /dev/null +++ b/src/invite/id.rs @@ -0,0 +1,24 @@ +use crate::id::Id as BaseId; + +// Stable identifier for an invite Prefixed with `I`. +#[derive(Clone, Debug, Eq, PartialEq, sqlx::Type, serde::Deserialize, serde::Serialize)] +#[sqlx(transparent)] +pub struct Id(BaseId); + +impl From for Id { + fn from(id: BaseId) -> Self { + Self(id) + } +} + +impl Id { + pub fn generate() -> Self { + BaseId::generate("I") + } +} + +impl std::fmt::Display for Id { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} diff --git a/src/invite/mod.rs b/src/invite/mod.rs new file mode 100644 index 0000000..5f9d490 --- /dev/null +++ b/src/invite/mod.rs @@ -0,0 +1,24 @@ +pub mod app; +mod id; +mod repo; +mod routes; + +use crate::{ + clock::DateTime, + login::{self, Login}, +}; + +pub use self::{id::Id, routes::router}; + +#[derive(Debug, serde::Serialize)] +pub struct Invite { + pub id: Id, + pub issuer: login::Id, + pub issued_at: DateTime, +} + +#[derive(serde::Serialize)] +pub struct Summary { + pub issuer: Login, + pub issued_at: DateTime, +} diff --git a/src/invite/repo.rs b/src/invite/repo.rs new file mode 100644 index 0000000..2ab993f --- /dev/null +++ b/src/invite/repo.rs @@ -0,0 +1,121 @@ +use sqlx::{sqlite::Sqlite, SqliteConnection, Transaction}; + +use super::{Id, Invite, Summary}; +use crate::{ + clock::DateTime, + login::{self, Login}, +}; + +pub trait Provider { + fn invites(&mut self) -> Invites; +} + +impl<'c> Provider for Transaction<'c, Sqlite> { + fn invites(&mut self) -> Invites { + Invites(self) + } +} + +pub struct Invites<'t>(&'t mut SqliteConnection); + +impl<'c> Invites<'c> { + pub async fn create( + &mut self, + issuer: &Login, + issued_at: &DateTime, + ) -> Result { + let id = Id::generate(); + let invite = sqlx::query_as!( + Invite, + r#" + insert into invite (id, issuer, issued_at) + values ($1, $2, $3) + returning + id as "id: Id", + issuer as "issuer: login::Id", + issued_at as "issued_at: DateTime" + "#, + id, + issuer.id, + issued_at + ) + .fetch_one(&mut *self.0) + .await?; + + Ok(invite) + } + + pub async fn by_id(&mut self, invite: &Id) -> Result { + let invite = sqlx::query_as!( + Invite, + r#" + select + id as "id: Id", + issuer as "issuer: login::Id", + issued_at as "issued_at: DateTime" + from invite + where id = $1 + "#, + invite, + ) + .fetch_one(&mut *self.0) + .await?; + + Ok(invite) + } + + pub async fn summary(&mut self, invite: &Id) -> Result { + let invite = sqlx::query!( + r#" + select + issuer.id as "issuer_id: login::Id", + issuer.name as "issuer_name", + invite.issued_at as "invite_issued_at: DateTime" + from invite + join login as issuer on (invite.issuer = issuer.id) + where invite.id = $1 + "#, + invite, + ) + .map(|row| Summary { + issuer: Login { + id: row.issuer_id, + name: row.issuer_name, + }, + issued_at: row.invite_issued_at, + }) + .fetch_one(&mut *self.0) + .await?; + + Ok(invite) + } + + pub async fn accept(&mut self, invite: &Invite) -> Result<(), sqlx::Error> { + sqlx::query_scalar!( + r#" + delete from invite + where id = $1 + returning 1 as "deleted: bool" + "#, + invite.id, + ) + .fetch_one(&mut *self.0) + .await?; + + Ok(()) + } + + pub async fn expire(&mut self, expire_at: &DateTime) -> Result<(), sqlx::Error> { + sqlx::query!( + r#" + delete from invite + where issued_at < $1 + "#, + expire_at, + ) + .execute(&mut *self.0) + .await?; + + Ok(()) + } +} diff --git a/src/invite/routes.rs b/src/invite/routes.rs new file mode 100644 index 0000000..3384e10 --- /dev/null +++ b/src/invite/routes.rs @@ -0,0 +1,94 @@ +use axum::{ + extract::{Json, Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, + routing::{get, post}, + Router, +}; + +use super::{app, Id, Invite, Summary}; +use crate::{ + app::App, + clock::RequestedAt, + error::{Internal, NotFound}, + login::{Login, Password}, + token::extract::IdentityToken, +}; + +pub fn router() -> Router { + Router::new() + .route("/api/invite", post(on_invite)) + .route("/api/invite/:invite", get(invite)) + .route("/api/invite/:invite", post(on_accept)) +} + +#[derive(serde::Deserialize)] +struct InviteRequest {} + +async fn on_invite( + State(app): State, + RequestedAt(issued_at): RequestedAt, + login: Login, + // Require `{}` as the only valid request for this endpoint. + _: Json, +) -> Result, Internal> { + let invite = app.invites().create(&login, &issued_at).await?; + Ok(Json(invite)) +} + +async fn invite( + State(app): State, + Path(invite): Path, +) -> Result, InviteError> { + app.invites() + .get(&invite) + .await + .map(Json) + .map_err(InviteError) +} + +struct InviteError(app::Error); + +impl IntoResponse for InviteError { + fn into_response(self) -> Response { + let Self(error) = self; + match error { + error @ app::Error::NotFound(_) => NotFound(error).into_response(), + other => Internal::from(other).into_response(), + } + } +} + +#[derive(serde::Deserialize)] +struct AcceptRequest { + name: String, + password: Password, +} + +async fn on_accept( + State(app): State, + RequestedAt(accepted_at): RequestedAt, + identity: IdentityToken, + Path(invite): Path, + Json(request): Json, +) -> Result<(IdentityToken, StatusCode), AcceptError> { + let secret = app + .invites() + .accept(&invite, &request.name, &request.password, &accepted_at) + .await + .map_err(AcceptError)?; + let identity = identity.set(secret); + Ok((identity, StatusCode::NO_CONTENT)) +} + +struct AcceptError(app::AcceptError); + +impl IntoResponse for AcceptError { + fn into_response(self) -> Response { + let Self(error) = self; + match error { + error @ app::AcceptError::NotFound(_) => NotFound(error).into_response(), + other => Internal::from(other).into_response(), + } + } +} -- cgit v1.2.3