feat(webapi): impl room member listing

This commit is contained in:
oxalica 2024-10-01 06:31:53 -04:00
parent bc6e6c2056
commit 367f6d2a4b
7 changed files with 250 additions and 18 deletions

View file

@ -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;
}

View file

@ -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<Vec<i64>> {
fn list_room_members(
&self,
rid: Id,
start_uid: Id,
page_len: Option<NonZero<u32>>,
) -> Result<Vec<(i64, PubKey, MemberPermission, Id)>> {
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::<rusqlite::Result<Vec<_>>>()
.map_err(Into::into)
}

View file

@ -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<AppState>) -> 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::<Vec<_>>();
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<RoomMember>,
#[serde(skip_serializing_if = "Option::is_none")]
pub skip_token: Option<Id>,
}
#[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<Id>,
}
async fn room_member_list(
st: ArcState,
R(Path(rid), _): RE<Path<Id>>,
R(Query(pagination), _): RE<Query<Pagination>>,
Auth(user): Auth,
) -> Result<Json<RoomMemberList>, 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::<Vec<_>>();
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,
}))
})
}

View file

@ -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::<RoomMemberList>(&format!("/room/{rid}/member"), None)
.await
.expect_api_err(StatusCode::UNAUTHORIZED, "unauthorized");
// Not a room member.
server
.get::<RoomMemberList>(&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::<RoomMemberList>(&format!("/room/{rid}/member"), Some(&auth(&BOB)))
.await
.expect_api_err(StatusCode::FORBIDDEN, "permission_denied");
// OK.
let got = server
.get::<RoomMemberList>(&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);
}

View file

@ -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:

View file

@ -41,7 +41,7 @@
& label {
margin-left: 0;
}
& > #rooms {
& > #rooms, & > #room-members {
flex: 1;
}
}
@ -128,6 +128,8 @@
<label for="join-new-room">join new room:</label>
<select id="join-new-room"></select>
</div>
<label for="room-members">members:</label>
<select id="room-members" size="2"></select>
</div>
<div id="mainbar">

View file

@ -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);