summaryrefslogtreecommitdiff
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
parenta0abed5ea08b2fc5b9ac4abdade1199f62cd5da7 (diff)
Create APIs for inviting users.
-rw-r--r--.sqlx/query-693b6758b8e8a21f8e8017573526a2ef050389298851a2c0da9adec7d05fc862.json32
-rw-r--r--.sqlx/query-736bf4e90f23163d7d3a551d5a848b53bc8febe8d4417b5162f8ca330daac885.json12
-rw-r--r--.sqlx/query-d704486ba77fb40e4dd40ff96acc7783f88618a18797958d558ef6687dc29750.json20
-rw-r--r--.sqlx/query-df22d29fc47244ee696b3eaab9d1be6f740ec4a7fc713056befc4ac700259951.json32
-rw-r--r--.sqlx/query-ec1bfce459faaf3d4adbed040105e9e7fa30a6aac5580036d052a1270e13a311.json32
-rw-r--r--docs/api.md76
-rw-r--r--migrations/20241012012022_invite.sql10
-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
17 files changed, 604 insertions, 3 deletions
diff --git a/.sqlx/query-693b6758b8e8a21f8e8017573526a2ef050389298851a2c0da9adec7d05fc862.json b/.sqlx/query-693b6758b8e8a21f8e8017573526a2ef050389298851a2c0da9adec7d05fc862.json
new file mode 100644
index 0000000..c0791f7
--- /dev/null
+++ b/.sqlx/query-693b6758b8e8a21f8e8017573526a2ef050389298851a2c0da9adec7d05fc862.json
@@ -0,0 +1,32 @@
+{
+ "db_name": "SQLite",
+ "query": "\n\t\t\t\tinsert into invite (id, issuer, issued_at)\n\t\t\t\tvalues ($1, $2, $3)\n\t\t\t\treturning\n\t\t\t\t\tid as \"id: Id\",\n\t\t\t\t\tissuer as \"issuer: login::Id\",\n\t\t\t\t\tissued_at as \"issued_at: DateTime\"\n\t\t\t",
+ "describe": {
+ "columns": [
+ {
+ "name": "id: Id",
+ "ordinal": 0,
+ "type_info": "Text"
+ },
+ {
+ "name": "issuer: login::Id",
+ "ordinal": 1,
+ "type_info": "Text"
+ },
+ {
+ "name": "issued_at: DateTime",
+ "ordinal": 2,
+ "type_info": "Text"
+ }
+ ],
+ "parameters": {
+ "Right": 3
+ },
+ "nullable": [
+ false,
+ false,
+ false
+ ]
+ },
+ "hash": "693b6758b8e8a21f8e8017573526a2ef050389298851a2c0da9adec7d05fc862"
+}
diff --git a/.sqlx/query-736bf4e90f23163d7d3a551d5a848b53bc8febe8d4417b5162f8ca330daac885.json b/.sqlx/query-736bf4e90f23163d7d3a551d5a848b53bc8febe8d4417b5162f8ca330daac885.json
new file mode 100644
index 0000000..f7c0552
--- /dev/null
+++ b/.sqlx/query-736bf4e90f23163d7d3a551d5a848b53bc8febe8d4417b5162f8ca330daac885.json
@@ -0,0 +1,12 @@
+{
+ "db_name": "SQLite",
+ "query": "\n\t\t\t\tdelete from invite\n\t\t\t\twhere issued_at < $1\n\t\t\t",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Right": 1
+ },
+ "nullable": []
+ },
+ "hash": "736bf4e90f23163d7d3a551d5a848b53bc8febe8d4417b5162f8ca330daac885"
+}
diff --git a/.sqlx/query-d704486ba77fb40e4dd40ff96acc7783f88618a18797958d558ef6687dc29750.json b/.sqlx/query-d704486ba77fb40e4dd40ff96acc7783f88618a18797958d558ef6687dc29750.json
new file mode 100644
index 0000000..2004e8f
--- /dev/null
+++ b/.sqlx/query-d704486ba77fb40e4dd40ff96acc7783f88618a18797958d558ef6687dc29750.json
@@ -0,0 +1,20 @@
+{
+ "db_name": "SQLite",
+ "query": "\n\t\t\t\tdelete from invite\n\t\t\t\twhere id = $1\n\t\t\t\treturning 1 as \"deleted: bool\"\n\t\t\t",
+ "describe": {
+ "columns": [
+ {
+ "name": "deleted: bool",
+ "ordinal": 0,
+ "type_info": "Null"
+ }
+ ],
+ "parameters": {
+ "Right": 1
+ },
+ "nullable": [
+ null
+ ]
+ },
+ "hash": "d704486ba77fb40e4dd40ff96acc7783f88618a18797958d558ef6687dc29750"
+}
diff --git a/.sqlx/query-df22d29fc47244ee696b3eaab9d1be6f740ec4a7fc713056befc4ac700259951.json b/.sqlx/query-df22d29fc47244ee696b3eaab9d1be6f740ec4a7fc713056befc4ac700259951.json
new file mode 100644
index 0000000..5a34da8
--- /dev/null
+++ b/.sqlx/query-df22d29fc47244ee696b3eaab9d1be6f740ec4a7fc713056befc4ac700259951.json
@@ -0,0 +1,32 @@
+{
+ "db_name": "SQLite",
+ "query": "\n\t\t\t\tselect\n\t\t\t\t\tissuer.id as \"issuer_id: login::Id\",\n\t\t\t\t\tissuer.name as \"issuer_name\",\n\t\t\t\t\tinvite.issued_at as \"invite_issued_at: DateTime\"\n\t\t\t\tfrom invite\n\t\t\t\tjoin login as issuer on (invite.issuer = issuer.id)\n\t\t\t\twhere invite.id = $1\n\t\t\t",
+ "describe": {
+ "columns": [
+ {
+ "name": "issuer_id: login::Id",
+ "ordinal": 0,
+ "type_info": "Text"
+ },
+ {
+ "name": "issuer_name",
+ "ordinal": 1,
+ "type_info": "Text"
+ },
+ {
+ "name": "invite_issued_at: DateTime",
+ "ordinal": 2,
+ "type_info": "Text"
+ }
+ ],
+ "parameters": {
+ "Right": 1
+ },
+ "nullable": [
+ false,
+ false,
+ false
+ ]
+ },
+ "hash": "df22d29fc47244ee696b3eaab9d1be6f740ec4a7fc713056befc4ac700259951"
+}
diff --git a/.sqlx/query-ec1bfce459faaf3d4adbed040105e9e7fa30a6aac5580036d052a1270e13a311.json b/.sqlx/query-ec1bfce459faaf3d4adbed040105e9e7fa30a6aac5580036d052a1270e13a311.json
new file mode 100644
index 0000000..f38401c
--- /dev/null
+++ b/.sqlx/query-ec1bfce459faaf3d4adbed040105e9e7fa30a6aac5580036d052a1270e13a311.json
@@ -0,0 +1,32 @@
+{
+ "db_name": "SQLite",
+ "query": "\n\t\t\t\tselect\n\t\t\t\t\tid as \"id: Id\",\n\t\t\t\t\tissuer as \"issuer: login::Id\",\n\t\t\t\t\tissued_at as \"issued_at: DateTime\"\n\t\t\t\tfrom invite\n\t\t\t\twhere id = $1\n\t\t\t",
+ "describe": {
+ "columns": [
+ {
+ "name": "id: Id",
+ "ordinal": 0,
+ "type_info": "Text"
+ },
+ {
+ "name": "issuer: login::Id",
+ "ordinal": 1,
+ "type_info": "Text"
+ },
+ {
+ "name": "issued_at: DateTime",
+ "ordinal": 2,
+ "type_info": "Text"
+ }
+ ],
+ "parameters": {
+ "Right": 1
+ },
+ "nullable": [
+ false,
+ false,
+ false
+ ]
+ },
+ "hash": "ec1bfce459faaf3d4adbed040105e9e7fa30a6aac5580036d052a1270e13a311"
+}
diff --git a/docs/api.md b/docs/api.md
index 3545a46..f91780e 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -88,7 +88,7 @@ If the token is not valid or has expired, then `hi` will send back a 401 Unautho
### `POST /api/auth/login`
-Authenticates the user by login name and password, creating a login if none exists. **This endpoint does not require an `identity` cookie.**
+Authenticates the user by login name and password. **This endpoint does not require an `identity` cookie.**
#### Request
@@ -123,6 +123,80 @@ Invalidates the identity token, logging the user out.
This endpoint returns a 204 No Content response on success, with a `Set-Cookie` header that clears the `identity` cookie. The cookie provided in the request is also invalidated, and will not authenticate future requests even if the client fails to process the `Set-Cookie` response header.
+## Invitations
+
+Other than the login created during initial setup, new logins can only be created by accepting _invitations_. An invitation can be used at most once, and expires after a day; to invite multiple people, create multiple invitations.
+
+### `POST /api/invite`
+
+Creates an invitation.
+
+#### Request
+
+```json
+{}
+```
+
+#### On success
+
+This endpoint returns a new invitation each time it's called:
+
+```json
+{
+ "id": "I3884",
+ "issuer": "Labcd1234",
+ "issued_at": "2024-10-12T01:43:12.001853Z"
+}
+```
+
+### `GET /api/invite/:invite`
+
+Returns information about a previously-created invite. **This endpoint does not require an `identity` cookie.**
+
+The `:invite` placeholder must be an invite ID, as returned by `POST /api/invite`.
+
+#### On success
+
+This endpoint returns the existing invitation:
+
+```json
+{
+ "issuer": {
+ "id": "Labcd1234",
+ "name": "i send you invites"
+ },
+ "issued_at": "2024-10-12T01:43:12.001853Z"
+}
+```
+
+#### Invite not found
+
+This endpoint returns a 404 Not Found response if the invite ID in the path does not exist, or has already been accepted.
+
+
+### `POST /api/invite/:invite`
+
+Accepts an invitation and creates a new login. **This endpoint does not require an `identity` cookie.**
+
+The `:invite` placeholder must be an invite ID, as returned by `POST /api/invite`.
+
+#### Request
+
+```json
+{
+ "name": "example login",
+ "password": "correct-horse-battery-staple"
+}
+```
+
+#### On success
+
+This endpoint returns a 204 No Content response on success, with a `Set-Cookie` header setting the `identity` cookie to a newly created token for this login. See the [Authentication](#Authentication) section for the details of this cookie. The invite is consumed, and cannot be accepted a second time.
+
+#### Invite not found
+
+This endpoint returns a 404 Not Found response if the invite ID in the path does not exist, or has already been accepted.
+
## Working with channels
Channels are the containers for conversations. The API supports listing channels, creating new channels, and send messages to an existing channel.
diff --git a/migrations/20241012012022_invite.sql b/migrations/20241012012022_invite.sql
new file mode 100644
index 0000000..13004b5
--- /dev/null
+++ b/migrations/20241012012022_invite.sql
@@ -0,0 +1,10 @@
+create table invite (
+ id text
+ primary key
+ not null,
+ issuer text
+ not null
+ references login (id),
+ issued_at text
+ not null
+);
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 {