feat(webapi): impl room deletion

This commit is contained in:
oxalica 2024-09-21 14:46:47 -04:00
parent 9acf857781
commit bc856f6c62
5 changed files with 205 additions and 25 deletions

View file

@ -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;
}

View file

@ -517,6 +517,18 @@ pub trait TransactionOps {
Ok(())
}
fn delete_room(&self, rid: Id) -> Result<bool> {
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(),

View file

@ -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<AppState>) -> 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<Path<Id>>,
SignedJson(op): SignedJson<DeleteRoomPayload>,
) -> Result<StatusCode, ApiError> {
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<Path<(Id, i64)>>,

View file

@ -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<Output = Result<Id>> + 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<Output = Result<()>> + 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::<RoomMetadata>(&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::<RoomMetadata>(&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) {

View file

@ -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: