diff --git a/blah-types/src/msg.rs b/blah-types/src/msg.rs index f2fc220..c04ce95 100644 --- a/blah-types/src/msg.rs +++ b/blah-types/src/msg.rs @@ -339,24 +339,36 @@ pub struct RoomMember { #[serde(tag = "typ", rename = "auth")] pub struct AuthPayload {} +// FIXME: Remove this. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] // `typ` is provided by `RoomAdminOp`. pub struct RoomAdminPayload { - pub room: Id, #[serde(flatten)] pub op: RoomAdminOp, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "typ", rename_all = "snake_case", rename = "remove_member")] +pub struct RemoveMemberPayload { + pub room: Id, + // TODO: This field name collide with `Signee::user`. + pub user: PubKey, +} + +// TODO: Maybe disallow adding other user without consent? #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "typ", rename_all = "snake_case")] +pub struct AddMemberPayload { + pub room: Id, + #[serde(flatten)] + pub member: RoomMember, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] pub enum RoomAdminOp { - AddMember { - permission: MemberPermission, - user: PubKey, - }, - RemoveMember { - user: PubKey, - }, + AddMember(AddMemberPayload), + RemoveMember(RemoveMemberPayload), // TODO: RU } @@ -479,6 +491,7 @@ mod sql_impl { mod tests { use ed25519_dalek::{SigningKey, PUBLIC_KEY_LENGTH}; use expect_test::expect; + use AddMemberPayload; use crate::SignExt; @@ -547,11 +560,13 @@ mod tests { #[test] fn room_admin_serde() { let data = RoomAdminPayload { - room: Id(42), - op: RoomAdminOp::AddMember { - permission: MemberPermission::POST_CHAT, - user: PubKey([0x42; PUBLIC_KEY_LENGTH]), - }, + op: RoomAdminOp::AddMember(AddMemberPayload { + room: Id(42), + member: RoomMember { + permission: MemberPermission::POST_CHAT, + user: PubKey([0x42; PUBLIC_KEY_LENGTH]), + }, + }), }; let raw = serde_jcs::to_string(&data).unwrap(); diff --git a/blahd/src/lib.rs b/blahd/src/lib.rs index a7daf61..abd43ed 100644 --- a/blahd/src/lib.rs +++ b/blahd/src/lib.rs @@ -1,3 +1,4 @@ +use std::marker::PhantomData; use std::num::NonZero; use std::sync::Arc; use std::time::Duration; @@ -7,24 +8,24 @@ use axum::body::Bytes; use axum::extract::{Path, Query, State}; use axum::http::{header, HeaderName, HeaderValue, StatusCode}; use axum::response::{IntoResponse, Response}; -use axum::routing::{get, post}; use axum::{Json, Router}; use axum_extra::extract::WithRejection as R; use blah_types::msg::{ - ChatPayload, CreateGroup, CreatePeerChat, CreateRoomPayload, DeleteRoomPayload, - MemberPermission, RoomAdminOp, RoomAdminPayload, RoomAttrs, ServerPermission, - SignedChatMsgWithId, WithMsgId, + AddMemberPayload, ChatPayload, CreateGroup, CreatePeerChat, CreateRoomPayload, + DeleteRoomPayload, MemberPermission, RemoveMemberPayload, RoomAdminOp, RoomAdminPayload, + RoomAttrs, ServerPermission, SignedChatMsgWithId, WithMsgId, }; use blah_types::server::{ ErrorResponseWithChallenge, RoomList, RoomMember, RoomMemberList, RoomMetadata, RoomMsgs, ServerCapabilities, ServerMetadata, }; -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 id::IdExt; use middleware::{Auth, ETag, MaybeAuth, ResultExt as _, SignedJson}; use parking_lot::Mutex; +use serde::de::DeserializeOwned; use serde::{Deserialize, Deserializer, Serialize}; use serde_inline_default::serde_inline_default; use sha2::Digest; @@ -148,7 +149,9 @@ impl AppState { type ArcState = State>; pub fn router(st: Arc) -> Router { - // NB. User consistent handler naming: `_[_
]`. + use axum::routing::{delete, get, post}; + + // NB. Use consistent handler naming: `_[_
]`. // Use prefix `list` for GET with pagination. // // One route per line. @@ -165,8 +168,11 @@ pub fn router(st: Arc) -> Router { .route("/room/:rid/feed.atom", get(feed::get_room_feed::)) .route("/room/:rid/msg", get(list_room_msg).post(post_room_msg)) .route("/room/:rid/msg/:cid/seen", post(post_room_msg_seen)) + // TODO!: remove this. .route("/room/:rid/admin", post(post_room_admin)) - .route("/room/:rid/member", get(list_room_member)); + .route("/room/:rid/member", get(list_room_member).post(post_room_member)) + .route("/room/:rid/member/:uid", delete(delete_room_member)) + ; let router = router .layer(tower_http::limit::RequestBodyLimitLayer::new( @@ -470,58 +476,81 @@ async fn post_room_admin( R(Path(rid), _): RE>, SignedJson(op): SignedJson, ) -> Result { - api_ensure!(rid == op.signee.payload.room, "room id mismatch with URI"); - api_ensure!(!rid.is_peer_chat(), "cannot operate on a peer chat room"); - - match op.signee.payload.op { - RoomAdminOp::AddMember { user, permission } => { - api_ensure!( - user == op.signee.user.id_key, - ApiError::NotImplemented("only self-adding is implemented yet"), - ); - api_ensure!( - MemberPermission::MAX_SELF_ADD.contains(permission), - "invalid initial permission", - ); - room_join(&st, rid, &op.signee.user, permission).await?; - } - RoomAdminOp::RemoveMember { user } => { - api_ensure!( - user == op.signee.user.id_key, - ApiError::NotImplemented("only self-removal is implemented yet"), - ); - room_leave(&st, rid, &op.signee.user).await?; - } + // TODO: This is a temporary endpoint so just reserialize them. + fn transcode(v: &T) -> SignedJson { + let v = serde_json::to_value(v).expect("serialization cannot fail"); + SignedJson(serde_json::from_value(v).expect("format should be compatible")) } - Ok(StatusCode::NO_CONTENT) + match op.signee.payload.op { + RoomAdminOp::AddMember(_) => { + post_room_member(st, R(Path(rid), PhantomData), transcode(&op)).await + } + RoomAdminOp::RemoveMember(_) => { + delete_room_member( + st, + R(Path((rid, op.signee.user.id_key.clone())), PhantomData), + transcode(&op), + ) + .await + } + } } -async fn room_join( - st: &AppState, - rid: Id, - user: &UserKey, - permission: MemberPermission, -) -> Result<(), ApiError> { +async fn post_room_member( + st: ArcState, + R(Path(rid), _): RE>, + SignedJson(op): SignedJson, +) -> Result { + api_ensure!(rid == op.signee.payload.room, "room id mismatch with URI"); + api_ensure!( + !rid.is_peer_chat(), + "cannot add members to a peer chat room" + ); + let member = op.signee.payload.member; + api_ensure!( + member.user == op.signee.user.id_key, + ApiError::NotImplemented("only self-adding is implemented yet"), + ); + api_ensure!( + MemberPermission::MAX_SELF_ADD.contains(member.permission), + "invalid member permission", + ); + st.db.with_write(|txn| { - let (uid, _perm) = txn.get_user(user)?; + let (uid, _perm) = txn.get_user(&op.signee.user)?; let (attrs, _) = txn.get_room_having(rid, RoomAttrs::PUBLIC_JOINABLE)?; // Sanity check. assert!(!attrs.contains(RoomAttrs::PEER_CHAT)); - txn.add_room_member(rid, uid, permission)?; - Ok(()) + txn.add_room_member(rid, uid, member.permission)?; + Ok(StatusCode::NO_CONTENT) }) } -async fn room_leave(st: &AppState, rid: Id, user: &UserKey) -> Result<(), ApiError> { +async fn delete_room_member( + st: ArcState, + R(Path((rid, id_key)), _): RE>, + SignedJson(op): SignedJson, +) -> Result { + api_ensure!(rid == op.signee.payload.room, "room id mismatch with URI"); + api_ensure!( + !rid.is_peer_chat(), + "cannot remove members from a peer chat room" + ); + let tgt_user = op.signee.payload.user; + api_ensure!(id_key == tgt_user, "user id mismatch with URI"); + api_ensure!( + op.signee.user.id_key == tgt_user, + ApiError::NotImplemented("only self-removal is implemented yet"), + ); + st.db.with_write(|txn| { - api_ensure!(!rid.is_peer_chat(), "cannot leave a peer chat room"); - let (uid, ..) = txn.get_room_member(rid, user)?; + let (uid, ..) = txn.get_room_member(rid, &op.signee.user)?; let (attrs, _) = txn.get_room_having(rid, RoomAttrs::empty())?; // Sanity check. assert!(!attrs.contains(RoomAttrs::PEER_CHAT)); txn.remove_room_member(rid, uid)?; - Ok(()) + Ok(StatusCode::NO_CONTENT) }) } diff --git a/blahd/tests/webapi.rs b/blahd/tests/webapi.rs index 862703e..f85aab6 100644 --- a/blahd/tests/webapi.rs +++ b/blahd/tests/webapi.rs @@ -10,10 +10,10 @@ use std::time::{Duration, Instant}; use anyhow::{Context, Result}; use blah_types::identity::{IdUrl, UserActKeyDesc, UserIdentityDesc, UserProfile}; use blah_types::msg::{ - AuthPayload, ChatPayload, CreateGroup, CreatePeerChat, CreateRoomPayload, DeleteRoomPayload, - MemberPermission, RichText, RoomAdminOp, RoomAdminPayload, RoomAttrs, ServerPermission, - SignedChatMsg, SignedChatMsgWithId, UserRegisterChallengeResponse, UserRegisterPayload, - WithMsgId, + self, AddMemberPayload, AuthPayload, ChatPayload, CreateGroup, CreatePeerChat, + CreateRoomPayload, DeleteRoomPayload, MemberPermission, RemoveMemberPayload, RichText, + RoomAttrs, ServerPermission, SignedChatMsg, SignedChatMsgWithId, UserRegisterChallengeResponse, + UserRegisterPayload, WithMsgId, }; use blah_types::server::{ RoomList, RoomMember, RoomMemberList, RoomMetadata, RoomMsgs, ServerEvent, ServerMetadata, @@ -302,30 +302,39 @@ impl Server { ) -> impl Future> + use<'_> { let req = self.sign( user, - RoomAdminPayload { + AddMemberPayload { room: rid, - op: RoomAdminOp::AddMember { + member: msg::RoomMember { permission, user: user.pubkeys.id_key.clone(), }, }, ); - self.request::<_, NoContent>(Method::POST, &format!("/room/{rid}/admin"), None, Some(req)) - .map_ok(|None| {}) + self.request::<_, NoContent>( + Method::POST, + &format!("/room/{rid}/member"), + None, + Some(req), + ) + .map_ok(|None| {}) } fn leave_room(&self, rid: Id, user: &User) -> impl Future> + use<'_> { + let user_id = user.pubkeys.id_key.clone(); let req = self.sign( user, - RoomAdminPayload { + RemoveMemberPayload { room: rid, - op: RoomAdminOp::RemoveMember { - user: user.pubkeys.id_key.clone(), - }, + user: user_id.clone(), }, ); - self.request::<_, NoContent>(Method::POST, &format!("/room/{rid}/admin"), None, Some(req)) - .map_ok(|None| {}) + self.request::<_, NoContent>( + Method::DELETE, + &format!("/room/{rid}/member/{user_id}"), + None, + Some(req), + ) + .map_ok(|None| {}) } fn post_chat( @@ -585,7 +594,7 @@ async fn room_join_leave(server: Server) { server .join_room(rid_priv, &BOB, MemberPermission::ALL) .await - .expect_invalid_request("invalid initial permission"); + .expect_invalid_request("invalid member permission"); // Bob is joined now. assert_eq!( diff --git a/docs/webapi.yaml b/docs/webapi.yaml index 1a68772..6d25cd7 100644 --- a/docs/webapi.yaml +++ b/docs/webapi.yaml @@ -327,7 +327,11 @@ paths: /_blah/room/{rid}/admin: post: - summary: Room management + summary: Room management (legacy) + deprecated: true + description: | + Use POST `/_blah/room/{rid}/member` or + DELETE `/_blah/room/{rid}/member/{member_id_key}` instead. requestBody: content: @@ -561,6 +565,67 @@ paths: schema: $ref: '#/components/schemas/ApiError' + post: + summary: Join a room + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Signed-AddMember' + + responses: + 204: + description: Operation completed. + + 404: + description: | + Room does not exist or the user does not have permission for the + operation. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + + 409: + description: + The user is already a room member. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + + /_blah/room/{rid}/member/{target_id_key}: + delete: + summary: Remove a room member. + + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Signed-RemoveMember' + + responses: + 204: + description: Operation completed. + + 403: + description: | + The user does not have permission to remove the operand member. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + + 404: + description: | + Room does not exist, the user does not have permission for the + operation, or the operand user is not a room member. + 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: @@ -775,6 +840,11 @@ components: const: 'auth' Signed-RoomAdmin: + oneOf: + - $ref: '#/components/schemas/Signed-AddMember' + - $ref: '#/components/schemas/Signed-RemoveMember' + + Signed-AddMember: type: object properties: sig: @@ -793,32 +863,47 @@ components: act_key: type: string payload: - oneOf: + type: object + properties: + typ: + type: string + const: 'add_member' + room: + type: string + permission: + type: integer + format: int32 + user: + type: string - - description: Add member to the room. - type: object - properties: - typ: - type: string - const: 'add_member' - room: - type: string - permission: - type: integer - format: int32 - user: - type: string - - - description: Remove member from the room. - type: object - properties: - typ: - type: string - const: 'remove_member' - room: - type: string - user: - type: string + Signed-RemoveMember: + type: object + properties: + sig: + type: string + signee: + type: object + properties: + nonce: + type: integer + format: uint32 + timestamp: + type: integer + format: uint64 + id_key: + type: string + act_key: + type: string + payload: + type: object + properties: + typ: + type: string + const: 'remove_member' + room: + type: string + user: + type: string Signed-Chat: type: object