diff --git a/blahctl/src/main.rs b/blahctl/src/main.rs index fae07e0..a7f5038 100644 --- a/blahctl/src/main.rs +++ b/blahctl/src/main.rs @@ -4,7 +4,10 @@ use std::{fs, io}; use anyhow::{Context, Result}; use bitflags::Flags; -use blah::types::{ChatPayload, CreateRoomPayload, RoomAttrs, ServerPermission, UserKey, WithSig}; +use blah::types::{ + ChatPayload, CreateRoomPayload, MemberPermission, RoomAttrs, RoomMember, RoomMemberList, + ServerPermission, UserKey, WithSig, +}; use ed25519_dalek::pkcs8::spki::der::pem::LineEnding; use ed25519_dalek::pkcs8::{DecodePrivateKey, DecodePublicKey, EncodePrivateKey, EncodePublicKey}; use ed25519_dalek::{SigningKey, VerifyingKey, PUBLIC_KEY_LENGTH}; @@ -208,8 +211,14 @@ async fn main_api(api_url: Url, command: ApiCommand) -> Result<()> { } => { let key = load_signing_key(&private_key_file)?; let payload = CreateRoomPayload { - title, attrs: attrs.unwrap_or_default(), + title, + // The CLI does not support passing multiple members because `User` itself is a + // disjoint arg-group. + members: RoomMemberList(vec![RoomMember { + permission: MemberPermission::ALL, + user: UserKey(key.verifying_key().to_bytes()), + }]), }; let payload = WithSig::sign(&key, &mut OsRng, payload)?; diff --git a/src/main.rs b/src/main.rs index a49592f..f170645 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,7 +12,7 @@ use axum::response::{sse, IntoResponse, Response}; use axum::routing::{get, post}; use axum::{async_trait, Json, Router}; use blah::types::{ - AuthPayload, ChatItem, ChatPayload, CreateRoomPayload, RoomAttrs, RoomPermission, + AuthPayload, ChatItem, ChatPayload, CreateRoomPayload, MemberPermission, RoomAttrs, ServerPermission, Signee, UserKey, WithSig, }; use ed25519_dalek::SIGNATURE_LENGTH; @@ -128,24 +128,30 @@ async fn room_create( st: ArcState, SignedJson(params): SignedJson, ) -> Result, StatusCode> { + let members = ¶ms.signee.payload.members.0; + if !members + .iter() + .any(|m| m.user == params.signee.user && m.permission == MemberPermission::ALL) + { + return Err(StatusCode::BAD_REQUEST); + } + let mut conn = st.conn.lock().unwrap(); - let Some((uid, _perm)) = conn + let Some(true) = conn .query_row( r" - SELECT `uid`, `permission` + SELECT `permission` FROM `user` WHERE `userkey` = ? ", params![params.signee.user], |row| { - let uid = row.get::<_, u64>("uid")?; let perm = row.get::<_, ServerPermission>("permission")?; - Ok((uid, perm)) + Ok(perm.contains(ServerPermission::CREATE_ROOM)) }, ) .optional() .map_err(from_db_error)? - .filter(|(_, perm)| perm.contains(ServerPermission::CREATE_ROOM)) else { return Err(StatusCode::FORBIDDEN); }; @@ -166,17 +172,31 @@ async fn room_create( }, |row| row.get::<_, u64>(0), )?; - txn.execute( + let mut insert_user = txn.prepare( + r" + INSERT INTO `user` (`userkey`) + VALUES (?) + ON CONFLICT (`userkey`) DO NOTHING + ", + )?; + let mut insert_member = txn.prepare( r" INSERT INTO `room_member` (`rid`, `uid`, `permission`) - VALUES (:rid, :uid, :permission) + SELECT :rid, `uid`, :permission + FROM `user` + WHERE `userkey` = :userkey ", - named_params! { - ":rid": rid, - ":uid": uid, - ":permission": RoomPermission::ALL, - }, )?; + for member in members { + insert_user.execute(params![member.user])?; + insert_member.execute(named_params! { + ":rid": rid, + ":userkey": member.user, + ":permission": member.permission, + })?; + } + drop(insert_member); + drop(insert_user); txn.commit()?; Ok(()) })() @@ -458,7 +478,7 @@ async fn room_post_item( named_params! { ":ruuid": ruuid, ":userkey": &chat.signee.user, - ":perm": RoomPermission::POST_CHAT, + ":perm": MemberPermission::POST_CHAT, }, |row| Ok((row.get::<_, u64>("rid")?, row.get::<_, u64>("uid")?)), ) diff --git a/src/types.rs b/src/types.rs index 6c73d1d..dd3afe5 100644 --- a/src/types.rs +++ b/src/types.rs @@ -17,7 +17,7 @@ use uuid::Uuid; const TIMESTAMP_TOLERENCE: u64 = 90; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(transparent)] pub struct UserKey(#[serde(with = "hex::serde")] pub [u8; PUBLIC_KEY_LENGTH]); @@ -93,9 +93,46 @@ pub type ChatItem = WithSig; #[serde(tag = "typ", rename = "create_room")] pub struct CreateRoomPayload { pub attrs: RoomAttrs, + /// The initial member list. Besides invariants of `RoomMemberList`, this also must include the + /// room creator themselves, with the highest permission (-1). + pub members: RoomMemberList, pub title: String, } +/// A collection of room members, with these invariants: +/// 1. Sorted by userkeys. +/// 2. No duplicated users. +#[derive(Debug, Deserialize)] +#[serde(try_from = "Vec")] +pub struct RoomMemberList(pub Vec); + +impl Serialize for RoomMemberList { + fn serialize(&self, ser: S) -> Result + where + S: serde::Serializer, + { + self.0.serialize(ser) + } +} + +impl TryFrom> for RoomMemberList { + type Error = &'static str; + + fn try_from(members: Vec) -> Result { + if members.windows(2).all(|w| w[0].user.0 < w[1].user.0) { + Ok(Self(members)) + } else { + Err("unsorted or duplicated users") + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RoomMember { + pub permission: MemberPermission, + pub user: UserKey, +} + /// Proof of room membership for read-access. /// /// TODO: Should we use JWT here instead? @@ -107,7 +144,7 @@ pub struct AuthPayload {} #[serde(deny_unknown_fields, tag = "typ", rename_all = "snake_case")] pub enum RoomAdminPayload { AddMember { - permission: RoomPermission, + permission: MemberPermission, room: Uuid, user: UserKey, }, @@ -123,7 +160,7 @@ bitflags! { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] - pub struct RoomPermission: u64 { + pub struct MemberPermission: u64 { const POST_CHAT = 1 << 0; const ADD_MEMBER = 1 << 1; @@ -139,7 +176,7 @@ bitflags! { } impl_serde_for_bitflags!(ServerPermission); -impl_serde_for_bitflags!(RoomPermission); +impl_serde_for_bitflags!(MemberPermission); impl_serde_for_bitflags!(RoomAttrs); mod sql_impl { @@ -184,7 +221,7 @@ mod sql_impl { }; } - impl_u64_flag!(ServerPermission, RoomPermission, RoomAttrs); + impl_u64_flag!(ServerPermission, MemberPermission, RoomAttrs); } #[cfg(test)]