diff --git a/blah-types/src/lib.rs b/blah-types/src/lib.rs index 8cebb36..3ff9e52 100644 --- a/blah-types/src/lib.rs +++ b/blah-types/src/lib.rs @@ -437,6 +437,12 @@ pub struct CreatePeerChat { pub peer: PubKey, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "typ", rename = "delete_room")] +pub struct DeleteRoomPayload { + pub room: Id, +} + /// A collection of room members, with these invariants: /// 1. Sorted by userkeys. /// 2. No duplicated users. @@ -514,9 +520,10 @@ bitflags::bitflags! { pub struct MemberPermission: u64 { const POST_CHAT = 1 << 0; const ADD_MEMBER = 1 << 1; + const DELETE_ROOM = 1 << 2; const MAX_SELF_ADD = Self::POST_CHAT.bits(); - const MAX_PEER_CHAT = Self::POST_CHAT.bits(); + const MAX_PEER_CHAT = Self::POST_CHAT.bits() | Self::DELETE_ROOM.bits(); const ALL = !0; } diff --git a/blahd/src/database.rs b/blahd/src/database.rs index de511ff..061cde4 100644 --- a/blahd/src/database.rs +++ b/blahd/src/database.rs @@ -517,6 +517,18 @@ pub trait TransactionOps { Ok(()) } + fn delete_room(&self, rid: Id) -> Result { + let deleted = prepare_cached_and_bind!( + self.conn(), + r" + DELETE FROM `room` + WHERE `rid` = :rid + " + ) + .raw_execute()?; + Ok(deleted == 1) + } + fn add_room_member(&self, rid: Id, uid: i64, perm: MemberPermission) -> Result<()> { let updated = prepare_cached_and_bind!( self.conn(), diff --git a/blahd/src/lib.rs b/blahd/src/lib.rs index f901de0..6b675f2 100644 --- a/blahd/src/lib.rs +++ b/blahd/src/lib.rs @@ -11,9 +11,10 @@ use axum::routing::{get, post}; use axum::{Json, Router}; use axum_extra::extract::WithRejection as R; use blah_types::{ - ChatPayload, CreateGroup, CreatePeerChat, CreateRoomPayload, Id, MemberPermission, RoomAdminOp, - RoomAdminPayload, RoomAttrs, RoomMetadata, ServerPermission, Signed, SignedChatMsg, UserKey, - UserRegisterPayload, WithMsgId, X_BLAH_DIFFICULTY, X_BLAH_NONCE, + ChatPayload, CreateGroup, CreatePeerChat, CreateRoomPayload, DeleteRoomPayload, Id, + MemberPermission, RoomAdminOp, RoomAdminPayload, RoomAttrs, RoomMetadata, ServerPermission, + Signed, SignedChatMsg, UserKey, UserRegisterPayload, WithMsgId, X_BLAH_DIFFICULTY, + X_BLAH_NONCE, }; use database::{Transaction, TransactionOps}; use ed25519_dalek::SIGNATURE_LENGTH; @@ -133,7 +134,7 @@ pub fn router(st: Arc) -> Router { .route("/user/me", get(user_get).post(user_register)) .route("/room", get(room_list)) .route("/room/create", post(room_create)) - .route("/room/:rid", get(room_get_metadata)) + .route("/room/:rid", get(room_get_metadata).delete(room_delete)) // NB. Sync with `feed_url` and `next_url` generation. .route("/room/:rid/feed.json", get(room_get_feed)) .route("/room/:rid/msg", get(room_msg_list).post(room_msg_post)) @@ -667,13 +668,47 @@ async fn room_join( async fn room_leave(st: &AppState, rid: Id, user: &UserKey) -> Result<(), ApiError> { st.db.with_write(|txn| { - // FIXME: Handle peer chat room? let (uid, ..) = txn.get_room_member(rid, user)?; + let (attrs, _) = txn.get_room_having(rid, RoomAttrs::empty())?; + if attrs.contains(RoomAttrs::PEER_CHAT) { + return Err(error_response!( + StatusCode::BAD_REQUEST, + "invalid_operation", + "cannot leave a peer chat room without deleting it", + )); + } txn.remove_room_member(rid, uid)?; Ok(()) }) } +async fn room_delete( + st: ArcState, + R(Path(rid), _): RE>, + SignedJson(op): SignedJson, +) -> Result { + if rid != op.signee.payload.room { + return Err(error_response!( + StatusCode::BAD_REQUEST, + "invalid_request", + "URI and payload room id mismatch", + )); + } + st.db.with_write(|txn| { + // TODO: Should we only shadow delete here? + let (_uid, perm, ..) = txn.get_room_member(rid, &op.signee.user)?; + if !perm.contains(MemberPermission::DELETE_ROOM) { + return Err(error_response!( + StatusCode::FORBIDDEN, + "permission_denied", + "the user does not have permission to delete the room", + )); + } + txn.delete_room(rid)?; + Ok(StatusCode::NO_CONTENT) + }) +} + async fn room_msg_mark_seen( st: ArcState, R(Path((rid, cid)), _): RE>, diff --git a/blahd/tests/webapi.rs b/blahd/tests/webapi.rs index d255834..a780db8 100644 --- a/blahd/tests/webapi.rs +++ b/blahd/tests/webapi.rs @@ -11,10 +11,10 @@ use anyhow::Result; use axum::http::HeaderMap; use blah_types::identity::{IdUrl, UserActKeyDesc, UserIdentityDesc, UserProfile}; use blah_types::{ - AuthPayload, ChatPayload, CreateGroup, CreatePeerChat, CreateRoomPayload, Id, MemberPermission, - PubKey, RichText, RoomAdminOp, RoomAdminPayload, RoomAttrs, RoomMetadata, ServerPermission, - SignExt, Signed, SignedChatMsg, UserKey, UserRegisterPayload, WithMsgId, X_BLAH_DIFFICULTY, - X_BLAH_NONCE, + AuthPayload, ChatPayload, CreateGroup, CreatePeerChat, CreateRoomPayload, DeleteRoomPayload, + Id, MemberPermission, PubKey, RichText, RoomAdminOp, RoomAdminPayload, RoomAttrs, RoomMetadata, + ServerPermission, SignExt, Signed, SignedChatMsg, UserKey, UserRegisterPayload, WithMsgId, + X_BLAH_DIFFICULTY, X_BLAH_NONCE, }; use blahd::{ApiError, AppState, Database, RoomList, RoomMsgs}; use ed25519_dalek::SigningKey; @@ -200,6 +200,31 @@ impl Server { } } + fn create_peer_chat( + &self, + src: &User, + tgt: &User, + ) -> impl Future> + use<'_> { + let req = self.sign( + src, + CreateRoomPayload::PeerChat(CreatePeerChat { + peer: tgt.pubkeys.id_key.clone(), + }), + ); + async move { + Ok(self + .request(Method::POST, "/room/create", None, Some(&req)) + .await? + .unwrap()) + } + } + + fn delete_room(&self, rid: Id, user: &User) -> impl Future> + use<'_> { + let req = self.sign(user, DeleteRoomPayload { room: rid }); + self.request::<_, NoContent>(Method::DELETE, &format!("/room/{rid}"), None, Some(req)) + .map_ok(|None| {}) + } + fn join_room( &self, rid: Id, @@ -718,28 +743,18 @@ async fn last_seen(server: Server) { #[rstest] #[tokio::test] async fn peer_chat(server: Server) { - let create_chat = |src: &User, tgt: &User| { - let req = server.sign( - src, - CreateRoomPayload::PeerChat(CreatePeerChat { - peer: tgt.pubkeys.id_key.clone(), - }), - ); - server - .request::<_, Id>(Method::POST, "/room/create", None, Some(req)) - .map_ok(|resp| resp.unwrap()) - }; - // Bob disallows peer chat. - create_chat(&ALICE, &BOB) + server + .create_peer_chat(&ALICE, &BOB) .await .expect_api_err(StatusCode::NOT_FOUND, "peer_user_not_found"); // Alice accepts bob. - let rid = create_chat(&BOB, &ALICE).await.unwrap(); + let rid = server.create_peer_chat(&BOB, &ALICE).await.unwrap(); // Room already exists. - create_chat(&BOB, &ALICE) + server + .create_peer_chat(&BOB, &ALICE) .await .expect_api_err(StatusCode::CONFLICT, "exists"); @@ -789,6 +804,69 @@ async fn peer_chat(server: Server) { } } +#[rstest] +#[case::group(false, true)] +#[case::peer_src(true, false)] +#[case::peer_tgt(true, true)] +#[tokio::test] +async fn delete_room(server: Server, #[case] peer_chat: bool, #[case] alice_delete: bool) { + let rid; + if peer_chat { + rid = server.create_peer_chat(&BOB, &ALICE).await.unwrap() + } else { + rid = server + .create_room(&ALICE, RoomAttrs::PUBLIC_JOINABLE, "public room") + .await + .unwrap(); + server + .join_room(rid, &BOB, MemberPermission::MAX_SELF_ADD) + .await + .unwrap(); + } + + // Invalid rid. + server + .delete_room(Id::INVALID, &ALICE) + .await + .expect_api_err(StatusCode::NOT_FOUND, "room_not_found"); + // Not in the room. + server + .delete_room(rid, &CAROL) + .await + .expect_api_err(StatusCode::NOT_FOUND, "room_not_found"); + if !peer_chat { + // No permission. + server + .delete_room(rid, &BOB) + .await + .expect_api_err(StatusCode::FORBIDDEN, "permission_denied"); + } + + // Not deleted yet. + server + .get::(&format!("/room/{rid}"), Some(&auth(&ALICE))) + .await + .unwrap(); + + // OK, deleted. + server + .delete_room(rid, if alice_delete { &ALICE } else { &BOB }) + .await + .unwrap(); + + // Should be deleted now. + server + .get::(&format!("/room/{rid}"), Some(&auth(&ALICE))) + .await + .expect_api_err(StatusCode::NOT_FOUND, "room_not_found"); + + // Peer found it deleted and cannot delete it again. + server + .delete_room(rid, if !alice_delete { &ALICE } else { &BOB }) + .await + .expect_api_err(StatusCode::NOT_FOUND, "room_not_found"); +} + #[rstest] #[tokio::test] async fn register(server: Server) { diff --git a/docs/webapi.yaml b/docs/webapi.yaml index 3e2b10d..e4951e9 100644 --- a/docs/webapi.yaml +++ b/docs/webapi.yaml @@ -264,6 +264,26 @@ paths: schema: $ref: '#/components/schemas/ApiError' + delete: + summary: Delete a room + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Signed-DeleteRoom' + + responses: + 204: + description: Operation completed. + + 401: + description: Missing or invalid Authorization header. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + + /_blah/room/{rid}/admin: post: summary: Room management @@ -712,6 +732,34 @@ components: peer: type: string + Signed-DeleteRoom: + 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: 'delete_room' + room: + type: integer + format: in64 + Signed-UserRegister: type: object properties: