Impl global room listing at /room

This commit is contained in:
oxalica 2024-09-03 03:13:05 -04:00
parent a9d5eb9631
commit 2b6fbe8794
4 changed files with 180 additions and 8 deletions

View file

@ -16,6 +16,46 @@ paths:
interested in (eg. chat from joined rooms). interested in (eg. chat from joined rooms).
The message has type `Outgoing` in `blahd/src/ws.rs`. 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<AuthPayload>
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: /room/create:
post: post:
summary: Create a new room summary: Create a new room
@ -196,9 +236,24 @@ components:
message: message:
type: string 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: RoomMetadata:
type: object type: object
properties: properties:
ruuid:
type: string
title: title:
type: string type: string
attrs: attrs:

View file

@ -21,6 +21,8 @@ CREATE TABLE IF NOT EXISTS `room_member` (
PRIMARY KEY (`rid`, `uid`) PRIMARY KEY (`rid`, `uid`)
) STRICT; ) STRICT;
CREATE INDEX IF NOT EXISTS `member_room` ON `room_member` (`uid` ASC, `rid` ASC);
CREATE TABLE IF NOT EXISTS `room_item` ( CREATE TABLE IF NOT EXISTS `room_item` (
`cid` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, `cid` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
`rid` INTEGER NOT NULL REFERENCES `room` ON DELETE CASCADE, `rid` INTEGER NOT NULL REFERENCES `room` ON DELETE CASCADE,

View file

@ -1,3 +1,4 @@
use std::num::NonZeroUsize;
use std::path::PathBuf; use std::path::PathBuf;
use std::time::Duration; use std::time::Duration;
@ -28,8 +29,8 @@ pub struct ServerConfig {
pub listen: String, pub listen: String,
pub base_url: Url, pub base_url: Url,
#[serde_inline_default(1024)] #[serde_inline_default(1024.try_into().unwrap())]
pub max_page_len: usize, pub max_page_len: NonZeroUsize,
#[serde_inline_default(4096)] // 4KiB #[serde_inline_default(4096)] // 4KiB
pub max_request_len: usize, pub max_request_len: usize,

View file

@ -18,7 +18,7 @@ use blah::types::{
use config::Config; use config::Config;
use ed25519_dalek::SIGNATURE_LENGTH; use ed25519_dalek::SIGNATURE_LENGTH;
use middleware::{ApiError, OptionalAuth, SignedJson}; 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 serde::{Deserialize, Serialize};
use url::Url; use url::Url;
use utils::ExpiringSet; use utils::ExpiringSet;
@ -149,6 +149,7 @@ async fn main_async(st: AppState) -> Result<()> {
let app = Router::new() let app = Router::new()
.route("/ws", get(handle_ws)) .route("/ws", get(handle_ws))
.route("/room", get(room_list))
.route("/room/create", post(room_create)) .route("/room/create", post(room_create))
.route("/room/:ruuid", get(room_get_metadata)) .route("/room/:ruuid", get(room_get_metadata))
// NB. Sync with `feed_url` and `next_url` generation. // 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<RoomMetadata>,
#[serde(skip_serializing_if = "Option::is_none")]
skip_token: Option<u64>,
}
#[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<u64>,
top: Option<NonZeroUsize>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
enum ListRoomFilter {
Public,
Joined,
}
async fn room_list(
st: ArcState,
WithRejection(params, _): WithRejection<Query<ListRoomParams>, ApiError>,
OptionalAuth(user): OptionalAuth,
) -> Result<Json<RoomList>, 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<RoomList, ApiError> {
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::<Result<Vec<_>, _>>()?;
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( async fn room_create(
st: ArcState, st: ArcState,
SignedJson(params): SignedJson<CreateRoomPayload>, SignedJson(params): SignedJson<CreateRoomPayload>,
@ -293,6 +396,15 @@ struct Pagination {
top: Option<NonZeroUsize>, top: Option<NonZeroUsize>,
} }
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)] #[derive(Debug, Serialize)]
struct RoomItems { struct RoomItems {
items: Vec<ChatItem>, items: Vec<ChatItem>,
@ -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( async fn room_get_feed(
@ -436,6 +552,7 @@ struct FeedItemExtra {
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct RoomMetadata { pub struct RoomMetadata {
pub ruuid: Uuid,
pub title: String, pub title: String,
pub attrs: RoomAttrs, pub attrs: RoomAttrs,
} }
@ -479,10 +596,7 @@ fn query_room_items(
ruuid: Uuid, ruuid: Uuid,
pagination: Pagination, pagination: Pagination,
) -> Result<(Vec<ChatItemWithId>, Option<u64>), ApiError> { ) -> Result<(Vec<ChatItemWithId>, Option<u64>), ApiError> {
let page_len = pagination let page_len = pagination.effective_page_len(st);
.top
.map_or(usize::MAX, |n| n.get())
.min(st.config.server.max_page_len);
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
r" r"
SELECT `cid`, `timestamp`, `nonce`, `sig`, `userkey`, `sig`, `rich_text` SELECT `cid`, `timestamp`, `nonce`, `sig`, `userkey`, `sig`, `rich_text`