feat: impl basic peer chat

This commit is contained in:
oxalica 2024-09-10 12:15:22 -04:00
parent 9e96927693
commit 1e944ead31
7 changed files with 361 additions and 64 deletions

View file

@ -28,7 +28,9 @@ impl fmt::Display for Id {
} }
impl Id { impl Id {
pub const INVALID: Self = Id(i64::MAX); pub const MIN: Self = Id(i64::MIN);
pub const MAX: Self = Id(i64::MAX);
pub const INVALID: Self = Self::MAX;
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@ -312,8 +314,8 @@ pub type ChatItem = WithSig<ChatPayload>;
pub struct RoomMetadata { pub struct RoomMetadata {
/// Room id. /// Room id.
pub rid: Id, pub rid: Id,
/// Plain text room title. /// Plain text room title. None for peer chat.
pub title: String, pub title: Option<String>,
/// Room attributes. /// Room attributes.
pub attrs: RoomAttrs, pub attrs: RoomAttrs,
@ -333,11 +335,23 @@ pub struct RoomMetadata {
/// Only available with authentication. /// Only available with authentication.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub member_permission: Option<MemberPermission>, pub member_permission: Option<MemberPermission>,
/// The peer user, if this is a peer chat room.
#[serde(skip_serializing_if = "Option::is_none")]
pub peer_user: Option<UserKey>,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "typ", rename = "create_room")] #[serde(tag = "typ")]
pub struct CreateRoomPayload { pub enum CreateRoomPayload {
#[serde(rename = "create_room")]
Group(CreateGroup),
#[serde(rename = "create_peer_chat")]
PeerChat(CreatePeerChat),
}
/// Multi-user room.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreateGroup {
pub attrs: RoomAttrs, pub attrs: RoomAttrs,
/// The initial member list. Besides invariants of `RoomMemberList`, this also must include the /// The initial member list. Besides invariants of `RoomMemberList`, this also must include the
/// room creator themselves, with the highest permission (-1). /// room creator themselves, with the highest permission (-1).
@ -345,6 +359,12 @@ pub struct CreateRoomPayload {
pub title: String, pub title: String,
} }
/// Peer-to-peer chat room with exactly two symmetric users.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreatePeerChat {
pub peer: UserKey,
}
/// A collection of room members, with these invariants: /// A collection of room members, with these invariants:
/// 1. Sorted by userkeys. /// 1. Sorted by userkeys.
/// 2. No duplicated users. /// 2. No duplicated users.
@ -408,10 +428,13 @@ pub enum RoomAdminOp {
} }
bitflags::bitflags! { bitflags::bitflags! {
/// TODO: Is this a really all about permission, or is a generic `UserFlags`?
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ServerPermission: u64 { pub struct ServerPermission: u64 {
const CREATE_ROOM = 1 << 0; const CREATE_ROOM = 1 << 0;
const ACCEPT_PEER_CHAT = 1 << 16;
const ALL = !0; const ALL = !0;
} }
@ -421,6 +444,7 @@ bitflags::bitflags! {
const ADD_MEMBER = 1 << 1; const ADD_MEMBER = 1 << 1;
const MAX_SELF_ADD = Self::POST_CHAT.bits(); const MAX_SELF_ADD = Self::POST_CHAT.bits();
const MAX_PEER_CHAT = Self::POST_CHAT.bits();
const ALL = !0; const ALL = !0;
} }
@ -430,6 +454,11 @@ bitflags::bitflags! {
const PUBLIC_READABLE = 1 << 0; const PUBLIC_READABLE = 1 << 0;
const PUBLIC_JOINABLE = 1 << 1; const PUBLIC_JOINABLE = 1 << 1;
const GROUP_ATTRS = (1 << 16) - 1;
// NB. Used by schema.
const PEER_CHAT = 1 << 16;
const _ = !0; const _ = !0;
} }
} }

View file

@ -4,8 +4,8 @@ use std::{fs, io};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use blah_types::{ use blah_types::{
bitflags, get_timestamp, ChatPayload, CreateRoomPayload, Id, MemberPermission, RichText, bitflags, get_timestamp, ChatPayload, CreateGroup, CreateRoomPayload, Id, MemberPermission,
RoomAttrs, RoomMember, RoomMemberList, ServerPermission, UserKey, WithSig, RichText, RoomAttrs, RoomMember, RoomMemberList, ServerPermission, UserKey, WithSig,
}; };
use ed25519_dalek::pkcs8::spki::der::pem::LineEnding; use ed25519_dalek::pkcs8::spki::der::pem::LineEnding;
use ed25519_dalek::pkcs8::{DecodePrivateKey, DecodePublicKey, EncodePrivateKey, EncodePublicKey}; use ed25519_dalek::pkcs8::{DecodePrivateKey, DecodePublicKey, EncodePrivateKey, EncodePublicKey};
@ -217,7 +217,7 @@ async fn main_api(api_url: Url, command: ApiCommand) -> Result<()> {
attrs, attrs,
} => { } => {
let key = load_signing_key(&private_key_file)?; let key = load_signing_key(&private_key_file)?;
let payload = CreateRoomPayload { let payload = CreateRoomPayload::Group(CreateGroup {
attrs: attrs.unwrap_or_default(), attrs: attrs.unwrap_or_default(),
title, title,
// The CLI does not support passing multiple members because `User` itself is a // The CLI does not support passing multiple members because `User` itself is a
@ -226,7 +226,7 @@ async fn main_api(api_url: Url, command: ApiCommand) -> Result<()> {
permission: MemberPermission::ALL, permission: MemberPermission::ALL,
user: UserKey(key.verifying_key().to_bytes()), user: UserKey(key.verifying_key().to_bytes()),
}]), }]),
}; });
let payload = WithSig::sign(&key, get_timestamp(), &mut OsRng, payload)?; let payload = WithSig::sign(&key, get_timestamp(), &mut OsRng, payload)?;
let ret = client let ret = client

View file

@ -7,12 +7,26 @@ CREATE TABLE IF NOT EXISTS `user` (
`permission` INTEGER NOT NULL DEFAULT 0 `permission` INTEGER NOT NULL DEFAULT 0
) STRICT; ) STRICT;
-- The highest bit of `rid` will be set for peer chat room.
-- So simply comparing it against 0 can filter them out.
CREATE TABLE IF NOT EXISTS `room` ( CREATE TABLE IF NOT EXISTS `room` (
`rid` INTEGER NOT NULL PRIMARY KEY, `rid` INTEGER NOT NULL PRIMARY KEY,
`title` TEXT NOT NULL, -- RoomAttrs::PEER_CHAT
`attrs` INTEGER NOT NULL `attrs` INTEGER NOT NULL
CHECK ((`attrs` & 0x10000 == 0x10000) == `rid` < 0),
`title` TEXT
CHECK ((`title` ISNULL) == `rid` < 0),
`peer1` INTEGER REFERENCES `user` ON DELETE RESTRICT
CHECK ((`peer1` NOTNULL) == `rid` < 0),
`peer2` INTEGER REFERENCES `user` ON DELETE RESTRICT
CHECK ((`peer2` NOTNULL AND `peer1` <= `peer2`) IS `rid` < 0)
) STRICT; ) STRICT;
CREATE UNIQUE INDEX IF NOT EXISTS `ix_peer_chat` ON `room`
(`peer1`, `peer2`)
WHERE `rid` < 0;
CREATE TABLE IF NOT EXISTS `room_member` ( CREATE TABLE IF NOT EXISTS `room_member` (
`rid` INTEGER NOT NULL REFERENCES `room` ON DELETE CASCADE, `rid` INTEGER NOT NULL REFERENCES `room` ON DELETE CASCADE,
`uid` INTEGER NOT NULL REFERENCES `user` ON DELETE RESTRICT, `uid` INTEGER NOT NULL REFERENCES `user` ON DELETE RESTRICT,

View file

@ -7,6 +7,7 @@ use blah_types::Id;
pub trait IdExt { pub trait IdExt {
fn gen() -> Self; fn gen() -> Self;
fn gen_peer_chat_rid() -> Self;
} }
impl IdExt for Id { impl IdExt for Id {
@ -14,8 +15,15 @@ impl IdExt for Id {
let timestamp = SystemTime::now() let timestamp = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH) .duration_since(SystemTime::UNIX_EPOCH)
.expect("after UNIX epoch"); .expect("after UNIX epoch");
let timestamp_ms = timestamp.as_millis() as i64; let timestamp_ms = timestamp.as_millis();
assert!(timestamp_ms > 0); assert!(
Id(timestamp_ms << 16) 0 < timestamp_ms && timestamp_ms < (1 << 48),
"invalid timestamp",
);
Id((timestamp_ms as i64) << 16)
}
fn gen_peer_chat_rid() -> Self {
Id(Self::gen().0 | i64::MIN)
} }
} }

View file

@ -11,8 +11,9 @@ use axum::routing::{get, post};
use axum::{Json, Router}; use axum::{Json, Router};
use axum_extra::extract::WithRejection as R; use axum_extra::extract::WithRejection as R;
use blah_types::{ use blah_types::{
ChatItem, ChatPayload, CreateRoomPayload, Id, MemberPermission, RoomAdminOp, RoomAdminPayload, ChatItem, ChatPayload, CreateGroup, CreatePeerChat, CreateRoomPayload, Id, MemberPermission,
RoomAttrs, RoomMetadata, ServerPermission, Signee, UserKey, WithItemId, WithSig, RoomAdminOp, RoomAdminPayload, RoomAttrs, RoomMetadata, ServerPermission, Signee, UserKey,
WithItemId, WithSig,
}; };
use config::ServerConfig; use config::ServerConfig;
use ed25519_dalek::SIGNATURE_LENGTH; use ed25519_dalek::SIGNATURE_LENGTH;
@ -177,7 +178,7 @@ async fn room_list(
until_token: None, until_token: None,
}; };
let page_len = pagination.effective_page_len(&st); let page_len = pagination.effective_page_len(&st);
let start_rid = pagination.skip_token.unwrap_or(Id(0)); let start_rid = pagination.skip_token.unwrap_or(Id::MIN);
let query = |sql: &str, params: &[(&str, &dyn ToSql)]| -> Result<RoomList, ApiError> { let query = |sql: &str, params: &[(&str, &dyn ToSql)]| -> Result<RoomList, ApiError> {
let rooms = st let rooms = st
@ -187,8 +188,6 @@ async fn room_list(
.query_map(params, |row| { .query_map(params, |row| {
// TODO: Extract this into a function. // TODO: Extract this into a function.
let rid = row.get("rid")?; let rid = row.get("rid")?;
let title = row.get("title")?;
let attrs = row.get("attrs")?;
let last_item = row let last_item = row
.get::<_, Option<Id>>("cid")? .get::<_, Option<Id>>("cid")?
.map(|cid| { .map(|cid| {
@ -209,18 +208,16 @@ async fn room_list(
}) })
}) })
.transpose()?; .transpose()?;
let last_seen_cid =
Some(row.get::<_, Id>("last_seen_cid")?).filter(|cid| cid.0 != 0);
let unseen_cnt = row.get("unseen_cnt").ok();
let member_permission = row.get("member_perm").ok();
Ok(RoomMetadata { Ok(RoomMetadata {
rid, rid,
title, title: row.get("title")?,
attrs, attrs: row.get("attrs")?,
last_item, last_item,
last_seen_cid, last_seen_cid: Some(row.get::<_, Id>("last_seen_cid")?)
unseen_cnt, .filter(|cid| cid.0 != 0),
member_permission, unseen_cnt: row.get("unseen_cnt").ok(),
member_permission: row.get("member_perm").ok(),
peer_user: row.get("peer_userkey").ok(),
}) })
})? })?
.collect::<Result<Vec<_>, _>>()?; .collect::<Result<Vec<_>, _>>()?;
@ -255,12 +252,15 @@ async fn room_list(
r" r"
SELECT SELECT
`rid`, `title`, `attrs`, `last_seen_cid`, `room_member`.`permission` AS `member_perm`, `rid`, `title`, `attrs`, `last_seen_cid`, `room_member`.`permission` AS `member_perm`,
`cid`, `last_author`.`userkey`, `timestamp`, `nonce`, `sig`, `rich_text` `cid`, `last_author`.`userkey`, `timestamp`, `nonce`, `sig`, `rich_text`,
`peer_user`.`userkey` AS `peer_userkey`
FROM `user` FROM `user`
JOIN `room_member` USING (`uid`) JOIN `room_member` USING (`uid`)
JOIN `room` USING (`rid`) JOIN `room` USING (`rid`)
LEFT JOIN `room_item` USING (`rid`) LEFT JOIN `room_item` USING (`rid`)
LEFT JOIN `user` AS `last_author` ON (`last_author`.`uid` = `room_item`.`uid`) LEFT JOIN `user` AS `last_author` ON (`last_author`.`uid` = `room_item`.`uid`)
LEFT JOIN `user` AS `peer_user` ON
(`peer_user`.`uid` = `room`.`peer1` + `room`.`peer2` - `user`.`uid`)
WHERE `user`.`userkey` = :userkey AND WHERE `user`.`userkey` = :userkey AND
`rid` > :start_rid `rid` > :start_rid
GROUP BY `rid` HAVING `cid` IS MAX(`cid`) GROUP BY `rid` HAVING `cid` IS MAX(`cid`)
@ -281,6 +281,7 @@ async fn room_list(
SELECT SELECT
`rid`, `title`, `attrs`, `last_seen_cid`, `room_member`.`permission` AS `member_perm`, `rid`, `title`, `attrs`, `last_seen_cid`, `room_member`.`permission` AS `member_perm`,
`cid`, `last_author`.`userkey`, `timestamp`, `nonce`, `sig`, `rich_text`, `cid`, `last_author`.`userkey`, `timestamp`, `nonce`, `sig`, `rich_text`,
`peer_user`.`userkey` AS `peer_userkey`,
(SELECT COUNT(*) (SELECT COUNT(*)
FROM `room_item` AS `unseen_item` FROM `room_item` AS `unseen_item`
WHERE `unseen_item`.`rid` = `room`.`rid` AND WHERE `unseen_item`.`rid` = `room`.`rid` AND
@ -290,6 +291,8 @@ async fn room_list(
JOIN `room` USING (`rid`) JOIN `room` USING (`rid`)
LEFT JOIN `room_item` USING (`rid`) LEFT JOIN `room_item` USING (`rid`)
LEFT JOIN `user` AS `last_author` ON (`last_author`.`uid` = `room_item`.`uid`) LEFT JOIN `user` AS `last_author` ON (`last_author`.`uid` = `room_item`.`uid`)
LEFT JOIN `user` AS `peer_user` ON
(`peer_user`.`uid` = `room`.`peer1` + `room`.`peer2` - `user`.`uid`)
WHERE `user`.`userkey` = :userkey AND WHERE `user`.`userkey` = :userkey AND
`rid` > :start_rid AND `rid` > :start_rid AND
`cid` > `last_seen_cid` `cid` > `last_seen_cid`
@ -312,10 +315,29 @@ async fn room_create(
st: ArcState, st: ArcState,
SignedJson(params): SignedJson<CreateRoomPayload>, SignedJson(params): SignedJson<CreateRoomPayload>,
) -> Result<Json<Id>, ApiError> { ) -> Result<Json<Id>, ApiError> {
let members = &params.signee.payload.members.0; match params.signee.payload {
CreateRoomPayload::Group(op) => room_create_group(&st, params.signee.user, op).await,
CreateRoomPayload::PeerChat(op) => room_create_peer_chat(&st, params.signee.user, op).await,
}
}
async fn room_create_group(
st: &AppState,
user: UserKey,
op: CreateGroup,
) -> Result<Json<Id>, ApiError> {
if !RoomAttrs::GROUP_ATTRS.contains(op.attrs) {
return Err(error_response!(
StatusCode::BAD_REQUEST,
"deserialization",
"invalid room attributes",
));
}
let members = &op.members.0;
if !members if !members
.iter() .iter()
.any(|m| m.user == params.signee.user && m.permission == MemberPermission::ALL) .any(|m| m.user == user && m.permission == MemberPermission::ALL)
{ {
return Err(error_response!( return Err(error_response!(
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
@ -332,7 +354,7 @@ async fn room_create(
FROM `user` FROM `user`
WHERE `userkey` = ? WHERE `userkey` = ?
", ",
params![params.signee.user], params![user],
|row| { |row| {
let perm = row.get::<_, ServerPermission>("permission")?; let perm = row.get::<_, ServerPermission>("permission")?;
Ok(perm.contains(ServerPermission::CREATE_ROOM)) Ok(perm.contains(ServerPermission::CREATE_ROOM))
@ -356,8 +378,8 @@ async fn room_create(
", ",
named_params! { named_params! {
":rid": rid, ":rid": rid,
":title": params.signee.payload.title, ":title": op.title,
":attrs": params.signee.payload.attrs, ":attrs": op.attrs,
}, },
)?; )?;
let mut insert_user = txn.prepare( let mut insert_user = txn.prepare(
@ -390,6 +412,118 @@ async fn room_create(
Ok(Json(rid)) Ok(Json(rid))
} }
async fn room_create_peer_chat(
st: &AppState,
src_user: UserKey,
op: CreatePeerChat,
) -> Result<Json<Id>, ApiError> {
let tgt_user = op.peer;
if tgt_user == src_user {
return Err(error_response!(
StatusCode::NOT_IMPLEMENTED,
"not_implemented",
"self-chat is not implemented yet",
));
}
// TODO: Access control and throttling.
let mut conn = st.db.get();
let txn = conn.transaction()?;
let tgt_uid = txn
.query_row(
r"
SELECT `uid`
FROM `user`
WHERE `userkey` = :userkey AND
`permission` & :perm = :perm
",
named_params! {
":userkey": tgt_user,
":perm": ServerPermission::ACCEPT_PEER_CHAT,
},
|row| row.get::<_, i64>(0),
)
.optional()?
.ok_or_else(|| {
error_response!(
StatusCode::NOT_FOUND,
"not_found",
"peer user does not exist or disallows peer chat",
)
})?;
let src_uid = txn
.query_row(
r"
SELECT `uid` FROM `user`
WHERE `userkey` = ?
",
params![src_user],
|row| row.get::<_, i64>(0),
)
.optional()?;
let src_uid = match src_uid {
Some(uid) => uid,
None => {
txn.execute(
r"
INSERT INTO `user` (`userkey`)
VALUES (?)
",
params![src_user],
)?;
txn.last_insert_rowid()
}
};
let (peer1, peer2) = if src_uid <= tgt_uid {
(src_uid, tgt_uid)
} else {
(tgt_uid, src_uid)
};
let rid = Id::gen_peer_chat_rid();
let updated = txn.execute(
r"
INSERT INTO `room` (`rid`, `attrs`, `peer1`, `peer2`)
VALUES (:rid, :attrs, :peer1, :peer2)
ON CONFLICT (`peer1`, `peer2`) WHERE `rid` < 0 DO NOTHING
",
named_params! {
":rid": rid,
":attrs": RoomAttrs::PEER_CHAT,
":peer1": peer1,
":peer2": peer2,
},
)?;
if updated == 0 {
return Err(error_response!(
StatusCode::CONFLICT,
"exists",
"room already exists"
));
}
{
let mut stmt = txn.prepare(
r"
INSERT INTO `room_member` (`rid`, `uid`, `permission`)
VALUES (:rid, :uid, :perm)
",
)?;
// TODO: Limit permission of the src user?
for uid in [peer1, peer2] {
stmt.execute(named_params! {
":rid": rid,
":uid": uid,
":perm": MemberPermission::MAX_PEER_CHAT,
})?;
}
}
txn.commit()?;
Ok(Json(rid))
}
/// Pagination query parameters. /// Pagination query parameters.
/// ///
/// Field names are inspired by Microsoft's design, which is an extension to OData spec. /// Field names are inspired by Microsoft's design, which is an extension to OData spec.
@ -444,7 +578,7 @@ async fn room_get_metadata(
let conn = st.db.get(); let conn = st.db.get();
let (title, attrs) = get_room_if_readable(&conn, rid, auth.into_optional()?.as_ref(), |row| { let (title, attrs) = get_room_if_readable(&conn, rid, auth.into_optional()?.as_ref(), |row| {
Ok(( Ok((
row.get::<_, String>("title")?, row.get::<_, Option<String>>("title")?,
row.get::<_, RoomAttrs>("attrs")?, row.get::<_, RoomAttrs>("attrs")?,
)) ))
})?; })?;
@ -459,6 +593,7 @@ async fn room_get_metadata(
last_seen_cid: None, last_seen_cid: None,
unseen_cnt: None, unseen_cnt: None,
member_permission: None, member_permission: None,
peer_user: None,
})) }))
} }
@ -624,8 +759,8 @@ fn query_room_items(
.query_and_then( .query_and_then(
named_params! { named_params! {
":rid": rid, ":rid": rid,
":after_cid": pagination.until_token.unwrap_or(Id(-1)), ":after_cid": pagination.until_token.unwrap_or(Id::MIN),
":before_cid": pagination.skip_token.unwrap_or(Id(i64::MAX)), ":before_cid": pagination.skip_token.unwrap_or(Id::MAX),
":limit": page_len, ":limit": page_len,
}, },
|row| { |row| {

View file

@ -7,9 +7,9 @@ use std::sync::{Arc, LazyLock};
use anyhow::Result; use anyhow::Result;
use blah_types::{ use blah_types::{
get_timestamp, AuthPayload, ChatItem, ChatPayload, CreateRoomPayload, Id, MemberPermission, get_timestamp, AuthPayload, ChatItem, ChatPayload, CreateGroup, CreatePeerChat,
RichText, RoomAdminOp, RoomAdminPayload, RoomAttrs, RoomMember, RoomMemberList, RoomMetadata, CreateRoomPayload, Id, MemberPermission, RichText, RoomAdminOp, RoomAdminPayload, RoomAttrs,
ServerPermission, UserKey, WithItemId, WithSig, RoomMember, RoomMemberList, RoomMetadata, ServerPermission, UserKey, WithItemId, WithSig,
}; };
use blahd::{ApiError, AppState, Database, RoomItems, RoomList}; use blahd::{ApiError, AppState, Database, RoomItems, RoomList};
use ed25519_dalek::SigningKey; use ed25519_dalek::SigningKey;
@ -29,7 +29,7 @@ const LOCALHOST: &str = "127.0.0.1";
static ALICE_PRIV: LazyLock<SigningKey> = LazyLock::new(|| SigningKey::from_bytes(&[b'A'; 32])); static ALICE_PRIV: LazyLock<SigningKey> = LazyLock::new(|| SigningKey::from_bytes(&[b'A'; 32]));
static ALICE: LazyLock<UserKey> = LazyLock::new(|| UserKey(ALICE_PRIV.verifying_key().to_bytes())); static ALICE: LazyLock<UserKey> = LazyLock::new(|| UserKey(ALICE_PRIV.verifying_key().to_bytes()));
static BOB_PRIV: LazyLock<SigningKey> = LazyLock::new(|| SigningKey::from_bytes(&[b'B'; 32])); static BOB_PRIV: LazyLock<SigningKey> = LazyLock::new(|| SigningKey::from_bytes(&[b'B'; 32]));
// static BOB: LazyLock<UserKey> = LazyLock::new(|| UserKey(BOB_PRIV.verifying_key().to_bytes())); static BOB: LazyLock<UserKey> = LazyLock::new(|| UserKey(BOB_PRIV.verifying_key().to_bytes()));
#[fixture] #[fixture]
fn rng() -> impl RngCore { fn rng() -> impl RngCore {
@ -118,14 +118,14 @@ impl Server {
let req = sign( let req = sign(
key, key,
&mut *self.rng.borrow_mut(), &mut *self.rng.borrow_mut(),
CreateRoomPayload { CreateRoomPayload::Group(CreateGroup {
attrs, attrs,
members: RoomMemberList(vec![RoomMember { members: RoomMemberList(vec![RoomMember {
permission: MemberPermission::ALL, permission: MemberPermission::ALL,
user: UserKey(key.verifying_key().to_bytes()), user: UserKey(key.verifying_key().to_bytes()),
}]), }]),
title: title.to_string(), title: title.to_string(),
}, }),
); );
async move { async move {
Ok(self Ok(self
@ -254,9 +254,10 @@ fn auth(key: &SigningKey, rng: &mut impl RngCore) -> String {
#[case::private(false)] #[case::private(false)]
#[tokio::test] #[tokio::test]
async fn room_create_get(server: Server, ref mut rng: impl RngCore, #[case] public: bool) { async fn room_create_get(server: Server, ref mut rng: impl RngCore, #[case] public: bool) {
let title = "test room";
let mut room_meta = RoomMetadata { let mut room_meta = RoomMetadata {
rid: Id(0), rid: Id(0),
title: "test room".into(), title: Some(title.into()),
attrs: if public { attrs: if public {
RoomAttrs::PUBLIC_READABLE | RoomAttrs::PUBLIC_JOINABLE RoomAttrs::PUBLIC_READABLE | RoomAttrs::PUBLIC_JOINABLE
} else { } else {
@ -266,18 +267,19 @@ async fn room_create_get(server: Server, ref mut rng: impl RngCore, #[case] publ
last_seen_cid: None, last_seen_cid: None,
unseen_cnt: None, unseen_cnt: None,
member_permission: None, member_permission: None,
peer_user: None,
}; };
// Alice has permission. // Alice has permission.
let rid = server let rid = server
.create_room(&ALICE_PRIV, room_meta.attrs, &room_meta.title) .create_room(&ALICE_PRIV, room_meta.attrs, title)
.await .await
.unwrap(); .unwrap();
room_meta.rid = rid; room_meta.rid = rid;
// Bob has no permission. // Bob has no permission.
server server
.create_room(&BOB_PRIV, room_meta.attrs, &room_meta.title) .create_room(&BOB_PRIV, room_meta.attrs, title)
.await .await
.expect_api_err(StatusCode::FORBIDDEN, "permission_denied"); .expect_api_err(StatusCode::FORBIDDEN, "permission_denied");
@ -579,12 +581,13 @@ async fn last_seen_item(server: Server, ref mut rng: impl RngCore) {
RoomList { RoomList {
rooms: vec![RoomMetadata { rooms: vec![RoomMetadata {
rid, rid,
title: title.into(), title: Some(title.into()),
attrs, attrs,
last_item: Some(alice_chat2.clone()), last_item: Some(alice_chat2.clone()),
last_seen_cid: None, last_seen_cid: None,
unseen_cnt: Some(2), unseen_cnt: Some(2),
member_permission: Some(member_perm), member_permission: Some(member_perm),
peer_user: None,
}], }],
skip_token: None, skip_token: None,
} }
@ -612,12 +615,13 @@ async fn last_seen_item(server: Server, ref mut rng: impl RngCore) {
RoomList { RoomList {
rooms: vec![RoomMetadata { rooms: vec![RoomMetadata {
rid, rid,
title: title.into(), title: Some(title.into()),
attrs, attrs,
last_item: Some(alice_chat2.clone()), last_item: Some(alice_chat2.clone()),
last_seen_cid: Some(alice_chat1.cid), last_seen_cid: Some(alice_chat1.cid),
unseen_cnt: Some(1), unseen_cnt: Some(1),
member_permission: Some(member_perm), member_permission: Some(member_perm),
peer_user: None,
}], }],
skip_token: None, skip_token: None,
} }
@ -639,3 +643,76 @@ async fn last_seen_item(server: Server, ref mut rng: impl RngCore) {
.unwrap(); .unwrap();
assert_eq!(rooms, RoomList::default()); assert_eq!(rooms, RoomList::default());
} }
#[rstest]
#[tokio::test]
async fn peer_chat(server: Server, ref mut rng: impl RngCore) {
let mut create_chat = |src: &SigningKey, tgt: &UserKey| {
let req = sign(
src,
rng,
CreateRoomPayload::PeerChat(CreatePeerChat { peer: tgt.clone() }),
);
server
.request::<_, Id>(Method::POST, "/room/create", None, Some(req))
.map_ok(|resp| resp.unwrap())
};
// Bob disallows peer chat.
create_chat(&ALICE_PRIV, &BOB)
.await
.expect_api_err(StatusCode::NOT_FOUND, "not_found");
// Alice accepts bob.
let rid = create_chat(&BOB_PRIV, &ALICE).await.unwrap();
// Room already exists.
create_chat(&BOB_PRIV, &ALICE)
.await
.expect_api_err(StatusCode::CONFLICT, "exists");
// Peer chat room is not public.
let rooms = server
.get::<RoomList>("/room?filter=public", None)
.await
.unwrap();
assert_eq!(rooms, RoomList::default());
server
.get::<RoomMetadata>(&format!("/room/{rid}"), None)
.await
.expect_api_err(StatusCode::NOT_FOUND, "not_found");
// Both alice and bob are in the room.
for (key, peer) in [(&*ALICE_PRIV, &*BOB), (&*BOB_PRIV, &*ALICE)] {
let mut expect_meta = RoomMetadata {
rid,
title: None,
attrs: RoomAttrs::PEER_CHAT,
last_item: None,
last_seen_cid: None,
unseen_cnt: None,
member_permission: None,
peer_user: None,
};
let meta = server
.get::<RoomMetadata>(&format!("/room/{rid}"), Some(&auth(key, rng)))
.await
.unwrap();
assert_eq!(meta, expect_meta);
expect_meta.member_permission = Some(MemberPermission::MAX_PEER_CHAT);
expect_meta.peer_user = Some(peer.clone());
let rooms = server
.get::<RoomList>("/room?filter=joined", Some(&auth(key, rng)))
.await
.unwrap();
assert_eq!(
rooms,
RoomList {
rooms: vec![expect_meta],
skip_token: None
}
);
}
}

View file

@ -108,6 +108,13 @@ paths:
/room/create: /room/create:
post: post:
summary: Create room summary: Create room
description:
When `typ="create_room"`, create a multi-user room.
When `typ="create_peer_chat"`, create a peer-to-peer room between two
users. There can be at most one peer room for each given user pair.
requestBody: requestBody:
content: content:
application/json: application/json:
@ -130,6 +137,20 @@ paths:
schema: schema:
$ref: '#/components/schemas/ApiError' $ref: '#/components/schemas/ApiError'
404:
description: The peer user does not exist or disallows peer chat.
content:
application/json:
schema:
$ref: '#/components/schemas/ApiError'
409:
description: There is already a peer chat room between the user pair.
content:
application/json:
schema:
$ref: '#/components/schemas/ApiError'
/room/{rid}: /room/{rid}:
get: get:
summary: Get room metadata summary: Get room metadata
@ -391,6 +412,10 @@ components:
member_permission: member_permission:
type: integer type: integer
format: int64 format: int64
peer_user:
type: string
description: |
For peer chat room, this gives the identity of the peer user.
RoomMetadata: RoomMetadata:
type: object type: object
@ -577,23 +602,32 @@ components:
type: integer type: integer
format: uint32 format: uint32
payload: payload:
type: object oneOf:
properties: - type: object
typ: properties:
type: string typ:
const: 'create_room' type: string
title: const: 'create_room'
type: string title:
members: type: string
type: array members:
items: type: array
type: object items:
properties: type: object
user: properties:
type: string user:
permission: type: string
type: integer permission:
format: int64 type: integer
format: int64
- type: object
properties:
typ:
type: string
const: 'create_peer_chat'
peer:
type: string
example: example:
sig: 99a77e836538268839ed3419c649eefb043cb51d448f641cc2a1c523811aab4aacd09f92e7c0688ffd659bfc6acb764fea79979a491e132bf6a56dd23adc1d09 sig: 99a77e836538268839ed3419c649eefb043cb51d448f641cc2a1c523811aab4aacd09f92e7c0688ffd659bfc6acb764fea79979a491e132bf6a56dd23adc1d09
signee: signee: