diff options
Diffstat (limited to 'src')
| -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 |
12 files changed, 139 insertions, 305 deletions
@@ -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, |
