summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOwen Jacobson <owen@grimoire.ca>2024-09-18 22:49:38 -0400
committerOwen Jacobson <owen@grimoire.ca>2024-09-20 16:42:44 -0400
commit22348bfa35f009e62abe2f30863e0434079a1fe2 (patch)
treec5b5b5e660a1ee2a05785f4669102c1023b6e7b0
parentaafdeb9ffaf9a993ca4462b3422667e04469b2e3 (diff)
Remove the HTML client, and expose a JSON API.
This API structure fell out of a conversation with Kit. Described loosely: kit: ok kit: Here's what I'm picturing in a client kit: list channels, make-new-channel, zero to one active channels, post-to-active. kit: login/sign-up, logout owen: you will likely also want "am I logged in" here kit: sure, whoami
-rw-r--r--.sqlx/query-dbe468d2a7f64a45e70dfbd76f0c34c759006d269ccccd6299c66b672076449d.json (renamed from .sqlx/query-d50791669f27ddafe83adfa1bb3b1887fe08a02a9d647f24e1ed59d9cf922a19.json)10
-rw-r--r--Cargo.lock131
-rw-r--r--Cargo.toml3
-rw-r--r--docs/api.md181
-rw-r--r--js/channel.js37
-rw-r--r--src/app.rs5
-rw-r--r--src/channel/app.rs32
-rw-r--r--src/channel/routes.rs68
-rw-r--r--src/cli.rs4
-rw-r--r--src/events.rs4
-rw-r--r--src/index/app.rs36
-rw-r--r--src/index/mod.rs3
-rw-r--r--src/index/routes.rs82
-rw-r--r--src/index/templates.rs127
-rw-r--r--src/lib.rs1
-rw-r--r--src/login/routes.rs73
-rw-r--r--src/repo/channel.rs9
17 files changed, 328 insertions, 478 deletions
diff --git a/.sqlx/query-d50791669f27ddafe83adfa1bb3b1887fe08a02a9d647f24e1ed59d9cf922a19.json b/.sqlx/query-dbe468d2a7f64a45e70dfbd76f0c34c759006d269ccccd6299c66b672076449d.json
index de6ab44..3db94ca 100644
--- a/.sqlx/query-d50791669f27ddafe83adfa1bb3b1887fe08a02a9d647f24e1ed59d9cf922a19.json
+++ b/.sqlx/query-dbe468d2a7f64a45e70dfbd76f0c34c759006d269ccccd6299c66b672076449d.json
@@ -1,20 +1,26 @@
{
"db_name": "SQLite",
- "query": "\n insert\n into channel (id, name)\n values ($1, $2)\n returning id as \"id: Id\"\n ",
+ "query": "\n insert\n into channel (id, name)\n values ($1, $2)\n returning id as \"id: Id\", name\n ",
"describe": {
"columns": [
{
"name": "id: Id",
"ordinal": 0,
"type_info": "Text"
+ },
+ {
+ "name": "name",
+ "ordinal": 1,
+ "type_info": "Text"
}
],
"parameters": {
"Right": 2
},
"nullable": [
+ false,
false
]
},
- "hash": "d50791669f27ddafe83adfa1bb3b1887fe08a02a9d647f24e1ed59d9cf922a19"
+ "hash": "dbe468d2a7f64a45e70dfbd76f0c34c759006d269ccccd6299c66b672076449d"
}
diff --git a/Cargo.lock b/Cargo.lock
index 5f284ab..f9d1cfe 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -769,12 +769,9 @@ dependencies = [
"clap",
"futures",
"headers",
- "maud",
- "mime_guess",
"password-hash",
"rand",
"rand_core",
- "rust-embed",
"serde",
"serde_json",
"sqlx",
@@ -1016,30 +1013,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]]
-name = "maud"
-version = "0.26.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "df518b75016b4289cdddffa1b01f2122f4a49802c93191f3133f6dc2472ebcaa"
-dependencies = [
- "axum-core",
- "http",
- "itoa",
- "maud_macros",
-]
-
-[[package]]
-name = "maud_macros"
-version = "0.26.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fa453238ec218da0af6b11fc5978d3b5c3a45ed97b722391a2a11f3306274e18"
-dependencies = [
- "proc-macro-error",
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[package]]
name = "md-5"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1062,16 +1035,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
-name = "mime_guess"
-version = "2.0.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
-dependencies = [
- "mime",
- "unicase",
-]
-
-[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1312,29 +1275,6 @@ dependencies = [
]
[[package]]
-name = "proc-macro-error"
-version = "1.0.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
-dependencies = [
- "proc-macro-error-attr",
- "proc-macro2",
- "quote",
- "version_check",
-]
-
-[[package]]
-name = "proc-macro-error-attr"
-version = "1.0.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
-dependencies = [
- "proc-macro2",
- "quote",
- "version_check",
-]
-
-[[package]]
name = "proc-macro2"
version = "1.0.86"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1412,40 +1352,6 @@ dependencies = [
]
[[package]]
-name = "rust-embed"
-version = "8.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fa66af4a4fdd5e7ebc276f115e895611a34739a9c1c01028383d612d550953c0"
-dependencies = [
- "rust-embed-impl",
- "rust-embed-utils",
- "walkdir",
-]
-
-[[package]]
-name = "rust-embed-impl"
-version = "8.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6125dbc8867951125eec87294137f4e9c2c96566e61bf72c45095a7c77761478"
-dependencies = [
- "proc-macro2",
- "quote",
- "rust-embed-utils",
- "syn",
- "walkdir",
-]
-
-[[package]]
-name = "rust-embed-utils"
-version = "8.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2e5347777e9aacb56039b0e1f28785929a8a3b709e87482e7442c72e7c12529d"
-dependencies = [
- "sha2",
- "walkdir",
-]
-
-[[package]]
name = "rustc-demangle"
version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1477,15 +1383,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"
[[package]]
-name = "same-file"
-version = "1.0.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
-dependencies = [
- "winapi-util",
-]
-
-[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2095,15 +1992,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825"
[[package]]
-name = "unicase"
-version = "2.7.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89"
-dependencies = [
- "version_check",
-]
-
-[[package]]
name = "unicode-bidi"
version = "0.3.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2175,16 +2063,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
-name = "walkdir"
-version = "2.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
-dependencies = [
- "same-file",
- "winapi-util",
-]
-
-[[package]]
name = "wasi"
version = "0.11.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2262,15 +2140,6 @@ dependencies = [
]
[[package]]
-name = "winapi-util"
-version = "0.1.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
-dependencies = [
- "windows-sys 0.59.0",
-]
-
-[[package]]
name = "windows-core"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
index eddd594..a80aed0 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -12,12 +12,9 @@ chrono = { version = "0.4.38", features = ["serde"] }
clap = { version = "4.5.17", features = ["derive", "env"] }
futures = "0.3.30"
headers = "0.4.0"
-maud = { version = "0.26.0", features = ["axum"] }
-mime_guess = "2.0.5"
password-hash = { version = "0.5.0", features = ["std"] }
rand = "0.8.5"
rand_core = { version = "0.6.4", features = ["getrandom"] }
-rust-embed = "8.5.0"
serde = { version = "1.0.210", features = ["derive"] }
serde_json = "1.0.128"
sqlx = { version = "0.8.2", features = ["chrono", "runtime-tokio", "sqlite"] }
diff --git a/docs/api.md b/docs/api.md
new file mode 100644
index 0000000..91e4148
--- /dev/null
+++ b/docs/api.md
@@ -0,0 +1,181 @@
+# the hi api
+
+## The basics
+
+The `hi` API is exposed as HTTP endpoints that accept JSON and return JSON on success, with a few exceptions noted below.
+
+On errors, the response body is freeform text and is meant to be shown to the user, logged, or otherwise handled. Programmatic action should rely on the status code, as documented.
+
+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.
+
+## Authentication
+
+Other than where noted below, all endpoints require authentication.
+
+To authenticate a request, send a `cookie: identity=YOUR TOKEN HERE` header in the request. Tokens can be obtained via the `/api/auth/login` endpoint. This authentication protocol is intended to integrate with browsers and browser-like environments, where the browser handles cookie headers automatically.
+
+If the token is not valid or has expired, then `hi` will send back a 401 Unauthorized response, instead of the responses documented for the endpoint the request was intended for.
+
+## Endpoints
+
+### `GET /api/boot`
+
+Returns information needed to boot the client. Also the recommended way to check whether the current `identity` cookie is valid, and what login it authenticates.
+
+#### On success
+
+```json
+{
+ "login": {
+ "name": "example username",
+ "id": "L1234abcd",
+ }
+}
+```
+
+### `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.**
+
+#### 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 this login. This cookie must be presented in future requests, and will authenticate the associated login.
+
+The cookie will expire if it is not used regularly. (As of this writing, identity cookies expire seven days after their last use, but this time period may change.)
+
+#### Authentication failures
+
+If the login already exists, and the provided password is different from the one used to create the login, then this will return a 401 Unauthorized response.
+
+### `POST /api/auth/logout`
+
+Invalidates the identity token, logging the user out.
+
+#### Request
+
+```json
+{}
+```
+
+#### On success
+
+This endpoint returns a 204 No Content response on success, with a `Set-Cookie` header that clears the `identity` cookie. Even if this header is not processed, the cookie provided in the request is invalidated and will not authenticate future requests.
+
+### `GET /api/channels`
+
+Lists channels.
+
+#### On success
+
+Responds with a list of channel objects, one per channel:
+
+```json
+[
+ {
+ "name": "nonsense and such",
+ "id": "C1234abcd",
+ }
+]
+```
+
+### `POST /api/channels`
+
+Creates a channel.
+
+#### Request
+
+```json
+{
+ "name": "a unique channel name"
+}
+```
+
+#### On succeess
+
+```json
+{
+ "name": "a unique channel name",
+ "id": "C9876cyyz"
+}
+```
+
+#### On duplicate channel name
+
+Channel names must be unique. If a channel with the same name already exists, this will return a 400 Bad Request error.
+
+### `POST /api/channels/:channel`
+
+Sends a chat message to a channel. It will be relayed to clients subscribed to the channel's events, and recorded for replay.
+
+The `:channel` placeholder must be a channel ID, as returned by `GET /api/channels` or `POST /api/channels`.
+
+#### Request
+
+```json
+{
+ "message": "my amazing thoughts, by bob"
+}
+```
+
+#### On success
+
+Once the message is accepted, this will return a 202 Accepted response. The message will be delivered to subscribers asynchronously, as soon as is feasible.
+
+#### Invalid channel ID
+
+If the channel ID is not valid, this will return a 404 Not Found response.
+
+### `GET /api/events`
+
+Subscribes to events. This endpoint returns an `application/event-stream` response, and is intended for use with the `EventSource` browser API. Events will be delivered on this stream as they occur, and the request will remain open to deliver events.
+
+The returned stream may terminate, to limit the number of outstanding messages held by the server. Clients can and should repeat the request, using the `Last-Event-Id` header to resume from where they left off. Events will be replayed from that point, and the stream will resume.
+
+#### Query parameters
+
+This endpoint accepts the following query parameters:
+
+* `channel`: a channel ID. Events for this channel will be included in the response. This parameter may be provided multiple times.
+
+Browsers generally limit the number of open connections, often to embarrassingly low limits. Clients should subscribe to multiple streams in a single request, and should not subscribe to each stream individually.
+
+Requests without parameters will be successful, but will return an empty stream.
+
+(If you're wondering: it has to be query parameters or something equivalent to it, since `EventSource` can only issue `GET` requests.)
+
+#### Request headers
+
+This endpoint accepts an optional `Last-Event-Id` header for resuming an interrupted stream. If this header is provided, it must be set to the `id` of the last event processed by the client. The new stream will resume immediately after that event. If this header is omitted, then the stream will start from the beginning.
+
+If you're using a browser's `EventSource` API, this is handled for you automatically.
+
+#### On success
+
+The returned event stream is a sequence of events:
+
+```json
+id: 1234
+data: {
+data: "channel": "C9876cyyz",
+data: "id": "Mabcd1234",
+data: "sender": {
+data: "id": "L1234abcd",
+data: "name": "example username"
+data: },
+data: "body": "my amazing thoughts, by bob",
+data: "sent_at": "2024-09-19T02:30:50.915462Z"
+data: }
+```
+
+The event `id` (`1234`, in the example above) is used to support resuming the stream after an interruption. See the "Request headers" section, above, for details.
+
+The `"id"` field uniquely identifies the message in related API requests, but is not used to resume the stream.
diff --git a/js/channel.js b/js/channel.js
deleted file mode 100644
index f994ada..0000000
--- a/js/channel.js
+++ /dev/null
@@ -1,37 +0,0 @@
-"use strict";
-
-function ready(callback) {
- if (document.readyState === 'loading') {
- document.addEventListener('DOMContentLoaded', callback);
- } else {
- callback();
- }
-}
-
-ready(() => {
- let channel = document.querySelector('meta[name=channel]').content;
- let template = document.querySelector('#message').content;
-
- document.querySelectorAll('link[rel=events]').forEach(elem => {
- let url = elem.getAttribute("href");
- let source = new EventSource(url);
- source.addEventListener('message', message => {
- let body = JSON.parse(message.data);
-
- if (body.channel === channel) {
- document.querySelectorAll('.messages').forEach(elem => {
- let message = template.cloneNode(true);
-
- message.querySelectorAll('.sender')
- .forEach(elem => elem.textContent = body.sender.name);
- message.querySelectorAll('.message')
- .forEach(elem => elem.textContent = body.body);
- message.querySelectorAll('.sent_at')
- .forEach(elem => elem.textContent = body.sent_at);
-
- message.childNodes.forEach(node => elem.appendChild(node));
- });
- }
- });
- });
-})
diff --git a/src/app.rs b/src/app.rs
index 1177c5e..0823a0c 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -2,7 +2,6 @@ use sqlx::sqlite::SqlitePool;
use crate::{
channel::app::{Broadcaster, Channels},
- index::app::Index,
login::app::Logins,
};
@@ -20,10 +19,6 @@ impl App {
}
impl App {
- pub const fn index(&self) -> Index {
- Index::new(&self.db)
- }
-
pub const fn logins(&self) -> Logins {
Logins::new(&self.db)
}
diff --git a/src/channel/app.rs b/src/channel/app.rs
index 2f37878..8ae0c3c 100644
--- a/src/channel/app.rs
+++ b/src/channel/app.rs
@@ -31,13 +31,17 @@ impl<'a> Channels<'a> {
Self { db, broadcaster }
}
- pub async fn create(&self, name: &str) -> Result<(), InternalError> {
+ pub async fn create(&self, name: &str) -> Result<Channel, CreateError> {
let mut tx = self.db.begin().await?;
- let channel = tx.channels().create(name).await?;
- self.broadcaster.register_channel(&channel);
+ let channel = tx
+ .channels()
+ .create(name)
+ .await
+ .map_err(|err| CreateError::from_duplicate_name(err, name))?;
+ self.broadcaster.register_channel(&channel.id);
tx.commit().await?;
- Ok(())
+ Ok(channel)
}
pub async fn all(&self) -> Result<Vec<Channel>, InternalError> {
@@ -122,6 +126,26 @@ impl<'a> Channels<'a> {
}
#[derive(Debug, thiserror::Error)]
+pub enum CreateError {
+ #[error("channel named {0} already exists")]
+ DuplicateName(String),
+ #[error(transparent)]
+ DatabaseError(#[from] sqlx::Error),
+}
+
+impl CreateError {
+ fn from_duplicate_name(error: sqlx::Error, name: &str) -> Self {
+ if let Some(error) = error.as_database_error() {
+ if error.is_unique_violation() {
+ return Self::DuplicateName(name.into());
+ }
+ }
+
+ Self::from(error)
+ }
+}
+
+#[derive(Debug, thiserror::Error)]
pub enum InternalError {
#[error(transparent)]
DatabaseError(#[from] sqlx::Error),
diff --git a/src/channel/routes.rs b/src/channel/routes.rs
index 847e0b4..383ec58 100644
--- a/src/channel/routes.rs
+++ b/src/channel/routes.rs
@@ -1,23 +1,43 @@
use axum::{
- extract::{Form, Path, State},
+ extract::{Json, Path, State},
http::StatusCode,
- response::{IntoResponse, Redirect, Response},
- routing::post,
+ response::{IntoResponse, Response},
+ routing::{get, post},
Router,
};
-use super::app::EventsError;
+use super::app::{self, EventsError};
use crate::{
app::App,
clock::RequestedAt,
error::InternalError,
- repo::{channel, login::Login},
+ repo::{
+ channel::{self, Channel},
+ login::Login,
+ },
};
pub fn router() -> Router<App> {
Router::new()
- .route("/create", post(on_create))
- .route("/:channel/send", post(on_send))
+ .route("/api/channels", get(list_channels))
+ .route("/api/channels", post(on_create))
+ .route("/api/channels/:channel", post(on_send))
+}
+
+async fn list_channels(State(app): State<App>, _: Login) -> Result<Channels, InternalError> {
+ let channels = app.channels().all().await?;
+ let response = Channels(channels);
+
+ Ok(response)
+}
+
+struct Channels(Vec<Channel>);
+
+impl IntoResponse for Channels {
+ fn into_response(self) -> Response {
+ let Self(channels) = self;
+ Json(channels).into_response()
+ }
}
#[derive(serde::Deserialize)]
@@ -28,11 +48,29 @@ struct CreateRequest {
async fn on_create(
State(app): State<App>,
_: Login, // requires auth, but doesn't actually care who you are
- Form(form): Form<CreateRequest>,
-) -> Result<impl IntoResponse, InternalError> {
- app.channels().create(&form.name).await?;
+ Json(form): Json<CreateRequest>,
+) -> Result<Json<Channel>, CreateError> {
+ let channel = app
+ .channels()
+ .create(&form.name)
+ .await
+ .map_err(CreateError)?;
- Ok(Redirect::to("/"))
+ Ok(Json(channel))
+}
+
+struct CreateError(app::CreateError);
+
+impl IntoResponse for CreateError {
+ fn into_response(self) -> Response {
+ let Self(error) = self;
+ match error {
+ duplicate @ app::CreateError::DuplicateName(_) => {
+ (StatusCode::BAD_REQUEST, duplicate.to_string()).into_response()
+ }
+ other => InternalError::from(other).into_response(),
+ }
+ }
}
#[derive(serde::Deserialize)]
@@ -45,15 +83,15 @@ async fn on_send(
RequestedAt(sent_at): RequestedAt,
State(app): State<App>,
login: Login,
- Form(form): Form<SendRequest>,
-) -> Result<impl IntoResponse, ErrorResponse> {
+ Json(form): Json<SendRequest>,
+) -> Result<StatusCode, ErrorResponse> {
app.channels()
.send(&login, &channel, &form.message, &sent_at)
.await
// Could impl `From` here, but it's more code and this is used once.
.map_err(ErrorResponse)?;
- Ok(Redirect::to(&format!("/{}", channel)))
+ Ok(StatusCode::ACCEPTED)
}
struct ErrorResponse(EventsError);
@@ -65,7 +103,7 @@ impl IntoResponse for ErrorResponse {
not_found @ EventsError::ChannelNotFound(_) => {
(StatusCode::NOT_FOUND, not_found.to_string()).into_response()
}
- EventsError::DatabaseError(error) => InternalError::from(error).into_response(),
+ other => InternalError::from(other).into_response(),
}
}
}
diff --git a/src/cli.rs b/src/cli.rs
index cc49001..9d0606d 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -6,7 +6,7 @@ use clap::Parser;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
use tokio::net;
-use crate::{app::App, channel, clock, events, index, login};
+use crate::{app::App, channel, clock, events, login};
pub type Result<T> = std::result::Result<T, Error>;
@@ -66,7 +66,7 @@ impl Args {
fn routers() -> Router<App> {
[channel::router(), events::router(), login::router()]
.into_iter()
- .fold(index::routes::router(), Router::merge)
+ .fold(Router::default(), Router::merge)
}
fn started_msg(listener: &net::TcpListener) -> io::Result<String> {
diff --git a/src/events.rs b/src/events.rs
index 2f5e145..9cbb0a3 100644
--- a/src/events.rs
+++ b/src/events.rs
@@ -22,7 +22,7 @@ use crate::{
};
pub fn router() -> Router<App> {
- Router::new().route("/events", get(on_events))
+ Router::new().route("/api/events", get(on_events))
}
#[derive(serde::Deserialize)]
@@ -104,7 +104,7 @@ impl IntoResponse for ErrorResponse {
}
fn to_sse_event(event: ChannelEvent<broadcast::Message>) -> Result<sse::Event, serde_json::Error> {
- let data = serde_json::to_string(&event)?;
+ let data = serde_json::to_string_pretty(&event)?;
let event = sse::Event::default()
.id(event
.message
diff --git a/src/index/app.rs b/src/index/app.rs
deleted file mode 100644
index 4f234ee..0000000
--- a/src/index/app.rs
+++ /dev/null
@@ -1,36 +0,0 @@
-use sqlx::sqlite::SqlitePool;
-
-use crate::repo::{
- channel::{self, Channel, Provider as _},
- error::NotFound as _,
-};
-
-pub struct Index<'a> {
- db: &'a SqlitePool,
-}
-
-impl<'a> Index<'a> {
- pub const fn new(db: &'a SqlitePool) -> Self {
- Self { db }
- }
-
- pub async fn channel(&self, channel: &channel::Id) -> Result<Channel, Error> {
- let mut tx = self.db.begin().await?;
- let channel = tx
- .channels()
- .by_id(channel)
- .await
- .not_found(|| Error::ChannelNotFound(channel.clone()))?;
- tx.commit().await?;
-
- Ok(channel)
- }
-}
-
-#[derive(Debug, thiserror::Error)]
-pub enum Error {
- #[error("channel {0} not found")]
- ChannelNotFound(channel::Id),
- #[error(transparent)]
- DatabaseError(#[from] sqlx::Error),
-}
diff --git a/src/index/mod.rs b/src/index/mod.rs
deleted file mode 100644
index 0d89a50..0000000
--- a/src/index/mod.rs
+++ /dev/null
@@ -1,3 +0,0 @@
-pub mod app;
-pub mod routes;
-mod templates;
diff --git a/src/index/routes.rs b/src/index/routes.rs
deleted file mode 100644
index 37f6dc9..0000000
--- a/src/index/routes.rs
+++ /dev/null
@@ -1,82 +0,0 @@
-use axum::{
- extract::{Path, State},
- http::{header, StatusCode},
- response::{IntoResponse, Response},
- routing::get,
- Router,
-};
-use maud::Markup;
-
-use super::{app, templates};
-use crate::{
- app::App,
- error::InternalError,
- repo::{channel, login::Login},
-};
-
-async fn index(State(app): State<App>, login: Option<Login>) -> Result<Markup, InternalError> {
- match login {
- None => Ok(templates::unauthenticated()),
- Some(login) => index_authenticated(app, login).await,
- }
-}
-
-async fn index_authenticated(app: App, login: Login) -> Result<Markup, InternalError> {
- let channels = app.channels().all().await?;
-
- Ok(templates::authenticated(login, &channels))
-}
-
-#[derive(rust_embed::Embed)]
-#[folder = "js"]
-struct Js;
-
-async fn js(Path(path): Path<String>) -> impl IntoResponse {
- let mime = mime_guess::from_path(&path).first_or_octet_stream();
-
- match Js::get(&path) {
- Some(file) => (
- StatusCode::OK,
- [(header::CONTENT_TYPE, mime.as_ref())],
- file.data,
- )
- .into_response(),
- None => (StatusCode::NOT_FOUND, "").into_response(),
- }
-}
-
-async fn channel(
- State(app): State<App>,
- _: Login,
- Path(channel): Path<channel::Id>,
-) -> Result<Markup, ChannelError> {
- let channel = app
- .index()
- .channel(&channel)
- .await
- // impl From would work here, but it'd take more code.
- .map_err(ChannelError)?;
- Ok(templates::channel(&channel))
-}
-
-#[derive(Debug)]
-struct ChannelError(app::Error);
-
-impl IntoResponse for ChannelError {
- fn into_response(self) -> Response {
- let Self(error) = self;
- match error {
- not_found @ app::Error::ChannelNotFound(_) => {
- (StatusCode::NOT_FOUND, not_found.to_string()).into_response()
- }
- app::Error::DatabaseError(error) => InternalError::from(error).into_response(),
- }
- }
-}
-
-pub fn router() -> Router<App> {
- Router::new()
- .route("/", get(index))
- .route("/js/*path", get(js))
- .route("/:channel", get(channel))
-}
diff --git a/src/index/templates.rs b/src/index/templates.rs
deleted file mode 100644
index d56972c..0000000
--- a/src/index/templates.rs
+++ /dev/null
@@ -1,127 +0,0 @@
-use maud::{html, Markup, DOCTYPE};
-
-use crate::repo::{channel::Channel, login::Login};
-
-pub fn authenticated<'c>(login: Login, channels: impl IntoIterator<Item = &'c Channel>) -> Markup {
- html! {
- (DOCTYPE)
- head {
- title { "hi" }
- }
- body {
- section {
- (channel_list(channels))
- (create_channel())
- }
- section {
- (logout_form(&login.name))
- }
- }
- }
-}
-
-fn channel_list<'c>(channels: impl IntoIterator<Item = &'c Channel>) -> Markup {
- html! {
- ul {
- @for channel in channels {
- (channel_list_entry(&channel))
- }
- }
- }
-}
-
-fn channel_list_entry(channel: &Channel) -> Markup {
- html! {
- li {
- a href=(format!("/{}", channel.id)) {
- (channel.name) " (" (channel.id) ")"
- }
- }
- }
-}
-
-fn create_channel() -> Markup {
- html! {
- form action="/create" method="post" {
- label {
- "name"
- input name="name" type="text" {}
- }
- button {
- "start channel"
- }
- }
- }
-}
-
-fn logout_form(name: &str) -> Markup {
- html! {
- form action="/logout" method="post" {
- button { "bye, " (name) }
- }
- }
-}
-
-pub fn unauthenticated() -> Markup {
- html! {
- (DOCTYPE)
- head {
- title { "hi" }
- }
- body {
- (login_form())
- }
- }
-}
-
-fn login_form() -> Markup {
- html! {
- form action="/login" method="post" {
- label {
- "login"
- input name="name" type="text" {}
- }
- label {
- "password"
- input name="password" type="password" {}
- }
- button { "hi" }
- }
- }
-}
-
-pub fn channel(channel: &Channel) -> Markup {
- html! {
- (DOCTYPE)
- head {
- title { "hi - " (channel.name) }
- script src="/js/channel.js" {}
- template id="message" {
- p {
- span.sender { "(sender)" }
- ": "
- span.message { "(message)" }
- " (at "
- span.sent_at { "(sent_at)" }
- ")" }
- }
- meta name="channel" content=(channel.id) {}
- link rel="events" href=(format!("/events?channel={}", channel.id)) {}
- }
- body {
- section class="messages" {}
- section {
- form action=(format!("/{}/send", channel.id)) method="post" {
- label {
- "message"
- input name="message" type="text" autofocus {}
- }
- button { "send" }
- }
- }
- section {
- a href="/" { "back" }
- }
- }
- }
-}
diff --git a/src/lib.rs b/src/lib.rs
index 8b6a78f..609142a 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -6,7 +6,6 @@ mod error;
mod events;
mod header;
mod id;
-mod index;
mod login;
mod password;
mod repo;
diff --git a/src/login/routes.rs b/src/login/routes.rs
index 1ed61ce..3052147 100644
--- a/src/login/routes.rs
+++ b/src/login/routes.rs
@@ -1,19 +1,35 @@
use axum::{
- extract::{Form, State},
+ extract::{Json, State},
http::StatusCode,
- response::{IntoResponse, Redirect, Response},
- routing::post,
+ response::{IntoResponse, Response},
+ routing::{get, post},
Router,
};
-use crate::{app::App, clock::RequestedAt, error::InternalError};
+use crate::{app::App, clock::RequestedAt, error::InternalError, repo::login::Login};
use super::{app, extract::IdentityToken};
pub fn router() -> Router<App> {
Router::new()
- .route("/login", post(on_login))
- .route("/logout", post(on_logout))
+ .route("/api/boot", get(boot))
+ .route("/api/auth/login", post(on_login))
+ .route("/api/auth/logout", post(on_logout))
+}
+
+async fn boot(login: Login) -> Boot {
+ Boot { login }
+}
+
+#[derive(serde::Serialize)]
+struct Boot {
+ login: Login,
+}
+
+impl IntoResponse for Boot {
+ fn into_response(self) -> Response {
+ Json(self).into_response()
+ }
}
#[derive(serde::Deserialize)]
@@ -26,24 +42,15 @@ async fn on_login(
State(app): State<App>,
RequestedAt(now): RequestedAt,
identity: IdentityToken,
- Form(form): Form<LoginRequest>,
-) -> Result<LoginSuccess, LoginError> {
+ Json(request): Json<LoginRequest>,
+) -> Result<(IdentityToken, StatusCode), LoginError> {
let token = app
.logins()
- .login(&form.name, &form.password, now)
+ .login(&request.name, &request.password, now)
.await
.map_err(LoginError)?;
let identity = identity.set(&token);
- Ok(LoginSuccess(identity))
-}
-
-struct LoginSuccess(IdentityToken);
-
-impl IntoResponse for LoginSuccess {
- fn into_response(self) -> Response {
- let Self(identity) = self;
- (identity, Redirect::to("/")).into_response()
- }
+ Ok((identity, StatusCode::NO_CONTENT))
}
struct LoginError(app::LoginError);
@@ -55,21 +62,39 @@ impl IntoResponse for LoginError {
app::LoginError::Rejected => {
(StatusCode::UNAUTHORIZED, "invalid name or password").into_response()
}
- app::LoginError::DatabaseError(error) => InternalError::from(error).into_response(),
- app::LoginError::PasswordHashError(error) => InternalError::from(error).into_response(),
+ other => InternalError::from(other).into_response(),
}
}
}
+#[derive(serde::Deserialize)]
+struct LogoutRequest {}
+
async fn on_logout(
State(app): State<App>,
identity: IdentityToken,
-) -> Result<impl IntoResponse, InternalError> {
+ // This forces the only valid request to be `{}`, and not the infinite
+ // variation allowed when there's no body extractor.
+ Json(LogoutRequest {}): Json<LogoutRequest>,
+) -> Result<(IdentityToken, StatusCode), LogoutError> {
if let Some(secret) = identity.secret() {
- app.logins().logout(secret).await?;
+ app.logins().logout(secret).await.map_err(LogoutError)?;
}
let identity = identity.clear();
+ Ok((identity, StatusCode::NO_CONTENT))
+}
+
+struct LogoutError(app::ValidateError);
- Ok((identity, Redirect::to("/")))
+impl IntoResponse for LogoutError {
+ fn into_response(self) -> Response {
+ let Self(error) = self;
+ match error {
+ error @ app::ValidateError::InvalidToken => {
+ (StatusCode::UNAUTHORIZED, error.to_string()).into_response()
+ }
+ other => InternalError::from(other).into_response(),
+ }
+ }
}
diff --git a/src/repo/channel.rs b/src/repo/channel.rs
index 8e3a471..95516d2 100644
--- a/src/repo/channel.rs
+++ b/src/repo/channel.rs
@@ -16,7 +16,7 @@ impl<'c> Provider for Transaction<'c, Sqlite> {
pub struct Channels<'t>(&'t mut SqliteConnection);
-#[derive(Debug)]
+#[derive(Debug, serde::Serialize)]
pub struct Channel {
pub id: Id,
pub name: String,
@@ -24,15 +24,16 @@ pub struct Channel {
impl<'c> Channels<'c> {
/// Create a new channel.
- pub async fn create(&mut self, name: &str) -> Result<Id, sqlx::Error> {
+ pub async fn create(&mut self, name: &str) -> Result<Channel, sqlx::Error> {
let id = Id::generate();
- let channel = sqlx::query_scalar!(
+ let channel = sqlx::query_as!(
+ Channel,
r#"
insert
into channel (id, name)
values ($1, $2)
- returning id as "id: Id"
+ returning id as "id: Id", name
"#,
id,
name,