mirror of
https://github.com/Blah-IM/blahrs.git
synced 2025-05-01 00:31:09 +00:00
Impl private rooms
This commit is contained in:
parent
9ced78d13d
commit
501b3e8db4
5 changed files with 159 additions and 64 deletions
|
@ -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")?)
|
||||
|
|
3
init.sql
3
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` (
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
|
|
123
src/main.rs
123
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<Uuid>,
|
||||
params: Query<GetRoomItemParams>,
|
||||
OptionalAuth(user): OptionalAuth,
|
||||
) -> Result<impl IntoResponse, StatusCode> {
|
||||
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<GetRoomItemParams>,
|
||||
) -> Result<impl IntoResponse, StatusCode> {
|
||||
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<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,23 +502,17 @@ 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),
|
||||
)
|
||||
.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(),
|
||||
|
|
17
src/types.rs
17
src/types.rs
|
@ -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);
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue