refactor(webapi): split /room/:rid/admin endpoint and deprecate

Since we alraedy use `/room/:rid/member`, member CRUD can use this path
for better semantics.

The `admin` endpoint will be removed later.
This commit is contained in:
oxalica 2024-10-12 06:52:54 -04:00
parent b8921a5485
commit d1dfda51db
4 changed files with 235 additions and 97 deletions

View file

@ -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();

View file

@ -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<Arc<AppState>>;
pub fn router(st: Arc<AppState>) -> Router {
// NB. User consistent handler naming: `<method>_<path>[_<details>]`.
use axum::routing::{delete, get, post};
// NB. Use consistent handler naming: `<method>_<path>[_<details>]`.
// Use prefix `list` for GET with pagination.
//
// One route per line.
@ -165,8 +168,11 @@ pub fn router(st: Arc<AppState>) -> Router {
.route("/room/:rid/feed.atom", get(feed::get_room_feed::<feed::AtomFeed>))
.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<Path<Id>>,
SignedJson(op): SignedJson<RoomAdminPayload>,
) -> Result<StatusCode, ApiError> {
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<T: Serialize, U: DeserializeOwned>(v: &T) -> SignedJson<U> {
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<Path<Id>>,
SignedJson(op): SignedJson<AddMemberPayload>,
) -> Result<StatusCode, ApiError> {
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<Path<(Id, PubKey)>>,
SignedJson(op): SignedJson<RemoveMemberPayload>,
) -> Result<StatusCode, ApiError> {
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)
})
}

View file

@ -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<Output = Result<()>> + 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<Output = Result<()>> + 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!(

View file

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