diff --git a/Cargo.lock b/Cargo.lock index a7095f5..05ab96a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -107,7 +107,7 @@ checksum = "6e0c28dcc82d7c8ead5cb13beb15405b57b8546e93215673ff8ca0349a028107" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.76", ] [[package]] @@ -231,6 +231,7 @@ dependencies = [ "ed25519-dalek", "futures-util", "hex", + "html-escape", "humantime", "rand_core", "rusqlite", @@ -239,7 +240,8 @@ dependencies = [ "serde-aux", "serde-constant", "serde_json", - "syn", + "serde_tuple", + "syn 2.0.76", "tokio", "tokio-stream", "tower-http", @@ -350,7 +352,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.76", ] [[package]] @@ -430,7 +432,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.76", ] [[package]] @@ -583,7 +585,7 @@ checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.76", ] [[package]] @@ -697,6 +699,15 @@ dependencies = [ "serde", ] +[[package]] +name = "html-escape" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476" +dependencies = [ + "utf8-width", +] + [[package]] name = "http" version = "1.1.0" @@ -1040,7 +1051,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.76", ] [[package]] @@ -1099,7 +1110,7 @@ checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.76", ] [[package]] @@ -1421,7 +1432,7 @@ checksum = "a5831b979fd7b5439637af1752d535ff49f4860c0f341d1baeb6faf0f4242170" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.76", ] [[package]] @@ -1446,6 +1457,27 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_tuple" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f025b91216f15a2a32aa39669329a475733590a015835d1783549a56d09427" +dependencies = [ + "serde", + "serde_tuple_macros", +] + +[[package]] +name = "serde_tuple_macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4076151d1a2b688e25aaf236997933c66e18b870d0369f8b248b8ab2be630d7e" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1547,6 +1579,17 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.76" @@ -1656,7 +1699,7 @@ checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.76", ] [[package]] @@ -1769,7 +1812,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.76", ] [[package]] @@ -1857,6 +1900,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "utf8-width" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86bd8d4e895da8537e5315b8254664e6b769c4ff3db18321b297a1e7004392e3" + [[package]] name = "utf8parse" version = "0.2.2" @@ -1928,7 +1977,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn", + "syn 2.0.76", "wasm-bindgen-shared", ] @@ -1962,7 +2011,7 @@ checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.76", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -2144,7 +2193,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.76", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index d0acf61..53b6c98 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ clap = { version = "4.5.16", features = ["derive"] } ed25519-dalek = { version = "2.1.1", features = ["digest", "serde"] } futures-util = "0.3.30" hex = { version = "0.4.3", features = ["serde"] } +html-escape = "0.2.13" humantime = "2.1.0" rand_core = "0.6.4" rusqlite = { version = "0.32.1", features = ["uuid"] } @@ -21,6 +22,7 @@ serde = { version = "1.0.209", features = ["derive"] } serde-aux = "4.5.0" serde-constant = "0.1.0" serde_json = "1.0.127" +serde_tuple = "0.5.0" tokio = { version = "1.39.3", features = ["macros", "rt-multi-thread", "sync"] } tokio-stream = { version = "0.1.15", features = ["sync"] } tower-http = { version = "0.5.2", features = ["cors", "limit"] } diff --git a/blahctl/src/main.rs b/blahctl/src/main.rs index a7f5038..2f6ed58 100644 --- a/blahctl/src/main.rs +++ b/blahctl/src/main.rs @@ -5,8 +5,8 @@ use std::{fs, io}; use anyhow::{Context, Result}; use bitflags::Flags; use blah::types::{ - ChatPayload, CreateRoomPayload, MemberPermission, RoomAttrs, RoomMember, RoomMemberList, - ServerPermission, UserKey, WithSig, + ChatPayload, CreateRoomPayload, MemberPermission, RichText, RoomAttrs, RoomMember, + RoomMemberList, ServerPermission, UserKey, WithSig, }; use ed25519_dalek::pkcs8::spki::der::pem::LineEnding; use ed25519_dalek::pkcs8::{DecodePrivateKey, DecodePublicKey, EncodePrivateKey, EncodePublicKey}; @@ -238,7 +238,10 @@ async fn main_api(api_url: Url, command: ApiCommand) -> Result<()> { text, } => { let key = load_signing_key(&private_key_file)?; - let payload = ChatPayload { room, text }; + let payload = ChatPayload { + room, + rich_text: RichText::from(text), + }; let payload = WithSig::sign(&key, &mut OsRng, payload)?; let ret = client diff --git a/docs/webapi.yaml b/docs/webapi.yaml index 6dd57d4..70a63b0 100644 --- a/docs/webapi.yaml +++ b/docs/webapi.yaml @@ -83,7 +83,7 @@ paths: payload: typ: chat room: 7ed9e067-ec37-4054-9fc2-b1bd890929bd - text: helloo + rich_text: [["before "],["bold ",{"b":true}],["italic bold ",{"b":true,"i":true}],["end"]] timestamp: 1724966284 user: 83ce46ced47ec0391c64846cbb6c507250ead4985b6a044d68751edc46015dd7 responses: diff --git a/init.sql b/init.sql index 218367e..817e5c4 100644 --- a/init.sql +++ b/init.sql @@ -28,5 +28,5 @@ CREATE TABLE IF NOT EXISTS `room_item` ( `timestamp` INTEGER NOT NULL, `nonce` INTEGER NOT NULL, `sig` BLOB NOT NULL, - `message` TEXT NOT NULL + `rich_text` TEXT NOT NULL ) STRICT; diff --git a/pages/main.js b/pages/main.js index af88027..d0eff03 100644 --- a/pages/main.js +++ b/pages/main.js @@ -111,10 +111,47 @@ async function showChatMsg(chat) { const el = document.createElement('div', {}); el.classList.add('msg'); - el.innerText = `${shortUser} [${time}] [${verifyRet}]: ${chat.signee.payload.text}`; + const elHeader = document.createElement('span', {}); + const elContent = document.createElement('span', {}); + elHeader.innerText = `${shortUser} [${time}] [${verifyRet}]:`; + elContent.innerHTML = richTextToHtml(chat.signee.payload.rich_text); + el.appendChild(elHeader); + el.appendChild(elContent); appendMsg(el) } +function richTextToHtml(richText) { + let ret = '' + for (let [text, attrs] of richText) { + if (attrs === undefined) attrs = {}; + // Incomplete cases. + const tags = [ + [attrs.b, 'b'], + [attrs.i, 'i'], + [attrs.m, 'code'], + [attrs.s, 'strike'], + [attrs.u, 'u'], + ]; + for (const [cond, tag] of tags) { + if (cond) ret += `<${tag}>`; + } + ret += escapeHtml(text); + tags.reverse(); + for (const [cond, tag] of tags) { + if (cond) ret += ``; + } + } + return ret; +} + +function escapeHtml(text) { + return text.replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + async function connectRoom(url) { if (url === '' || url == roomUrl) return; const match = url.match(/^https?:\/\/.*\/([a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12})\/?/); @@ -200,10 +237,16 @@ async function postChat(text) { chatInput.disabled = true; try { + let richText; + if (text.startsWith('[')) { + richText = JSON.parse(text); + } else { + richText = [[text]]; + } const signedPayload = await signData({ typ: 'chat', + rich_text: richText, room: roomUuid, - text, }); const resp = await fetch(`${roomUrl}/item`, { method: 'POST', diff --git a/src/main.rs b/src/main.rs index f170645..9b81cd3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -247,7 +247,7 @@ async fn room_get_feed( }; FeedItem { id: cid.to_string(), - content_text: item.signee.payload.text, + content_html: item.signee.payload.rich_text.html().to_string(), date_published: humantime::format_rfc3339(time).to_string(), authors: (author,), extra: FeedItemExtra { @@ -292,7 +292,7 @@ struct FeedRoom { #[derive(Debug, Serialize)] struct FeedItem { id: String, - content_text: String, + content_html: String, date_published: String, authors: (FeedAuthor,), #[serde(rename = "_blah")] @@ -363,7 +363,7 @@ fn query_room_items( let mut stmt = conn.prepare( r" - SELECT `cid`, `timestamp`, `nonce`, `sig`, `userkey`, `sig`, `message` + SELECT `cid`, `timestamp`, `nonce`, `sig`, `userkey`, `sig`, `rich_text` FROM `room_item` JOIN `user` USING (`uid`) WHERE `rid` = :rid AND @@ -389,7 +389,7 @@ fn query_room_items( user: row.get("userkey")?, payload: ChatPayload { room: ruuid, - text: row.get("message")?, + rich_text: row.get("rich_text")?, }, }, }; @@ -492,8 +492,8 @@ async fn room_post_item( let cid = conn .query_row( r" - INSERT INTO `room_item` (`rid`, `uid`, `timestamp`, `nonce`, `sig`, `message`) - VALUES (:rid, :uid, :timestamp, :nonce, :sig, :message) + INSERT INTO `room_item` (`rid`, `uid`, `timestamp`, `nonce`, `sig`, `rich_text`) + VALUES (:rid, :uid, :timestamp, :nonce, :sig, :rich_text) RETURNING `cid` ", named_params! { @@ -501,7 +501,7 @@ async fn room_post_item( ":uid": uid, ":timestamp": chat.signee.timestamp, ":nonce": chat.signee.nonce, - ":message": &chat.signee.payload.text, + ":rich_text": &chat.signee.payload.rich_text, ":sig": chat.sig, }, |row| row.get::<_, u64>(0), diff --git a/src/types.rs b/src/types.rs index dd3afe5..b243752 100644 --- a/src/types.rs +++ b/src/types.rs @@ -12,7 +12,8 @@ use ed25519_dalek::{ Signature, Signer, SigningKey, VerifyingKey, PUBLIC_KEY_LENGTH, SIGNATURE_LENGTH, }; use rand_core::RngCore; -use serde::{Deserialize, Serialize}; +use serde::{de, Deserialize, Deserializer, Serialize}; +use serde_tuple::{Deserialize_tuple, Serialize_tuple}; use uuid::Uuid; const TIMESTAMP_TOLERENCE: u64 = 90; @@ -83,8 +84,184 @@ impl WithSig { #[derive(Debug, Serialize, Deserialize)] #[serde(tag = "typ", rename = "chat")] pub struct ChatPayload { + pub rich_text: RichText, pub room: Uuid, +} + +/// Ref: +#[derive(Debug, Default, PartialEq, Eq, Serialize)] +#[serde(transparent)] +pub struct RichText(pub Vec); + +// NB. This field is excluded from field order check, because it has tuple representation. +#[derive(Debug, PartialEq, Eq, Serialize_tuple)] +pub struct RichTextPiece { pub text: String, + #[serde(skip_serializing_if = "is_default::")] + pub attrs: TextAttrs, +} + +/// The protocol representation of `RichTextPiece` which keeps nullity of `attrs` for +/// canonicalization check. +// NB. This field is excluded from field order check, because it has tuple representation. +#[derive(Debug, Deserialize_tuple)] +struct RichTextPieceRaw { + pub text: String, + #[serde(default, skip_serializing_if = "is_default::")] + pub attrs: Option, +} + +fn is_default(v: &T) -> bool { + *v == T::default() +} + +impl<'de> Deserialize<'de> for RichText { + fn deserialize(de: D) -> Result + where + D: Deserializer<'de>, + { + let pieces = >::deserialize(de)?; + if pieces + .iter() + .any(|p| matches!(&p.attrs, Some(attrs) if is_default(attrs))) + { + return Err(de::Error::custom("not in canonical form")); + } + let this = Self( + pieces + .into_iter() + .map(|RichTextPieceRaw { text, attrs }| RichTextPiece { + text, + attrs: attrs.unwrap_or_default(), + }) + .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, 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, + #[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 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 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, "", ""), + (p.attrs.code, "", ""), + (p.attrs.italic, "", ""), + (p.attrs.strike, "", ""), + (p.attrs.underline, "", ""), + (p.attrs.hashtag || p.attrs.link.is_some(), "", ""), + ]; + for (cond, begin, _) in tags { + if cond { + f.write_str(begin)?; + } + } + if p.attrs.hashtag { + // TODO: Link target for hashtag? + write!(f, r#""#)?; + } else if let Some(link) = &p.attrs.link { + let href = html_escape::encode_quoted_attribute(link); + write!(f, r#"; @@ -201,6 +378,21 @@ mod sql_impl { } } + impl ToSql for RichText { + fn to_sql(&self) -> Result> { + 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 { + serde_json::from_str::(value.as_str()?) + .map_err(|err| FromSqlError::Other(format!("invalid rich text: {err}").into())) + } + } + macro_rules! impl_u64_flag { ($($name:ident),*) => { $( @@ -228,11 +420,17 @@ mod sql_impl { mod tests { use std::fmt::Write; + use syn::visit; + + use super::*; + #[derive(Default)] struct Visitor { errors: String, } + const SKIP_CHECK_STRUCTS: &[&str] = &["RichTextPiece", "RichTextPieceRaw"]; + impl<'ast> syn::visit::Visit<'ast> for Visitor { fn visit_fields_named(&mut self, i: &'ast syn::FieldsNamed) { let fields = i @@ -245,6 +443,12 @@ mod tests { writeln!(self.errors, "unsorted fields: {fields:?}").unwrap(); } } + + fn visit_item_struct(&mut self, i: &'ast syn::ItemStruct) { + if !SKIP_CHECK_STRUCTS.contains(&&*i.ident.to_string()) { + visit::visit_item_struct(self, i); + } + } } #[test] @@ -258,4 +462,36 @@ mod tests { panic!("{}", v.errors); } } + + #[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::(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); + } }