From 5ff106e910544788bc916626ae7665cb26e5af30 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Fri, 11 Oct 2024 20:55:36 -0400 Subject: Provide a separate "initial setup" endpoint that creates a user. --- ...af0c246e75ce95f6eb10f705c16b22345d31aefc61.json | 20 +++++++ docs/api.md | 29 ++++++++++ src/app.rs | 5 ++ src/cli.rs | 32 +++++++---- src/lib.rs | 1 + src/setup/app.rs | 62 ++++++++++++++++++++++ src/setup/middleware.rs | 16 ++++++ src/setup/mod.rs | 6 +++ src/setup/repo.rs | 28 ++++++++++ src/setup/routes.rs | 50 +++++++++++++++++ src/token/mod.rs | 2 +- src/ui.rs | 38 ++++++++++--- ui/lib/apiServer.js | 9 ++++ ui/lib/components/LogIn.svelte | 27 +++------- ui/routes/(app)/+layout.svelte | 34 ++++++------ ui/routes/(login)/login/+page.svelte | 23 +++++++- ui/routes/(login)/setup/+page.svelte | 25 +++++++++ ...ig.js.timestamp-1728693131039-b4efd57a8faec.mjs | 20 +++++++ 18 files changed, 371 insertions(+), 56 deletions(-) create mode 100644 .sqlx/query-3c7cca4f823bd5e4cb0562af0c246e75ce95f6eb10f705c16b22345d31aefc61.json create mode 100644 src/setup/app.rs create mode 100644 src/setup/middleware.rs create mode 100644 src/setup/mod.rs create mode 100644 src/setup/repo.rs create mode 100644 src/setup/routes.rs create mode 100644 ui/routes/(login)/setup/+page.svelte create mode 100644 vite.config.js.timestamp-1728693131039-b4efd57a8faec.mjs diff --git a/.sqlx/query-3c7cca4f823bd5e4cb0562af0c246e75ce95f6eb10f705c16b22345d31aefc61.json b/.sqlx/query-3c7cca4f823bd5e4cb0562af0c246e75ce95f6eb10f705c16b22345d31aefc61.json new file mode 100644 index 0000000..6aab5fc --- /dev/null +++ b/.sqlx/query-3c7cca4f823bd5e4cb0562af0c246e75ce95f6eb10f705c16b22345d31aefc61.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "\n select count(*) > 0 as \"completed: bool\"\n from login\n ", + "describe": { + "columns": [ + { + "name": "completed: bool", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false + ] + }, + "hash": "3c7cca4f823bd5e4cb0562af0c246e75ce95f6eb10f705c16b22345d31aefc61" +} diff --git a/docs/api.md b/docs/api.md index 4957666..3545a46 100644 --- a/docs/api.md +++ b/docs/api.md @@ -8,6 +8,35 @@ On errors, the response body is freeform text and is meant to be shown to the us Requests that require a JSON body must include a `content-type: application/json` header. For requests that take a JSON body, if the body does not match the required schema, the endpoint will return a 422 Unprocessable Entity response, instead of the responses documented for that endpoint. +## Initial setup + +The `hi` service requires setup before it can enter service. This setup is performed online, via the `hi` API. Any request to an API endpoint before setup has been completed will return a 409 Conflict response, unless the endpoint is documented as allowing requests before setup. + +### `POST /api/setup` + +Performs the initial setup, creating an initial login without requiring an invite. + +This endpoint does not require an `identity` cookie. + +This endpoint can be called before initial setup. + +#### Request + +```json +{ + "name": "example username", + "password": "the plaintext password", +} +``` + +#### 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 the initial login. See the [Authentication](#Authentication) section for details on how this cookie should be used. + +#### Setup already completed + +Once performed, this operation cannot be performed a second time. Subsequent requests to this endpoint will return a 409 Conflict response. + ## Client initialization Clients will generally need some information about the session in order to present a coherent view to the user, including the session's login identity. diff --git a/src/app.rs b/src/app.rs index 177c134..cf49070 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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) } diff --git a/src/cli.rs b/src/cli.rs index 1c0d007..5ee9566 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -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 { +fn routers(app: &App) -> Router { [ - 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) diff --git a/src/lib.rs b/src/lib.rs index 2673d2d..f1eb603 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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 { + 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::>()); + + Ok(secret) + } + + pub async fn completed(&self) -> Result { + 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, 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 { + 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 { + Router::new().route("/api/setup", post(on_setup)) +} + +#[derive(serde::Deserialize)] +struct SetupRequest { + name: String, + password: Password, +} + +async fn on_setup( + State(app): State, + RequestedAt(setup_at): RequestedAt, + identity: IdentityToken, + Json(request): Json, +) -> 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}; diff --git a/src/ui.rs b/src/ui.rs index 34eb6f1..d0bb095 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -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 { - Router::new() - .route("/*path", get(asset)) - .route("/", get(root)) - .route("/login", get(login)) - .route("/ch/:channel", get(channel)) +pub fn router(app: &App) -> Router { + [ + 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) -> Result> { @@ -55,6 +63,14 @@ async fn login() -> Result { Assets::index() } +async fn setup(State(app): State) -> Result { + if app.setup().completed().await? { + Ok(Redirect::to("/login").into_response()) + } else { + Ok(Assets::index().into_response()) + } +} + async fn channel( State(app): State, login: Option, @@ -96,3 +112,11 @@ where (StatusCode::NOT_FOUND, response).into_response() } } + +pub async fn setup_required(State(app): State, 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(), + } +} diff --git a/ui/lib/apiServer.js b/ui/lib/apiServer.js index ccd6e66..46fcb53 100644 --- a/ui/lib/apiServer.js +++ b/ui/lib/apiServer.js @@ -3,12 +3,21 @@ import { channelsList, logins, messages } from '$lib/store'; export const apiServer = axios.create({ baseURL: '/api/', + validateStatus: (status) => status >= 200 && status < 500, }); export async function boot() { return apiServer.get('/boot'); } +export async function setup(username, password) { + const data = { + name: username, + password, + }; + return apiServer.post('/setup', data); +} + export async function logIn(username, password) { const data = { name: username, diff --git a/ui/lib/components/LogIn.svelte b/ui/lib/components/LogIn.svelte index e1cda8a..bb80ccd 100644 --- a/ui/lib/components/LogIn.svelte +++ b/ui/lib/components/LogIn.svelte @@ -1,27 +1,12 @@
-
+
diff --git a/ui/routes/(app)/+layout.svelte b/ui/routes/(app)/+layout.svelte index 0f8e83c..38df9b9 100644 --- a/ui/routes/(app)/+layout.svelte +++ b/ui/routes/(app)/+layout.svelte @@ -26,23 +26,23 @@ } onMount(async () => { - try { - let response = await boot(); - switch (response.status) { - case 200: - onBooted(response.data); - events = subscribeToEvents(response.data.resume_point); - break; - case 401: - currentUser.update(() => null); - goto('/login'); - break; - default: - // TODO: display error. - break; - } - } catch (_) { - // I don't want exceptions on non-200 series responses, dammit. + let response = await boot(); + switch (response.status) { + case 200: + onBooted(response.data); + events = subscribeToEvents(response.data.resume_point); + break; + case 401: + currentUser.update(() => null); + goto('/login'); + break; + case 409: + currentUser.update(() => null); + goto('/setup'); + break; + default: + // TODO: display error. + break; } loading = false; }); diff --git a/ui/routes/(login)/login/+page.svelte b/ui/routes/(login)/login/+page.svelte index c333fdd..a349660 100644 --- a/ui/routes/(login)/login/+page.svelte +++ b/ui/routes/(login)/login/+page.svelte @@ -1,5 +1,26 @@ - + diff --git a/ui/routes/(login)/setup/+page.svelte b/ui/routes/(login)/setup/+page.svelte new file mode 100644 index 0000000..252e79b --- /dev/null +++ b/ui/routes/(login)/setup/+page.svelte @@ -0,0 +1,25 @@ + + + diff --git a/vite.config.js.timestamp-1728693131039-b4efd57a8faec.mjs b/vite.config.js.timestamp-1728693131039-b4efd57a8faec.mjs new file mode 100644 index 0000000..67d1673 --- /dev/null +++ b/vite.config.js.timestamp-1728693131039-b4efd57a8faec.mjs @@ -0,0 +1,20 @@ +// vite.config.js +import { sveltekit } from "file:///Users/owen/Projects/grimoire.ca/hi/node_modules/@sveltejs/kit/src/exports/vite/index.js"; +import { defineConfig } from "file:///Users/owen/Projects/grimoire.ca/hi/node_modules/vite/dist/node/index.js"; +var vite_config_default = defineConfig({ + plugins: [sveltekit()], + server: { + fs: { + allow: [ + "ui" + ] + }, + proxy: { + "/api": "http://localhost:64209" + } + } +}); +export { + vite_config_default as default +}; +//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcuanMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCIvVXNlcnMvb3dlbi9Qcm9qZWN0cy9ncmltb2lyZS5jYS9oaVwiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9maWxlbmFtZSA9IFwiL1VzZXJzL293ZW4vUHJvamVjdHMvZ3JpbW9pcmUuY2EvaGkvdml0ZS5jb25maWcuanNcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfaW1wb3J0X21ldGFfdXJsID0gXCJmaWxlOi8vL1VzZXJzL293ZW4vUHJvamVjdHMvZ3JpbW9pcmUuY2EvaGkvdml0ZS5jb25maWcuanNcIjtpbXBvcnQgeyBzdmVsdGVraXQgfSBmcm9tICdAc3ZlbHRlanMva2l0L3ZpdGUnO1xuaW1wb3J0IHsgZGVmaW5lQ29uZmlnIH0gZnJvbSAndml0ZSc7XG5cbmV4cG9ydCBkZWZhdWx0IGRlZmluZUNvbmZpZyh7XG4gIHBsdWdpbnM6IFtzdmVsdGVraXQoKV0sXG4gIHNlcnZlcjoge1xuICAgIGZzOiB7XG4gICAgICBhbGxvdzogW1xuICAgICAgICAndWknXG4gICAgICBdXG4gICAgfSxcbiAgICBwcm94eToge1xuICAgICAgJy9hcGknOiAnaHR0cDovL2xvY2FsaG9zdDo2NDIwOScsXG4gICAgfSxcbiAgfSxcbn0pO1xuIl0sCiAgIm1hcHBpbmdzIjogIjtBQUEyUixTQUFTLGlCQUFpQjtBQUNyVCxTQUFTLG9CQUFvQjtBQUU3QixJQUFPLHNCQUFRLGFBQWE7QUFBQSxFQUMxQixTQUFTLENBQUMsVUFBVSxDQUFDO0FBQUEsRUFDckIsUUFBUTtBQUFBLElBQ04sSUFBSTtBQUFBLE1BQ0YsT0FBTztBQUFBLFFBQ0w7QUFBQSxNQUNGO0FBQUEsSUFDRjtBQUFBLElBQ0EsT0FBTztBQUFBLE1BQ0wsUUFBUTtBQUFBLElBQ1Y7QUFBQSxFQUNGO0FBQ0YsQ0FBQzsiLAogICJuYW1lcyI6IFtdCn0K -- cgit v1.2.3