diff --git a/blahd/docs/webapi.yaml b/blahd/docs/webapi.yaml index feea19d..f252d13 100644 --- a/blahd/docs/webapi.yaml +++ b/blahd/docs/webapi.yaml @@ -4,6 +4,23 @@ info: version: 0.0.1 paths: + /room: + get: + summary: Get room metadata + responses: + 200: + content: + application/json: + schema: + $ref: '#/components/schemas/RoomMetadata' + 404: + description: | + Room does not exist or the user does not have permission to get metadata of it. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + /room/create: post: summary: Create a new room @@ -144,6 +161,36 @@ paths: application/json: $ref: '#/components/schemas/ApiError' + /room/{ruuid}/admin: + post: + summary: Room management + requestBody: + content: + application/json: + schema: + $ref: WithSig + example: + sig: 99a77e836538268839ed3419c649eefb043cb51d448f641cc2a1c523811aab4aacd09f92e7c0688ffd659bfc6acb764fea79979a491e132bf6a56dd23adc1d09 + signee: + nonce: 670593955 + payload: + permission: 1 + room: 7ed9e067-ec37-4054-9fc2-b1bd890929bd + typ: add_member + user: 83ce46ced47ec0391c64846cbb6c507250ead4985b6a044d68751edc46015dd7 + timestamp: 1724966284 + user: 83ce46ced47ec0391c64846cbb6c507250ead4985b6a044d68751edc46015dd7 + responses: + 204: + description: Operation completed. + 404: + description: | + Room does not exist or the user does not have permission for management. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + components: schemas: ApiError: @@ -156,3 +203,11 @@ components: type: string message: type: string + + RoomMetadata: + type: object + properties: + title: + type: string + attrs: + type: int64 diff --git a/blahd/src/main.rs b/blahd/src/main.rs index ce21b94..2b9221f 100644 --- a/blahd/src/main.rs +++ b/blahd/src/main.rs @@ -13,8 +13,8 @@ use axum::routing::{get, post}; use axum::{Json, Router}; use axum_extra::extract::WithRejection; use blah::types::{ - ChatItem, ChatPayload, CreateRoomPayload, MemberPermission, RoomAttrs, ServerPermission, - Signee, UserKey, WithSig, + ChatItem, ChatPayload, CreateRoomPayload, MemberPermission, RoomAdminPayload, RoomAttrs, + ServerPermission, Signee, UserKey, WithSig, }; use config::Config; use ed25519_dalek::SIGNATURE_LENGTH; @@ -153,10 +153,12 @@ async fn main_async(st: AppState) -> Result<()> { let app = Router::new() .route("/room/create", post(room_create)) + .route("/room/:ruuid", get(room_get_metadata)) // NB. Sync with `feed_url` and `next_url` generation. .route("/room/:ruuid/feed.json", get(room_get_feed)) .route("/room/:ruuid/event", get(room_event)) .route("/room/:ruuid/item", get(room_get_item).post(room_post_item)) + .route("/room/:ruuid/admin", post(room_admin)) .with_state(st.clone()) // NB. This comes at last (outmost layer), so inner errors will still be wraped with // correct CORS headers. @@ -269,6 +271,7 @@ struct GetRoomItemParams { deserialize_with = "serde_aux::field_attributes::deserialize_number_from_string" )] before_id: u64, + page_len: Option, } async fn room_get_item( @@ -284,6 +287,23 @@ async fn room_get_item( Ok(Json((room_meta, items))) } +async fn room_get_metadata( + st: ArcState, + WithRejection(Path(ruuid), _): WithRejection, ApiError>, + OptionalAuth(user): OptionalAuth, +) -> Result, ApiError> { + let (room_meta, _) = query_room_items( + &st, + ruuid, + user.as_ref(), + &GetRoomItemParams { + before_id: 0, + page_len: Some(0), + }, + )?; + Ok(Json(room_meta)) +} + async fn room_get_feed( st: ArcState, WithRejection(Path(ruuid), _): WithRejection, ApiError>, @@ -312,9 +332,14 @@ async fn room_get_feed( }) .collect::>(); + let page_len = params + .page_len + .unwrap_or(st.config.server.max_page_len) + .min(st.config.server.max_page_len); + let base_url = &st.config.server.base_url; let feed_url = format!("{base_url}/room/{ruuid}/feed.json"); - let next_url = (items.len() == st.config.server.max_page_len).then(|| { + let next_url = (items.len() == page_len).then(|| { let last_id = &items.last().expect("page size is not 0").id; format!("{feed_url}?before_id={last_id}") }); @@ -418,6 +443,15 @@ fn query_room_items( let room_meta = RoomMetadata { title, attrs }; + if params.page_len == Some(0) { + return Ok((room_meta, Vec::new())); + } + + let page_len = params + .page_len + .unwrap_or(st.config.server.max_page_len) + .min(st.config.server.max_page_len); + let mut stmt = conn.prepare( r" SELECT `cid`, `timestamp`, `nonce`, `sig`, `userkey`, `sig`, `rich_text` @@ -434,7 +468,7 @@ fn query_room_items( named_params! { ":rid": rid, ":before_cid": params.before_id, - ":limit": st.config.server.max_page_len, + ":limit": page_len, }, |row| { let cid = row.get::<_, u64>("cid")?; @@ -584,3 +618,86 @@ async fn room_event( let stream = futures_util::stream::iter(Some(Ok(first_event))).chain(stream); Ok(sse::Sse::new(stream).keep_alive(sse::KeepAlive::default())) } + +async fn room_admin( + st: ArcState, + Path(ruuid): Path, + SignedJson(op): SignedJson, +) -> Result { + if ruuid != *op.signee.payload.room() { + return Err(error_response!( + StatusCode::BAD_REQUEST, + "invalid_request", + "URI and payload room id mismatch", + )); + } + + let RoomAdminPayload::AddMember { + permission, user, .. + } = op.signee.payload; + if user != op.signee.user { + return Err(error_response!( + StatusCode::NOT_IMPLEMENTED, + "not_implemented", + "only self-adding is implemented yet", + )); + } + if permission.is_empty() || !MemberPermission::MAX_SELF_ADD.contains(permission) { + return Err(error_response!( + StatusCode::BAD_REQUEST, + "deserialization", + "invalid permission", + )); + } + + let mut conn = st.conn.lock().unwrap(); + let txn = conn.transaction()?; + let Some(rid) = txn + .query_row( + r" + SELECT `rid` + FROM `room` + WHERE `ruuid` = :ruuid AND + (`room`.`attrs` & :joinable) = :joinable + ", + named_params! { + ":ruuid": ruuid, + ":joinable": RoomAttrs::PUBLIC_JOINABLE, + }, + |row| row.get::<_, u64>("rid"), + ) + .optional()? + else { + return Err(error_response!( + StatusCode::FORBIDDEN, + "permission_denied", + "room does not exists or user is not allowed to join this room", + )); + }; + txn.execute( + r" + INSERT INTO `user` (`userkey`) + VALUES (?) + ON CONFLICT (`userkey`) DO NOTHING + ", + params![user], + )?; + txn.execute( + r" + INSERT INTO `room_member` (`rid`, `uid`, `permission`) + SELECT :rid, `uid`, :perm + FROM `user` + WHERE `userkey` = :userkey + ON CONFLICT (`rid`, `uid`) DO UPDATE SET + `permission` = :perm + ", + named_params! { + ":rid": rid, + ":userkey": user, + ":perm": permission, + }, + )?; + txn.commit()?; + + Ok(StatusCode::NO_CONTENT) +} diff --git a/pages/index.html b/pages/index.html index b2ac964..339c6a5 100644 --- a/pages/index.html +++ b/pages/index.html @@ -63,6 +63,7 @@ pattern="https://.*" required /> +
diff --git a/pages/main.js b/pages/main.js index 5389974..fa8f53e 100644 --- a/pages/main.js +++ b/pages/main.js @@ -3,6 +3,7 @@ const userPubkeyDisplay = document.querySelector('#user-pubkey'); const roomUrlInput = document.querySelector('#room-url'); const chatInput = document.querySelector('#chat'); const regenKeyBtn = document.querySelector('#regen-key'); +const joinRoomBtn = document.querySelector('#join-room'); let roomUrl = ''; let roomUuid = null; @@ -19,6 +20,11 @@ function hexToBuf(hex) { return new Uint8Array(hex.match(/[\da-f]{2}/gi).map(m => parseInt(m, 16))) } +async function getUserPubkey() { + if (keypair === null) throw new Error('no userkey'); + return bufToHex(await crypto.subtle.exportKey('raw', keypair.publicKey)); +} + function appendMsg(el) { msgFlow.append(el); msgFlow.scrollTo({ @@ -60,6 +66,7 @@ async function generateKeypair() { log('generating keypair'); regenKeyBtn.disabled = true; chatInput.disabled = true; + joinRoomBtn.disabled = true; try { keypair = await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify']); } catch (e) { @@ -79,6 +86,7 @@ async function generateKeypair() { regenKeyBtn.disabled = false; chatInput.disabled = false; + joinRoomBtn.disabled = false; try { const ser = (k) => crypto.subtle.exportKey('jwk', k); @@ -94,7 +102,6 @@ async function generateKeypair() { async function showChatMsg(chat) { let verifyRet = null; - crypto.subtle.exportKey('raw', keypair.publicKey) try { const sortKeys = (obj) => Object.fromEntries(Object.entries(obj).sort((lhs, rhs) => lhs[0] > rhs[0])); @@ -217,8 +224,44 @@ async function connectRoom(url) { }; } +async function joinRoom() { + try { + joinRoomBtn.disabled = true; + await signAndPost(`${roomUrl}/admin`, { + // sorted fields. + permission: 1, // POST_CHAT + room: roomUuid, + typ: 'add_member', + user: await getUserPubkey(), + }); + log('joined room'); + } catch (e) { + console.error(e); + log(`failed to join room: ${e}`); + } finally { + joinRoomBtn.disabled = false; + } +} + +async function signAndPost(url, data) { + const signedPayload = await signData(data); + const resp = await fetch(url, { + method: 'POST', + cache: 'no-cache', + body: signedPayload, + headers: { + 'Content-Type': 'application/json', + }, + }); + if (!resp.ok) { + const errResp = await resp.json(); + throw new Error(`status ${resp.status}: ${errResp.error.message}`); + } + return resp; +} + async function signData(payload) { - const userKey = bufToHex(await crypto.subtle.exportKey('raw', keypair.publicKey)); + const userKey = await getUserPubkey(); const nonceBuf = new Uint32Array(1); crypto.getRandomValues(nonceBuf); const timestamp = (Number(new Date()) / 1000) | 0; @@ -248,24 +291,12 @@ async function postChat(text) { } else { richText = [text]; } - const signedPayload = await signData({ + await signAndPost(`${roomUrl}/item`, { // sorted fields. rich_text: richText, room: roomUuid, typ: 'chat', }); - const resp = await fetch(`${roomUrl}/item`, { - method: 'POST', - cache: 'no-cache', - body: signedPayload, - headers: { - 'Content-Type': 'application/json', - }, - }); - if (!resp.ok) { - const errResp = await resp.json(); - throw new Error(`status ${resp.status}: ${errResp.error.message}`); - } chatInput.value = ''; } catch (e) { console.error(e); @@ -280,20 +311,21 @@ window.onload = async (_) => { await generateKeypair(); } if (keypair !== null) { - userPubkeyDisplay.value = bufToHex(await crypto.subtle.exportKey('raw', keypair.publicKey)); + userPubkeyDisplay.value = await getUserPubkey(); } - connectRoom(roomUrlInput.value); + await connectRoom(roomUrlInput.value); }; -roomUrlInput.onchange = (e) => { - connectRoom(e.target.value); +roomUrlInput.onchange = async (e) => { + await connectRoom(e.target.value); }; -chatInput.onkeypress = (e) => { +chatInput.onkeypress = async (e) => { if (e.key === 'Enter') { - chatInput.disabled = true; - postChat(chatInput.value); - chatInput.disabled = false; + await postChat(chatInput.value); } }; -regenKeyBtn.onclick = (_) => { - generateKeypair(); +regenKeyBtn.onclick = async (_) => { + await generateKeypair(); +}; +joinRoomBtn.onclick = async (_) => { + await joinRoom(); }; diff --git a/src/types.rs b/src/types.rs index 183338c..4279345 100644 --- a/src/types.rs +++ b/src/types.rs @@ -337,6 +337,14 @@ pub enum RoomAdminPayload { // TODO: CRUD } +impl RoomAdminPayload { + pub fn room(&self) -> &Uuid { + match self { + RoomAdminPayload::AddMember { room, .. } => room, + } + } +} + bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ServerPermission: u64 { @@ -350,12 +358,15 @@ bitflags! { const POST_CHAT = 1 << 0; const ADD_MEMBER = 1 << 1; + const MAX_SELF_ADD = Self::POST_CHAT.bits(); + const ALL = !0; } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct RoomAttrs: u64 { const PUBLIC_READABLE = 1 << 0; + const PUBLIC_JOINABLE = 1 << 1; const _ = !0; }