diff --git a/blah-types/src/msg.rs b/blah-types/src/msg.rs index 0c3f81c..f2fc220 100644 --- a/blah-types/src/msg.rs +++ b/blah-types/src/msg.rs @@ -376,9 +376,10 @@ bitflags::bitflags! { const POST_CHAT = 1 << 0; const ADD_MEMBER = 1 << 1; const DELETE_ROOM = 1 << 2; + const LIST_MEMBERS = 1 << 3; const MAX_SELF_ADD = Self::POST_CHAT.bits(); - const MAX_PEER_CHAT = Self::POST_CHAT.bits() | Self::DELETE_ROOM.bits(); + const MAX_PEER_CHAT = Self::POST_CHAT.bits() | Self::DELETE_ROOM.bits() | Self::LIST_MEMBERS.bits(); const ALL = !0; } diff --git a/blahd/src/database.rs b/blahd/src/database.rs index c2a64d4..6e3a033 100644 --- a/blahd/src/database.rs +++ b/blahd/src/database.rs @@ -242,19 +242,26 @@ pub trait TransactionOps { .ok_or(ApiError::RoomNotFound) } - // FIXME: Eliminate this. - // Currently broadcasting msgs requires traversing over all members. - fn list_room_members(&self, rid: Id) -> Result> { + fn list_room_members( + &self, + rid: Id, + start_uid: Id, + page_len: Option>, + ) -> Result> { + let page_len = page_len.map_or(-1i64, |v| v.get().into()); prepare_cached_and_bind!( self.conn(), r" - SELECT `uid` + SELECT `uid`, `id_key`, `room_member`.`permission`, `last_seen_cid` FROM `room_member` - WHERE `rid` = :rid + JOIN `user` USING (`uid`) + WHERE `rid` = :rid AND + `uid` > :start_uid + LIMIT :page_len " ) .raw_query() - .mapped(|row| row.get::<_, i64>(0)) + .mapped(|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))) .collect::>>() .map_err(Into::into) } diff --git a/blahd/src/lib.rs b/blahd/src/lib.rs index 23c68f3..f6ffebd 100644 --- a/blahd/src/lib.rs +++ b/blahd/src/lib.rs @@ -17,7 +17,7 @@ use blah_types::msg::{ SignedChatMsgWithId, UserRegisterPayload, }; use blah_types::server::{RoomMetadata, ServerCapabilities, ServerMetadata, UserRegisterChallenge}; -use blah_types::{get_timestamp, Id, Signed, UserKey}; +use blah_types::{get_timestamp, Id, PubKey, Signed, UserKey}; use data_encoding::BASE64_NOPAD; use database::{Transaction, TransactionOps}; use feed::FeedData; @@ -159,6 +159,7 @@ pub fn router(st: Arc) -> Router { .route("/room/:rid/msg", get(room_msg_list).post(room_msg_post)) .route("/room/:rid/msg/:cid/seen", post(room_msg_mark_seen)) .route("/room/:rid/admin", post(room_admin)) + .route("/room/:rid/member", get(room_member_list)) .layer(tower_http::limit::RequestBodyLimitLayer::new( st.config.max_request_len, )) @@ -554,7 +555,11 @@ async fn room_msg_post( let cid = Id::gen(); txn.add_room_chat_msg(rid, uid, cid, &chat)?; - let members = txn.list_room_members(rid)?; + let members = txn + .list_room_members(rid, Id::MIN, None)? + .into_iter() + .map(|(uid, ..)| uid) + .collect::>(); Ok((cid, members)) })?; @@ -668,3 +673,64 @@ async fn room_msg_mark_seen( })?; Ok(StatusCode::NO_CONTENT) } + +// TODO: Hoist these into types crate. +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RoomMemberList { + pub members: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub skip_token: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RoomMember { + pub id_key: PubKey, + pub permission: MemberPermission, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_seen_cid: Option, +} + +async fn room_member_list( + st: ArcState, + R(Path(rid), _): RE>, + R(Query(pagination), _): RE>, + Auth(user): Auth, +) -> Result, ApiError> { + api_ensure!( + pagination.until_token.is_none(), + "untilToken is not supported for this API" + ); + + st.db.with_read(|txn| { + let (_, perm, _) = txn.get_room_member(rid, &user)?; + api_ensure!( + perm.contains(MemberPermission::LIST_MEMBERS), + ApiError::PermissionDenied("the user does not have permission to get room members"), + ); + + let page_len = pagination.effective_page_len(&st); + let mut last_uid = None; + let members = txn + .list_room_members( + rid, + pagination.skip_token.unwrap_or(Id::MIN), + Some(page_len), + )? + .into_iter() + .map(|(uid, id_key, permission, last_seen_cid)| { + last_uid = Some(Id(uid)); + RoomMember { + id_key, + permission, + last_seen_cid: (last_seen_cid != Id(0)).then_some(last_seen_cid), + } + }) + .collect::>(); + let skip_token = (members.len() as u32 == page_len.get()) + .then(|| last_uid.expect("page must not be empty")); + Ok(Json(RoomMemberList { + members, + skip_token, + })) + }) +} diff --git a/blahd/tests/webapi.rs b/blahd/tests/webapi.rs index 58994be..20445a7 100644 --- a/blahd/tests/webapi.rs +++ b/blahd/tests/webapi.rs @@ -17,7 +17,7 @@ use blah_types::msg::{ }; use blah_types::server::{RoomMetadata, ServerMetadata, UserRegisterChallenge}; use blah_types::{Id, SignExt, Signed, UserKey}; -use blahd::{AppState, Database, RoomList, RoomMsgs}; +use blahd::{AppState, Database, RoomList, RoomMember, RoomMemberList, RoomMsgs}; use ed25519_dalek::SigningKey; use expect_test::expect; use futures_util::future::BoxFuture; @@ -1566,3 +1566,61 @@ async fn event(server: Server) { assert_eq!(got2, WsEvent::Msg(chat.msg)); } } + +#[rstest] +#[tokio::test] +async fn room_member(server: Server) { + let rid = server + .create_room( + &ALICE, + RoomAttrs::PUBLIC_READABLE | RoomAttrs::PUBLIC_JOINABLE, + "public", + ) + .await + .unwrap(); + + // Authentication is required. + server + .get::(&format!("/room/{rid}/member"), None) + .await + .expect_api_err(StatusCode::UNAUTHORIZED, "unauthorized"); + + // Not a room member. + server + .get::(&format!("/room/{rid}/member"), Some(&auth(&BOB))) + .await + .expect_api_err(StatusCode::NOT_FOUND, "room_not_found"); + + server + .join_room(rid, &BOB, MemberPermission::POST_CHAT) + .await + .unwrap(); + + // No permission. + server + .get::(&format!("/room/{rid}/member"), Some(&auth(&BOB))) + .await + .expect_api_err(StatusCode::FORBIDDEN, "permission_denied"); + + // OK. + let got = server + .get::(&format!("/room/{rid}/member"), Some(&auth(&ALICE))) + .await + .unwrap(); + let expect = RoomMemberList { + members: vec![ + RoomMember { + id_key: ALICE.pubkeys.id_key.clone(), + permission: MemberPermission::ALL, + last_seen_cid: None, + }, + RoomMember { + id_key: BOB.pubkeys.id_key.clone(), + permission: MemberPermission::POST_CHAT, + last_seen_cid: None, + }, + ], + skip_token: None, + }; + assert_eq!(got, expect); +} diff --git a/docs/webapi.yaml b/docs/webapi.yaml index cd1ecee..ec49b8a 100644 --- a/docs/webapi.yaml +++ b/docs/webapi.yaml @@ -511,6 +511,56 @@ paths: schema: $ref: '#/components/schemas/ApiError' + /_blah/room/{rid}/member: + get: + summary: List room members + parameters: + - name: Authorization + in: header + required: true + description: Proof of membership. + schema: + $ref: '#/components/schemas/Signed-Auth' + + - name: top + in: query + schema: + type: string + description: + The maximum count of rooms returned in a single response. This is + only an advice and server can clamp it to a smaller value. + + - name: skipToken + in: query + schema: + type: string + description: + The page token returned from a previous list response to fetch the + next page. + + responses: + 200: + content: + application/json: + schema: + $ref: '#/components/schemas/RoomMemberList' + + 403: + description: | + The user does not have permission to get room members + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + + 404: + description: | + Room does not exist or the user is not in the room. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + # Ideally we should generate these from src, but we need to # WAIT: https://github.com/juhaku/utoipa/pull/1034 components: @@ -635,6 +685,34 @@ components: description: The token for fetching the next page. type: string + RoomMemberList: + type: object + required: + - members + properties: + members: + description: Room members in server-specified order. + type: array + items: + $ref: '#/components/schemas/RoomMember' + skip_token: + description: The token for fetching the next page. + type: string + + RoomMember: + type: object + required: + - id_key + - permission + properties: + id_key: + type: string + permission: + type: integer + format: int32 + last_seen_cid: + type: string + RichText: type: array items: diff --git a/test-frontend/index.html b/test-frontend/index.html index 00929d2..6a93cf5 100644 --- a/test-frontend/index.html +++ b/test-frontend/index.html @@ -41,7 +41,7 @@ & label { margin-left: 0; } - & > #rooms { + & > #rooms, & > #room-members { flex: 1; } } @@ -128,6 +128,8 @@ + +
diff --git a/test-frontend/main.js b/test-frontend/main.js index ede5b34..f8ef60d 100644 --- a/test-frontend/main.js +++ b/test-frontend/main.js @@ -4,7 +4,8 @@ const msgFlow = document.querySelector('#msg-flow'); const idPubkeyInput = document.querySelector('#id-pubkey'); const actPubkeyDisplay = document.querySelector('#act-pubkey'); const serverUrlInput = document.querySelector('#server-url'); -const roomsInput = document.querySelector('#rooms'); +const roomsList = document.querySelector('#rooms'); +const membersList = document.querySelector('#room-members'); const joinNewRoomInput = document.querySelector('#join-new-room'); const chatInput = document.querySelector('#chat'); @@ -25,6 +26,10 @@ function hexToBuf(hex) { return new Uint8Array(hex.match(/[\da-f]{2}/gi).map(m => parseInt(m, 16))) } +function shortenIdKey(id_key) { + return id_key.replace(/^(.{4}).*(.{4})$/, '$1…$2'); +} + function getIdPubkey() { const s = idPubkeyInput.value.trim(); if (!s.match(/^[a-zA-Z0-9]{64}$/)) { @@ -186,7 +191,7 @@ async function showChatMsg(chat) { } // TODO: The relationship of id_key and act_key is not verified. - const shortUser = chat.signee.id_key.replace(/^(.{4}).*(.{4})$/, '$1…$2'); + const shortUser = shortenIdKey(chat.signee.id_key); const time = new Date(chat.signee.timestamp * 1000).toISOString(); const el = document.createElement('div', {}); @@ -243,9 +248,10 @@ async function genAuthHeader() { async function enterRoom(rid) { log(`loading room: ${rid}`); curRoom = rid; - roomsInput.value = rid; + roomsList.value = rid; msgFlow.replaceChildren(); + membersList.replaceChildren(); let roomMetadata; try { @@ -257,6 +263,20 @@ async function enterRoom(rid) { log(`failed to get room metadata: ${err}`); } + try { + const resp = await fetch(`${apiUrl}/room/${rid}/member`, await genAuthHeader()); + const json = await resp.json(); + if (resp.status !== 200) throw new Error(`status ${resp.status}: ${json.error.message}`); + for (const { id_key, permission, last_seen_cid } of json.members) { + const el = document.createElement('option') + el.value = id_key; + el.innerText = `${shortenIdKey(id_key)} perm=${permission} last_seen=${last_seen_cid || '-'}`; + membersList.appendChild(el); + } + } catch (err) { + log(`failed to fetch members: ${err}`); + } + try { const resp = await fetch(`${apiUrl}/room/${rid}/msg`, await genAuthHeader()); const json = await resp.json(); @@ -357,10 +377,10 @@ async function loadRoomList(autoJoin) { } } - loadInto(roomsInput, 'joined') + loadInto(roomsList, 'joined') .then(async (_) => { if (autoJoin) { - const el = roomsInput.querySelector('option:nth-child(2)'); + const el = roomsList.querySelector('option:nth-child(2)'); if (el !== null) { await enterRoom(el.value); } @@ -556,8 +576,8 @@ chatInput.onkeypress = async (e) => { chatInput.focus(); } }; -roomsInput.onchange = async (_) => { - await enterRoom(roomsInput.value); +roomsList.onchange = async (_) => { + await enterRoom(roomsList.value); }; joinNewRoomInput.onchange = async (_) => { await joinRoom(joinNewRoomInput.value);