mirror of
https://github.com/Blah-IM/blahrs.git
synced 2025-05-01 08:41: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 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")?)
|
||||||
|
|
3
init.sql
3
init.sql
|
@ -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` (
|
||||||
|
|
|
@ -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',
|
||||||
},
|
},
|
||||||
|
|
121
src/main.rs
121
src/main.rs
|
@ -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, ¶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
|
// 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, ¶ms).map_err(from_db_error)?;
|
query_room_items(&st.conn.lock().unwrap(), ruuid, None, ¶ms).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) {
|
||||||
|
|
17
src/types.rs
17
src/types.rs
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue