Impl private rooms

This commit is contained in:
oxalica 2024-08-29 15:57:46 -04:00
parent 9ced78d13d
commit 501b3e8db4
5 changed files with 159 additions and 64 deletions

View file

@ -4,7 +4,7 @@ use std::{fs, io};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use bitflags::Flags; 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::spki::der::pem::LineEnding;
use ed25519_dalek::pkcs8::{DecodePrivateKey, DecodePublicKey, EncodePrivateKey, EncodePublicKey}; use ed25519_dalek::pkcs8::{DecodePrivateKey, DecodePublicKey, EncodePrivateKey, EncodePublicKey};
use ed25519_dalek::{SigningKey, VerifyingKey, PUBLIC_KEY_LENGTH}; use ed25519_dalek::{SigningKey, VerifyingKey, PUBLIC_KEY_LENGTH};
@ -77,6 +77,9 @@ enum ApiCommand {
#[arg(long)] #[arg(long)]
title: String, title: String,
#[arg(long, value_parser = flag_parser::<RoomAttrs>)]
attrs: Option<RoomAttrs>,
}, },
PostChat { PostChat {
#[arg(long, short = 'f')] #[arg(long, short = 'f')]
@ -201,9 +204,14 @@ async fn main_api(api_url: Url, command: ApiCommand) -> Result<()> {
ApiCommand::CreateRoom { ApiCommand::CreateRoom {
private_key_file, private_key_file,
title, title,
attrs,
} => { } => {
let key = load_signing_key(&private_key_file)?; 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 let ret = client
.post(api_url.join("/room/create")?) .post(api_url.join("/room/create")?)

View file

@ -10,7 +10,8 @@ CREATE TABLE IF NOT EXISTS `user` (
CREATE TABLE IF NOT EXISTS `room` ( CREATE TABLE IF NOT EXISTS `room` (
`rid` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, `rid` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
`ruuid` BLOB NOT NULL UNIQUE, `ruuid` BLOB NOT NULL UNIQUE,
`title` TEXT NOT NULL `title` TEXT NOT NULL,
`attrs` INTEGER NOT NULL
) STRICT; ) STRICT;
CREATE TABLE IF NOT EXISTS `room_member` ( CREATE TABLE IF NOT EXISTS `room_member` (

View file

@ -131,8 +131,19 @@ async function connectRoom(url) {
log(`fetching room: ${url}`); log(`fetching room: ${url}`);
fetch(`${url}/item`) const auth = await signData({ typ: 'auth' });
.then((resp) => resp.json()) 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. // TODO: This response format is to-be-decided.
.then(async (json) => { .then(async (json) => {
const [{ title }, items] = json const [{ title }, items] = json
@ -141,7 +152,10 @@ async function connectRoom(url) {
for (const [_cid, chat] of items) { for (const [_cid, chat] of items) {
await showChatMsg(chat); await showChatMsg(chat);
} }
log('---history---') log('---history---');
})
.catch((e) => {
log(`failed to fetch history: ${e}`);
}); });
// TODO: There is a time window where events would be lost. // TODO: There is a time window where events would be lost.
@ -161,23 +175,14 @@ async function connectRoom(url) {
}; };
} }
async function postChat(text) { async function signData(payload) {
text = text.trim();
if (keypair === null || roomUuid === null || text === '') return;
chatInput.disabled = true;
const userKey = bufToHex(await crypto.subtle.exportKey('raw', keypair.publicKey)); const userKey = bufToHex(await crypto.subtle.exportKey('raw', keypair.publicKey));
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;
const signee = { const signee = {
nonce: nonceBuf[0], nonce: nonceBuf[0],
payload: { payload,
typ: 'chat',
room: roomUuid,
text,
},
timestamp, timestamp,
user: userKey, user: userKey,
}; };
@ -185,12 +190,25 @@ async function postChat(text) {
const signeeBytes = (new TextEncoder()).encode(JSON.stringify(signee)); const signeeBytes = (new TextEncoder()).encode(JSON.stringify(signee));
const sig = await crypto.subtle.sign('Ed25519', keypair.privateKey, signeeBytes); 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 { try {
const signedPayload = await signData({
typ: 'chat',
room: roomUuid,
text,
});
const resp = await fetch(`${roomUrl}/item`, { const resp = await fetch(`${roomUrl}/item`, {
method: 'POST', method: 'POST',
cache: 'no-cache', cache: 'no-cache',
body: payload, body: signedPayload,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },

View file

@ -6,16 +6,17 @@ use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime}; use std::time::{Duration, SystemTime};
use anyhow::{ensure, Context, Result}; use anyhow::{ensure, Context, Result};
use axum::extract::{FromRequest, Path, Query, Request, State}; use axum::extract::{FromRequest, FromRequestParts, Path, Query, Request, State};
use axum::http::{header, StatusCode}; use axum::http::{header, request, StatusCode};
use axum::response::{sse, IntoResponse, Response}; use axum::response::{sse, IntoResponse, Response};
use axum::routing::{get, post}; use axum::routing::{get, post};
use axum::{async_trait, Json, Router}; use axum::{async_trait, Json, Router};
use blah::types::{ 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 ed25519_dalek::SIGNATURE_LENGTH;
use rusqlite::{named_params, params, OptionalExtension}; use rusqlite::{named_params, params, OptionalExtension, Row};
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::sync::broadcast; use tokio::sync::broadcast;
@ -198,9 +199,11 @@ async fn room_get_item(
st: ArcState, st: ArcState,
Path(ruuid): Path<Uuid>, Path(ruuid): Path<Uuid>,
params: Query<GetRoomItemParams>, params: Query<GetRoomItemParams>,
OptionalAuth(user): OptionalAuth,
) -> Result<impl IntoResponse, StatusCode> { ) -> Result<impl IntoResponse, StatusCode> {
let (room_meta, items) = let (room_meta, items) =
query_room_items(&st.conn.lock().unwrap(), ruuid, &params).map_err(from_db_error)?; query_room_items(&st.conn.lock().unwrap(), ruuid, user.as_ref(), &params)
.map_err(from_db_error)?;
// TODO: This format is to-be-decided. Or do we even need this interface other than // TODO: This format is to-be-decided. Or do we even need this interface other than
// `feed.json`? // `feed.json`?
@ -213,7 +216,7 @@ async fn room_get_feed(
params: Query<GetRoomItemParams>, params: Query<GetRoomItemParams>,
) -> Result<impl IntoResponse, StatusCode> { ) -> Result<impl IntoResponse, StatusCode> {
let (room_meta, items) = let (room_meta, items) =
query_room_items(&st.conn.lock().unwrap(), ruuid, &params).map_err(from_db_error)?; query_room_items(&st.conn.lock().unwrap(), ruuid, None, &params).map_err(from_db_error)?;
let items = items let items = items
.into_iter() .into_iter()
@ -292,27 +295,51 @@ struct FeedItemExtra {
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct RoomMetadata { pub struct RoomMetadata {
pub title: String, pub title: String,
pub attrs: RoomAttrs,
}
fn get_room_if_readable<T>(
conn: &rusqlite::Connection,
ruuid: Uuid,
user: Option<&UserKey>,
f: impl FnOnce(&Row<'_>) -> rusqlite::Result<T>,
) -> rusqlite::Result<T> {
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( fn query_room_items(
conn: &rusqlite::Connection, conn: &rusqlite::Connection,
ruuid: Uuid, ruuid: Uuid,
user: Option<&UserKey>,
params: &GetRoomItemParams, params: &GetRoomItemParams,
) -> rusqlite::Result<(RoomMetadata, Vec<(u64, ChatItem)>)> { ) -> rusqlite::Result<(RoomMetadata, Vec<(u64, ChatItem)>)> {
let (rid, title) = conn.query_row( let (rid, title, attrs) = get_room_if_readable(conn, ruuid, user, |row| {
r" Ok((
SELECT `rid`, `title` row.get::<_, u64>("rid")?,
FROM `room` row.get::<_, String>("title")?,
WHERE `ruuid` = ? row.get::<_, RoomAttrs>("attrs")?,
", ))
params![ruuid], })?;
|row| {
let rid = row.get::<_, u64>("rid")?; let room_meta = RoomMetadata { title, attrs };
let title = row.get::<_, String>("title")?;
Ok((rid, title))
},
)?;
let room_meta = RoomMetadata { title };
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
r" r"
@ -374,6 +401,38 @@ impl<S: Send + Sync, T: Serialize + DeserializeOwned> FromRequest<S> for SignedJ
} }
} }
/// Extractor for optional verified JSON authorization header.
#[derive(Debug)]
struct OptionalAuth(Option<UserKey>);
#[async_trait]
impl<S: Send + Sync> FromRequestParts<S> for OptionalAuth {
type Rejection = StatusCode;
async fn from_request_parts(
parts: &mut request::Parts,
_state: &S,
) -> Result<Self, Self::Rejection> {
let Some(auth) = parts.headers.get(header::AUTHORIZATION) else {
return Ok(Self(None));
};
let ret = serde_json::from_slice::<WithSig<AuthPayload>>(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( async fn room_post_item(
st: ArcState, st: ArcState,
Path(ruuid): Path<Uuid>, Path(ruuid): Path<Uuid>,
@ -443,22 +502,16 @@ async fn room_post_item(
} }
async fn room_event( async fn room_event(
Path(ruuid): Path<Uuid>,
st: ArcState, st: ArcState,
Path(ruuid): Path<Uuid>,
// 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<impl IntoResponse, StatusCode> { ) -> Result<impl IntoResponse, StatusCode> {
let rid = st let rid = get_room_if_readable(&st.conn.lock().unwrap(), ruuid, user.as_ref(), |row| {
.conn row.get::<_, u64>(0)
.lock() })
.unwrap()
.query_row(
r"
SELECT `rid`
FROM `room`
WHERE `ruuid` = ?
",
params![ruuid],
|row| row.get::<_, u64>(0),
)
.map_err(from_db_error)?; .map_err(from_db_error)?;
let rx = match st.room_listeners.lock().unwrap().entry(rid) { let rx = match st.room_listeners.lock().unwrap().entry(rid) {

View file

@ -92,8 +92,16 @@ pub type ChatItem = WithSig<ChatPayload>;
#[serde(tag = "typ", rename = "create_room")] #[serde(tag = "typ", rename = "create_room")]
pub struct CreateRoomPayload { pub struct CreateRoomPayload {
pub title: String, 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)] #[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields, tag = "typ", rename_all = "snake_case")] #[serde(deny_unknown_fields, tag = "typ", rename_all = "snake_case")]
pub enum RoomAdminPayload { pub enum RoomAdminPayload {
@ -121,6 +129,13 @@ bitflags! {
const ALL = !0; 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 { mod sql_impl {
@ -165,5 +180,5 @@ mod sql_impl {
}; };
} }
impl_u64_flag!(ServerPermission, RoomPermission); impl_u64_flag!(ServerPermission, RoomPermission, RoomAttrs);
} }