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 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::<RoomAttrs>)]
attrs: Option<RoomAttrs>,
},
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")?)

View file

@ -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` (

View file

@ -131,8 +131,19 @@ async function connectRoom(url) {
log(`fetching room: ${url}`);
fetch(`${url}/item`)
.then((resp) => resp.json())
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
@ -141,7 +152,10 @@ async function connectRoom(url) {
for (const [_cid, chat] of items) {
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.
@ -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',
},

View file

@ -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<Uuid>,
params: Query<GetRoomItemParams>,
OptionalAuth(user): OptionalAuth,
) -> Result<impl IntoResponse, StatusCode> {
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
// `feed.json`?
@ -213,7 +216,7 @@ async fn room_get_feed(
params: Query<GetRoomItemParams>,
) -> Result<impl IntoResponse, StatusCode> {
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
.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<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(
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<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(
st: ArcState,
Path(ruuid): Path<Uuid>,
@ -443,22 +502,16 @@ async fn room_post_item(
}
async fn room_event(
Path(ruuid): Path<Uuid>,
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> {
let rid = st
.conn
.lock()
.unwrap()
.query_row(
r"
SELECT `rid`
FROM `room`
WHERE `ruuid` = ?
",
params![ruuid],
|row| row.get::<_, u64>(0),
)
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) {

View file

@ -92,8 +92,16 @@ pub type ChatItem = WithSig<ChatPayload>;
#[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);
}