summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.sqlx/query-3c7cca4f823bd5e4cb0562af0c246e75ce95f6eb10f705c16b22345d31aefc61.json20
-rw-r--r--docs/api.md29
-rw-r--r--src/app.rs5
-rw-r--r--src/cli.rs32
-rw-r--r--src/lib.rs1
-rw-r--r--src/setup/app.rs62
-rw-r--r--src/setup/middleware.rs16
-rw-r--r--src/setup/mod.rs6
-rw-r--r--src/setup/repo.rs28
-rw-r--r--src/setup/routes.rs50
-rw-r--r--src/token/mod.rs2
-rw-r--r--src/ui.rs38
-rw-r--r--ui/lib/apiServer.js9
-rw-r--r--ui/lib/components/LogIn.svelte27
-rw-r--r--ui/routes/(app)/+layout.svelte34
-rw-r--r--ui/routes/(login)/login/+page.svelte23
-rw-r--r--ui/routes/(login)/setup/+page.svelte25
-rw-r--r--vite.config.js.timestamp-1728693131039-b4efd57a8faec.mjs20
18 files changed, 371 insertions, 56 deletions
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<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)
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<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};
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<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(),
+ }
+}
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 @@
<script>
- import { goto } from '$app/navigation';
- import { logIn } from '$lib/apiServer';
- import { currentUser } from '$lib/store';
-
- let disabled = false;
- let username = '';
- let password = '';
-
- async function handleLogin() {
- disabled = true;
- const response = await logIn(username, password);
- if (200 <= response.status && response.status < 300) {
- currentUser.update(() => ({ username }));
- username = '';
- password = '';
- goto('/');
- }
- disabled = false;
- }
+ export let disabled = false;
+ export let username = '';
+ export let password = '';
+ export let legend = 'sign in';
</script>
<div class="card m-4 p-4">
- <form on:submit|preventDefault={handleLogin}>
+ <form on:submit|preventDefault>
<label class="label" for="username">
username
<input class="input" name="username" type="text" placeholder="username" bind:value={username} disabled={disabled}>
@@ -31,7 +16,7 @@
<input class="input" name="password" type="password" placeholder="password" bind:value={password} disabled={disabled}>
</label>
<button class="btn variant-filled" type="submit">
- sign in or up
+ {legend}
</button>
</form>
</div>
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 @@
<script>
+ import { goto } from '$app/navigation';
+ import { logIn } from '$lib/apiServer';
+ import { currentUser } from '$lib/store';
+
import LogIn from '$lib/components/LogIn.svelte';
+
+ let disabled;
+ let username;
+ let password;
+
+ async function onSubmit() {
+ debugger;
+ disabled = true;
+ const response = await logIn(username, password);
+ if (200 <= response.status && response.status < 300) {
+ currentUser.update(() => ({ username }));
+ username = '';
+ password = '';
+ goto('/');
+ }
+ disabled = false;
+ }
</script>
-<LogIn />
+<LogIn bind:disabled bind:username bind:password on:submit={onSubmit} />
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 @@
+<script>
+ import { goto } from '$app/navigation';
+ import { setup } from '$lib/apiServer';
+ import { currentUser } from '$lib/store';
+
+ import LogIn from '$lib/components/LogIn.svelte';
+
+ let disabled;
+ let username;
+ let password;
+
+ async function onSubmit() {
+ disabled = true;
+ const response = await setup(username, password);
+ if (200 <= response.status && response.status < 300) {
+ currentUser.update(() => ({ username }));
+ username = '';
+ password = '';
+ goto('/');
+ }
+ disabled = false;
+ }
+</script>
+
+<LogIn bind:disabled bind:username bind:password legend="set up" on:submit={onSubmit} />
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