diff options
| -rw-r--r-- | .sqlx/query-dbe468d2a7f64a45e70dfbd76f0c34c759006d269ccccd6299c66b672076449d.json (renamed from .sqlx/query-d50791669f27ddafe83adfa1bb3b1887fe08a02a9d647f24e1ed59d9cf922a19.json) | 10 | ||||
| -rw-r--r-- | Cargo.lock | 131 | ||||
| -rw-r--r-- | Cargo.toml | 3 | ||||
| -rw-r--r-- | docs/api.md | 181 | ||||
| -rw-r--r-- | js/channel.js | 37 | ||||
| -rw-r--r-- | src/app.rs | 5 | ||||
| -rw-r--r-- | src/channel/app.rs | 32 | ||||
| -rw-r--r-- | src/channel/routes.rs | 68 | ||||
| -rw-r--r-- | src/cli.rs | 4 | ||||
| -rw-r--r-- | src/events.rs | 4 | ||||
| -rw-r--r-- | src/index/app.rs | 36 | ||||
| -rw-r--r-- | src/index/mod.rs | 3 | ||||
| -rw-r--r-- | src/index/routes.rs | 82 | ||||
| -rw-r--r-- | src/index/templates.rs | 127 | ||||
| -rw-r--r-- | src/lib.rs | 1 | ||||
| -rw-r--r-- | src/login/routes.rs | 73 | ||||
| -rw-r--r-- | src/repo/channel.rs | 9 |
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" } @@ -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" @@ -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)); - }); - } - }); - }); -}) @@ -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(), } } } @@ -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" } - } - } - } -} @@ -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, |
