diff --git a/Cargo.lock b/Cargo.lock index 4225066..a3ebc91 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -177,6 +177,28 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-extra" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be6ea09c9b96cb5076af0de2e383bd2bc0c18f827cf1967bdd353e0b910d733" +dependencies = [ + "axum", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "serde", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "backtrace" version = "0.3.73" @@ -257,6 +279,7 @@ version = "0.0.0" dependencies = [ "anyhow", "axum", + "axum-extra", "blah", "clap", "ed25519-dalek", diff --git a/blahd/Cargo.toml b/blahd/Cargo.toml index 510fbaf..2493dc8 100644 --- a/blahd/Cargo.toml +++ b/blahd/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [dependencies] anyhow = "1" axum = { version = "0.7", features = ["tokio"] } +axum-extra = "0.9" clap = { version = "4", features = ["derive"] } ed25519-dalek = "2" futures-util = "0.3" diff --git a/blahd/docs/webapi.yaml b/blahd/docs/webapi.yaml index 995147a..feea19d 100644 --- a/blahd/docs/webapi.yaml +++ b/blahd/docs/webapi.yaml @@ -33,6 +33,9 @@ paths: description: UUID of the newly created room (ruuid). 403: description: The user does not have permission to create room. + content: + application/json: + $ref: '#/components/schemas/ApiError' /room/{ruuid}/feed.json: get: @@ -45,6 +48,9 @@ paths: $ref: 'https://www.jsonfeed.org/version/1.1/' 404: description: Room does not exist or is private. + content: + application/json: + $ref: '#/components/schemas/ApiError' /room/{ruuid}/item: get: @@ -68,6 +74,12 @@ paths: content: application/json: x-description: TODO + 404: + description: | + Room does not exist or the user does not have permission to read it. + content: + application/json: + $ref: '#/components/schemas/ApiError' post: summary: Post a chat in room {ruuid} @@ -94,10 +106,15 @@ paths: description: Created chat id (cid). 400: description: Body is invalid or fails the verification. + content: + application/json: + $ref: '#/components/schemas/ApiError' 403: - description: The user does not have permission to post in this room. - 404: - description: Room not found. + description: | + The user does not have permission to post in this room, or the room does not exist. + content: + application/json: + $ref: '#/components/schemas/ApiError' /room/{ruuid}/event: get: @@ -118,5 +135,24 @@ paths: x-description: An event stream, each event is a JSON with type WithSig 400: description: Body is invalid or fails the verification. + content: + application/json: + $ref: '#/components/schemas/ApiError' 404: description: Room not found. + content: + application/json: + $ref: '#/components/schemas/ApiError' + +components: + schemas: + ApiError: + type: object + properties: + error: + type: object + properties: + code: + type: string + message: + type: string diff --git a/blahd/src/main.rs b/blahd/src/main.rs index 819e5ea..c5ad394 100644 --- a/blahd/src/main.rs +++ b/blahd/src/main.rs @@ -6,18 +6,19 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, SystemTime}; use anyhow::{ensure, Context, Result}; -use axum::extract::{FromRef, FromRequest, FromRequestParts, Path, Query, Request, State}; -use axum::http::{header, request, StatusCode}; -use axum::response::{sse, IntoResponse, Response}; +use axum::extract::{Path, Query, State}; +use axum::http::{header, StatusCode}; +use axum::response::{sse, IntoResponse}; use axum::routing::{get, post}; -use axum::{async_trait, Json, Router}; +use axum::{Json, Router}; +use axum_extra::extract::WithRejection; use blah::types::{ - AuthPayload, ChatItem, ChatPayload, CreateRoomPayload, MemberPermission, RoomAttrs, - ServerPermission, Signee, UserKey, WithSig, + ChatItem, ChatPayload, CreateRoomPayload, MemberPermission, RoomAttrs, ServerPermission, + Signee, UserKey, WithSig, }; use ed25519_dalek::SIGNATURE_LENGTH; +use middleware::{ApiError, OptionalAuth, SignedJson}; use rusqlite::{named_params, params, OptionalExtension, Row}; -use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use tokio::sync::broadcast; use tokio_stream::StreamExt; @@ -29,6 +30,8 @@ const EVENT_QUEUE_LEN: usize = 1024; const MAX_BODY_LEN: usize = 4 << 10; // 4KiB const TIMESTAMP_TOLERENCE: u64 = 90; +#[macro_use] +mod middleware; mod utils; #[derive(Debug, clap::Parser)] @@ -93,24 +96,38 @@ impl AppState { }) } - fn verify_signed_data(&self, data: &WithSig) -> Result<()> { - data.verify().context("unsigned payload")?; + fn verify_signed_data(&self, data: &WithSig) -> Result<(), ApiError> { + let Ok(()) = data.verify() else { + return Err(error_response!( + StatusCode::BAD_REQUEST, + "invalid_signature", + "signature verification failed" + )); + }; let timestamp_diff = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .expect("after UNIX epoch") .as_secs() .abs_diff(data.signee.timestamp); - ensure!( - timestamp_diff <= TIMESTAMP_TOLERENCE, - "invalid timestamp, off by {timestamp_diff}s" - ); - ensure!( - self.used_nonces - .lock() - .unwrap() - .try_insert(data.signee.nonce), - "duplicated nonce", - ); + if timestamp_diff > TIMESTAMP_TOLERENCE { + return Err(error_response!( + StatusCode::BAD_REQUEST, + "invalid_timestamp", + "invalid timestamp, off by {timestamp_diff}s" + )); + } + if !self + .used_nonces + .lock() + .unwrap() + .try_insert(data.signee.nonce) + { + return Err(error_response!( + StatusCode::BAD_REQUEST, + "duplicated_nonce", + "duplicated nonce", + )); + } Ok(()) } } @@ -125,9 +142,9 @@ async fn main_async(opt: Cli, st: AppState) -> Result<()> { .route("/room/:ruuid/event", get(room_event)) .route("/room/:ruuid/item", get(room_get_item).post(room_post_item)) .with_state(Arc::new(st)) - .layer(tower_http::limit::RequestBodyLimitLayer::new(MAX_BODY_LEN)) // NB. This comes at last (outmost layer), so inner errors will still be wraped with // correct CORS headers. + .layer(tower_http::limit::RequestBodyLimitLayer::new(MAX_BODY_LEN)) .layer(tower_http::cors::CorsLayer::permissive()); let listener = tokio::net::TcpListener::bind(&opt.listen) @@ -143,26 +160,20 @@ async fn main_async(opt: Cli, st: AppState) -> Result<()> { Ok(()) } -fn from_db_error(err: rusqlite::Error) -> StatusCode { - match err { - rusqlite::Error::QueryReturnedNoRows => StatusCode::NOT_FOUND, - err => { - tracing::error!(%err, "database error"); - StatusCode::INTERNAL_SERVER_ERROR - } - } -} - async fn room_create( st: ArcState, SignedJson(params): SignedJson, -) -> Result, StatusCode> { +) -> Result, ApiError> { let members = ¶ms.signee.payload.members.0; if !members .iter() .any(|m| m.user == params.signee.user && m.permission == MemberPermission::ALL) { - return Err(StatusCode::BAD_REQUEST); + return Err(error_response!( + StatusCode::BAD_REQUEST, + "deserialization", + "invalid initial members", + )); } let mut conn = st.conn.lock().unwrap(); @@ -179,57 +190,56 @@ async fn room_create( Ok(perm.contains(ServerPermission::CREATE_ROOM)) }, ) - .optional() - .map_err(from_db_error)? + .optional()? else { - return Err(StatusCode::FORBIDDEN); + return Err(error_response!( + StatusCode::FORBIDDEN, + "permission_denied", + "user does not have permission to create room", + )); }; let ruuid = Uuid::new_v4(); - (|| { - let txn = conn.transaction()?; - let rid = txn.query_row( - r" - INSERT INTO `room` (`ruuid`, `title`) - VALUES (:ruuid, :title) - RETURNING `rid` - ", - named_params! { - ":ruuid": ruuid, - ":title": params.signee.payload.title, - }, - |row| row.get::<_, u64>(0), - )?; - let mut insert_user = txn.prepare( - r" - INSERT INTO `user` (`userkey`) - VALUES (?) - ON CONFLICT (`userkey`) DO NOTHING - ", - )?; - let mut insert_member = txn.prepare( - r" - INSERT INTO `room_member` (`rid`, `uid`, `permission`) - SELECT :rid, `uid`, :permission - FROM `user` - WHERE `userkey` = :userkey - ", - )?; - for member in members { - insert_user.execute(params![member.user])?; - insert_member.execute(named_params! { - ":rid": rid, - ":userkey": member.user, - ":permission": member.permission, - })?; - } - drop(insert_member); - drop(insert_user); - txn.commit()?; - Ok(()) - })() - .map_err(from_db_error)?; + let txn = conn.transaction()?; + let rid = txn.query_row( + r" + INSERT INTO `room` (`ruuid`, `title`) + VALUES (:ruuid, :title) + RETURNING `rid` + ", + named_params! { + ":ruuid": ruuid, + ":title": params.signee.payload.title, + }, + |row| row.get::<_, u64>(0), + )?; + let mut insert_user = txn.prepare( + r" + INSERT INTO `user` (`userkey`) + VALUES (?) + ON CONFLICT (`userkey`) DO NOTHING + ", + )?; + let mut insert_member = txn.prepare( + r" + INSERT INTO `room_member` (`rid`, `uid`, `permission`) + SELECT :rid, `uid`, :permission + FROM `user` + WHERE `userkey` = :userkey + ", + )?; + for member in members { + insert_user.execute(params![member.user])?; + insert_member.execute(named_params! { + ":rid": rid, + ":userkey": member.user, + ":permission": member.permission, + })?; + } + drop(insert_member); + drop(insert_user); + txn.commit()?; Ok(Json(ruuid)) } @@ -246,13 +256,12 @@ struct GetRoomItemParams { async fn room_get_item( st: ArcState, - Path(ruuid): Path, - params: Query, + WithRejection(Path(ruuid), _): WithRejection, ApiError>, + WithRejection(params, _): WithRejection, ApiError>, OptionalAuth(user): OptionalAuth, -) -> Result { +) -> Result { let (room_meta, items) = - query_room_items(&st.conn.lock().unwrap(), ruuid, user.as_ref(), ¶ms) - .map_err(from_db_error)?; + query_room_items(&st.conn.lock().unwrap(), ruuid, user.as_ref(), ¶ms)?; // TODO: This format is to-be-decided. Or do we even need this interface other than // `feed.json`? @@ -261,11 +270,10 @@ async fn room_get_item( async fn room_get_feed( st: ArcState, - Path(ruuid): Path, + WithRejection(Path(ruuid), _): WithRejection, ApiError>, params: Query, -) -> Result { - let (room_meta, items) = - query_room_items(&st.conn.lock().unwrap(), ruuid, None, ¶ms).map_err(from_db_error)?; +) -> Result { + let (room_meta, items) = query_room_items(&st.conn.lock().unwrap(), ruuid, None, ¶ms)?; let items = items .into_iter() @@ -352,7 +360,7 @@ fn get_room_if_readable( ruuid: Uuid, user: Option<&UserKey>, f: impl FnOnce(&Row<'_>) -> rusqlite::Result, -) -> rusqlite::Result { +) -> Result { conn.query_row( r" SELECT `rid`, `title`, `attrs` @@ -372,6 +380,8 @@ fn get_room_if_readable( }, f, ) + .optional()? + .ok_or_else(|| error_response!(StatusCode::NOT_FOUND, "not_found", "room not found")) } fn query_room_items( @@ -379,7 +389,7 @@ fn query_room_items( ruuid: Uuid, user: Option<&UserKey>, params: &GetRoomItemParams, -) -> rusqlite::Result<(RoomMetadata, Vec<(u64, ChatItem)>)> { +) -> Result<(RoomMetadata, Vec<(u64, ChatItem)>), ApiError> { let (rid, title, attrs) = get_room_if_readable(conn, ruuid, user, |row| { Ok(( row.get::<_, u64>("rid")?, @@ -430,76 +440,17 @@ fn query_room_items( Ok((room_meta, items)) } -/// Extractor for verified JSON payload. -#[derive(Debug)] -struct SignedJson(WithSig); - -#[async_trait] -impl FromRequest for SignedJson -where - S: Send + Sync, - T: Serialize + DeserializeOwned, - Arc: FromRef, -{ - type Rejection = Response; - - async fn from_request(req: Request, state: &S) -> Result { - let Json(data) = > as FromRequest>::from_request(req, state) - .await - .map_err(|err| err.into_response())?; - let st = >::from_ref(state); - st.verify_signed_data(&data).map_err(|err| { - tracing::debug!(%err, "unsigned payload"); - StatusCode::BAD_REQUEST.into_response() - })?; - Ok(Self(data)) - } -} - -/// Extractor for optional verified JSON authorization header. -#[derive(Debug)] -struct OptionalAuth(Option); - -#[async_trait] -impl FromRequestParts for OptionalAuth -where - S: Send + Sync, - Arc: FromRef, -{ - type Rejection = StatusCode; - - async fn from_request_parts( - parts: &mut request::Parts, - state: &S, - ) -> Result { - let Some(auth) = parts.headers.get(header::AUTHORIZATION) else { - return Ok(Self(None)); - }; - - let st = >::from_ref(state); - let ret = serde_json::from_slice::>(auth.as_bytes()) - .context("invalid JSON") - .and_then(|data| { - st.verify_signed_data(&data)?; - Ok(data.signee.user) - }); - match ret { - Ok(user) => Ok(Self(Some(user))), - Err(err) => { - tracing::debug!(%err, "invalid authorization"); - Err(StatusCode::BAD_REQUEST) - } - } - } -} - async fn room_post_item( st: ArcState, Path(ruuid): Path, SignedJson(chat): SignedJson, -) -> Result, StatusCode> { +) -> Result, ApiError> { if ruuid != chat.signee.payload.room { - return Err(StatusCode::BAD_REQUEST); + return Err(error_response!( + StatusCode::BAD_REQUEST, + "invalid_request", + "URI and payload room id mismatch", + )); } let (rid, cid) = { @@ -522,41 +473,39 @@ async fn room_post_item( }, |row| Ok((row.get::<_, u64>("rid")?, row.get::<_, u64>("uid")?)), ) - .optional() - .map_err(from_db_error)? + .optional()? else { - tracing::debug!("rejected post: unpermitted user {}", chat.signee.user); - return Err(StatusCode::FORBIDDEN); + return Err(error_response!( + StatusCode::FORBIDDEN, + "permission_denied", + "the user does not have permission to post in this room", + )); }; - let cid = conn - .query_row( - r" - INSERT INTO `room_item` (`rid`, `uid`, `timestamp`, `nonce`, `sig`, `rich_text`) - VALUES (:rid, :uid, :timestamp, :nonce, :sig, :rich_text) - RETURNING `cid` - ", - named_params! { - ":rid": rid, - ":uid": uid, - ":timestamp": chat.signee.timestamp, - ":nonce": chat.signee.nonce, - ":rich_text": &chat.signee.payload.rich_text, - ":sig": chat.sig, - }, - |row| row.get::<_, u64>(0), - ) - .map_err(from_db_error)?; + let cid = conn.query_row( + r" + INSERT INTO `room_item` (`rid`, `uid`, `timestamp`, `nonce`, `sig`, `rich_text`) + VALUES (:rid, :uid, :timestamp, :nonce, :sig, :rich_text) + RETURNING `cid` + ", + named_params! { + ":rid": rid, + ":uid": uid, + ":timestamp": chat.signee.timestamp, + ":nonce": chat.signee.nonce, + ":rich_text": &chat.signee.payload.rich_text, + ":sig": chat.sig, + }, + |row| row.get::<_, u64>(0), + )?; (rid, cid) }; - { - let mut listeners = st.room_listeners.lock().unwrap(); - if let Some(tx) = listeners.get(&rid) { - if tx.send(Arc::new(chat)).is_err() { - // Clean up because all receivers died. - listeners.remove(&rid); - } + let mut listeners = st.room_listeners.lock().unwrap(); + if let Some(tx) = listeners.get(&rid) { + if tx.send(Arc::new(chat)).is_err() { + // Clean up because all receivers died. + listeners.remove(&rid); } } @@ -570,11 +519,10 @@ async fn room_event( // But this API is kinda temporary and need a better replacement anyway. // So just only support public room for now. OptionalAuth(user): OptionalAuth, -) -> Result { +) -> Result { let rid = get_room_if_readable(&st.conn.lock().unwrap(), ruuid, user.as_ref(), |row| { row.get::<_, u64>(0) - }) - .map_err(from_db_error)?; + })?; let rx = match st.room_listeners.lock().unwrap().entry(rid) { Entry::Occupied(ent) => ent.get().subscribe(), diff --git a/blahd/src/middleware.rs b/blahd/src/middleware.rs new file mode 100644 index 0000000..b1ecd3a --- /dev/null +++ b/blahd/src/middleware.rs @@ -0,0 +1,135 @@ +use std::sync::Arc; + +use axum::extract::rejection::{JsonRejection, PathRejection, QueryRejection}; +use axum::extract::{FromRef, FromRequest, FromRequestParts, Request}; +use axum::http::{header, request, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::{async_trait, Json}; +use blah::types::{AuthPayload, UserKey, WithSig}; +use serde::de::DeserializeOwned; +use serde::Serialize; + +use crate::AppState; + +/// Error response body for json endpoints. +/// +/// Mostly following: +#[derive(Debug, Serialize)] +pub struct ApiError { + #[serde(skip)] + pub status: StatusCode, + pub code: &'static str, + pub message: String, +} + +macro_rules! error_response { + ($status:expr, $code:literal, $msg:literal $(, $msg_args:expr)* $(,)?) => { + $crate::middleware::ApiError { + status: $status, + code: $code, + message: ::std::format!($msg $(, $msg_args)*), + } + }; +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + #[derive(Serialize)] + struct Resp<'a> { + error: &'a ApiError, + } + let mut resp = Json(Resp { error: &self }).into_response(); + *resp.status_mut() = self.status; + resp + } +} + +macro_rules! define_from_deser_rejection { + ($($ty:ty, $name:literal;)*) => { + $( + impl From<$ty> for ApiError { + fn from(rej: $ty) -> Self { + error_response!( + StatusCode::BAD_REQUEST, + "deserialization", + "invalid {}: {}", + $name, + rej, + ) + } + } + )* + }; +} + +define_from_deser_rejection! { + JsonRejection, "json"; + QueryRejection, "query"; + PathRejection, "path"; +} + +impl From for ApiError { + fn from(err: rusqlite::Error) -> Self { + tracing::error!(%err, "database error"); + error_response!( + StatusCode::INTERNAL_SERVER_ERROR, + "server_error", + "internal server error", + ) + } +} + +/// Extractor for verified JSON payload. +#[derive(Debug)] +pub struct SignedJson(pub WithSig); + +#[async_trait] +impl FromRequest for SignedJson +where + S: Send + Sync, + T: Serialize + DeserializeOwned, + Arc: FromRef, +{ + type Rejection = ApiError; + + async fn from_request(req: Request, state: &S) -> Result { + let Json(data) = > as FromRequest>::from_request(req, state).await?; + let st = >::from_ref(state); + st.verify_signed_data(&data)?; + Ok(Self(data)) + } +} + +/// Extractor for optional verified JSON authorization header. +#[derive(Debug)] +pub struct OptionalAuth(pub Option); + +#[async_trait] +impl FromRequestParts for OptionalAuth +where + S: Send + Sync, + Arc: FromRef, +{ + type Rejection = ApiError; + + async fn from_request_parts( + parts: &mut request::Parts, + state: &S, + ) -> Result { + let Some(auth) = parts.headers.get(header::AUTHORIZATION) else { + return Ok(Self(None)); + }; + + let st = >::from_ref(state); + let data = + serde_json::from_slice::>(auth.as_bytes()).map_err(|err| { + error_response!( + StatusCode::BAD_REQUEST, + "deserialization", + "invalid authorization header: {err}", + ) + })?; + st.verify_signed_data(&data)?; + Ok(Self(Some(data.signee.user))) + } +} diff --git a/pages/main.js b/pages/main.js index fc18c8e..d0524a5 100644 --- a/pages/main.js +++ b/pages/main.js @@ -177,12 +177,12 @@ async function connectRoom(url) { }, }, ) - .then((resp) => { - if (!resp.ok) throw new Error(`status ${resp.status} ${resp.statusText}`); - return resp.json(); + .then(async (resp) => { + return [resp.status, await resp.json()]; }) // TODO: This response format is to-be-decided. - .then(async (json) => { + .then(async ([status, json]) => { + if (status !== 200) throw new Error(`status ${status}: ${json.error.message}`); const [{ title }, items] = json document.title = `room: ${title}` items.reverse(); @@ -256,7 +256,10 @@ async function postChat(text) { 'Content-Type': 'application/json', }, }); - if (!resp.ok) throw new Error(`status ${resp.status} ${resp.statusText}`); + if (!resp.ok) { + const errResp = await resp.json(); + throw new Error(`status ${resp.status}: ${errResp.error.message}`); + } chatInput.value = ''; } catch (e) { console.error(e);