mirror of
https://github.com/Blah-IM/blahrs.git
synced 2025-05-01 00:31:09 +00:00
refactor(types)!: blow up big lib.rs into submods
This commit is contained in:
parent
066061e2ec
commit
8551540798
14 changed files with 778 additions and 745 deletions
|
@ -2,9 +2,8 @@
|
||||||
use std::hint::black_box;
|
use std::hint::black_box;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use blah_types::{
|
use blah_types::msg::{ChatPayload, UserRegisterPayload};
|
||||||
get_timestamp, ChatPayload, Id, PubKey, SignExt, Signee, UserKey, UserRegisterPayload,
|
use blah_types::{get_timestamp, Id, PubKey, SignExt, Signee, UserKey};
|
||||||
};
|
|
||||||
use criterion::{criterion_group, criterion_main, Criterion};
|
use criterion::{criterion_group, criterion_main, Criterion};
|
||||||
use ed25519_dalek::SigningKey;
|
use ed25519_dalek::SigningKey;
|
||||||
use rand::rngs::mock::StepRng;
|
use rand::rngs::mock::StepRng;
|
||||||
|
|
152
blah-types/src/crypto.rs
Normal file
152
blah-types/src/crypto.rs
Normal file
|
@ -0,0 +1,152 @@
|
||||||
|
//! Cryptographic operations and types for user signatures.
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
use std::str::FromStr;
|
||||||
|
use std::time::SystemTime;
|
||||||
|
|
||||||
|
use ed25519_dalek::{
|
||||||
|
Signature, SignatureError, Signer, SigningKey, VerifyingKey, PUBLIC_KEY_LENGTH,
|
||||||
|
SIGNATURE_LENGTH,
|
||||||
|
};
|
||||||
|
use rand::RngCore;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct UserKey {
|
||||||
|
pub id_key: PubKey,
|
||||||
|
pub act_key: PubKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct PubKey(#[serde(with = "hex::serde")] pub [u8; PUBLIC_KEY_LENGTH]);
|
||||||
|
|
||||||
|
impl FromStr for PubKey {
|
||||||
|
type Err = hex::FromHexError;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
hex::FromHex::from_hex(s).map(Self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for PubKey {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_tuple("PubKey").field(&self.to_string()).finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for PubKey {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
let mut buf = [0u8; PUBLIC_KEY_LENGTH * 2];
|
||||||
|
hex::encode_to_slice(self.0, &mut buf).expect("buf size is correct");
|
||||||
|
f.write_str(std::str::from_utf8(&buf).expect("hex must be UTF-8"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<VerifyingKey> for PubKey {
|
||||||
|
fn from(vk: VerifyingKey) -> Self {
|
||||||
|
Self(vk.to_bytes())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&VerifyingKey> for PubKey {
|
||||||
|
fn from(vk: &VerifyingKey) -> Self {
|
||||||
|
Self(vk.to_bytes())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct Signed<T> {
|
||||||
|
#[serde(with = "hex::serde")]
|
||||||
|
pub sig: [u8; SIGNATURE_LENGTH],
|
||||||
|
pub signee: Signee<T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct Signee<T> {
|
||||||
|
pub nonce: u32,
|
||||||
|
pub payload: T,
|
||||||
|
pub timestamp: u64,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub user: UserKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait SignExt: Sized {
|
||||||
|
fn sign_msg_with(
|
||||||
|
self,
|
||||||
|
id_key: &PubKey,
|
||||||
|
act_key: &SigningKey,
|
||||||
|
timestamp: u64,
|
||||||
|
rng: &mut (impl RngCore + ?Sized),
|
||||||
|
) -> Result<Signed<Self>, SignatureError>;
|
||||||
|
|
||||||
|
fn sign_msg(
|
||||||
|
self,
|
||||||
|
id_key: &PubKey,
|
||||||
|
act_key: &SigningKey,
|
||||||
|
) -> Result<Signed<Self>, SignatureError> {
|
||||||
|
self.sign_msg_with(id_key, act_key, get_timestamp(), &mut rand::thread_rng())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Serialize> SignExt for T {
|
||||||
|
fn sign_msg_with(
|
||||||
|
self,
|
||||||
|
id_key: &PubKey,
|
||||||
|
act_key: &SigningKey,
|
||||||
|
timestamp: u64,
|
||||||
|
rng: &mut (impl RngCore + ?Sized),
|
||||||
|
) -> Result<Signed<Self>, SignatureError> {
|
||||||
|
Signed::new(id_key, act_key, timestamp, rng, self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_timestamp() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(SystemTime::UNIX_EPOCH)
|
||||||
|
.expect("after UNIX epoch")
|
||||||
|
.as_secs()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Serialize> Signed<T> {
|
||||||
|
/// Get the canonically serialized signee bytes.
|
||||||
|
pub fn canonical_signee(&self) -> Vec<u8> {
|
||||||
|
serde_jcs::to_vec(&self.signee).expect("serialization cannot fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign the payload with the given `key`.
|
||||||
|
///
|
||||||
|
/// This operation only fail when serialization of `payload` fails.
|
||||||
|
pub fn new(
|
||||||
|
id_key: &PubKey,
|
||||||
|
act_key: &SigningKey,
|
||||||
|
timestamp: u64,
|
||||||
|
rng: &mut (impl RngCore + ?Sized),
|
||||||
|
payload: T,
|
||||||
|
) -> Result<Self, SignatureError> {
|
||||||
|
let signee = Signee {
|
||||||
|
nonce: rng.next_u32(),
|
||||||
|
payload,
|
||||||
|
timestamp,
|
||||||
|
user: UserKey {
|
||||||
|
act_key: act_key.verifying_key().into(),
|
||||||
|
id_key: id_key.clone(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let canonical_signee = serde_jcs::to_vec(&signee).map_err(|_| SignatureError::new())?;
|
||||||
|
let sig = act_key.sign(&canonical_signee).to_bytes();
|
||||||
|
|
||||||
|
Ok(Self { sig, signee })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify `sig` is valid for `signee`.
|
||||||
|
///
|
||||||
|
/// Note that this does not check validity of timestamp and other data.
|
||||||
|
pub fn verify(&self) -> Result<(), SignatureError> {
|
||||||
|
VerifyingKey::from_bytes(&self.signee.user.act_key.0)?
|
||||||
|
.verify_strict(&self.canonical_signee(), &Signature::from_bytes(&self.sig))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
use core::fmt;
|
//! User identity description.
|
||||||
use std::ops;
|
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
use std::{fmt, ops};
|
||||||
|
|
||||||
use ed25519_dalek::SignatureError;
|
use ed25519_dalek::SignatureError;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
|
@ -1,723 +1,12 @@
|
||||||
use std::fmt;
|
|
||||||
use std::str::FromStr;
|
|
||||||
use std::time::SystemTime;
|
|
||||||
|
|
||||||
use bitflags_serde_shim::impl_serde_for_bitflags;
|
|
||||||
use ed25519_dalek::{
|
|
||||||
Signature, SignatureError, Signer, SigningKey, VerifyingKey, PUBLIC_KEY_LENGTH,
|
|
||||||
SIGNATURE_LENGTH,
|
|
||||||
};
|
|
||||||
use identity::IdUrl;
|
|
||||||
use rand::RngCore;
|
|
||||||
use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
|
|
||||||
use serde_with::{serde_as, DisplayFromStr};
|
|
||||||
use url::Url;
|
|
||||||
|
|
||||||
// Re-export of public dependencies.
|
// Re-export of public dependencies.
|
||||||
pub use bitflags;
|
pub use bitflags;
|
||||||
pub use ed25519_dalek;
|
pub use ed25519_dalek;
|
||||||
pub use url;
|
pub use url;
|
||||||
|
|
||||||
|
pub use crypto::{get_timestamp, PubKey, SignExt, Signed, Signee, UserKey};
|
||||||
|
pub use msg::Id;
|
||||||
|
|
||||||
|
pub mod crypto;
|
||||||
pub mod identity;
|
pub mod identity;
|
||||||
|
pub mod msg;
|
||||||
pub const X_BLAH_NONCE: &str = "x-blah-nonce";
|
pub mod server;
|
||||||
pub const X_BLAH_DIFFICULTY: &str = "x-blah-difficulty";
|
|
||||||
|
|
||||||
/// An opaque server-specific ID for rooms, messages, and etc.
|
|
||||||
/// It's currently serialized as a string for JavaScript's convenience.
|
|
||||||
#[serde_as]
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
|
||||||
#[serde(transparent)]
|
|
||||||
pub struct Id(#[serde_as(as = "DisplayFromStr")] pub i64);
|
|
||||||
|
|
||||||
impl fmt::Display for Id {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
self.0.fmt(f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Id {
|
|
||||||
pub const MIN: Self = Id(i64::MIN);
|
|
||||||
pub const MAX: Self = Id(i64::MAX);
|
|
||||||
pub const INVALID: Self = Self::MAX;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct WithMsgId<T> {
|
|
||||||
pub cid: Id,
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub msg: T,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T> WithMsgId<T> {
|
|
||||||
pub fn new(cid: Id, msg: T) -> Self {
|
|
||||||
Self { cid, msg }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct UserKey {
|
|
||||||
pub id_key: PubKey,
|
|
||||||
pub act_key: PubKey,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(transparent)]
|
|
||||||
pub struct PubKey(#[serde(with = "hex::serde")] pub [u8; PUBLIC_KEY_LENGTH]);
|
|
||||||
|
|
||||||
impl FromStr for PubKey {
|
|
||||||
type Err = hex::FromHexError;
|
|
||||||
|
|
||||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
||||||
hex::FromHex::from_hex(s).map(Self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Debug for PubKey {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
f.debug_tuple("PubKey").field(&self.to_string()).finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for PubKey {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
let mut buf = [0u8; PUBLIC_KEY_LENGTH * 2];
|
|
||||||
hex::encode_to_slice(self.0, &mut buf).expect("buf size is correct");
|
|
||||||
f.write_str(std::str::from_utf8(&buf).expect("hex must be UTF-8"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<VerifyingKey> for PubKey {
|
|
||||||
fn from(vk: VerifyingKey) -> Self {
|
|
||||||
Self(vk.to_bytes())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&VerifyingKey> for PubKey {
|
|
||||||
fn from(vk: &VerifyingKey) -> Self {
|
|
||||||
Self(vk.to_bytes())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct Signed<T> {
|
|
||||||
#[serde(with = "hex::serde")]
|
|
||||||
pub sig: [u8; SIGNATURE_LENGTH],
|
|
||||||
pub signee: Signee<T>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct Signee<T> {
|
|
||||||
pub nonce: u32,
|
|
||||||
pub payload: T,
|
|
||||||
pub timestamp: u64,
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub user: UserKey,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait SignExt: Sized {
|
|
||||||
fn sign_msg_with(
|
|
||||||
self,
|
|
||||||
id_key: &PubKey,
|
|
||||||
act_key: &SigningKey,
|
|
||||||
timestamp: u64,
|
|
||||||
rng: &mut (impl RngCore + ?Sized),
|
|
||||||
) -> Result<Signed<Self>, SignatureError>;
|
|
||||||
|
|
||||||
fn sign_msg(
|
|
||||||
self,
|
|
||||||
id_key: &PubKey,
|
|
||||||
act_key: &SigningKey,
|
|
||||||
) -> Result<Signed<Self>, SignatureError> {
|
|
||||||
self.sign_msg_with(id_key, act_key, get_timestamp(), &mut rand::thread_rng())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: Serialize> SignExt for T {
|
|
||||||
fn sign_msg_with(
|
|
||||||
self,
|
|
||||||
id_key: &PubKey,
|
|
||||||
act_key: &SigningKey,
|
|
||||||
timestamp: u64,
|
|
||||||
rng: &mut (impl RngCore + ?Sized),
|
|
||||||
) -> Result<Signed<Self>, SignatureError> {
|
|
||||||
Signed::new(id_key, act_key, timestamp, rng, self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_timestamp() -> u64 {
|
|
||||||
SystemTime::now()
|
|
||||||
.duration_since(SystemTime::UNIX_EPOCH)
|
|
||||||
.expect("after UNIX epoch")
|
|
||||||
.as_secs()
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: Serialize> Signed<T> {
|
|
||||||
/// Get the canonically serialized signee bytes.
|
|
||||||
pub fn canonical_signee(&self) -> Vec<u8> {
|
|
||||||
serde_jcs::to_vec(&self.signee).expect("serialization cannot fail")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sign the payload with the given `key`.
|
|
||||||
///
|
|
||||||
/// This operation only fail when serialization of `payload` fails.
|
|
||||||
pub fn new(
|
|
||||||
id_key: &PubKey,
|
|
||||||
act_key: &SigningKey,
|
|
||||||
timestamp: u64,
|
|
||||||
rng: &mut (impl RngCore + ?Sized),
|
|
||||||
payload: T,
|
|
||||||
) -> Result<Self, SignatureError> {
|
|
||||||
let signee = Signee {
|
|
||||||
nonce: rng.next_u32(),
|
|
||||||
payload,
|
|
||||||
timestamp,
|
|
||||||
user: UserKey {
|
|
||||||
act_key: act_key.verifying_key().into(),
|
|
||||||
id_key: id_key.clone(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
let canonical_signee = serde_jcs::to_vec(&signee).map_err(|_| SignatureError::new())?;
|
|
||||||
let sig = act_key.sign(&canonical_signee).to_bytes();
|
|
||||||
|
|
||||||
Ok(Self { sig, signee })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Verify `sig` is valid for `signee`.
|
|
||||||
///
|
|
||||||
/// Note that this does not check validity of timestamp and other data.
|
|
||||||
pub fn verify(&self) -> Result<(), SignatureError> {
|
|
||||||
VerifyingKey::from_bytes(&self.signee.user.act_key.0)?
|
|
||||||
.verify_strict(&self.canonical_signee(), &Signature::from_bytes(&self.sig))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Register a user on a chat server.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(tag = "typ", rename = "user_register")]
|
|
||||||
pub struct UserRegisterPayload {
|
|
||||||
pub server_url: Url,
|
|
||||||
pub id_url: IdUrl,
|
|
||||||
pub id_key: PubKey,
|
|
||||||
pub challenge_nonce: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
// FIXME: `deny_unknown_fields` breaks this.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(tag = "typ", rename = "chat")]
|
|
||||||
pub struct ChatPayload {
|
|
||||||
pub rich_text: RichText,
|
|
||||||
pub room: Id,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ref: <https://github.com/Blah-IM/Weblah/blob/a3fa0f265af54c846f8d65f42aa4409c8dba9dd9/src/lib/richText.ts>
|
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
|
||||||
#[serde(transparent)]
|
|
||||||
pub struct RichText(pub Vec<RichTextPiece>);
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct RichTextPiece {
|
|
||||||
pub attrs: TextAttrs,
|
|
||||||
pub text: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Serialize for RichTextPiece {
|
|
||||||
fn serialize<S>(&self, ser: S) -> Result<S::Ok, S::Error>
|
|
||||||
where
|
|
||||||
S: Serializer,
|
|
||||||
{
|
|
||||||
if is_default(&self.attrs) {
|
|
||||||
self.text.serialize(ser)
|
|
||||||
} else {
|
|
||||||
(&self.text, &self.attrs).serialize(ser)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The protocol representation of `RichTextPiece`.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
|
||||||
#[serde(untagged)]
|
|
||||||
enum RichTextPieceRaw {
|
|
||||||
Text(String),
|
|
||||||
TextWithAttrs(String, TextAttrs),
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_default<T: Default + PartialEq>(v: &T) -> bool {
|
|
||||||
*v == T::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'de> Deserialize<'de> for RichText {
|
|
||||||
fn deserialize<D>(de: D) -> Result<Self, D::Error>
|
|
||||||
where
|
|
||||||
D: Deserializer<'de>,
|
|
||||||
{
|
|
||||||
let pieces = <Vec<RichTextPieceRaw>>::deserialize(de)?;
|
|
||||||
if pieces
|
|
||||||
.iter()
|
|
||||||
.any(|p| matches!(&p, RichTextPieceRaw::TextWithAttrs(_, attrs) if is_default(attrs)))
|
|
||||||
{
|
|
||||||
return Err(de::Error::custom("not in canonical form"));
|
|
||||||
}
|
|
||||||
let this = Self(
|
|
||||||
pieces
|
|
||||||
.into_iter()
|
|
||||||
.map(|raw| {
|
|
||||||
let (text, attrs) = match raw {
|
|
||||||
RichTextPieceRaw::Text(text) => (text, TextAttrs::default()),
|
|
||||||
RichTextPieceRaw::TextWithAttrs(text, attrs) => (text, attrs),
|
|
||||||
};
|
|
||||||
RichTextPiece { text, attrs }
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
);
|
|
||||||
if !this.is_canonical() {
|
|
||||||
return Err(de::Error::custom("not in canonical form"));
|
|
||||||
}
|
|
||||||
Ok(this)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: This protocol format is quite large. Could use bitflags for database.
|
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct TextAttrs {
|
|
||||||
#[serde(default, rename = "b", skip_serializing_if = "is_default")]
|
|
||||||
pub bold: bool,
|
|
||||||
#[serde(default, rename = "m", skip_serializing_if = "is_default")]
|
|
||||||
pub code: bool,
|
|
||||||
#[serde(default, skip_serializing_if = "is_default")]
|
|
||||||
pub hashtag: bool,
|
|
||||||
#[serde(default, rename = "i", skip_serializing_if = "is_default")]
|
|
||||||
pub italic: bool,
|
|
||||||
// TODO: Should we validate and/or filter the URL.
|
|
||||||
#[serde(default, skip_serializing_if = "is_default")]
|
|
||||||
pub link: Option<String>,
|
|
||||||
#[serde(default, rename = "s", skip_serializing_if = "is_default")]
|
|
||||||
pub strike: bool,
|
|
||||||
#[serde(default, rename = "u", skip_serializing_if = "is_default")]
|
|
||||||
pub underline: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&'_ str> for RichText {
|
|
||||||
fn from(text: &'_ str) -> Self {
|
|
||||||
text.to_owned().into()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<String> for RichText {
|
|
||||||
fn from(text: String) -> Self {
|
|
||||||
if text.is_empty() {
|
|
||||||
Self::default()
|
|
||||||
} else {
|
|
||||||
Self(vec![RichTextPiece {
|
|
||||||
text,
|
|
||||||
attrs: TextAttrs::default(),
|
|
||||||
}])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&'_ str> for RichTextPiece {
|
|
||||||
fn from(text: &'_ str) -> Self {
|
|
||||||
text.to_owned().into()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<String> for RichTextPiece {
|
|
||||||
fn from(text: String) -> Self {
|
|
||||||
Self {
|
|
||||||
text,
|
|
||||||
attrs: TextAttrs::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RichText {
|
|
||||||
/// Is this rich text valid and in the canonical form?
|
|
||||||
///
|
|
||||||
/// This is automatically enforced by `Deserialize` impl.
|
|
||||||
pub fn is_canonical(&self) -> bool {
|
|
||||||
self.0.iter().all(|p| !p.text.is_empty())
|
|
||||||
&& self.0.windows(2).all(|w| w[0].attrs != w[1].attrs)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Format the text into plain text, stripping all styles.
|
|
||||||
pub fn plain_text(&self) -> impl fmt::Display + '_ {
|
|
||||||
struct Fmt<'a>(&'a RichText);
|
|
||||||
impl fmt::Display for Fmt<'_> {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
for p in &self.0 .0 {
|
|
||||||
f.write_str(&p.text)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Fmt(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Format the text into HTML.
|
|
||||||
pub fn html(&self) -> impl fmt::Display + '_ {
|
|
||||||
struct Fmt<'a>(&'a RichText);
|
|
||||||
impl fmt::Display for Fmt<'_> {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
for p in &self.0 .0 {
|
|
||||||
let tags = [
|
|
||||||
(p.attrs.bold, "<b>", "</b>"),
|
|
||||||
(p.attrs.code, "<code>", "</code>"),
|
|
||||||
(p.attrs.italic, "<i>", "</i>"),
|
|
||||||
(p.attrs.strike, "<strike>", "</strike>"),
|
|
||||||
(p.attrs.underline, "<u>", "</u>"),
|
|
||||||
(p.attrs.hashtag || p.attrs.link.is_some(), "", "</a>"),
|
|
||||||
];
|
|
||||||
for (cond, begin, _) in tags {
|
|
||||||
if cond {
|
|
||||||
f.write_str(begin)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if p.attrs.hashtag {
|
|
||||||
// TODO: Link target for hashtag?
|
|
||||||
write!(f, r#"<a class="hashtag">"#)?;
|
|
||||||
} else if let Some(link) = &p.attrs.link {
|
|
||||||
let href = html_escape::encode_quoted_attribute(link);
|
|
||||||
write!(f, r#"<a target="_blank" href="{href}""#)?;
|
|
||||||
let href = html_escape::encode_quoted_attribute(link);
|
|
||||||
write!(f, r#"<a target="_blank" href="{href}""#)?;
|
|
||||||
}
|
|
||||||
f.write_str(&p.text)?;
|
|
||||||
for (cond, _, end) in tags.iter().rev() {
|
|
||||||
if *cond {
|
|
||||||
f.write_str(end)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Fmt(self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type SignedChatMsg = Signed<ChatPayload>;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct RoomMetadata {
|
|
||||||
/// Room id.
|
|
||||||
pub rid: Id,
|
|
||||||
/// Plain text room title. None for peer chat.
|
|
||||||
pub title: Option<String>,
|
|
||||||
/// Room attributes.
|
|
||||||
pub attrs: RoomAttrs,
|
|
||||||
|
|
||||||
// Extra information is only available for some APIs.
|
|
||||||
/// The last message in the room.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub last_msg: Option<WithMsgId<SignedChatMsg>>,
|
|
||||||
/// The current user's last seen message's `cid`.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub last_seen_cid: Option<Id>,
|
|
||||||
/// The number of unseen messages, ie. the number of messages from `last_seen_cid` to
|
|
||||||
/// `last_msg.cid`.
|
|
||||||
/// This may or may not be a precise number.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub unseen_cnt: Option<u32>,
|
|
||||||
/// The member permission of current user in the room, or `None` if it is not a member.
|
|
||||||
/// Only available with authentication.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub member_permission: Option<MemberPermission>,
|
|
||||||
/// The peer user, if this is a peer chat room.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub peer_user: Option<PubKey>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(tag = "typ")]
|
|
||||||
pub enum CreateRoomPayload {
|
|
||||||
#[serde(rename = "create_room")]
|
|
||||||
Group(CreateGroup),
|
|
||||||
#[serde(rename = "create_peer_chat")]
|
|
||||||
PeerChat(CreatePeerChat),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Multi-user room.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct CreateGroup {
|
|
||||||
pub attrs: RoomAttrs,
|
|
||||||
pub title: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Peer-to-peer chat room with exactly two symmetric users.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct CreatePeerChat {
|
|
||||||
pub peer: PubKey,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(tag = "typ", rename = "delete_room")]
|
|
||||||
pub struct DeleteRoomPayload {
|
|
||||||
pub room: Id,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A collection of room members, with these invariants:
|
|
||||||
/// 1. Sorted by userkeys.
|
|
||||||
/// 2. No duplicated users.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
|
||||||
#[serde(try_from = "Vec<RoomMember>")]
|
|
||||||
pub struct RoomMemberList(pub Vec<RoomMember>);
|
|
||||||
|
|
||||||
impl Serialize for RoomMemberList {
|
|
||||||
fn serialize<S>(&self, ser: S) -> Result<S::Ok, S::Error>
|
|
||||||
where
|
|
||||||
S: Serializer,
|
|
||||||
{
|
|
||||||
self.0.serialize(ser)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<Vec<RoomMember>> for RoomMemberList {
|
|
||||||
type Error = &'static str;
|
|
||||||
|
|
||||||
fn try_from(members: Vec<RoomMember>) -> Result<Self, Self::Error> {
|
|
||||||
if members.windows(2).all(|w| w[0].user.0 < w[1].user.0) {
|
|
||||||
Ok(Self(members))
|
|
||||||
} else {
|
|
||||||
Err("unsorted or duplicated users")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct RoomMember {
|
|
||||||
pub permission: MemberPermission,
|
|
||||||
pub user: PubKey,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Proof of room membership for read-access.
|
|
||||||
///
|
|
||||||
/// TODO: Should we use JWT here instead?
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(tag = "typ", rename = "auth")]
|
|
||||||
pub struct AuthPayload {}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
// `typ` is provided by `RoomAdminOp`.
|
|
||||||
pub struct RoomAdminPayload {
|
|
||||||
pub room: Id,
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub op: RoomAdminOp,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(tag = "typ", rename_all = "snake_case")]
|
|
||||||
pub enum RoomAdminOp {
|
|
||||||
AddMember {
|
|
||||||
permission: MemberPermission,
|
|
||||||
user: PubKey,
|
|
||||||
},
|
|
||||||
RemoveMember {
|
|
||||||
user: PubKey,
|
|
||||||
},
|
|
||||||
// TODO: RU
|
|
||||||
}
|
|
||||||
|
|
||||||
bitflags::bitflags! {
|
|
||||||
/// TODO: Is this a really all about permission, or is a generic `UserFlags`?
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub struct ServerPermission: i32 {
|
|
||||||
const CREATE_ROOM = 1 << 0;
|
|
||||||
|
|
||||||
const ACCEPT_PEER_CHAT = 1 << 16;
|
|
||||||
|
|
||||||
const ALL = !0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub struct MemberPermission: i32 {
|
|
||||||
const POST_CHAT = 1 << 0;
|
|
||||||
const ADD_MEMBER = 1 << 1;
|
|
||||||
const DELETE_ROOM = 1 << 2;
|
|
||||||
|
|
||||||
const MAX_SELF_ADD = Self::POST_CHAT.bits();
|
|
||||||
const MAX_PEER_CHAT = Self::POST_CHAT.bits() | Self::DELETE_ROOM.bits();
|
|
||||||
|
|
||||||
const ALL = !0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
||||||
pub struct RoomAttrs: i32 {
|
|
||||||
// NB. Used by schema.
|
|
||||||
const PUBLIC_READABLE = 1 << 0;
|
|
||||||
const PUBLIC_JOINABLE = 1 << 1;
|
|
||||||
|
|
||||||
const GROUP_ATTRS = (1 << 16) - 1;
|
|
||||||
|
|
||||||
// NB. Used by schema.
|
|
||||||
const PEER_CHAT = 1 << 16;
|
|
||||||
|
|
||||||
const _ = !0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl_serde_for_bitflags!(ServerPermission);
|
|
||||||
impl_serde_for_bitflags!(MemberPermission);
|
|
||||||
impl_serde_for_bitflags!(RoomAttrs);
|
|
||||||
|
|
||||||
#[cfg(feature = "rusqlite")]
|
|
||||||
mod sql_impl {
|
|
||||||
use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ToSqlOutput, ValueRef};
|
|
||||||
use rusqlite::{Result, ToSql};
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
impl ToSql for Id {
|
|
||||||
fn to_sql(&self) -> Result<ToSqlOutput<'_>> {
|
|
||||||
self.0.to_sql()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromSql for Id {
|
|
||||||
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
|
|
||||||
i64::column_result(value).map(Self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ToSql for PubKey {
|
|
||||||
fn to_sql(&self) -> Result<ToSqlOutput<'_>> {
|
|
||||||
// TODO: Extensive key format?
|
|
||||||
self.0.to_sql()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromSql for PubKey {
|
|
||||||
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
|
|
||||||
let rawkey = <[u8; PUBLIC_KEY_LENGTH]>::column_result(value)?;
|
|
||||||
let key = VerifyingKey::from_bytes(&rawkey)
|
|
||||||
.map_err(|err| FromSqlError::Other(format!("invalid pubkey: {err}").into()))?;
|
|
||||||
Ok(key.into())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ToSql for RichText {
|
|
||||||
fn to_sql(&self) -> Result<ToSqlOutput<'_>> {
|
|
||||||
assert!(self.is_canonical());
|
|
||||||
let json = serde_json::to_string(&self).expect("serialization cannot fail");
|
|
||||||
Ok(json.into())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromSql for RichText {
|
|
||||||
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
|
|
||||||
serde_json::from_str::<Self>(value.as_str()?)
|
|
||||||
.map_err(|err| FromSqlError::Other(format!("invalid rich text: {err}").into()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
macro_rules! impl_flag_to_from_sql {
|
|
||||||
($($name:ident),*) => {
|
|
||||||
$(
|
|
||||||
impl ToSql for $name {
|
|
||||||
fn to_sql(&self) -> Result<ToSqlOutput<'_>> {
|
|
||||||
Ok(self.bits().into())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromSql for $name {
|
|
||||||
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
|
|
||||||
i32::column_result(value).map($name::from_bits_retain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)*
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
impl_flag_to_from_sql!(ServerPermission, MemberPermission, RoomAttrs);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use expect_test::expect;
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn canonical_msg() {
|
|
||||||
let mut fake_rng = rand::rngs::mock::StepRng::new(0x42, 1);
|
|
||||||
let id_key = SigningKey::from_bytes(&[0x42; 32]);
|
|
||||||
let act_key = SigningKey::from_bytes(&[0x43; 32]);
|
|
||||||
let timestamp = 0xDEAD_BEEF;
|
|
||||||
let msg = ChatPayload {
|
|
||||||
rich_text: RichText::from("hello"),
|
|
||||||
room: Id(42),
|
|
||||||
}
|
|
||||||
.sign_msg_with(
|
|
||||||
&id_key.verifying_key().into(),
|
|
||||||
&act_key,
|
|
||||||
timestamp,
|
|
||||||
&mut fake_rng,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let json = serde_jcs::to_string(&msg).unwrap();
|
|
||||||
let expect = expect![[
|
|
||||||
r#"{"sig":"74ca2895ac94e741e086bae28ce8c282bf375e3e59a3408f562420d72e98d799f7e627879aa883fa0804a0799eb9b90398150b0150c2e3550635ff28b9991502","signee":{"act_key":"22fc297792f0b6ffc0bfcfdb7edb0c0aa14e025a365ec0e342e86e3829cb74b6","id_key":"2152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db12","nonce":66,"payload":{"rich_text":["hello"],"room":"42","typ":"chat"},"timestamp":3735928559}}"#
|
|
||||||
]];
|
|
||||||
expect.assert_eq(&json);
|
|
||||||
|
|
||||||
let roundtrip_msg = serde_json::from_str::<Signed<ChatPayload>>(&json).unwrap();
|
|
||||||
assert_eq!(roundtrip_msg, msg);
|
|
||||||
roundtrip_msg.verify().unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rich_text_serde() {
|
|
||||||
let raw = r#"["before ",["bold ",{"b":true}],["italic bold ",{"b":true,"i":true}],"end"]"#;
|
|
||||||
let text = serde_json::from_str::<RichText>(raw).unwrap();
|
|
||||||
assert!(text.is_canonical());
|
|
||||||
assert_eq!(
|
|
||||||
text,
|
|
||||||
RichText(vec![
|
|
||||||
"before ".into(),
|
|
||||||
RichTextPiece {
|
|
||||||
text: "bold ".into(),
|
|
||||||
attrs: TextAttrs {
|
|
||||||
bold: true,
|
|
||||||
..TextAttrs::default()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
RichTextPiece {
|
|
||||||
text: "italic bold ".into(),
|
|
||||||
attrs: TextAttrs {
|
|
||||||
italic: true,
|
|
||||||
bold: true,
|
|
||||||
..TextAttrs::default()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"end".into(),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
let got = serde_json::to_string(&text).unwrap();
|
|
||||||
assert_eq!(got, raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn room_admin_serde() {
|
|
||||||
let data = RoomAdminPayload {
|
|
||||||
room: Id(42),
|
|
||||||
op: RoomAdminOp::AddMember {
|
|
||||||
permission: MemberPermission::POST_CHAT,
|
|
||||||
user: PubKey([0x42; PUBLIC_KEY_LENGTH]),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
let raw = serde_jcs::to_string(&data).unwrap();
|
|
||||||
|
|
||||||
let expect = expect![[
|
|
||||||
r#"{"permission":1,"room":"42","typ":"add_member","user":"4242424242424242424242424242424242424242424242424242424242424242"}"#
|
|
||||||
]];
|
|
||||||
expect.assert_eq(&raw);
|
|
||||||
let roundtrip = serde_json::from_str::<RoomAdminPayload>(&raw).unwrap();
|
|
||||||
assert_eq!(roundtrip, data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
544
blah-types/src/msg.rs
Normal file
544
blah-types/src/msg.rs
Normal file
|
@ -0,0 +1,544 @@
|
||||||
|
//! Core message subtypes.
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use bitflags_serde_shim::impl_serde_for_bitflags;
|
||||||
|
use serde::{de, ser, Deserialize, Serialize};
|
||||||
|
use serde_with::{serde_as, DisplayFromStr};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use crate::identity::IdUrl;
|
||||||
|
use crate::{PubKey, Signed};
|
||||||
|
|
||||||
|
/// An opaque server-specific ID for rooms, messages, and etc.
|
||||||
|
/// It's currently serialized as a string for JavaScript's convenience.
|
||||||
|
#[serde_as]
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct Id(#[serde_as(as = "DisplayFromStr")] pub i64);
|
||||||
|
|
||||||
|
impl fmt::Display for Id {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
self.0.fmt(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Id {
|
||||||
|
pub const MIN: Self = Id(i64::MIN);
|
||||||
|
pub const MAX: Self = Id(i64::MAX);
|
||||||
|
pub const INVALID: Self = Self::MAX;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct WithMsgId<T> {
|
||||||
|
pub cid: Id,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub msg: T,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> WithMsgId<T> {
|
||||||
|
pub fn new(cid: Id, msg: T) -> Self {
|
||||||
|
Self { cid, msg }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a user on a chat server.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "typ", rename = "user_register")]
|
||||||
|
pub struct UserRegisterPayload {
|
||||||
|
pub server_url: Url,
|
||||||
|
pub id_url: IdUrl,
|
||||||
|
pub id_key: PubKey,
|
||||||
|
pub challenge_nonce: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME: `deny_unknown_fields` breaks this.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "typ", rename = "chat")]
|
||||||
|
pub struct ChatPayload {
|
||||||
|
pub rich_text: RichText,
|
||||||
|
pub room: Id,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ref: <https://github.com/Blah-IM/Weblah/blob/a3fa0f265af54c846f8d65f42aa4409c8dba9dd9/src/lib/richText.ts>
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct RichText(pub Vec<RichTextPiece>);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct RichTextPiece {
|
||||||
|
pub attrs: TextAttrs,
|
||||||
|
pub text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for RichTextPiece {
|
||||||
|
fn serialize<S>(&self, ser: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: ser::Serializer,
|
||||||
|
{
|
||||||
|
if is_default(&self.attrs) {
|
||||||
|
self.text.serialize(ser)
|
||||||
|
} else {
|
||||||
|
(&self.text, &self.attrs).serialize(ser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The protocol representation of `RichTextPiece`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum RichTextPieceRaw {
|
||||||
|
Text(String),
|
||||||
|
TextWithAttrs(String, TextAttrs),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_default<T: Default + PartialEq>(v: &T) -> bool {
|
||||||
|
*v == T::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for RichText {
|
||||||
|
fn deserialize<D>(de: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: de::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let pieces = <Vec<RichTextPieceRaw>>::deserialize(de)?;
|
||||||
|
if pieces
|
||||||
|
.iter()
|
||||||
|
.any(|p| matches!(&p, RichTextPieceRaw::TextWithAttrs(_, attrs) if is_default(attrs)))
|
||||||
|
{
|
||||||
|
return Err(de::Error::custom("not in canonical form"));
|
||||||
|
}
|
||||||
|
let this = Self(
|
||||||
|
pieces
|
||||||
|
.into_iter()
|
||||||
|
.map(|raw| {
|
||||||
|
let (text, attrs) = match raw {
|
||||||
|
RichTextPieceRaw::Text(text) => (text, TextAttrs::default()),
|
||||||
|
RichTextPieceRaw::TextWithAttrs(text, attrs) => (text, attrs),
|
||||||
|
};
|
||||||
|
RichTextPiece { text, attrs }
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
if !this.is_canonical() {
|
||||||
|
return Err(de::Error::custom("not in canonical form"));
|
||||||
|
}
|
||||||
|
Ok(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: This protocol format is quite large. Could use bitflags for database.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct TextAttrs {
|
||||||
|
#[serde(default, rename = "b", skip_serializing_if = "is_default")]
|
||||||
|
pub bold: bool,
|
||||||
|
#[serde(default, rename = "m", skip_serializing_if = "is_default")]
|
||||||
|
pub code: bool,
|
||||||
|
#[serde(default, skip_serializing_if = "is_default")]
|
||||||
|
pub hashtag: bool,
|
||||||
|
#[serde(default, rename = "i", skip_serializing_if = "is_default")]
|
||||||
|
pub italic: bool,
|
||||||
|
// TODO: Should we validate and/or filter the URL.
|
||||||
|
#[serde(default, skip_serializing_if = "is_default")]
|
||||||
|
pub link: Option<String>,
|
||||||
|
#[serde(default, rename = "s", skip_serializing_if = "is_default")]
|
||||||
|
pub strike: bool,
|
||||||
|
#[serde(default, rename = "u", skip_serializing_if = "is_default")]
|
||||||
|
pub underline: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&'_ str> for RichText {
|
||||||
|
fn from(text: &'_ str) -> Self {
|
||||||
|
text.to_owned().into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<String> for RichText {
|
||||||
|
fn from(text: String) -> Self {
|
||||||
|
if text.is_empty() {
|
||||||
|
Self::default()
|
||||||
|
} else {
|
||||||
|
Self(vec![RichTextPiece {
|
||||||
|
text,
|
||||||
|
attrs: TextAttrs::default(),
|
||||||
|
}])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&'_ str> for RichTextPiece {
|
||||||
|
fn from(text: &'_ str) -> Self {
|
||||||
|
text.to_owned().into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<String> for RichTextPiece {
|
||||||
|
fn from(text: String) -> Self {
|
||||||
|
Self {
|
||||||
|
text,
|
||||||
|
attrs: TextAttrs::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RichText {
|
||||||
|
/// Is this rich text valid and in the canonical form?
|
||||||
|
///
|
||||||
|
/// This is automatically enforced by `Deserialize` impl.
|
||||||
|
pub fn is_canonical(&self) -> bool {
|
||||||
|
self.0.iter().all(|p| !p.text.is_empty())
|
||||||
|
&& self.0.windows(2).all(|w| w[0].attrs != w[1].attrs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format the text into plain text, stripping all styles.
|
||||||
|
pub fn plain_text(&self) -> impl fmt::Display + '_ {
|
||||||
|
struct Fmt<'a>(&'a RichText);
|
||||||
|
impl fmt::Display for Fmt<'_> {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
for p in &self.0 .0 {
|
||||||
|
f.write_str(&p.text)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Fmt(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format the text into HTML.
|
||||||
|
pub fn html(&self) -> impl fmt::Display + '_ {
|
||||||
|
struct Fmt<'a>(&'a RichText);
|
||||||
|
impl fmt::Display for Fmt<'_> {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
for p in &self.0 .0 {
|
||||||
|
let tags = [
|
||||||
|
(p.attrs.bold, "<b>", "</b>"),
|
||||||
|
(p.attrs.code, "<code>", "</code>"),
|
||||||
|
(p.attrs.italic, "<i>", "</i>"),
|
||||||
|
(p.attrs.strike, "<strike>", "</strike>"),
|
||||||
|
(p.attrs.underline, "<u>", "</u>"),
|
||||||
|
(p.attrs.hashtag || p.attrs.link.is_some(), "", "</a>"),
|
||||||
|
];
|
||||||
|
for (cond, begin, _) in tags {
|
||||||
|
if cond {
|
||||||
|
f.write_str(begin)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.attrs.hashtag {
|
||||||
|
// TODO: Link target for hashtag?
|
||||||
|
write!(f, r#"<a class="hashtag">"#)?;
|
||||||
|
} else if let Some(link) = &p.attrs.link {
|
||||||
|
let href = html_escape::encode_quoted_attribute(link);
|
||||||
|
write!(f, r#"<a target="_blank" href="{href}""#)?;
|
||||||
|
let href = html_escape::encode_quoted_attribute(link);
|
||||||
|
write!(f, r#"<a target="_blank" href="{href}""#)?;
|
||||||
|
}
|
||||||
|
f.write_str(&p.text)?;
|
||||||
|
for (cond, _, end) in tags.iter().rev() {
|
||||||
|
if *cond {
|
||||||
|
f.write_str(end)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Fmt(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type SignedChatMsg = Signed<ChatPayload>;
|
||||||
|
pub type SignedChatMsgWithId = WithMsgId<SignedChatMsg>;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "typ")]
|
||||||
|
pub enum CreateRoomPayload {
|
||||||
|
#[serde(rename = "create_room")]
|
||||||
|
Group(CreateGroup),
|
||||||
|
#[serde(rename = "create_peer_chat")]
|
||||||
|
PeerChat(CreatePeerChat),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Multi-user room.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct CreateGroup {
|
||||||
|
pub attrs: RoomAttrs,
|
||||||
|
pub title: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Peer-to-peer chat room with exactly two symmetric users.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct CreatePeerChat {
|
||||||
|
pub peer: PubKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "typ", rename = "delete_room")]
|
||||||
|
pub struct DeleteRoomPayload {
|
||||||
|
pub room: Id,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A collection of room members, with these invariants:
|
||||||
|
/// 1. Sorted by userkeys.
|
||||||
|
/// 2. No duplicated users.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||||
|
#[serde(try_from = "Vec<RoomMember>")]
|
||||||
|
pub struct RoomMemberList(pub Vec<RoomMember>);
|
||||||
|
|
||||||
|
impl Serialize for RoomMemberList {
|
||||||
|
fn serialize<S>(&self, ser: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: ser::Serializer,
|
||||||
|
{
|
||||||
|
self.0.serialize(ser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<Vec<RoomMember>> for RoomMemberList {
|
||||||
|
type Error = &'static str;
|
||||||
|
|
||||||
|
fn try_from(members: Vec<RoomMember>) -> Result<Self, Self::Error> {
|
||||||
|
if members.windows(2).all(|w| w[0].user.0 < w[1].user.0) {
|
||||||
|
Ok(Self(members))
|
||||||
|
} else {
|
||||||
|
Err("unsorted or duplicated users")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RoomMember {
|
||||||
|
pub permission: MemberPermission,
|
||||||
|
pub user: PubKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Proof of room membership for read-access.
|
||||||
|
///
|
||||||
|
/// TODO: Should we use JWT here instead?
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "typ", rename = "auth")]
|
||||||
|
pub struct AuthPayload {}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
// `typ` is provided by `RoomAdminOp`.
|
||||||
|
pub struct RoomAdminPayload {
|
||||||
|
pub room: Id,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub op: RoomAdminOp,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "typ", rename_all = "snake_case")]
|
||||||
|
pub enum RoomAdminOp {
|
||||||
|
AddMember {
|
||||||
|
permission: MemberPermission,
|
||||||
|
user: PubKey,
|
||||||
|
},
|
||||||
|
RemoveMember {
|
||||||
|
user: PubKey,
|
||||||
|
},
|
||||||
|
// TODO: RU
|
||||||
|
}
|
||||||
|
|
||||||
|
bitflags::bitflags! {
|
||||||
|
/// TODO: Is this a really all about permission, or is a generic `UserFlags`?
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct ServerPermission: i32 {
|
||||||
|
const CREATE_ROOM = 1 << 0;
|
||||||
|
|
||||||
|
const ACCEPT_PEER_CHAT = 1 << 16;
|
||||||
|
|
||||||
|
const ALL = !0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct MemberPermission: i32 {
|
||||||
|
const POST_CHAT = 1 << 0;
|
||||||
|
const ADD_MEMBER = 1 << 1;
|
||||||
|
const DELETE_ROOM = 1 << 2;
|
||||||
|
|
||||||
|
const MAX_SELF_ADD = Self::POST_CHAT.bits();
|
||||||
|
const MAX_PEER_CHAT = Self::POST_CHAT.bits() | Self::DELETE_ROOM.bits();
|
||||||
|
|
||||||
|
const ALL = !0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub struct RoomAttrs: i32 {
|
||||||
|
// NB. Used by schema.
|
||||||
|
const PUBLIC_READABLE = 1 << 0;
|
||||||
|
const PUBLIC_JOINABLE = 1 << 1;
|
||||||
|
|
||||||
|
const GROUP_ATTRS = (1 << 16) - 1;
|
||||||
|
|
||||||
|
// NB. Used by schema.
|
||||||
|
const PEER_CHAT = 1 << 16;
|
||||||
|
|
||||||
|
const _ = !0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl_serde_for_bitflags!(ServerPermission);
|
||||||
|
impl_serde_for_bitflags!(MemberPermission);
|
||||||
|
impl_serde_for_bitflags!(RoomAttrs);
|
||||||
|
|
||||||
|
#[cfg(feature = "rusqlite")]
|
||||||
|
mod sql_impl {
|
||||||
|
use ed25519_dalek::{VerifyingKey, PUBLIC_KEY_LENGTH};
|
||||||
|
use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ToSqlOutput, ValueRef};
|
||||||
|
use rusqlite::{Result, ToSql};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
impl ToSql for Id {
|
||||||
|
fn to_sql(&self) -> Result<ToSqlOutput<'_>> {
|
||||||
|
self.0.to_sql()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromSql for Id {
|
||||||
|
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
|
||||||
|
i64::column_result(value).map(Self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToSql for PubKey {
|
||||||
|
fn to_sql(&self) -> Result<ToSqlOutput<'_>> {
|
||||||
|
// TODO: Extensive key format?
|
||||||
|
self.0.to_sql()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromSql for PubKey {
|
||||||
|
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
|
||||||
|
let rawkey = <[u8; PUBLIC_KEY_LENGTH]>::column_result(value)?;
|
||||||
|
let key = VerifyingKey::from_bytes(&rawkey)
|
||||||
|
.map_err(|err| FromSqlError::Other(format!("invalid pubkey: {err}").into()))?;
|
||||||
|
Ok(key.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToSql for RichText {
|
||||||
|
fn to_sql(&self) -> Result<ToSqlOutput<'_>> {
|
||||||
|
assert!(self.is_canonical());
|
||||||
|
let json = serde_json::to_string(&self).expect("serialization cannot fail");
|
||||||
|
Ok(json.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromSql for RichText {
|
||||||
|
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
|
||||||
|
serde_json::from_str::<Self>(value.as_str()?)
|
||||||
|
.map_err(|err| FromSqlError::Other(format!("invalid rich text: {err}").into()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! impl_flag_to_from_sql {
|
||||||
|
($($name:ident),*) => {
|
||||||
|
$(
|
||||||
|
impl ToSql for $name {
|
||||||
|
fn to_sql(&self) -> Result<ToSqlOutput<'_>> {
|
||||||
|
Ok(self.bits().into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromSql for $name {
|
||||||
|
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
|
||||||
|
i32::column_result(value).map($name::from_bits_retain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)*
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
impl_flag_to_from_sql!(ServerPermission, MemberPermission, RoomAttrs);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use ed25519_dalek::{SigningKey, PUBLIC_KEY_LENGTH};
|
||||||
|
use expect_test::expect;
|
||||||
|
|
||||||
|
use crate::SignExt;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn canonical_msg() {
|
||||||
|
let mut fake_rng = rand::rngs::mock::StepRng::new(0x42, 1);
|
||||||
|
let id_key = SigningKey::from_bytes(&[0x42; 32]);
|
||||||
|
let act_key = SigningKey::from_bytes(&[0x43; 32]);
|
||||||
|
let timestamp = 0xDEAD_BEEF;
|
||||||
|
let msg = ChatPayload {
|
||||||
|
rich_text: RichText::from("hello"),
|
||||||
|
room: Id(42),
|
||||||
|
}
|
||||||
|
.sign_msg_with(
|
||||||
|
&id_key.verifying_key().into(),
|
||||||
|
&act_key,
|
||||||
|
timestamp,
|
||||||
|
&mut fake_rng,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let json = serde_jcs::to_string(&msg).unwrap();
|
||||||
|
let expect = expect![[
|
||||||
|
r#"{"sig":"74ca2895ac94e741e086bae28ce8c282bf375e3e59a3408f562420d72e98d799f7e627879aa883fa0804a0799eb9b90398150b0150c2e3550635ff28b9991502","signee":{"act_key":"22fc297792f0b6ffc0bfcfdb7edb0c0aa14e025a365ec0e342e86e3829cb74b6","id_key":"2152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db12","nonce":66,"payload":{"rich_text":["hello"],"room":"42","typ":"chat"},"timestamp":3735928559}}"#
|
||||||
|
]];
|
||||||
|
expect.assert_eq(&json);
|
||||||
|
|
||||||
|
let roundtrip_msg = serde_json::from_str::<Signed<ChatPayload>>(&json).unwrap();
|
||||||
|
assert_eq!(roundtrip_msg, msg);
|
||||||
|
roundtrip_msg.verify().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rich_text_serde() {
|
||||||
|
let raw = r#"["before ",["bold ",{"b":true}],["italic bold ",{"b":true,"i":true}],"end"]"#;
|
||||||
|
let text = serde_json::from_str::<RichText>(raw).unwrap();
|
||||||
|
assert!(text.is_canonical());
|
||||||
|
assert_eq!(
|
||||||
|
text,
|
||||||
|
RichText(vec![
|
||||||
|
"before ".into(),
|
||||||
|
RichTextPiece {
|
||||||
|
text: "bold ".into(),
|
||||||
|
attrs: TextAttrs {
|
||||||
|
bold: true,
|
||||||
|
..TextAttrs::default()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
RichTextPiece {
|
||||||
|
text: "italic bold ".into(),
|
||||||
|
attrs: TextAttrs {
|
||||||
|
italic: true,
|
||||||
|
bold: true,
|
||||||
|
..TextAttrs::default()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"end".into(),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
let got = serde_json::to_string(&text).unwrap();
|
||||||
|
assert_eq!(got, raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn room_admin_serde() {
|
||||||
|
let data = RoomAdminPayload {
|
||||||
|
room: Id(42),
|
||||||
|
op: RoomAdminOp::AddMember {
|
||||||
|
permission: MemberPermission::POST_CHAT,
|
||||||
|
user: PubKey([0x42; PUBLIC_KEY_LENGTH]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let raw = serde_jcs::to_string(&data).unwrap();
|
||||||
|
|
||||||
|
let expect = expect![[
|
||||||
|
r#"{"permission":1,"room":"42","typ":"add_member","user":"4242424242424242424242424242424242424242424242424242424242424242"}"#
|
||||||
|
]];
|
||||||
|
expect.assert_eq(&raw);
|
||||||
|
let roundtrip = serde_json::from_str::<RoomAdminPayload>(&raw).unwrap();
|
||||||
|
assert_eq!(roundtrip, data);
|
||||||
|
}
|
||||||
|
}
|
39
blah-types/src/server.rs
Normal file
39
blah-types/src/server.rs
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
//! Data types and constants for Chat Server interaction.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::msg::{Id, MemberPermission, RoomAttrs, SignedChatMsgWithId};
|
||||||
|
use crate::PubKey;
|
||||||
|
|
||||||
|
pub const X_BLAH_NONCE: &str = "x-blah-nonce";
|
||||||
|
pub const X_BLAH_DIFFICULTY: &str = "x-blah-difficulty";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RoomMetadata {
|
||||||
|
/// Room id.
|
||||||
|
pub rid: Id,
|
||||||
|
/// Plain text room title. None for peer chat.
|
||||||
|
pub title: Option<String>,
|
||||||
|
/// Room attributes.
|
||||||
|
pub attrs: RoomAttrs,
|
||||||
|
|
||||||
|
// Extra information is only available for some APIs.
|
||||||
|
/// The last message in the room.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub last_msg: Option<SignedChatMsgWithId>,
|
||||||
|
/// The current user's last seen message's `cid`.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub last_seen_cid: Option<Id>,
|
||||||
|
/// The number of unseen messages, ie. the number of messages from `last_seen_cid` to
|
||||||
|
/// `last_msg.cid`.
|
||||||
|
/// This may or may not be a precise number.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub unseen_cnt: Option<u32>,
|
||||||
|
/// The member permission of current user in the room, or `None` if it is not a member.
|
||||||
|
/// Only available with authentication.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub member_permission: Option<MemberPermission>,
|
||||||
|
/// The peer user, if this is a peer chat room.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub peer_user: Option<PubKey>,
|
||||||
|
}
|
|
@ -4,7 +4,8 @@ use std::time::SystemTime;
|
||||||
|
|
||||||
use anyhow::{ensure, Context, Result};
|
use anyhow::{ensure, Context, Result};
|
||||||
use blah_types::identity::{IdUrl, UserActKeyDesc, UserIdentityDesc, UserProfile};
|
use blah_types::identity::{IdUrl, UserActKeyDesc, UserIdentityDesc, UserProfile};
|
||||||
use blah_types::{bitflags, get_timestamp, PubKey, RoomAttrs, ServerPermission, SignExt};
|
use blah_types::msg::{RoomAttrs, ServerPermission};
|
||||||
|
use blah_types::{bitflags, get_timestamp, PubKey, SignExt};
|
||||||
use clap::value_parser;
|
use clap::value_parser;
|
||||||
use ed25519_dalek::pkcs8::spki::der::pem::LineEnding;
|
use ed25519_dalek::pkcs8::spki::der::pem::LineEnding;
|
||||||
use ed25519_dalek::pkcs8::{DecodePrivateKey, DecodePublicKey, EncodePrivateKey};
|
use ed25519_dalek::pkcs8::{DecodePrivateKey, DecodePublicKey, EncodePrivateKey};
|
||||||
|
|
|
@ -3,10 +3,12 @@ use std::path::PathBuf;
|
||||||
|
|
||||||
use anyhow::{ensure, Context};
|
use anyhow::{ensure, Context};
|
||||||
use blah_types::identity::UserIdentityDesc;
|
use blah_types::identity::UserIdentityDesc;
|
||||||
use blah_types::{
|
use blah_types::msg::{
|
||||||
ChatPayload, Id, MemberPermission, PubKey, RoomAttrs, RoomMetadata, ServerPermission,
|
ChatPayload, MemberPermission, RoomAttrs, ServerPermission, SignedChatMsg, SignedChatMsgWithId,
|
||||||
SignedChatMsg, Signee, UserKey, WithMsgId,
|
WithMsgId,
|
||||||
};
|
};
|
||||||
|
use blah_types::server::RoomMetadata;
|
||||||
|
use blah_types::{Id, PubKey, Signee, UserKey};
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use rusqlite::{named_params, params, prepare_cached_and_bind, Connection, OpenFlags, Row};
|
use rusqlite::{named_params, params, prepare_cached_and_bind, Connection, OpenFlags, Row};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
@ -127,7 +129,7 @@ impl Database {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_msg(rid: Id, row: &Row<'_>) -> Result<WithMsgId<SignedChatMsg>> {
|
fn parse_msg(rid: Id, row: &Row<'_>) -> Result<SignedChatMsgWithId> {
|
||||||
Ok(WithMsgId {
|
Ok(WithMsgId {
|
||||||
cid: row.get("cid")?,
|
cid: row.get("cid")?,
|
||||||
msg: SignedChatMsg {
|
msg: SignedChatMsg {
|
||||||
|
@ -366,7 +368,7 @@ pub trait TransactionOps {
|
||||||
after_cid: Id,
|
after_cid: Id,
|
||||||
before_cid: Id,
|
before_cid: Id,
|
||||||
page_len: NonZero<u32>,
|
page_len: NonZero<u32>,
|
||||||
) -> Result<Vec<WithMsgId<SignedChatMsg>>> {
|
) -> Result<Vec<SignedChatMsgWithId>> {
|
||||||
prepare_cached_and_bind!(
|
prepare_cached_and_bind!(
|
||||||
self.conn(),
|
self.conn(),
|
||||||
r"
|
r"
|
||||||
|
|
|
@ -9,7 +9,8 @@ use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{bail, Context as _, Result};
|
use anyhow::{bail, Context as _, Result};
|
||||||
use axum::extract::ws::{Message, WebSocket};
|
use axum::extract::ws::{Message, WebSocket};
|
||||||
use blah_types::{AuthPayload, Signed, SignedChatMsg};
|
use blah_types::msg::{AuthPayload, SignedChatMsg};
|
||||||
|
use blah_types::Signed;
|
||||||
use futures_util::future::Either;
|
use futures_util::future::Either;
|
||||||
use futures_util::stream::SplitSink;
|
use futures_util::stream::SplitSink;
|
||||||
use futures_util::{stream_select, SinkExt as _, Stream, StreamExt};
|
use futures_util::{stream_select, SinkExt as _, Stream, StreamExt};
|
||||||
|
|
|
@ -6,7 +6,8 @@ use std::time::{Duration, SystemTime};
|
||||||
use axum::http::header;
|
use axum::http::header;
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use blah_types::{Id, SignedChatMsg, WithMsgId};
|
use blah_types::msg::{SignedChatMsgWithId, WithMsgId};
|
||||||
|
use blah_types::Id;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
|
@ -32,7 +33,7 @@ impl Default for Config {
|
||||||
pub struct FeedData {
|
pub struct FeedData {
|
||||||
pub rid: Id,
|
pub rid: Id,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub msgs: Vec<WithMsgId<SignedChatMsg>>,
|
pub msgs: Vec<SignedChatMsgWithId>,
|
||||||
pub self_url: Url,
|
pub self_url: Url,
|
||||||
pub next_url: Option<Url>,
|
pub next_url: Option<Url>,
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,12 +10,13 @@ use axum::response::Response;
|
||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
use axum::{Json, Router};
|
use axum::{Json, Router};
|
||||||
use axum_extra::extract::WithRejection as R;
|
use axum_extra::extract::WithRejection as R;
|
||||||
use blah_types::{
|
use blah_types::msg::{
|
||||||
get_timestamp, ChatPayload, CreateGroup, CreatePeerChat, CreateRoomPayload, DeleteRoomPayload,
|
ChatPayload, CreateGroup, CreatePeerChat, CreateRoomPayload, DeleteRoomPayload,
|
||||||
Id, MemberPermission, RoomAdminOp, RoomAdminPayload, RoomAttrs, RoomMetadata, ServerPermission,
|
MemberPermission, RoomAdminOp, RoomAdminPayload, RoomAttrs, ServerPermission,
|
||||||
Signed, SignedChatMsg, UserKey, UserRegisterPayload, WithMsgId, X_BLAH_DIFFICULTY,
|
SignedChatMsgWithId, UserRegisterPayload,
|
||||||
X_BLAH_NONCE,
|
|
||||||
};
|
};
|
||||||
|
use blah_types::server::{RoomMetadata, X_BLAH_DIFFICULTY, X_BLAH_NONCE};
|
||||||
|
use blah_types::{get_timestamp, Id, Signed, UserKey};
|
||||||
use database::{Transaction, TransactionOps};
|
use database::{Transaction, TransactionOps};
|
||||||
use feed::FeedData;
|
use feed::FeedData;
|
||||||
use id::IdExt;
|
use id::IdExt;
|
||||||
|
@ -345,7 +346,7 @@ impl Pagination {
|
||||||
|
|
||||||
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct RoomMsgs {
|
pub struct RoomMsgs {
|
||||||
pub msgs: Vec<WithMsgId<SignedChatMsg>>,
|
pub msgs: Vec<SignedChatMsgWithId>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub skip_token: Option<Id>,
|
pub skip_token: Option<Id>,
|
||||||
}
|
}
|
||||||
|
@ -460,7 +461,7 @@ fn query_room_msgs(
|
||||||
txn: &Transaction<'_>,
|
txn: &Transaction<'_>,
|
||||||
rid: Id,
|
rid: Id,
|
||||||
pagination: Pagination,
|
pagination: Pagination,
|
||||||
) -> Result<(Vec<WithMsgId<SignedChatMsg>>, Option<Id>), ApiError> {
|
) -> Result<(Vec<SignedChatMsgWithId>, Option<Id>), ApiError> {
|
||||||
let page_len = pagination.effective_page_len(st);
|
let page_len = pagination.effective_page_len(st);
|
||||||
let msgs = txn.list_room_msgs(
|
let msgs = txn.list_room_msgs(
|
||||||
rid,
|
rid,
|
||||||
|
|
|
@ -8,7 +8,8 @@ use axum::extract::{FromRef, FromRequest, FromRequestParts, Request};
|
||||||
use axum::http::{header, request, StatusCode};
|
use axum::http::{header, request, StatusCode};
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::{async_trait, Json};
|
use axum::{async_trait, Json};
|
||||||
use blah_types::{AuthPayload, Signed, UserKey};
|
use blah_types::msg::AuthPayload;
|
||||||
|
use blah_types::{Signed, UserKey};
|
||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,9 @@ use std::time::{Duration, Instant};
|
||||||
use anyhow::{anyhow, ensure};
|
use anyhow::{anyhow, ensure};
|
||||||
use axum::http::{HeaderMap, HeaderName, StatusCode};
|
use axum::http::{HeaderMap, HeaderName, StatusCode};
|
||||||
use blah_types::identity::{IdUrl, UserIdentityDesc};
|
use blah_types::identity::{IdUrl, UserIdentityDesc};
|
||||||
use blah_types::{get_timestamp, Signed, UserRegisterPayload, X_BLAH_DIFFICULTY, X_BLAH_NONCE};
|
use blah_types::msg::UserRegisterPayload;
|
||||||
|
use blah_types::server::{X_BLAH_DIFFICULTY, X_BLAH_NONCE};
|
||||||
|
use blah_types::{get_timestamp, Signed};
|
||||||
use http_body_util::BodyExt;
|
use http_body_util::BodyExt;
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use rand::rngs::OsRng;
|
use rand::rngs::OsRng;
|
||||||
|
|
|
@ -10,12 +10,13 @@ use std::time::{Duration, Instant};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use axum::http::HeaderMap;
|
use axum::http::HeaderMap;
|
||||||
use blah_types::identity::{IdUrl, UserActKeyDesc, UserIdentityDesc, UserProfile};
|
use blah_types::identity::{IdUrl, UserActKeyDesc, UserIdentityDesc, UserProfile};
|
||||||
use blah_types::{
|
use blah_types::msg::{
|
||||||
AuthPayload, ChatPayload, CreateGroup, CreatePeerChat, CreateRoomPayload, DeleteRoomPayload,
|
AuthPayload, ChatPayload, CreateGroup, CreatePeerChat, CreateRoomPayload, DeleteRoomPayload,
|
||||||
Id, MemberPermission, RichText, RoomAdminOp, RoomAdminPayload, RoomAttrs, RoomMetadata,
|
MemberPermission, RichText, RoomAdminOp, RoomAdminPayload, RoomAttrs, ServerPermission,
|
||||||
ServerPermission, SignExt, Signed, SignedChatMsg, UserKey, UserRegisterPayload, WithMsgId,
|
SignedChatMsg, SignedChatMsgWithId, UserRegisterPayload, WithMsgId,
|
||||||
X_BLAH_DIFFICULTY, X_BLAH_NONCE,
|
|
||||||
};
|
};
|
||||||
|
use blah_types::server::{RoomMetadata, X_BLAH_DIFFICULTY, X_BLAH_NONCE};
|
||||||
|
use blah_types::{Id, SignExt, Signed, UserKey};
|
||||||
use blahd::{AppState, Database, RoomList, RoomMsgs};
|
use blahd::{AppState, Database, RoomList, RoomMsgs};
|
||||||
use ed25519_dalek::SigningKey;
|
use ed25519_dalek::SigningKey;
|
||||||
use expect_test::expect;
|
use expect_test::expect;
|
||||||
|
@ -329,7 +330,7 @@ impl Server {
|
||||||
rid: Id,
|
rid: Id,
|
||||||
user: &User,
|
user: &User,
|
||||||
text: &str,
|
text: &str,
|
||||||
) -> impl Future<Output = Result<WithMsgId<SignedChatMsg>>> + use<'_> {
|
) -> impl Future<Output = Result<SignedChatMsgWithId>> + use<'_> {
|
||||||
let msg = self.sign(
|
let msg = self.sign(
|
||||||
user,
|
user,
|
||||||
ChatPayload {
|
ChatPayload {
|
||||||
|
|
Loading…
Add table
Reference in a new issue