Impl /room and /room/{ruuid}/admin endpoints

This commit is contained in:
oxalica 2024-08-31 18:33:23 -04:00
parent e84b13c876
commit 5d15900436
5 changed files with 245 additions and 29 deletions

View file

@ -4,6 +4,23 @@ info:
version: 0.0.1 version: 0.0.1
paths: 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: /room/create:
post: post:
summary: Create a new room summary: Create a new room
@ -144,6 +161,36 @@ paths:
application/json: application/json:
$ref: '#/components/schemas/ApiError' $ref: '#/components/schemas/ApiError'
/room/{ruuid}/admin:
post:
summary: Room management
requestBody:
content:
application/json:
schema:
$ref: WithSig<ChatPayload>
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: components:
schemas: schemas:
ApiError: ApiError:
@ -156,3 +203,11 @@ components:
type: string type: string
message: message:
type: string type: string
RoomMetadata:
type: object
properties:
title:
type: string
attrs:
type: int64

View file

@ -13,8 +13,8 @@ use axum::routing::{get, post};
use axum::{Json, Router}; use axum::{Json, Router};
use axum_extra::extract::WithRejection; use axum_extra::extract::WithRejection;
use blah::types::{ use blah::types::{
ChatItem, ChatPayload, CreateRoomPayload, MemberPermission, RoomAttrs, ServerPermission, ChatItem, ChatPayload, CreateRoomPayload, MemberPermission, RoomAdminPayload, RoomAttrs,
Signee, UserKey, WithSig, ServerPermission, Signee, UserKey, WithSig,
}; };
use config::Config; use config::Config;
use ed25519_dalek::SIGNATURE_LENGTH; use ed25519_dalek::SIGNATURE_LENGTH;
@ -153,10 +153,12 @@ async fn main_async(st: AppState) -> Result<()> {
let app = Router::new() let app = Router::new()
.route("/room/create", post(room_create)) .route("/room/create", post(room_create))
.route("/room/:ruuid", get(room_get_metadata))
// NB. Sync with `feed_url` and `next_url` generation. // NB. Sync with `feed_url` and `next_url` generation.
.route("/room/:ruuid/feed.json", get(room_get_feed)) .route("/room/:ruuid/feed.json", get(room_get_feed))
.route("/room/:ruuid/event", get(room_event)) .route("/room/:ruuid/event", get(room_event))
.route("/room/:ruuid/item", get(room_get_item).post(room_post_item)) .route("/room/:ruuid/item", get(room_get_item).post(room_post_item))
.route("/room/:ruuid/admin", post(room_admin))
.with_state(st.clone()) .with_state(st.clone())
// NB. This comes at last (outmost layer), so inner errors will still be wraped with // NB. This comes at last (outmost layer), so inner errors will still be wraped with
// correct CORS headers. // correct CORS headers.
@ -269,6 +271,7 @@ struct GetRoomItemParams {
deserialize_with = "serde_aux::field_attributes::deserialize_number_from_string" deserialize_with = "serde_aux::field_attributes::deserialize_number_from_string"
)] )]
before_id: u64, before_id: u64,
page_len: Option<usize>,
} }
async fn room_get_item( async fn room_get_item(
@ -284,6 +287,23 @@ async fn room_get_item(
Ok(Json((room_meta, items))) Ok(Json((room_meta, items)))
} }
async fn room_get_metadata(
st: ArcState,
WithRejection(Path(ruuid), _): WithRejection<Path<Uuid>, ApiError>,
OptionalAuth(user): OptionalAuth,
) -> Result<Json<RoomMetadata>, 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( async fn room_get_feed(
st: ArcState, st: ArcState,
WithRejection(Path(ruuid), _): WithRejection<Path<Uuid>, ApiError>, WithRejection(Path(ruuid), _): WithRejection<Path<Uuid>, ApiError>,
@ -312,9 +332,14 @@ async fn room_get_feed(
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
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 base_url = &st.config.server.base_url;
let feed_url = format!("{base_url}/room/{ruuid}/feed.json"); 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; let last_id = &items.last().expect("page size is not 0").id;
format!("{feed_url}?before_id={last_id}") format!("{feed_url}?before_id={last_id}")
}); });
@ -418,6 +443,15 @@ fn query_room_items(
let room_meta = RoomMetadata { title, attrs }; 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( let mut stmt = conn.prepare(
r" r"
SELECT `cid`, `timestamp`, `nonce`, `sig`, `userkey`, `sig`, `rich_text` SELECT `cid`, `timestamp`, `nonce`, `sig`, `userkey`, `sig`, `rich_text`
@ -434,7 +468,7 @@ fn query_room_items(
named_params! { named_params! {
":rid": rid, ":rid": rid,
":before_cid": params.before_id, ":before_cid": params.before_id,
":limit": st.config.server.max_page_len, ":limit": page_len,
}, },
|row| { |row| {
let cid = row.get::<_, u64>("cid")?; 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); let stream = futures_util::stream::iter(Some(Ok(first_event))).chain(stream);
Ok(sse::Sse::new(stream).keep_alive(sse::KeepAlive::default())) Ok(sse::Sse::new(stream).keep_alive(sse::KeepAlive::default()))
} }
async fn room_admin(
st: ArcState,
Path(ruuid): Path<Uuid>,
SignedJson(op): SignedJson<RoomAdminPayload>,
) -> Result<StatusCode, ApiError> {
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)
}

View file

@ -63,6 +63,7 @@
pattern="https://.*" pattern="https://.*"
required required
/> />
<button id="join-room">join room</button>
</div> </div>
<div> <div>
<label for="chat">chat:</label> <label for="chat">chat:</label>

View file

@ -3,6 +3,7 @@ const userPubkeyDisplay = document.querySelector('#user-pubkey');
const roomUrlInput = document.querySelector('#room-url'); const roomUrlInput = document.querySelector('#room-url');
const chatInput = document.querySelector('#chat'); const chatInput = document.querySelector('#chat');
const regenKeyBtn = document.querySelector('#regen-key'); const regenKeyBtn = document.querySelector('#regen-key');
const joinRoomBtn = document.querySelector('#join-room');
let roomUrl = ''; let roomUrl = '';
let roomUuid = null; let roomUuid = null;
@ -19,6 +20,11 @@ function hexToBuf(hex) {
return new Uint8Array(hex.match(/[\da-f]{2}/gi).map(m => parseInt(m, 16))) 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) { function appendMsg(el) {
msgFlow.append(el); msgFlow.append(el);
msgFlow.scrollTo({ msgFlow.scrollTo({
@ -60,6 +66,7 @@ async function generateKeypair() {
log('generating keypair'); log('generating keypair');
regenKeyBtn.disabled = true; regenKeyBtn.disabled = true;
chatInput.disabled = true; chatInput.disabled = true;
joinRoomBtn.disabled = true;
try { try {
keypair = await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify']); keypair = await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify']);
} catch (e) { } catch (e) {
@ -79,6 +86,7 @@ async function generateKeypair() {
regenKeyBtn.disabled = false; regenKeyBtn.disabled = false;
chatInput.disabled = false; chatInput.disabled = false;
joinRoomBtn.disabled = false;
try { try {
const ser = (k) => crypto.subtle.exportKey('jwk', k); const ser = (k) => crypto.subtle.exportKey('jwk', k);
@ -94,7 +102,6 @@ async function generateKeypair() {
async function showChatMsg(chat) { async function showChatMsg(chat) {
let verifyRet = null; let verifyRet = null;
crypto.subtle.exportKey('raw', keypair.publicKey)
try { try {
const sortKeys = (obj) => const sortKeys = (obj) =>
Object.fromEntries(Object.entries(obj).sort((lhs, rhs) => lhs[0] > rhs[0])); 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) { async function signData(payload) {
const userKey = bufToHex(await crypto.subtle.exportKey('raw', keypair.publicKey)); const userKey = await getUserPubkey();
const nonceBuf = new Uint32Array(1); const nonceBuf = new Uint32Array(1);
crypto.getRandomValues(nonceBuf); crypto.getRandomValues(nonceBuf);
const timestamp = (Number(new Date()) / 1000) | 0; const timestamp = (Number(new Date()) / 1000) | 0;
@ -248,24 +291,12 @@ async function postChat(text) {
} else { } else {
richText = [text]; richText = [text];
} }
const signedPayload = await signData({ await signAndPost(`${roomUrl}/item`, {
// sorted fields. // sorted fields.
rich_text: richText, rich_text: richText,
room: roomUuid, room: roomUuid,
typ: 'chat', 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 = ''; chatInput.value = '';
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@ -280,20 +311,21 @@ window.onload = async (_) => {
await generateKeypair(); await generateKeypair();
} }
if (keypair !== null) { 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) => { roomUrlInput.onchange = async (e) => {
connectRoom(e.target.value); await connectRoom(e.target.value);
}; };
chatInput.onkeypress = (e) => { chatInput.onkeypress = async (e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
chatInput.disabled = true; await postChat(chatInput.value);
postChat(chatInput.value);
chatInput.disabled = false;
} }
}; };
regenKeyBtn.onclick = (_) => { regenKeyBtn.onclick = async (_) => {
generateKeypair(); await generateKeypair();
};
joinRoomBtn.onclick = async (_) => {
await joinRoom();
}; };

View file

@ -337,6 +337,14 @@ pub enum RoomAdminPayload {
// TODO: CRUD // TODO: CRUD
} }
impl RoomAdminPayload {
pub fn room(&self) -> &Uuid {
match self {
RoomAdminPayload::AddMember { room, .. } => room,
}
}
}
bitflags! { bitflags! {
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ServerPermission: u64 { pub struct ServerPermission: u64 {
@ -350,12 +358,15 @@ bitflags! {
const POST_CHAT = 1 << 0; const POST_CHAT = 1 << 0;
const ADD_MEMBER = 1 << 1; const ADD_MEMBER = 1 << 1;
const MAX_SELF_ADD = Self::POST_CHAT.bits();
const ALL = !0; const ALL = !0;
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct RoomAttrs: u64 { pub struct RoomAttrs: u64 {
const PUBLIC_READABLE = 1 << 0; const PUBLIC_READABLE = 1 << 0;
const PUBLIC_JOINABLE = 1 << 1;
const _ = !0; const _ = !0;
} }