mirror of
https://github.com/Blah-IM/blahrs.git
synced 2025-04-30 16:21:10 +00:00
feat(webapi): impl room member listing
This commit is contained in:
parent
bc6e6c2056
commit
367f6d2a4b
7 changed files with 250 additions and 18 deletions
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Add table
Reference in a new issue