summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app.rs5
-rw-r--r--src/cli.rs3
-rw-r--r--src/expire.rs1
-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
-rw-r--r--src/lib.rs1
-rw-r--r--src/ui.rs14
10 files changed, 391 insertions, 2 deletions
diff --git a/src/app.rs b/src/app.rs
index 69831ee..cb05061 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -4,6 +4,7 @@ use crate::{
boot::app::Boot,
channel::app::Channels,
event::{self, app::Events},
+ invite::app::Invites,
message::app::Messages,
setup::app::Setup,
token::{self, app::Tokens},
@@ -44,6 +45,10 @@ impl App {
Events::new(&self.db, &self.events)
}
+ pub const fn invites(&self) -> Invites {
+ Invites::new(&self.db)
+ }
+
#[cfg(test)]
pub const fn logins(&self) -> Logins {
Logins::new(&self.db, &self.events)
diff --git a/src/cli.rs b/src/cli.rs
index 5ee9566..ade61ae 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -17,7 +17,7 @@ use tokio::net;
use crate::{
app::App,
- boot, channel, clock, db, event, expire, login, message,
+ boot, channel, clock, db, event, expire, invite, login, message,
setup::{self, middleware::setup_required},
ui,
};
@@ -138,6 +138,7 @@ fn routers(app: &App) -> Router<App> {
boot::router(),
channel::router(),
event::router(),
+ invite::router(),
login::router(),
message::router(),
]
diff --git a/src/expire.rs b/src/expire.rs
index e50bcb4..eaedc44 100644
--- a/src/expire.rs
+++ b/src/expire.rs
@@ -14,6 +14,7 @@ pub async fn middleware(
next: Next,
) -> Result<Response, Internal> {
app.tokens().expire(&expired_at).await?;
+ app.invites().expire(&expired_at).await?;
app.messages().expire(&expired_at).await?;
app.channels().expire(&expired_at).await?;
Ok(next.run(req).await)
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(),
+ }
+ }
+}
diff --git a/src/lib.rs b/src/lib.rs
index f1eb603..73a2cb0 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -13,6 +13,7 @@ mod error;
mod event;
mod expire;
mod id;
+mod invite;
mod login;
mod message;
mod setup;
diff --git a/src/ui.rs b/src/ui.rs
index d0bb095..91d0eb8 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -9,7 +9,7 @@ use axum::{
use mime_guess::Mime;
use rust_embed::EmbeddedFile;
-use crate::{app::App, channel, error::Internal, login::Login};
+use crate::{app::App, channel, error::Internal, invite, login::Login};
#[derive(rust_embed::Embed)]
#[folder = "target/ui"]
@@ -41,6 +41,7 @@ pub fn router(app: &App) -> Router<App> {
.route("/", get(root))
.route("/login", get(login))
.route("/ch/:channel", get(channel))
+ .route("/invite/:invite", get(invite))
.route_layer(middleware::from_fn_with_state(app.clone(), setup_required)),
]
.into_iter()
@@ -85,6 +86,17 @@ async fn channel(
}
}
+async fn invite(
+ State(app): State<App>,
+ Path(invite): Path<invite::Id>,
+) -> Result<impl IntoResponse, Internal> {
+ match app.invites().get(&invite).await {
+ Ok(_) => Ok(Assets::index()?.into_response()),
+ Err(invite::app::Error::NotFound(_)) => Ok(NotFound(Assets::index()?).into_response()),
+ Err(other) => Err(Internal::from(other)),
+ }
+}
+
struct Asset(Mime, EmbeddedFile);
impl IntoResponse for Asset {