diff --git a/blahd/docs/webapi.yaml b/blahd/docs/webapi.yaml index 26b32c3..0fc394c 100644 --- a/blahd/docs/webapi.yaml +++ b/blahd/docs/webapi.yaml @@ -16,6 +16,46 @@ paths: interested in (eg. chat from joined rooms). The message has type `Outgoing` in `blahd/src/ws.rs`. + /room: + get: + summary: List rooms on the server + parameters: + filter: + in: query + required: true + description: | + Either "public" or "joined". + For "public", it returns all public rooms on the server. + For "joined", `Authorization` must be provided and it will return + rooms user have joined. + page_len: + in: query + description: + The maximum number of items returned in each page. This is only an + advice and server can clamp it to a smaller value. + page_token: + in: query + description: + The page token returned from a previous list response to fetch the + next page. NB. Other parameters (eg. `joined` and `page_len`) + should be included (as the same value) for each page fetch. + headers: + Authorization: + description: Proof of membership for private rooms. Required if `joined` is true. + required: false + schema: + $ret: WithSig + responses: + 200: + content: + application/json: + $ref: '#/components/schema/ListRoom' + 401: + description: Missing or invalid Authorization header. + content: + application/json: + $ref: '#/components/schemas/ApiError' + /room/create: post: summary: Create a new room @@ -196,9 +236,24 @@ components: message: type: string + RoomList: + type: object + required: + - rooms + properties: + rooms: + type: array + items: + $ref: '#/components/schemas/RoomMetadata' + next_token: + type: string + description: An opaque token to fetch the next page. + RoomMetadata: type: object properties: + ruuid: + type: string title: type: string attrs: diff --git a/blahd/init.sql b/blahd/init.sql index 817e5c4..34103ac 100644 --- a/blahd/init.sql +++ b/blahd/init.sql @@ -21,6 +21,8 @@ CREATE TABLE IF NOT EXISTS `room_member` ( PRIMARY KEY (`rid`, `uid`) ) STRICT; +CREATE INDEX IF NOT EXISTS `member_room` ON `room_member` (`uid` ASC, `rid` ASC); + CREATE TABLE IF NOT EXISTS `room_item` ( `cid` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, `rid` INTEGER NOT NULL REFERENCES `room` ON DELETE CASCADE, diff --git a/blahd/src/config.rs b/blahd/src/config.rs index 38663db..64ee923 100644 --- a/blahd/src/config.rs +++ b/blahd/src/config.rs @@ -1,3 +1,4 @@ +use std::num::NonZeroUsize; use std::path::PathBuf; use std::time::Duration; @@ -28,8 +29,8 @@ pub struct ServerConfig { pub listen: String, pub base_url: Url, - #[serde_inline_default(1024)] - pub max_page_len: usize, + #[serde_inline_default(1024.try_into().unwrap())] + pub max_page_len: NonZeroUsize, #[serde_inline_default(4096)] // 4KiB pub max_request_len: usize, diff --git a/blahd/src/main.rs b/blahd/src/main.rs index 363ac67..fe7e787 100644 --- a/blahd/src/main.rs +++ b/blahd/src/main.rs @@ -18,7 +18,7 @@ use blah::types::{ use config::Config; use ed25519_dalek::SIGNATURE_LENGTH; use middleware::{ApiError, OptionalAuth, SignedJson}; -use rusqlite::{named_params, params, Connection, OptionalExtension, Row}; +use rusqlite::{named_params, params, Connection, OptionalExtension, Row, ToSql}; use serde::{Deserialize, Serialize}; use url::Url; use utils::ExpiringSet; @@ -149,6 +149,7 @@ async fn main_async(st: AppState) -> Result<()> { let app = Router::new() .route("/ws", get(handle_ws)) + .route("/room", get(room_list)) .route("/room/create", post(room_create)) .route("/room/:ruuid", get(room_get_metadata)) // NB. Sync with `feed_url` and `next_url` generation. @@ -196,6 +197,108 @@ async fn handle_ws(State(st): ArcState, ws: WebSocketUpgrade) -> Response { }) } +#[derive(Debug, Serialize)] +struct RoomList { + rooms: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + skip_token: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct ListRoomParams { + filter: ListRoomFilter, + // Workaround: serde(flatten) breaks deserialization + // See: https://github.com/nox/serde_urlencoded/issues/33 + skip_token: Option, + top: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +enum ListRoomFilter { + Public, + Joined, +} + +async fn room_list( + st: ArcState, + WithRejection(params, _): WithRejection, ApiError>, + OptionalAuth(user): OptionalAuth, +) -> Result, ApiError> { + let pagination = Pagination { + skip_token: params.skip_token, + top: params.top, + }; + let page_len = pagination.effective_page_len(&st); + let start_rid = pagination.skip_token.unwrap_or(0); + + let query = |sql: &str, params: &[(&str, &dyn ToSql)]| -> Result { + let mut last_rid = None; + let rooms = st + .conn + .lock() + .unwrap() + .prepare(sql)? + .query_map(params, |row| { + last_rid = Some(row.get::<_, u64>("rid")?); + Ok(RoomMetadata { + ruuid: row.get("ruuid")?, + title: row.get("title")?, + attrs: row.get("attrs")?, + }) + })? + .collect::, _>>()?; + let skip_token = (rooms.len() == page_len).then_some(()).and(last_rid); + Ok(RoomList { rooms, skip_token }) + }; + + match params.filter { + ListRoomFilter::Public => query( + r" + SELECT `rid`, `ruuid`, `title`, `attrs` + FROM `room` + WHERE `rid` > :start_rid AND + (`attrs` & :perm) = :perm + ORDER BY `rid` ASC + LIMIT :page_len + ", + named_params! { + ":start_rid": start_rid, + ":page_len": page_len, + ":perm": RoomAttrs::PUBLIC_READABLE, + }, + ), + ListRoomFilter::Joined => { + let Some(user) = user else { + return Err(error_response!( + StatusCode::UNAUTHORIZED, + "unauthorized", + "missing Authorization header for listing joined rooms", + )); + }; + query( + r" + SELECT `rid`, `ruuid`, `title`, `attrs` + FROM `user` + JOIN `room_member` USING (`uid`) + JOIN `room` USING (`rid`) + WHERE `userkey` = :userkey AND + `rid` > :start_rid + ORDER BY `rid` ASC + LIMIT :page_len + ", + named_params! { + ":start_rid": start_rid, + ":page_len": page_len, + ":userkey": user, + }, + ) + } + } + .map(Json) +} + async fn room_create( st: ArcState, SignedJson(params): SignedJson, @@ -293,6 +396,15 @@ struct Pagination { top: Option, } +impl Pagination { + fn effective_page_len(&self, st: &AppState) -> usize { + self.top + .unwrap_or(usize::MAX.try_into().unwrap()) + .min(st.config.server.max_page_len) + .get() + } +} + #[derive(Debug, Serialize)] struct RoomItems { items: Vec, @@ -331,7 +443,11 @@ async fn room_get_metadata( )) })?; - Ok(Json(RoomMetadata { title, attrs })) + Ok(Json(RoomMetadata { + ruuid, + title, + attrs, + })) } async fn room_get_feed( @@ -436,6 +552,7 @@ struct FeedItemExtra { #[derive(Debug, Serialize)] pub struct RoomMetadata { + pub ruuid: Uuid, pub title: String, pub attrs: RoomAttrs, } @@ -479,10 +596,7 @@ fn query_room_items( ruuid: Uuid, pagination: Pagination, ) -> Result<(Vec, Option), ApiError> { - let page_len = pagination - .top - .map_or(usize::MAX, |n| n.get()) - .min(st.config.server.max_page_len); + let page_len = pagination.effective_page_len(st); let mut stmt = conn.prepare( r" SELECT `cid`, `timestamp`, `nonce`, `sig`, `userkey`, `sig`, `rich_text`