From 501b3e8db4c206279c35de0eaa59ac872799eb02 Mon Sep 17 00:00:00 2001 From: oxalica Date: Thu, 29 Aug 2024 15:57:46 -0400 Subject: [PATCH] Impl private rooms --- blahctl/src/main.rs | 12 ++++- init.sql | 3 +- pages/main.js | 68 +++++++++++++++--------- src/main.rs | 123 +++++++++++++++++++++++++++++++------------- src/types.rs | 17 +++++- 5 files changed, 159 insertions(+), 64 deletions(-) diff --git a/blahctl/src/main.rs b/blahctl/src/main.rs index d1918da..fae07e0 100644 --- a/blahctl/src/main.rs +++ b/blahctl/src/main.rs @@ -4,7 +4,7 @@ use std::{fs, io}; use anyhow::{Context, Result}; use bitflags::Flags; -use blah::types::{ChatPayload, CreateRoomPayload, ServerPermission, UserKey, WithSig}; +use blah::types::{ChatPayload, CreateRoomPayload, RoomAttrs, 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}; @@ -77,6 +77,9 @@ enum ApiCommand { #[arg(long)] title: String, + + #[arg(long, value_parser = flag_parser::)] + attrs: Option, }, PostChat { #[arg(long, short = 'f')] @@ -201,9 +204,14 @@ async fn main_api(api_url: Url, command: ApiCommand) -> Result<()> { ApiCommand::CreateRoom { private_key_file, title, + attrs, } => { let key = load_signing_key(&private_key_file)?; - let payload = WithSig::sign(&key, &mut OsRng, CreateRoomPayload { title })?; + let payload = CreateRoomPayload { + title, + attrs: attrs.unwrap_or_default(), + }; + let payload = WithSig::sign(&key, &mut OsRng, payload)?; let ret = client .post(api_url.join("/room/create")?) diff --git a/init.sql b/init.sql index 499f1c3..218367e 100644 --- a/init.sql +++ b/init.sql @@ -10,7 +10,8 @@ CREATE TABLE IF NOT EXISTS `user` ( CREATE TABLE IF NOT EXISTS `room` ( `rid` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, `ruuid` BLOB NOT NULL UNIQUE, - `title` TEXT NOT NULL + `title` TEXT NOT NULL, + `attrs` INTEGER NOT NULL ) STRICT; CREATE TABLE IF NOT EXISTS `room_member` ( diff --git a/pages/main.js b/pages/main.js index e89b241..af88027 100644 --- a/pages/main.js +++ b/pages/main.js @@ -131,18 +131,32 @@ async function connectRoom(url) { log(`fetching room: ${url}`); - fetch(`${url}/item`) - .then((resp) => resp.json()) - // TODO: This response format is to-be-decided. - .then(async (json) => { - const [{ title }, items] = json - document.title = `room: ${title}` - items.reverse(); - for (const [_cid, chat] of items) { - await showChatMsg(chat); - } - log('---history---') - }); + const auth = await signData({ typ: 'auth' }); + fetch( + `${url}/item`, + { + headers: { + 'Authorization': auth, + }, + }, + ) + .then((resp) => { + if (!resp.ok) throw new Error(`status ${resp.status} ${resp.statusText}`); + return resp.json(); + }) + // TODO: This response format is to-be-decided. + .then(async (json) => { + const [{ title }, items] = json + document.title = `room: ${title}` + items.reverse(); + for (const [_cid, chat] of items) { + await showChatMsg(chat); + } + log('---history---'); + }) + .catch((e) => { + log(`failed to fetch history: ${e}`); + }); // TODO: There is a time window where events would be lost. @@ -161,23 +175,14 @@ async function connectRoom(url) { }; } -async function postChat(text) { - text = text.trim(); - if (keypair === null || roomUuid === null || text === '') return; - - chatInput.disabled = true; - +async function signData(payload) { const userKey = bufToHex(await crypto.subtle.exportKey('raw', keypair.publicKey)); const nonceBuf = new Uint32Array(1); crypto.getRandomValues(nonceBuf); const timestamp = (Number(new Date()) / 1000) | 0; const signee = { nonce: nonceBuf[0], - payload: { - typ: 'chat', - room: roomUuid, - text, - }, + payload, timestamp, user: userKey, }; @@ -185,12 +190,25 @@ async function postChat(text) { const signeeBytes = (new TextEncoder()).encode(JSON.stringify(signee)); const sig = await crypto.subtle.sign('Ed25519', keypair.privateKey, signeeBytes); - const payload = JSON.stringify({ sig: bufToHex(sig), signee }); + return JSON.stringify({ sig: bufToHex(sig), signee }); +} + +async function postChat(text) { + text = text.trim(); + if (keypair === null || roomUuid === null || text === '') return; + + chatInput.disabled = true; + try { + const signedPayload = await signData({ + typ: 'chat', + room: roomUuid, + text, + }); const resp = await fetch(`${roomUrl}/item`, { method: 'POST', cache: 'no-cache', - body: payload, + body: signedPayload, headers: { 'Content-Type': 'application/json', }, diff --git a/src/main.rs b/src/main.rs index b3ca7c7..08fced6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,16 +6,17 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, SystemTime}; use anyhow::{ensure, Context, Result}; -use axum::extract::{FromRequest, Path, Query, Request, State}; -use axum::http::{header, StatusCode}; +use axum::extract::{FromRequest, FromRequestParts, Path, Query, Request, State}; +use axum::http::{header, request, StatusCode}; use axum::response::{sse, IntoResponse, Response}; use axum::routing::{get, post}; use axum::{async_trait, Json, Router}; use blah::types::{ - ChatItem, ChatPayload, CreateRoomPayload, RoomPermission, ServerPermission, Signee, WithSig, + AuthPayload, ChatItem, ChatPayload, CreateRoomPayload, RoomAttrs, RoomPermission, + ServerPermission, Signee, UserKey, WithSig, }; use ed25519_dalek::SIGNATURE_LENGTH; -use rusqlite::{named_params, params, OptionalExtension}; +use rusqlite::{named_params, params, OptionalExtension, Row}; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use tokio::sync::broadcast; @@ -198,9 +199,11 @@ async fn room_get_item( st: ArcState, Path(ruuid): Path, params: Query, + OptionalAuth(user): OptionalAuth, ) -> Result { let (room_meta, items) = - query_room_items(&st.conn.lock().unwrap(), ruuid, ¶ms).map_err(from_db_error)?; + query_room_items(&st.conn.lock().unwrap(), ruuid, user.as_ref(), ¶ms) + .map_err(from_db_error)?; // TODO: This format is to-be-decided. Or do we even need this interface other than // `feed.json`? @@ -213,7 +216,7 @@ async fn room_get_feed( params: Query, ) -> Result { let (room_meta, items) = - query_room_items(&st.conn.lock().unwrap(), ruuid, ¶ms).map_err(from_db_error)?; + query_room_items(&st.conn.lock().unwrap(), ruuid, None, ¶ms).map_err(from_db_error)?; let items = items .into_iter() @@ -292,27 +295,51 @@ struct FeedItemExtra { #[derive(Debug, Serialize, Deserialize)] pub struct RoomMetadata { pub title: String, + pub attrs: RoomAttrs, +} + +fn get_room_if_readable( + conn: &rusqlite::Connection, + ruuid: Uuid, + user: Option<&UserKey>, + f: impl FnOnce(&Row<'_>) -> rusqlite::Result, +) -> rusqlite::Result { + conn.query_row( + r" + SELECT `rid`, `title`, `attrs` + FROM `room` + WHERE `ruuid` = :ruuid AND + ((`attrs` & :perm) = :perm OR + EXISTS(SELECT 1 + FROM `room_member` + JOIN `user` USING (`uid`) + WHERE `room_member`.`rid` = `room`.`rid` AND + `userkey` = :userkey)) + ", + named_params! { + ":perm": RoomAttrs::PUBLIC_READABLE, + ":ruuid": ruuid, + ":userkey": user, + }, + f, + ) } fn query_room_items( conn: &rusqlite::Connection, ruuid: Uuid, + user: Option<&UserKey>, params: &GetRoomItemParams, ) -> rusqlite::Result<(RoomMetadata, Vec<(u64, ChatItem)>)> { - let (rid, title) = conn.query_row( - r" - SELECT `rid`, `title` - FROM `room` - WHERE `ruuid` = ? - ", - params![ruuid], - |row| { - let rid = row.get::<_, u64>("rid")?; - let title = row.get::<_, String>("title")?; - Ok((rid, title)) - }, - )?; - let room_meta = RoomMetadata { title }; + let (rid, title, attrs) = get_room_if_readable(conn, ruuid, user, |row| { + Ok(( + row.get::<_, u64>("rid")?, + row.get::<_, String>("title")?, + row.get::<_, RoomAttrs>("attrs")?, + )) + })?; + + let room_meta = RoomMetadata { title, attrs }; let mut stmt = conn.prepare( r" @@ -374,6 +401,38 @@ impl FromRequest for SignedJ } } +/// Extractor for optional verified JSON authorization header. +#[derive(Debug)] +struct OptionalAuth(Option); + +#[async_trait] +impl FromRequestParts for OptionalAuth { + type Rejection = StatusCode; + + async fn from_request_parts( + parts: &mut request::Parts, + _state: &S, + ) -> Result { + let Some(auth) = parts.headers.get(header::AUTHORIZATION) else { + return Ok(Self(None)); + }; + + let ret = serde_json::from_slice::>(auth.as_bytes()) + .context("invalid JSON") + .and_then(|data| { + data.verify()?; + Ok(data.signee.user) + }); + match ret { + Ok(user) => Ok(Self(Some(user))), + Err(err) => { + tracing::debug!(%err, "invalid authorization"); + Err(StatusCode::BAD_REQUEST) + } + } + } +} + async fn room_post_item( st: ArcState, Path(ruuid): Path, @@ -443,23 +502,17 @@ async fn room_post_item( } async fn room_event( - Path(ruuid): Path, st: ArcState, + Path(ruuid): Path, + // TODO: There is actually no way to add headers via `EventSource` in client side. + // But this API is kinda temporary and need a better replacement anyway. + // So just only support public room for now. + OptionalAuth(user): OptionalAuth, ) -> Result { - let rid = st - .conn - .lock() - .unwrap() - .query_row( - r" - SELECT `rid` - FROM `room` - WHERE `ruuid` = ? - ", - params![ruuid], - |row| row.get::<_, u64>(0), - ) - .map_err(from_db_error)?; + let rid = get_room_if_readable(&st.conn.lock().unwrap(), ruuid, user.as_ref(), |row| { + row.get::<_, u64>(0) + }) + .map_err(from_db_error)?; let rx = match st.room_listeners.lock().unwrap().entry(rid) { Entry::Occupied(ent) => ent.get().subscribe(), diff --git a/src/types.rs b/src/types.rs index 8a46fd2..f064b1f 100644 --- a/src/types.rs +++ b/src/types.rs @@ -92,8 +92,16 @@ pub type ChatItem = WithSig; #[serde(tag = "typ", rename = "create_room")] pub struct CreateRoomPayload { pub title: String, + pub attrs: RoomAttrs, } +/// Proof of room membership for read-access. +/// +/// TODO: Should we use JWT here instead? +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "typ", rename = "auth")] +pub struct AuthPayload {} + #[derive(Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields, tag = "typ", rename_all = "snake_case")] pub enum RoomAdminPayload { @@ -121,6 +129,13 @@ bitflags! { const ALL = !0; } + + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] + pub struct RoomAttrs: u64 { + const PUBLIC_READABLE = 1 << 0; + + const _ = !0; + } } mod sql_impl { @@ -165,5 +180,5 @@ mod sql_impl { }; } - impl_u64_flag!(ServerPermission, RoomPermission); + impl_u64_flag!(ServerPermission, RoomPermission, RoomAttrs); }