From 2b6fbe87947e7b5527ab765d9b14541db81b4484 Mon Sep 17 00:00:00 2001
From: oxalica <oxalicc@pm.me>
Date: Tue, 3 Sep 2024 03:13:05 -0400
Subject: [PATCH] Impl global room listing at `/room`

---
 blahd/docs/webapi.yaml |  55 ++++++++++++++++++
 blahd/init.sql         |   2 +
 blahd/src/config.rs    |   5 +-
 blahd/src/main.rs      | 126 +++++++++++++++++++++++++++++++++++++++--
 4 files changed, 180 insertions(+), 8 deletions(-)

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<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:
     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<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(
     st: ArcState,
     SignedJson(params): SignedJson<CreateRoomPayload>,
@@ -293,6 +396,15 @@ struct Pagination {
     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)]
 struct RoomItems {
     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(
@@ -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<ChatItemWithId>, Option<u64>), 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`