mirror of
https://github.com/Blah-IM/blahrs.git
synced 2025-05-01 00:31:09 +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 POST_CHAT = 1 << 0;
|
||||||
const ADD_MEMBER = 1 << 1;
|
const ADD_MEMBER = 1 << 1;
|
||||||
const DELETE_ROOM = 1 << 2;
|
const DELETE_ROOM = 1 << 2;
|
||||||
|
const LIST_MEMBERS = 1 << 3;
|
||||||
|
|
||||||
const MAX_SELF_ADD = Self::POST_CHAT.bits();
|
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;
|
const ALL = !0;
|
||||||
}
|
}
|
||||||
|
|
|
@ -242,19 +242,26 @@ pub trait TransactionOps {
|
||||||
.ok_or(ApiError::RoomNotFound)
|
.ok_or(ApiError::RoomNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME: Eliminate this.
|
fn list_room_members(
|
||||||
// Currently broadcasting msgs requires traversing over all members.
|
&self,
|
||||||
fn list_room_members(&self, rid: Id) -> Result<Vec<i64>> {
|
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!(
|
prepare_cached_and_bind!(
|
||||||
self.conn(),
|
self.conn(),
|
||||||
r"
|
r"
|
||||||
SELECT `uid`
|
SELECT `uid`, `id_key`, `room_member`.`permission`, `last_seen_cid`
|
||||||
FROM `room_member`
|
FROM `room_member`
|
||||||
WHERE `rid` = :rid
|
JOIN `user` USING (`uid`)
|
||||||
|
WHERE `rid` = :rid AND
|
||||||
|
`uid` > :start_uid
|
||||||
|
LIMIT :page_len
|
||||||
"
|
"
|
||||||
)
|
)
|
||||||
.raw_query()
|
.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<_>>>()
|
.collect::<rusqlite::Result<Vec<_>>>()
|
||||||
.map_err(Into::into)
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,7 +17,7 @@ use blah_types::msg::{
|
||||||
SignedChatMsgWithId, UserRegisterPayload,
|
SignedChatMsgWithId, UserRegisterPayload,
|
||||||
};
|
};
|
||||||
use blah_types::server::{RoomMetadata, ServerCapabilities, ServerMetadata, UserRegisterChallenge};
|
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 data_encoding::BASE64_NOPAD;
|
||||||
use database::{Transaction, TransactionOps};
|
use database::{Transaction, TransactionOps};
|
||||||
use feed::FeedData;
|
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", get(room_msg_list).post(room_msg_post))
|
||||||
.route("/room/:rid/msg/:cid/seen", post(room_msg_mark_seen))
|
.route("/room/:rid/msg/:cid/seen", post(room_msg_mark_seen))
|
||||||
.route("/room/:rid/admin", post(room_admin))
|
.route("/room/:rid/admin", post(room_admin))
|
||||||
|
.route("/room/:rid/member", get(room_member_list))
|
||||||
.layer(tower_http::limit::RequestBodyLimitLayer::new(
|
.layer(tower_http::limit::RequestBodyLimitLayer::new(
|
||||||
st.config.max_request_len,
|
st.config.max_request_len,
|
||||||
))
|
))
|
||||||
|
@ -554,7 +555,11 @@ async fn room_msg_post(
|
||||||
|
|
||||||
let cid = Id::gen();
|
let cid = Id::gen();
|
||||||
txn.add_room_chat_msg(rid, uid, cid, &chat)?;
|
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))
|
Ok((cid, members))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
@ -668,3 +673,64 @@ async fn room_msg_mark_seen(
|
||||||
})?;
|
})?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
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::server::{RoomMetadata, ServerMetadata, UserRegisterChallenge};
|
||||||
use blah_types::{Id, SignExt, Signed, UserKey};
|
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 ed25519_dalek::SigningKey;
|
||||||
use expect_test::expect;
|
use expect_test::expect;
|
||||||
use futures_util::future::BoxFuture;
|
use futures_util::future::BoxFuture;
|
||||||
|
@ -1566,3 +1566,61 @@ async fn event(server: Server) {
|
||||||
assert_eq!(got2, WsEvent::Msg(chat.msg));
|
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:
|
schema:
|
||||||
$ref: '#/components/schemas/ApiError'
|
$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
|
# Ideally we should generate these from src, but we need to
|
||||||
# WAIT: https://github.com/juhaku/utoipa/pull/1034
|
# WAIT: https://github.com/juhaku/utoipa/pull/1034
|
||||||
components:
|
components:
|
||||||
|
@ -635,6 +685,34 @@ components:
|
||||||
description: The token for fetching the next page.
|
description: The token for fetching the next page.
|
||||||
type: string
|
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:
|
RichText:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
|
|
|
@ -41,7 +41,7 @@
|
||||||
& label {
|
& label {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
& > #rooms {
|
& > #rooms, & > #room-members {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -128,6 +128,8 @@
|
||||||
<label for="join-new-room">join new room:</label>
|
<label for="join-new-room">join new room:</label>
|
||||||
<select id="join-new-room"></select>
|
<select id="join-new-room"></select>
|
||||||
</div>
|
</div>
|
||||||
|
<label for="room-members">members:</label>
|
||||||
|
<select id="room-members" size="2"></select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="mainbar">
|
<div id="mainbar">
|
||||||
|
|
|
@ -4,7 +4,8 @@ const msgFlow = document.querySelector('#msg-flow');
|
||||||
const idPubkeyInput = document.querySelector('#id-pubkey');
|
const idPubkeyInput = document.querySelector('#id-pubkey');
|
||||||
const actPubkeyDisplay = document.querySelector('#act-pubkey');
|
const actPubkeyDisplay = document.querySelector('#act-pubkey');
|
||||||
const serverUrlInput = document.querySelector('#server-url');
|
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 joinNewRoomInput = document.querySelector('#join-new-room');
|
||||||
const chatInput = document.querySelector('#chat');
|
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)))
|
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() {
|
function getIdPubkey() {
|
||||||
const s = idPubkeyInput.value.trim();
|
const s = idPubkeyInput.value.trim();
|
||||||
if (!s.match(/^[a-zA-Z0-9]{64}$/)) {
|
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.
|
// 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 time = new Date(chat.signee.timestamp * 1000).toISOString();
|
||||||
|
|
||||||
const el = document.createElement('div', {});
|
const el = document.createElement('div', {});
|
||||||
|
@ -243,9 +248,10 @@ async function genAuthHeader() {
|
||||||
async function enterRoom(rid) {
|
async function enterRoom(rid) {
|
||||||
log(`loading room: ${rid}`);
|
log(`loading room: ${rid}`);
|
||||||
curRoom = rid;
|
curRoom = rid;
|
||||||
roomsInput.value = rid;
|
roomsList.value = rid;
|
||||||
|
|
||||||
msgFlow.replaceChildren();
|
msgFlow.replaceChildren();
|
||||||
|
membersList.replaceChildren();
|
||||||
|
|
||||||
let roomMetadata;
|
let roomMetadata;
|
||||||
try {
|
try {
|
||||||
|
@ -257,6 +263,20 @@ async function enterRoom(rid) {
|
||||||
log(`failed to get room metadata: ${err}`);
|
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 {
|
try {
|
||||||
const resp = await fetch(`${apiUrl}/room/${rid}/msg`, await genAuthHeader());
|
const resp = await fetch(`${apiUrl}/room/${rid}/msg`, await genAuthHeader());
|
||||||
const json = await resp.json();
|
const json = await resp.json();
|
||||||
|
@ -357,10 +377,10 @@ async function loadRoomList(autoJoin) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadInto(roomsInput, 'joined')
|
loadInto(roomsList, 'joined')
|
||||||
.then(async (_) => {
|
.then(async (_) => {
|
||||||
if (autoJoin) {
|
if (autoJoin) {
|
||||||
const el = roomsInput.querySelector('option:nth-child(2)');
|
const el = roomsList.querySelector('option:nth-child(2)');
|
||||||
if (el !== null) {
|
if (el !== null) {
|
||||||
await enterRoom(el.value);
|
await enterRoom(el.value);
|
||||||
}
|
}
|
||||||
|
@ -556,8 +576,8 @@ chatInput.onkeypress = async (e) => {
|
||||||
chatInput.focus();
|
chatInput.focus();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
roomsInput.onchange = async (_) => {
|
roomsList.onchange = async (_) => {
|
||||||
await enterRoom(roomsInput.value);
|
await enterRoom(roomsList.value);
|
||||||
};
|
};
|
||||||
joinNewRoomInput.onchange = async (_) => {
|
joinNewRoomInput.onchange = async (_) => {
|
||||||
await joinRoom(joinNewRoomInput.value);
|
await joinRoom(joinNewRoomInput.value);
|
||||||
|
|
Loading…
Add table
Reference in a new issue