diff options
| author | Owen Jacobson <owen@grimoire.ca> | 2024-10-11 20:55:36 -0400 |
|---|---|---|
| committer | Owen Jacobson <owen@grimoire.ca> | 2024-10-11 20:55:36 -0400 |
| commit | 5ff106e910544788bc916626ae7665cb26e5af30 (patch) | |
| tree | f03f98677293a9d892e2d21d1a9a80aeedab60a3 /src | |
| parent | d33c8af14c4adc1c15ab048299e06f9f35ae4de6 (diff) | |
Provide a separate "initial setup" endpoint that creates a user.
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.rs | 5 | ||||
| -rw-r--r-- | src/cli.rs | 32 | ||||
| -rw-r--r-- | src/lib.rs | 1 | ||||
| -rw-r--r-- | src/setup/app.rs | 62 | ||||
| -rw-r--r-- | src/setup/middleware.rs | 16 | ||||
| -rw-r--r-- | src/setup/mod.rs | 6 | ||||
| -rw-r--r-- | src/setup/repo.rs | 28 | ||||
| -rw-r--r-- | src/setup/routes.rs | 50 | ||||
| -rw-r--r-- | src/token/mod.rs | 2 | ||||
| -rw-r--r-- | src/ui.rs | 38 |
10 files changed, 223 insertions, 17 deletions
@@ -5,6 +5,7 @@ use crate::{ channel::app::Channels, event::{self, app::Events}, message::app::Messages, + setup::app::Setup, token::{self, app::Tokens}, }; @@ -52,6 +53,10 @@ impl App { Messages::new(&self.db, &self.events) } + pub const fn setup(&self) -> Setup { + Setup::new(&self.db, &self.events) + } + pub const fn tokens(&self) -> Tokens { Tokens::new(&self.db, &self.events, &self.token_events) } @@ -15,7 +15,12 @@ use clap::{CommandFactory, Parser}; use sqlx::sqlite::SqlitePool; use tokio::net; -use crate::{app::App, boot, channel, clock, db, event, expire, login, message, ui}; +use crate::{ + app::App, + boot, channel, clock, db, event, expire, login, message, + setup::{self, middleware::setup_required}, + ui, +}; /// Command-line entry point for running the `hi` server. /// @@ -81,7 +86,7 @@ impl Args { let pool = self.pool().await?; let app = App::from(pool); - let app = routers() + let app = routers(&app) .route_layer(middleware::from_fn_with_state( app.clone(), expire::middleware, @@ -126,14 +131,23 @@ impl Args { } } -fn routers() -> Router<App> { +fn routers(app: &App) -> Router<App> { [ - boot::router(), - channel::router(), - event::router(), - login::router(), - message::router(), - ui::router(), + [ + // API endpoints that require setup to function + boot::router(), + channel::router(), + event::router(), + login::router(), + message::router(), + ] + .into_iter() + .fold(Router::default(), Router::merge) + .route_layer(middleware::from_fn_with_state(app.clone(), setup_required)), + // API endpoints that handle setup + setup::router(), + // The UI (handles setup state itself) + ui::router(app), ] .into_iter() .fold(Router::default(), Router::merge) @@ -15,6 +15,7 @@ mod expire; mod id; mod login; mod message; +mod setup; #[cfg(test)] mod test; mod token; diff --git a/src/setup/app.rs b/src/setup/app.rs new file mode 100644 index 0000000..24e0010 --- /dev/null +++ b/src/setup/app.rs @@ -0,0 +1,62 @@ +use sqlx::sqlite::SqlitePool; + +use super::repo::Provider as _; +use crate::{ + clock::DateTime, + event::{repo::Provider as _, Broadcaster, Event}, + login::{repo::Provider as _, Password}, + token::{repo::Provider as _, Secret}, +}; + +pub struct Setup<'a> { + db: &'a SqlitePool, + events: &'a Broadcaster, +} + +impl<'a> Setup<'a> { + pub const fn new(db: &'a SqlitePool, events: &'a Broadcaster) -> Self { + Self { db, events } + } + + pub async fn initial( + &self, + name: &str, + password: &Password, + created_at: &DateTime, + ) -> Result<Secret, Error> { + let password_hash = password.hash()?; + + let mut tx = self.db.begin().await?; + let login = if tx.setup().completed().await? { + Err(Error::SetupCompleted)? + } else { + let created = tx.sequence().next(created_at).await?; + tx.logins().create(name, &password_hash, &created).await? + }; + let secret = tx.tokens().issue(&login, created_at).await?; + tx.commit().await?; + + self.events + .broadcast(login.events().map(Event::from).collect::<Vec<_>>()); + + Ok(secret) + } + + pub async fn completed(&self) -> Result<bool, sqlx::Error> { + let mut tx = self.db.begin().await?; + let completed = tx.setup().completed().await?; + tx.commit().await?; + + Ok(completed) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("initial setup previously completed")] + SetupCompleted, + #[error(transparent)] + Database(#[from] sqlx::Error), + #[error(transparent)] + PasswordHash(#[from] password_hash::Error), +} diff --git a/src/setup/middleware.rs b/src/setup/middleware.rs new file mode 100644 index 0000000..a5f9070 --- /dev/null +++ b/src/setup/middleware.rs @@ -0,0 +1,16 @@ +use axum::{ + extract::{Request, State}, + http::StatusCode, + middleware::Next, + response::{IntoResponse, Response}, +}; + +use crate::{app::App, error::Internal}; + +pub async fn setup_required(State(app): State<App>, request: Request, next: Next) -> Response { + match app.setup().completed().await { + Ok(true) => next.run(request).await, + Ok(false) => (StatusCode::CONFLICT, "initial setup not completed").into_response(), + Err(error) => Internal::from(error).into_response(), + } +} diff --git a/src/setup/mod.rs b/src/setup/mod.rs new file mode 100644 index 0000000..5a8fa37 --- /dev/null +++ b/src/setup/mod.rs @@ -0,0 +1,6 @@ +pub mod app; +pub mod middleware; +pub mod repo; +mod routes; + +pub use self::routes::router; diff --git a/src/setup/repo.rs b/src/setup/repo.rs new file mode 100644 index 0000000..de93f51 --- /dev/null +++ b/src/setup/repo.rs @@ -0,0 +1,28 @@ +use sqlx::{sqlite::Sqlite, SqliteConnection, Transaction}; + +pub trait Provider { + fn setup(&mut self) -> Setup; +} + +impl<'c> Provider for Transaction<'c, Sqlite> { + fn setup(&mut self) -> Setup { + Setup(self) + } +} + +pub struct Setup<'t>(&'t mut SqliteConnection); + +impl<'c> Setup<'c> { + pub async fn completed(&mut self) -> Result<bool, sqlx::Error> { + let completed = sqlx::query_scalar!( + r#" + select count(*) > 0 as "completed: bool" + from login + "#, + ) + .fetch_one(&mut *self.0) + .await?; + + Ok(completed) + } +} diff --git a/src/setup/routes.rs b/src/setup/routes.rs new file mode 100644 index 0000000..ff41734 --- /dev/null +++ b/src/setup/routes.rs @@ -0,0 +1,50 @@ +use axum::{ + extract::{Json, State}, + http::StatusCode, + response::{IntoResponse, Response}, + routing::post, + Router, +}; + +use super::app; +use crate::{ + app::App, clock::RequestedAt, error::Internal, login::Password, token::extract::IdentityToken, +}; + +pub fn router() -> Router<App> { + Router::new().route("/api/setup", post(on_setup)) +} + +#[derive(serde::Deserialize)] +struct SetupRequest { + name: String, + password: Password, +} + +async fn on_setup( + State(app): State<App>, + RequestedAt(setup_at): RequestedAt, + identity: IdentityToken, + Json(request): Json<SetupRequest>, +) -> Result<(IdentityToken, StatusCode), SetupError> { + let secret = app + .setup() + .initial(&request.name, &request.password, &setup_at) + .await + .map_err(SetupError)?; + let identity = identity.set(secret); + Ok((identity, StatusCode::NO_CONTENT)) +} + +#[derive(Debug)] +struct SetupError(app::Error); + +impl IntoResponse for SetupError { + fn into_response(self) -> Response { + let Self(error) = self; + match error { + app::Error::SetupCompleted => (StatusCode::CONFLICT, error.to_string()).into_response(), + other => Internal::from(other).into_response(), + } + } +} diff --git a/src/token/mod.rs b/src/token/mod.rs index eccb3cd..33403ef 100644 --- a/src/token/mod.rs +++ b/src/token/mod.rs @@ -3,7 +3,7 @@ mod broadcaster; mod event; pub mod extract; mod id; -mod repo; +pub mod repo; mod secret; pub use self::{broadcaster::Broadcaster, event::Event, id::Id, secret::Secret}; @@ -1,6 +1,7 @@ use axum::{ - extract::{Path, State}, + extract::{Path, Request, State}, http::{header, StatusCode}, + middleware::{self, Next}, response::{IntoResponse, Redirect, Response}, routing::get, Router, @@ -31,12 +32,19 @@ impl Assets { } } -pub fn router() -> Router<App> { - Router::new() - .route("/*path", get(asset)) - .route("/", get(root)) - .route("/login", get(login)) - .route("/ch/:channel", get(channel)) +pub fn router(app: &App) -> Router<App> { + [ + Router::new() + .route("/*path", get(asset)) + .route("/setup", get(setup)), + Router::new() + .route("/", get(root)) + .route("/login", get(login)) + .route("/ch/:channel", get(channel)) + .route_layer(middleware::from_fn_with_state(app.clone(), setup_required)), + ] + .into_iter() + .fold(Router::default(), Router::merge) } async fn asset(Path(path): Path<String>) -> Result<Asset, NotFound<String>> { @@ -55,6 +63,14 @@ async fn login() -> Result<impl IntoResponse, Internal> { Assets::index() } +async fn setup(State(app): State<App>) -> Result<impl IntoResponse, Internal> { + if app.setup().completed().await? { + Ok(Redirect::to("/login").into_response()) + } else { + Ok(Assets::index().into_response()) + } +} + async fn channel( State(app): State<App>, login: Option<Login>, @@ -96,3 +112,11 @@ where (StatusCode::NOT_FOUND, response).into_response() } } + +pub async fn setup_required(State(app): State<App>, request: Request, next: Next) -> Response { + match app.setup().completed().await { + Ok(true) => next.run(request).await, + Ok(false) => Redirect::to("/setup").into_response(), + Err(error) => Internal::from(error).into_response(), + } +} |
