summaryrefslogtreecommitdiff
path: root/src/invite
diff options
context:
space:
mode:
authorOwen Jacobson <owen@grimoire.ca>2024-10-11 22:40:03 -0400
committerOwen Jacobson <owen@grimoire.ca>2024-10-11 22:40:03 -0400
commit812f1cafe3f8a68bf45b677fade7417c30d92eac (patch)
treee2633472a31bd86bf08bcc16ddb4296df89064b4 /src/invite
parenta0abed5ea08b2fc5b9ac4abdade1199f62cd5da7 (diff)
Create APIs for inviting users.
Diffstat (limited to 'src/invite')
-rw-r--r--src/invite/app.rs106
-rw-r--r--src/invite/id.rs24
-rw-r--r--src/invite/mod.rs24
-rw-r--r--src/invite/repo.rs121
-rw-r--r--src/invite/routes.rs94
5 files changed, 369 insertions, 0 deletions
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<Invite, sqlx::Error> {
+ 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<Summary, Error> {
+ 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<Secret, AcceptError> {
+ 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<BaseId> 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<Invite, sqlx::Error> {
+ 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<Invite, sqlx::Error> {
+ 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<Summary, sqlx::Error> {
+ 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<App> {
+ 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<App>,
+ RequestedAt(issued_at): RequestedAt,
+ login: Login,
+ // Require `{}` as the only valid request for this endpoint.
+ _: Json<InviteRequest>,
+) -> Result<Json<Invite>, Internal> {
+ let invite = app.invites().create(&login, &issued_at).await?;
+ Ok(Json(invite))
+}
+
+async fn invite(
+ State(app): State<App>,
+ Path(invite): Path<Id>,
+) -> Result<Json<Summary>, 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<App>,
+ RequestedAt(accepted_at): RequestedAt,
+ identity: IdentityToken,
+ Path(invite): Path<Id>,
+ Json(request): Json<AcceptRequest>,
+) -> 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(),
+ }
+ }
+}