max / makenotwork
11 files changed,
+1345 insertions,
-0 deletions
| @@ -0,0 +1,325 @@ | |||
| 1 | + | # This file is automatically @generated by Cargo. | |
| 2 | + | # It is not intended for manual editing. | |
| 3 | + | version = 4 | |
| 4 | + | ||
| 5 | + | [[package]] | |
| 6 | + | name = "async-trait" | |
| 7 | + | version = "0.1.91" | |
| 8 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 9 | + | checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" | |
| 10 | + | dependencies = [ | |
| 11 | + | "proc-macro2", | |
| 12 | + | "quote", | |
| 13 | + | "syn 3.0.0", | |
| 14 | + | ] | |
| 15 | + | ||
| 16 | + | [[package]] | |
| 17 | + | name = "bumpalo" | |
| 18 | + | version = "3.20.3" | |
| 19 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 20 | + | checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" | |
| 21 | + | ||
| 22 | + | [[package]] | |
| 23 | + | name = "cfg-if" | |
| 24 | + | version = "1.0.4" | |
| 25 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 26 | + | checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" | |
| 27 | + | ||
| 28 | + | [[package]] | |
| 29 | + | name = "futures-core" | |
| 30 | + | version = "0.3.33" | |
| 31 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 32 | + | checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" | |
| 33 | + | ||
| 34 | + | [[package]] | |
| 35 | + | name = "futures-task" | |
| 36 | + | version = "0.3.33" | |
| 37 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 38 | + | checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" | |
| 39 | + | ||
| 40 | + | [[package]] | |
| 41 | + | name = "futures-util" | |
| 42 | + | version = "0.3.33" | |
| 43 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 44 | + | checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" | |
| 45 | + | dependencies = [ | |
| 46 | + | "futures-core", | |
| 47 | + | "futures-task", | |
| 48 | + | "pin-project-lite", | |
| 49 | + | "slab", | |
| 50 | + | ] | |
| 51 | + | ||
| 52 | + | [[package]] | |
| 53 | + | name = "getrandom" | |
| 54 | + | version = "0.4.3" | |
| 55 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 56 | + | checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" | |
| 57 | + | dependencies = [ | |
| 58 | + | "cfg-if", | |
| 59 | + | "libc", | |
| 60 | + | "r-efi", | |
| 61 | + | ] | |
| 62 | + | ||
| 63 | + | [[package]] | |
| 64 | + | name = "itoa" | |
| 65 | + | version = "1.0.18" | |
| 66 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 67 | + | checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" | |
| 68 | + | ||
| 69 | + | [[package]] | |
| 70 | + | name = "js-sys" | |
| 71 | + | version = "0.3.103" | |
| 72 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 73 | + | checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" | |
| 74 | + | dependencies = [ | |
| 75 | + | "cfg-if", | |
| 76 | + | "futures-util", | |
| 77 | + | "wasm-bindgen", | |
| 78 | + | ] | |
| 79 | + | ||
| 80 | + | [[package]] | |
| 81 | + | name = "libc" | |
| 82 | + | version = "0.2.186" | |
| 83 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 84 | + | checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" | |
| 85 | + | ||
| 86 | + | [[package]] | |
| 87 | + | name = "livechat" | |
| 88 | + | version = "0.1.0" | |
| 89 | + | dependencies = [ | |
| 90 | + | "async-trait", | |
| 91 | + | "serde", | |
| 92 | + | "serde_json", | |
| 93 | + | "thiserror", | |
| 94 | + | "tokio", | |
| 95 | + | "uuid", | |
| 96 | + | ] | |
| 97 | + | ||
| 98 | + | [[package]] | |
| 99 | + | name = "memchr" | |
| 100 | + | version = "2.8.3" | |
| 101 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 102 | + | checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" | |
| 103 | + | ||
| 104 | + | [[package]] | |
| 105 | + | name = "once_cell" | |
| 106 | + | version = "1.21.4" | |
| 107 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 108 | + | checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" | |
| 109 | + | ||
| 110 | + | [[package]] | |
| 111 | + | name = "pin-project-lite" | |
| 112 | + | version = "0.2.17" | |
| 113 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 114 | + | checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" | |
| 115 | + | ||
| 116 | + | [[package]] | |
| 117 | + | name = "proc-macro2" | |
| 118 | + | version = "1.0.106" | |
| 119 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 120 | + | checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" | |
| 121 | + | dependencies = [ | |
| 122 | + | "unicode-ident", | |
| 123 | + | ] | |
| 124 | + | ||
| 125 | + | [[package]] | |
| 126 | + | name = "quote" | |
| 127 | + | version = "1.0.46" | |
| 128 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 129 | + | checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" | |
| 130 | + | dependencies = [ | |
| 131 | + | "proc-macro2", | |
| 132 | + | ] | |
| 133 | + | ||
| 134 | + | [[package]] | |
| 135 | + | name = "r-efi" | |
| 136 | + | version = "6.0.0" | |
| 137 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 138 | + | checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" | |
| 139 | + | ||
| 140 | + | [[package]] | |
| 141 | + | name = "rustversion" | |
| 142 | + | version = "1.0.23" | |
| 143 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 144 | + | checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" | |
| 145 | + | ||
| 146 | + | [[package]] | |
| 147 | + | name = "serde" | |
| 148 | + | version = "1.0.229" | |
| 149 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 150 | + | checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" | |
| 151 | + | dependencies = [ | |
| 152 | + | "serde_core", | |
| 153 | + | "serde_derive", | |
| 154 | + | ] | |
| 155 | + | ||
| 156 | + | [[package]] | |
| 157 | + | name = "serde_core" | |
| 158 | + | version = "1.0.229" | |
| 159 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 160 | + | checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" | |
| 161 | + | dependencies = [ | |
| 162 | + | "serde_derive", | |
| 163 | + | ] | |
| 164 | + | ||
| 165 | + | [[package]] | |
| 166 | + | name = "serde_derive" | |
| 167 | + | version = "1.0.229" | |
| 168 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 169 | + | checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" | |
| 170 | + | dependencies = [ | |
| 171 | + | "proc-macro2", | |
| 172 | + | "quote", | |
| 173 | + | "syn 3.0.0", | |
| 174 | + | ] | |
| 175 | + | ||
| 176 | + | [[package]] | |
| 177 | + | name = "serde_json" | |
| 178 | + | version = "1.0.150" | |
| 179 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 180 | + | checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" | |
| 181 | + | dependencies = [ | |
| 182 | + | "itoa", | |
| 183 | + | "memchr", | |
| 184 | + | "serde", | |
| 185 | + | "serde_core", | |
| 186 | + | "zmij", | |
| 187 | + | ] | |
| 188 | + | ||
| 189 | + | [[package]] | |
| 190 | + | name = "slab" | |
| 191 | + | version = "0.4.12" | |
| 192 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 193 | + | checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" | |
| 194 | + | ||
| 195 | + | [[package]] | |
| 196 | + | name = "syn" | |
| 197 | + | version = "2.0.119" | |
| 198 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 199 | + | checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" | |
| 200 | + | dependencies = [ | |
| 201 | + | "proc-macro2", | |
| 202 | + | "quote", | |
| 203 | + | "unicode-ident", | |
| 204 | + | ] | |
| 205 | + | ||
| 206 | + | [[package]] | |
| 207 | + | name = "syn" | |
| 208 | + | version = "3.0.0" | |
| 209 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 210 | + | checksum = "f2fac314a64dc9a36e61a9eb4261a5e9bbfbc922b27e518af97bc32b926cf967" | |
| 211 | + | dependencies = [ | |
| 212 | + | "proc-macro2", | |
| 213 | + | "quote", | |
| 214 | + | "unicode-ident", | |
| 215 | + | ] | |
| 216 | + | ||
| 217 | + | [[package]] | |
| 218 | + | name = "thiserror" | |
| 219 | + | version = "2.0.19" | |
| 220 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 221 | + | checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" | |
| 222 | + | dependencies = [ | |
| 223 | + | "thiserror-impl", | |
| 224 | + | ] | |
| 225 | + | ||
| 226 | + | [[package]] | |
| 227 | + | name = "thiserror-impl" | |
| 228 | + | version = "2.0.19" | |
| 229 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 230 | + | checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" | |
| 231 | + | dependencies = [ | |
| 232 | + | "proc-macro2", | |
| 233 | + | "quote", | |
| 234 | + | "syn 3.0.0", | |
| 235 | + | ] | |
| 236 | + | ||
| 237 | + | [[package]] | |
| 238 | + | name = "tokio" | |
| 239 | + | version = "1.53.0" | |
| 240 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 241 | + | checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" | |
| 242 | + | dependencies = [ | |
| 243 | + | "pin-project-lite", | |
| 244 | + | "tokio-macros", | |
| 245 | + | ] | |
| 246 | + | ||
| 247 | + | [[package]] | |
| 248 | + | name = "tokio-macros" | |
| 249 | + | version = "2.7.1" | |
| 250 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 251 | + | checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" | |
| 252 | + | dependencies = [ | |
| 253 | + | "proc-macro2", | |
| 254 | + | "quote", | |
| 255 | + | "syn 2.0.119", | |
| 256 | + | ] | |
| 257 | + | ||
| 258 | + | [[package]] | |
| 259 | + | name = "unicode-ident" | |
| 260 | + | version = "1.0.24" | |
| 261 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 262 | + | checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" | |
| 263 | + | ||
| 264 | + | [[package]] | |
| 265 | + | name = "uuid" | |
| 266 | + | version = "1.24.0" | |
| 267 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 268 | + | checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" | |
| 269 | + | dependencies = [ | |
| 270 | + | "getrandom", | |
| 271 | + | "js-sys", | |
| 272 | + | "serde_core", | |
| 273 | + | "wasm-bindgen", | |
| 274 | + | ] | |
| 275 | + | ||
| 276 | + | [[package]] | |
| 277 | + | name = "wasm-bindgen" | |
| 278 | + | version = "0.2.126" | |
| 279 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 280 | + | checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" | |
| 281 | + | dependencies = [ | |
| 282 | + | "cfg-if", | |
| 283 | + | "once_cell", | |
| 284 | + | "rustversion", | |
| 285 | + | "wasm-bindgen-macro", | |
| 286 | + | "wasm-bindgen-shared", | |
| 287 | + | ] | |
| 288 | + | ||
| 289 | + | [[package]] | |
| 290 | + | name = "wasm-bindgen-macro" | |
| 291 | + | version = "0.2.126" | |
| 292 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 293 | + | checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" | |
| 294 | + | dependencies = [ | |
| 295 | + | "quote", | |
| 296 | + | "wasm-bindgen-macro-support", | |
| 297 | + | ] | |
| 298 | + | ||
| 299 | + | [[package]] | |
| 300 | + | name = "wasm-bindgen-macro-support" | |
| 301 | + | version = "0.2.126" | |
| 302 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 303 | + | checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" | |
| 304 | + | dependencies = [ | |
| 305 | + | "bumpalo", | |
| 306 | + | "proc-macro2", | |
| 307 | + | "quote", | |
| 308 | + | "syn 2.0.119", | |
| 309 | + | "wasm-bindgen-shared", | |
| 310 | + | ] | |
| 311 | + | ||
| 312 | + | [[package]] | |
| 313 | + | name = "wasm-bindgen-shared" | |
| 314 | + | version = "0.2.126" | |
| 315 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 316 | + | checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" | |
| 317 | + | dependencies = [ | |
| 318 | + | "unicode-ident", | |
| 319 | + | ] | |
| 320 | + | ||
| 321 | + | [[package]] | |
| 322 | + | name = "zmij" | |
| 323 | + | version = "1.0.23" | |
| 324 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 325 | + | checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" |
| @@ -0,0 +1,14 @@ | |||
| 1 | + | [package] | |
| 2 | + | name = "livechat" | |
| 3 | + | version = "0.1.0" | |
| 4 | + | edition = "2024" | |
| 5 | + | ||
| 6 | + | [dependencies] | |
| 7 | + | async-trait = "0.1" | |
| 8 | + | serde = { version = "1", features = ["derive"] } | |
| 9 | + | thiserror = "2" | |
| 10 | + | uuid = { version = "1", features = ["v4", "serde"] } | |
| 11 | + | ||
| 12 | + | [dev-dependencies] | |
| 13 | + | serde_json = "1" | |
| 14 | + | tokio = { version = "1", features = ["macros", "rt"] } |
| @@ -0,0 +1,47 @@ | |||
| 1 | + | //! Crate error type. | |
| 2 | + | //! | |
| 3 | + | //! [`ChatError::Host`] is the seam for the host app: a trait impl that fails in | |
| 4 | + | //! the database or in its own authz layer boxes that error rather than the crate | |
| 5 | + | //! trying to name every failure either consumer might have. | |
| 6 | + | ||
| 7 | + | use std::time::Duration; | |
| 8 | + | ||
| 9 | + | /// Anything that can go wrong in the crate or in a host's trait impl. | |
| 10 | + | #[derive(Debug, thiserror::Error)] | |
| 11 | + | pub enum ChatError { | |
| 12 | + | #[error("retention exceeds the crate ceiling (asked for {max_age:?} / {max_messages} messages)")] | |
| 13 | + | RetentionTooLong { | |
| 14 | + | max_age: Duration, | |
| 15 | + | max_messages: usize, | |
| 16 | + | }, | |
| 17 | + | ||
| 18 | + | #[error("retention must be non-zero in both bounds")] | |
| 19 | + | RetentionTooShort, | |
| 20 | + | ||
| 21 | + | #[error("message is {len} characters, over the {max} character limit")] | |
| 22 | + | MessageTooLong { len: usize, max: usize }, | |
| 23 | + | ||
| 24 | + | #[error("message is empty")] | |
| 25 | + | MessageEmpty, | |
| 26 | + | ||
| 27 | + | #[error("invalid send nonce")] | |
| 28 | + | InvalidNonce, | |
| 29 | + | ||
| 30 | + | #[error("room is closed")] | |
| 31 | + | RoomClosed, | |
| 32 | + | ||
| 33 | + | #[error("room is read-only")] | |
| 34 | + | RoomReadOnly, | |
| 35 | + | ||
| 36 | + | /// An error from the host app's trait impl: a query failure, a session | |
| 37 | + | /// lookup, anything the crate has no vocabulary for. | |
| 38 | + | #[error("host error: {0}")] | |
| 39 | + | Host(#[source] Box<dyn std::error::Error + Send + Sync>), | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | impl ChatError { | |
| 43 | + | /// Wrap a host-side error. | |
| 44 | + | pub fn host<E: std::error::Error + Send + Sync + 'static>(err: E) -> Self { | |
| 45 | + | Self::Host(Box::new(err)) | |
| 46 | + | } | |
| 47 | + | } |
| @@ -0,0 +1,108 @@ | |||
| 1 | + | //! What the server sends down the stream. | |
| 2 | + | //! | |
| 3 | + | //! Deliberately four variants. Presence, typing, join, and leave are all absent | |
| 4 | + | //! by decision: typing in particular is the largest driver of event volume, and | |
| 5 | + | //! both consumers run under a hard 512M cgroup cap where an OOM restarts the | |
| 6 | + | //! whole site. | |
| 7 | + | ||
| 8 | + | use serde::{Deserialize, Serialize}; | |
| 9 | + | ||
| 10 | + | use crate::ids::{MessageId, UserId}; | |
| 11 | + | use crate::message::Message; | |
| 12 | + | ||
| 13 | + | /// A single frame on the stream. | |
| 14 | + | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] | |
| 15 | + | #[serde(tag = "type", rename_all = "snake_case")] | |
| 16 | + | pub enum ChatEvent { | |
| 17 | + | /// A new message. | |
| 18 | + | Message(Message), | |
| 19 | + | ||
| 20 | + | /// One message removed, by its author or by a moderator. | |
| 21 | + | Delete { id: MessageId }, | |
| 22 | + | ||
| 23 | + | /// Every message from one author removed at once, following a ban. | |
| 24 | + | /// | |
| 25 | + | /// One event rather than N `Delete`s: a ban is issued on a hot path and the | |
| 26 | + | /// deletion is a single statement keyed by (room, author), so fanning it out | |
| 27 | + | /// per message would put an unbounded burst through every open connection | |
| 28 | + | /// for no gain. | |
| 29 | + | Purge { author_id: UserId }, | |
| 30 | + | ||
| 31 | + | /// The connection fell behind and messages were dropped from the broadcast | |
| 32 | + | /// buffer. The client re-fetches from its cursor. | |
| 33 | + | /// | |
| 34 | + | /// Never silently swallowed. SyncKit's SSE can ignore lag because its clients | |
| 35 | + | /// re-pull on their own schedule; a chat client that ignored it would show a | |
| 36 | + | /// room with a hole in it and no indication anything was missing. | |
| 37 | + | Gap, | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | impl ChatEvent { | |
| 41 | + | /// The SSE event name for this frame. | |
| 42 | + | pub fn name(&self) -> &'static str { | |
| 43 | + | match self { | |
| 44 | + | Self::Message(_) => "message", | |
| 45 | + | Self::Delete { .. } => "delete", | |
| 46 | + | Self::Purge { .. } => "purge", | |
| 47 | + | Self::Gap => "gap", | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | /// The cursor a client should record after handling this frame, if any. | |
| 52 | + | /// | |
| 53 | + | /// Only `Message` advances the cursor. A deletion is not a position in the | |
| 54 | + | /// room, and reconnecting past a `Gap` is exactly what the cursor is for. | |
| 55 | + | pub fn cursor(&self) -> Option<MessageId> { | |
| 56 | + | match self { | |
| 57 | + | Self::Message(m) => Some(m.id), | |
| 58 | + | _ => None, | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | #[cfg(test)] | |
| 64 | + | mod tests { | |
| 65 | + | use super::*; | |
| 66 | + | use crate::ids::RoomId; | |
| 67 | + | use uuid::Uuid; | |
| 68 | + | ||
| 69 | + | fn message(id: i64) -> Message { | |
| 70 | + | Message { | |
| 71 | + | id: MessageId(id), | |
| 72 | + | room_id: RoomId(Uuid::nil()), | |
| 73 | + | author_id: UserId(Uuid::nil()), | |
| 74 | + | body_html: "hi".into(), | |
| 75 | + | created_at: 0, | |
| 76 | + | nonce: None, | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | #[test] | |
| 81 | + | fn only_messages_advance_the_cursor() { | |
| 82 | + | assert_eq!( | |
| 83 | + | ChatEvent::Message(message(7)).cursor(), | |
| 84 | + | Some(MessageId(7)) | |
| 85 | + | ); | |
| 86 | + | assert_eq!(ChatEvent::Delete { id: MessageId(7) }.cursor(), None); | |
| 87 | + | assert_eq!( | |
| 88 | + | ChatEvent::Purge { | |
| 89 | + | author_id: UserId(Uuid::nil()) | |
| 90 | + | } | |
| 91 | + | .cursor(), | |
| 92 | + | None | |
| 93 | + | ); | |
| 94 | + | assert_eq!(ChatEvent::Gap.cursor(), None); | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | #[test] | |
| 98 | + | fn serializes_tagged_for_the_client() { | |
| 99 | + | let json = serde_json::to_string(&ChatEvent::Delete { id: MessageId(3) }).unwrap(); | |
| 100 | + | assert!(json.contains(r#""type":"delete""#), "{json}"); | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | #[test] | |
| 104 | + | fn nonce_is_omitted_when_absent() { | |
| 105 | + | let json = serde_json::to_string(&message(1)).unwrap(); | |
| 106 | + | assert!(!json.contains("nonce"), "{json}"); | |
| 107 | + | } | |
| 108 | + | } |
| @@ -0,0 +1,96 @@ | |||
| 1 | + | //! Identifier newtypes shared by both consumers. | |
| 2 | + | //! | |
| 3 | + | //! Both apps key users and rooms by UUID (Multithreaded's `users.mnw_account_id` | |
| 4 | + | //! and `communities.id`; the MNW server's UUID domain entities), so the crate can | |
| 5 | + | //! name those concretely without constraining either schema. | |
| 6 | + | //! | |
| 7 | + | //! Messages are the exception. `MessageId` is a monotonic `i64` rather than a | |
| 8 | + | //! UUID because the reconnect cursor (`?after=<id>`) needs a total order, and | |
| 9 | + | //! because chat messages are log-shaped, which is the case the MNW server already | |
| 10 | + | //! spells `BIGINT GENERATED ALWAYS AS IDENTITY`. | |
| 11 | + | ||
| 12 | + | use serde::{Deserialize, Serialize}; | |
| 13 | + | use uuid::Uuid; | |
| 14 | + | ||
| 15 | + | macro_rules! uuid_id { | |
| 16 | + | ($name:ident, $doc:literal) => { | |
| 17 | + | #[doc = $doc] | |
| 18 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] | |
| 19 | + | #[serde(transparent)] | |
| 20 | + | pub struct $name(pub Uuid); | |
| 21 | + | ||
| 22 | + | impl From<Uuid> for $name { | |
| 23 | + | fn from(id: Uuid) -> Self { | |
| 24 | + | Self(id) | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | impl From<$name> for Uuid { | |
| 29 | + | fn from(id: $name) -> Self { | |
| 30 | + | id.0 | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | impl std::fmt::Display for $name { | |
| 35 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 36 | + | self.0.fmt(f) | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | }; | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | uuid_id!( | |
| 43 | + | RoomId, | |
| 44 | + | "A chat room. One per community in Multithreaded; one per stream in MNW." | |
| 45 | + | ); | |
| 46 | + | uuid_id!(UserId, "A chat participant, in the host app's user namespace."); | |
| 47 | + | ||
| 48 | + | /// A single message, ordered. Doubles as the reconnect cursor. | |
| 49 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] | |
| 50 | + | #[serde(transparent)] | |
| 51 | + | pub struct MessageId(pub i64); | |
| 52 | + | ||
| 53 | + | impl std::fmt::Display for MessageId { | |
| 54 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 55 | + | self.0.fmt(f) | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | /// Client-generated send nonce, echoed back so an optimistically rendered | |
| 60 | + | /// message reconciles instead of appearing twice. | |
| 61 | + | /// | |
| 62 | + | /// Client-supplied and therefore untrusted: it is opaque to the server, capped | |
| 63 | + | /// in length, and only ever compared for equality. It never reaches a query. | |
| 64 | + | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] | |
| 65 | + | #[serde(transparent)] | |
| 66 | + | pub struct Nonce(pub String); | |
| 67 | + | ||
| 68 | + | impl Nonce { | |
| 69 | + | /// Longest accepted nonce. A client only needs enough to disambiguate its own | |
| 70 | + | /// in-flight sends; anything longer is someone probing. | |
| 71 | + | pub const MAX_LEN: usize = 64; | |
| 72 | + | ||
| 73 | + | /// Returns `None` if the nonce is empty or over [`Nonce::MAX_LEN`]. | |
| 74 | + | pub fn parse(raw: impl Into<String>) -> Option<Self> { | |
| 75 | + | let raw = raw.into(); | |
| 76 | + | (!raw.is_empty() && raw.len() <= Self::MAX_LEN).then_some(Self(raw)) | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | #[cfg(test)] | |
| 81 | + | mod tests { | |
| 82 | + | use super::*; | |
| 83 | + | ||
| 84 | + | #[test] | |
| 85 | + | fn nonce_rejects_empty_and_overlong() { | |
| 86 | + | assert!(Nonce::parse("").is_none()); | |
| 87 | + | assert!(Nonce::parse("a").is_some()); | |
| 88 | + | assert!(Nonce::parse("x".repeat(Nonce::MAX_LEN)).is_some()); | |
| 89 | + | assert!(Nonce::parse("x".repeat(Nonce::MAX_LEN + 1)).is_none()); | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | #[test] | |
| 93 | + | fn message_ids_order_by_value() { | |
| 94 | + | assert!(MessageId(1) < MessageId(2)); | |
| 95 | + | } | |
| 96 | + | } |
| @@ -0,0 +1,56 @@ | |||
| 1 | + | //! Live chat shared by Multithreaded forums and MNW stream pages. | |
| 2 | + | //! | |
| 3 | + | //! Design + decisions: maintainer wiki. | |
| 4 | + | //! <!-- wiki: livechat-design --> | |
| 5 | + | //! | |
| 6 | + | //! A thin crate. It owns the message and event vocabulary, bounded retention, | |
| 7 | + | //! and (as they land) the hub and SSE transport. It owns no schema, no authz, and | |
| 8 | + | //! no moderation policy: those arrive through four traits the host implements, so | |
| 9 | + | //! Multithreaded can gate on `CommunityScope` and the MNW server on | |
| 10 | + | //! `SubscriptionGate` without either concept appearing here. | |
| 11 | + | //! | |
| 12 | + | //! # Shape | |
| 13 | + | //! | |
| 14 | + | //! SSE downstream, plain `POST` upstream. Not a WebSocket. The upstream volume is | |
| 15 | + | //! one request per message typed, so bidirectionality buys nothing, and a `POST` | |
| 16 | + | //! passes through the CSRF, rate-limit, and session middleware both apps already | |
| 17 | + | //! run. It also avoids adding a `connect-src` CSP directive on the Multithreaded | |
| 18 | + | //! side, which currently has none. | |
| 19 | + | //! | |
| 20 | + | //! # Ephemeral, but written down | |
| 21 | + | //! | |
| 22 | + | //! Chat in both consumers expires. Messages are stored anyway, for two reasons: | |
| 23 | + | //! a deploy is a symlink swap plus a restart with no connection draining, so | |
| 24 | + | //! every client reconnects and needs its backlog replayed from a cursor; and | |
| 25 | + | //! moderation has to be able to reach a message that already scrolled past. | |
| 26 | + | //! | |
| 27 | + | //! [`Retention`] makes the bound un-opt-out-able. There is no `Option`, no | |
| 28 | + | //! "forever" sentinel, and no public field: the only constructor rejects anything | |
| 29 | + | //! over the crate ceilings. | |
| 30 | + | //! | |
| 31 | + | //! # Not here, on purpose | |
| 32 | + | //! | |
| 33 | + | //! No presence, no typing indicators, no edit path, and no link previews. The | |
| 34 | + | //! first two are the largest drivers of event volume against a hard 512M cgroup | |
| 35 | + | //! cap where an OOM restarts the whole site. Edit is surface with no benefit in a | |
| 36 | + | //! stream nobody re-reads. Link previews would point a server-side fetcher at | |
| 37 | + | //! unreviewed user input at chat volume, which is a much hotter SSRF surface than | |
| 38 | + | //! the forum-post path it would borrow. | |
| 39 | + | ||
| 40 | + | mod error; | |
| 41 | + | mod event; | |
| 42 | + | mod ids; | |
| 43 | + | mod message; | |
| 44 | + | mod retention; | |
| 45 | + | mod room; | |
| 46 | + | mod traits; | |
| 47 | + | ||
| 48 | + | pub use error::ChatError; | |
| 49 | + | pub use event::ChatEvent; | |
| 50 | + | pub use ids::{MessageId, Nonce, RoomId, UserId}; | |
| 51 | + | pub use message::{MAX_MESSAGE_LEN, Message, MessageBody}; | |
| 52 | + | pub use retention::{MAX_AGE_CEILING, MAX_MESSAGES_CEILING, Retention}; | |
| 53 | + | pub use room::{Room, RoomState}; | |
| 54 | + | pub use traits::{ | |
| 55 | + | ChatAuthz, ChatIdentity, ChatModeration, ChatRooms, DenyReason, Identity, WriteAccess, | |
| 56 | + | }; |
| @@ -0,0 +1,115 @@ | |||
| 1 | + | //! Message body validation and the stored message shape. | |
| 2 | + | ||
| 3 | + | use serde::{Deserialize, Serialize}; | |
| 4 | + | ||
| 5 | + | use crate::error::ChatError; | |
| 6 | + | use crate::ids::{MessageId, Nonce, RoomId, UserId}; | |
| 7 | + | ||
| 8 | + | /// Longest message a room accepts. | |
| 9 | + | /// | |
| 10 | + | /// Enforced here, on the server, as the authority. The client enforces the same | |
| 11 | + | /// number for feedback while typing, but that is a courtesy and not a control. | |
| 12 | + | pub const MAX_MESSAGE_LEN: usize = 500; | |
| 13 | + | ||
| 14 | + | /// A validated message body. | |
| 15 | + | /// | |
| 16 | + | /// Constructing one is the only way to reach the send path, so "did anyone check | |
| 17 | + | /// the length" is not a question a caller can get wrong. | |
| 18 | + | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] | |
| 19 | + | #[serde(transparent)] | |
| 20 | + | pub struct MessageBody(String); | |
| 21 | + | ||
| 22 | + | impl MessageBody { | |
| 23 | + | /// Trims surrounding whitespace, then rejects empty or overlong bodies. | |
| 24 | + | /// | |
| 25 | + | /// Length is counted in `chars`, not bytes, so the limit means the same thing | |
| 26 | + | /// to someone typing in a script that is not Latin. | |
| 27 | + | /// | |
| 28 | + | /// # Errors | |
| 29 | + | /// | |
| 30 | + | /// [`ChatError::MessageEmpty`] or [`ChatError::MessageTooLong`]. | |
| 31 | + | pub fn parse(raw: &str) -> Result<Self, ChatError> { | |
| 32 | + | let trimmed = raw.trim(); | |
| 33 | + | if trimmed.is_empty() { | |
| 34 | + | return Err(ChatError::MessageEmpty); | |
| 35 | + | } | |
| 36 | + | let len = trimmed.chars().count(); | |
| 37 | + | if len > MAX_MESSAGE_LEN { | |
| 38 | + | return Err(ChatError::MessageTooLong { | |
| 39 | + | len, | |
| 40 | + | max: MAX_MESSAGE_LEN, | |
| 41 | + | }); | |
| 42 | + | } | |
| 43 | + | Ok(Self(trimmed.to_owned())) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | pub fn as_str(&self) -> &str { | |
| 47 | + | &self.0 | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | /// A message as stored and as broadcast. | |
| 52 | + | /// | |
| 53 | + | /// `body_html` is rendered once at insert by the host, through docengine's chat | |
| 54 | + | /// preset, and never re-rendered: there is no edit path anywhere in the system. | |
| 55 | + | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] | |
| 56 | + | pub struct Message { | |
| 57 | + | pub id: MessageId, | |
| 58 | + | pub room_id: RoomId, | |
| 59 | + | pub author_id: UserId, | |
| 60 | + | pub body_html: String, | |
| 61 | + | /// Unix seconds. Wall clock is adequate: a single process assigns every id, | |
| 62 | + | /// and `MessageId` is what actually orders the room. | |
| 63 | + | pub created_at: i64, | |
| 64 | + | /// Echoed to the sender so an optimistically rendered message reconciles. | |
| 65 | + | /// Absent for every other recipient and for backlog replay. | |
| 66 | + | #[serde(skip_serializing_if = "Option::is_none")] | |
| 67 | + | pub nonce: Option<Nonce>, | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | #[cfg(test)] | |
| 71 | + | mod tests { | |
| 72 | + | use super::*; | |
| 73 | + | ||
| 74 | + | #[test] | |
| 75 | + | fn trims_then_rejects_empty() { | |
| 76 | + | assert!(matches!( | |
| 77 | + | MessageBody::parse(" \n\t "), | |
| 78 | + | Err(ChatError::MessageEmpty) | |
| 79 | + | )); | |
| 80 | + | assert!(matches!(MessageBody::parse(""), Err(ChatError::MessageEmpty))); | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | #[test] | |
| 84 | + | fn trims_surrounding_whitespace() { | |
| 85 | + | assert_eq!(MessageBody::parse(" hi ").unwrap().as_str(), "hi"); | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | #[test] | |
| 89 | + | fn boundary_is_inclusive() { | |
| 90 | + | let at = "x".repeat(MAX_MESSAGE_LEN); | |
| 91 | + | assert!(MessageBody::parse(&at).is_ok()); | |
| 92 | + | ||
| 93 | + | let over = "x".repeat(MAX_MESSAGE_LEN + 1); | |
| 94 | + | assert!(matches!( | |
| 95 | + | MessageBody::parse(&over), | |
| 96 | + | Err(ChatError::MessageTooLong { len, max }) | |
| 97 | + | if len == MAX_MESSAGE_LEN + 1 && max == MAX_MESSAGE_LEN | |
| 98 | + | )); | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | #[test] | |
| 102 | + | fn length_counts_chars_not_bytes() { | |
| 103 | + | // Four bytes each: a body at the char limit is well over it in bytes, | |
| 104 | + | // and must still be accepted. | |
| 105 | + | let emoji_free_multibyte = "\u{1F00}".repeat(MAX_MESSAGE_LEN); | |
| 106 | + | assert!(emoji_free_multibyte.len() > MAX_MESSAGE_LEN); | |
| 107 | + | assert!(MessageBody::parse(&emoji_free_multibyte).is_ok()); | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | #[test] | |
| 111 | + | fn length_is_measured_after_trimming() { | |
| 112 | + | let padded = format!(" {} ", "x".repeat(MAX_MESSAGE_LEN)); | |
| 113 | + | assert!(MessageBody::parse(&padded).is_ok()); | |
| 114 | + | } | |
| 115 | + | } |
| @@ -0,0 +1,143 @@ | |||
| 1 | + | //! Bounded retention. There is deliberately no way to express "keep forever". | |
| 2 | + | //! | |
| 3 | + | //! Chat in both consumers is ephemeral: messages are written down only so that a | |
| 4 | + | //! reconnect after a deploy is lossless and so moderation can reach a message | |
| 5 | + | //! that already scrolled past. Neither app is a chat archive. | |
| 6 | + | //! | |
| 7 | + | //! That intent is enforced in the type rather than in a comment. [`Retention`] | |
| 8 | + | //! has no `Option`, no sentinel, and no public fields; the only constructor | |
| 9 | + | //! rejects anything above the crate ceilings. An app cannot configure its way to | |
| 10 | + | //! unbounded growth, and a future caller cannot reintroduce "forever" without | |
| 11 | + | //! deleting this module's constructor. | |
| 12 | + | ||
| 13 | + | use std::time::{Duration, SystemTime}; | |
| 14 | + | ||
| 15 | + | use crate::error::ChatError; | |
| 16 | + | ||
| 17 | + | /// Longest retention any room may request. | |
| 18 | + | pub const MAX_AGE_CEILING: Duration = Duration::from_secs(30 * 24 * 60 * 60); | |
| 19 | + | ||
| 20 | + | /// Most messages any room may hold. | |
| 21 | + | pub const MAX_MESSAGES_CEILING: usize = 20_000; | |
| 22 | + | ||
| 23 | + | /// How long a room keeps messages, and how many. | |
| 24 | + | /// | |
| 25 | + | /// Whichever bound is hit first wins: a busy room is trimmed by count long | |
| 26 | + | /// before `max_age` elapses, and a quiet one expires by age while well under | |
| 27 | + | /// `max_messages`. | |
| 28 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 29 | + | pub struct Retention { | |
| 30 | + | max_age: Duration, | |
| 31 | + | max_messages: usize, | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | impl Retention { | |
| 35 | + | /// Both bounds are required and both are checked against the crate ceilings. | |
| 36 | + | /// | |
| 37 | + | /// # Errors | |
| 38 | + | /// | |
| 39 | + | /// [`ChatError::RetentionTooLong`] if either bound exceeds its ceiling, or | |
| 40 | + | /// [`ChatError::RetentionTooShort`] if either is zero. A zero bound is | |
| 41 | + | /// rejected rather than treated as "disabled", because a room that discards | |
| 42 | + | /// every message the instant it arrives is a configuration mistake, not a | |
| 43 | + | /// policy. | |
| 44 | + | pub fn new(max_age: Duration, max_messages: usize) -> Result<Self, ChatError> { | |
| 45 | + | if max_age.is_zero() || max_messages == 0 { | |
| 46 | + | return Err(ChatError::RetentionTooShort); | |
| 47 | + | } | |
| 48 | + | if max_age > MAX_AGE_CEILING || max_messages > MAX_MESSAGES_CEILING { | |
| 49 | + | return Err(ChatError::RetentionTooLong { | |
| 50 | + | max_age, | |
| 51 | + | max_messages, | |
| 52 | + | }); | |
| 53 | + | } | |
| 54 | + | Ok(Self { | |
| 55 | + | max_age, | |
| 56 | + | max_messages, | |
| 57 | + | }) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | /// Multithreaded's default for a newly enabled forum room: 7 days, 5000 | |
| 61 | + | /// messages. | |
| 62 | + | pub fn forum_default() -> Self { | |
| 63 | + | Self { | |
| 64 | + | max_age: Duration::from_secs(7 * 24 * 60 * 60), | |
| 65 | + | max_messages: 5_000, | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | /// The MNW server's default for stream chat: 24 hours past the stream, 5000 | |
| 70 | + | /// messages. A creator may raise it, still bounded by the ceilings. | |
| 71 | + | pub fn stream_default() -> Self { | |
| 72 | + | Self { | |
| 73 | + | max_age: Duration::from_secs(24 * 60 * 60), | |
| 74 | + | max_messages: 5_000, | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | pub fn max_age(&self) -> Duration { | |
| 79 | + | self.max_age | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | pub fn max_messages(&self) -> usize { | |
| 83 | + | self.max_messages | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | /// When a message written at `now` should expire. | |
| 87 | + | /// | |
| 88 | + | /// Stored on the row at insert so the sweep is a single indexed delete rather | |
| 89 | + | /// than a join against room policy. Shortening a room's retention is | |
| 90 | + | /// therefore a recompute `UPDATE` over that room, which is what makes a | |
| 91 | + | /// shortened window apply to messages already sent. | |
| 92 | + | pub fn expires_at(&self, now: SystemTime) -> SystemTime { | |
| 93 | + | now + self.max_age | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | #[cfg(test)] | |
| 98 | + | mod tests { | |
| 99 | + | use super::*; | |
| 100 | + | ||
| 101 | + | #[test] | |
| 102 | + | fn rejects_anything_above_the_ceilings() { | |
| 103 | + | assert!(matches!( | |
| 104 | + | Retention::new(MAX_AGE_CEILING + Duration::from_secs(1), 100), | |
| 105 | + | Err(ChatError::RetentionTooLong { .. }) | |
| 106 | + | )); | |
| 107 | + | assert!(matches!( | |
| 108 | + | Retention::new(Duration::from_secs(60), MAX_MESSAGES_CEILING + 1), | |
| 109 | + | Err(ChatError::RetentionTooLong { .. }) | |
| 110 | + | )); | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | #[test] | |
| 114 | + | fn accepts_exactly_the_ceilings() { | |
| 115 | + | assert!(Retention::new(MAX_AGE_CEILING, MAX_MESSAGES_CEILING).is_ok()); | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | #[test] | |
| 119 | + | fn rejects_zero_bounds() { | |
| 120 | + | assert!(matches!( | |
| 121 | + | Retention::new(Duration::ZERO, 100), | |
| 122 | + | Err(ChatError::RetentionTooShort) | |
| 123 | + | )); | |
| 124 | + | assert!(matches!( | |
| 125 | + | Retention::new(Duration::from_secs(60), 0), | |
| 126 | + | Err(ChatError::RetentionTooShort) | |
| 127 | + | )); | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | #[test] | |
| 131 | + | fn defaults_are_within_the_ceilings() { | |
| 132 | + | for r in [Retention::forum_default(), Retention::stream_default()] { | |
| 133 | + | assert!(Retention::new(r.max_age(), r.max_messages()).is_ok()); | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | #[test] | |
| 138 | + | fn expiry_is_insert_time_plus_max_age() { | |
| 139 | + | let r = Retention::forum_default(); | |
| 140 | + | let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000); | |
| 141 | + | assert_eq!(r.expires_at(now), now + r.max_age()); | |
| 142 | + | } | |
| 143 | + | } |
| @@ -0,0 +1,63 @@ | |||
| 1 | + | //! Room identity and lifecycle. | |
| 2 | + | ||
| 3 | + | use crate::ids::RoomId; | |
| 4 | + | use crate::retention::Retention; | |
| 5 | + | ||
| 6 | + | /// Whether a room accepts reads and writes right now. | |
| 7 | + | /// | |
| 8 | + | /// The host maps its own state onto this. Multithreaded: an active community | |
| 9 | + | /// with chat enabled is [`RoomState::Open`]; `Frozen` and `Archived` communities | |
| 10 | + | /// are [`RoomState::ReadOnly`], matching how threads already behave; a community | |
| 11 | + | /// with `chat_policy = off` is [`RoomState::Closed`]. | |
| 12 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 13 | + | pub enum RoomState { | |
| 14 | + | /// Reads and writes, subject to per-user authz. | |
| 15 | + | Open, | |
| 16 | + | /// Reads only. The room and its backlog stay visible; nobody can send. | |
| 17 | + | ReadOnly, | |
| 18 | + | /// The room does not exist as far as the outside is concerned. Callers should | |
| 19 | + | /// render a 404, not a disabled input: a closed room has no route, no hub | |
| 20 | + | /// entry, and no UI affordance. | |
| 21 | + | Closed, | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | impl RoomState { | |
| 25 | + | pub fn allows_reads(&self) -> bool { | |
| 26 | + | matches!(self, Self::Open | Self::ReadOnly) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | pub fn allows_writes(&self) -> bool { | |
| 30 | + | matches!(self, Self::Open) | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | /// A resolved room. | |
| 35 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 36 | + | pub struct Room { | |
| 37 | + | pub id: RoomId, | |
| 38 | + | pub state: RoomState, | |
| 39 | + | pub retention: Retention, | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | #[cfg(test)] | |
| 43 | + | mod tests { | |
| 44 | + | use super::*; | |
| 45 | + | ||
| 46 | + | #[test] | |
| 47 | + | fn closed_rooms_allow_nothing() { | |
| 48 | + | assert!(!RoomState::Closed.allows_reads()); | |
| 49 | + | assert!(!RoomState::Closed.allows_writes()); | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | #[test] | |
| 53 | + | fn read_only_rooms_read_but_do_not_write() { | |
| 54 | + | assert!(RoomState::ReadOnly.allows_reads()); | |
| 55 | + | assert!(!RoomState::ReadOnly.allows_writes()); | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | #[test] | |
| 59 | + | fn open_rooms_allow_both() { | |
| 60 | + | assert!(RoomState::Open.allows_reads()); | |
| 61 | + | assert!(RoomState::Open.allows_writes()); | |
| 62 | + | } | |
| 63 | + | } |
| @@ -0,0 +1,188 @@ | |||
| 1 | + | //! The seams each host app implements. | |
| 2 | + | //! | |
| 3 | + | //! The crate knows nothing about communities, streams, subscriptions, or bans. | |
| 4 | + | //! Everything app-specific arrives through these four traits, which is what lets | |
| 5 | + | //! Multithreaded gate on `CommunityScope` and the MNW server gate on | |
| 6 | + | //! `SubscriptionGate` without either concept appearing here. | |
| 7 | + | //! | |
| 8 | + | //! `async_trait` rather than native AFIT: the returned futures need `Send` to be | |
| 9 | + | //! spawned onto tokio, and the MNW server already depends on the crate. | |
| 10 | + | ||
| 11 | + | use std::collections::HashMap; | |
| 12 | + | use std::time::Duration; | |
| 13 | + | ||
| 14 | + | use async_trait::async_trait; | |
| 15 | + | ||
| 16 | + | use crate::error::ChatError; | |
| 17 | + | use crate::ids::{MessageId, RoomId, UserId}; | |
| 18 | + | use crate::room::Room; | |
| 19 | + | ||
| 20 | + | /// Resolves an app-specific key to a room. | |
| 21 | + | #[async_trait] | |
| 22 | + | pub trait ChatRooms: Send + Sync { | |
| 23 | + | /// Resolve a host-namespaced key: a community slug in Multithreaded, a stream | |
| 24 | + | /// id in the MNW server. | |
| 25 | + | /// | |
| 26 | + | /// Returns `Ok(None)` when no such room exists. A room whose chat is disabled | |
| 27 | + | /// should come back as [`crate::room::RoomState::Closed`] rather than `None`, | |
| 28 | + | /// so the host can decide whether "disabled" and "absent" look different. | |
| 29 | + | async fn resolve(&self, key: &str) -> Result<Option<Room>, ChatError>; | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | /// Why a write was refused. Carried back so the client can say something | |
| 33 | + | /// specific instead of a bare refusal. | |
| 34 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 35 | + | pub enum DenyReason { | |
| 36 | + | /// Not signed in. | |
| 37 | + | Anonymous, | |
| 38 | + | /// Signed in, but not a member of this community. | |
| 39 | + | NotAMember, | |
| 40 | + | /// Banned from the room. | |
| 41 | + | Banned, | |
| 42 | + | /// Muted: still reads, cannot write. | |
| 43 | + | Muted, | |
| 44 | + | /// Suspended at the platform level. | |
| 45 | + | Suspended, | |
| 46 | + | /// The room requires a paid tier the user does not have. | |
| 47 | + | TierRequired, | |
| 48 | + | /// Sending too fast. | |
| 49 | + | RateLimited { retry_after: Duration }, | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | /// The outcome of a write check. | |
| 53 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 54 | + | pub enum WriteAccess { | |
| 55 | + | Allow, | |
| 56 | + | Deny(DenyReason), | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | impl WriteAccess { | |
| 60 | + | pub fn is_allowed(&self) -> bool { | |
| 61 | + | matches!(self, Self::Allow) | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | /// Per-user read and write gating. | |
| 66 | + | #[async_trait] | |
| 67 | + | pub trait ChatAuthz: Send + Sync { | |
| 68 | + | /// Whether this viewer may read the room. | |
| 69 | + | /// | |
| 70 | + | /// `viewer` is `None` for a logged-out request, which is a real case: | |
| 71 | + | /// Multithreaded's `public_read` policy shows the room to anyone. | |
| 72 | + | async fn can_read(&self, viewer: Option<UserId>, room: &Room) -> Result<bool, ChatError>; | |
| 73 | + | ||
| 74 | + | /// Whether this user may send, and if not, why. | |
| 75 | + | async fn can_write(&self, user: UserId, room: &Room) -> Result<WriteAccess, ChatError>; | |
| 76 | + | ||
| 77 | + | /// Whether this user holds moderator powers in the room. | |
| 78 | + | async fn is_moderator(&self, user: UserId, room: &Room) -> Result<bool, ChatError>; | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | /// How a user is displayed. | |
| 82 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 83 | + | pub struct Identity { | |
| 84 | + | pub display_name: String, | |
| 85 | + | pub avatar_url: Option<String>, | |
| 86 | + | /// Role or badge text, if the host shows one. | |
| 87 | + | pub flair: Option<String>, | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | /// Display information for participants. | |
| 91 | + | #[async_trait] | |
| 92 | + | pub trait ChatIdentity: Send + Sync { | |
| 93 | + | /// Resolve many users at once. | |
| 94 | + | /// | |
| 95 | + | /// Batched because backlog replay needs every author in the window, and doing | |
| 96 | + | /// that one query at a time is an N+1 on the reconnect path. Neither app gets | |
| 97 | + | /// this for free: Multithreaded's `SessionUser` carries no `avatar_url`, so | |
| 98 | + | /// this is a join plus a cache. | |
| 99 | + | /// | |
| 100 | + | /// Users the host cannot resolve are omitted from the map rather than | |
| 101 | + | /// erroring. A deleted account should not take down the room. | |
| 102 | + | async fn identify(&self, users: &[UserId]) -> Result<HashMap<UserId, Identity>, ChatError>; | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | /// Moderation actions. | |
| 106 | + | /// | |
| 107 | + | /// There is no edit anywhere in this trait, and that is deliberate: chat is a | |
| 108 | + | /// stream nobody re-reads, so an edit path would be surface with no benefit and | |
| 109 | + | /// would drag an edit-history table behind it. | |
| 110 | + | /// | |
| 111 | + | /// Deletion is a real delete, not a tombstone. The row expires anyway, and a | |
| 112 | + | /// tombstone would outlive the content it describes, which is the opposite of | |
| 113 | + | /// what bounded retention is for. | |
| 114 | + | #[async_trait] | |
| 115 | + | pub trait ChatModeration: Send + Sync { | |
| 116 | + | /// Remove one message. `actor` is the author for a self-delete, or a | |
| 117 | + | /// moderator. Implementations must verify the actor is entitled to this | |
| 118 | + | /// message rather than trusting the caller. | |
| 119 | + | async fn delete_message( | |
| 120 | + | &self, | |
| 121 | + | actor: UserId, | |
| 122 | + | room: &Room, | |
| 123 | + | message: MessageId, | |
| 124 | + | ) -> Result<(), ChatError>; | |
| 125 | + | ||
| 126 | + | /// Remove every message by `target` in the room's current retention window, | |
| 127 | + | /// returning how many rows went. | |
| 128 | + | /// | |
| 129 | + | /// Issued as part of a ban. Implementations should do this as one statement | |
| 130 | + | /// keyed by (room, author); the caller broadcasts a single | |
| 131 | + | /// [`crate::event::ChatEvent::Purge`] rather than one event per message. | |
| 132 | + | async fn purge_user( | |
| 133 | + | &self, | |
| 134 | + | actor: UserId, | |
| 135 | + | room: &Room, | |
| 136 | + | target: UserId, | |
| 137 | + | ) -> Result<u64, ChatError>; | |
| 138 | + | ||
| 139 | + | /// Block a user from sending for a bounded period. Reads are unaffected. | |
| 140 | + | async fn timeout_user( | |
| 141 | + | &self, | |
| 142 | + | actor: UserId, | |
| 143 | + | room: &Room, | |
| 144 | + | target: UserId, | |
| 145 | + | duration: Duration, | |
| 146 | + | ) -> Result<(), ChatError>; | |
| 147 | + | ||
| 148 | + | /// Ban a user from the room. | |
| 149 | + | /// | |
| 150 | + | /// The caller pairs this with [`ChatModeration::purge_user`]: a banned user's | |
| 151 | + | /// messages in the retention window are the damage, so the ban removes them. | |
| 152 | + | async fn ban_user( | |
| 153 | + | &self, | |
| 154 | + | actor: UserId, | |
| 155 | + | room: &Room, | |
| 156 | + | target: UserId, | |
| 157 | + | reason: Option<&str>, | |
| 158 | + | ) -> Result<(), ChatError>; | |
| 159 | + | ||
| 160 | + | /// Record an action in the host's moderation log. | |
| 161 | + | /// | |
| 162 | + | /// Separate from the actions above so a host can log in the same transaction | |
| 163 | + | /// as the mutation, which is what Multithreaded's `mod_log` expects. | |
| 164 | + | async fn log_action( | |
| 165 | + | &self, | |
| 166 | + | actor: UserId, | |
| 167 | + | room_id: RoomId, | |
| 168 | + | action: &str, | |
| 169 | + | detail: Option<&str>, | |
| 170 | + | ) -> Result<(), ChatError>; | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | #[cfg(test)] | |
| 174 | + | mod tests { | |
| 175 | + | use super::*; | |
| 176 | + | ||
| 177 | + | #[test] | |
| 178 | + | fn only_allow_is_allowed() { | |
| 179 | + | assert!(WriteAccess::Allow.is_allowed()); | |
| 180 | + | assert!(!WriteAccess::Deny(DenyReason::Muted).is_allowed()); | |
| 181 | + | assert!( | |
| 182 | + | !WriteAccess::Deny(DenyReason::RateLimited { | |
| 183 | + | retry_after: Duration::from_secs(3) | |
| 184 | + | }) | |
| 185 | + | .is_allowed() | |
| 186 | + | ); | |
| 187 | + | } | |
| 188 | + | } |
| @@ -0,0 +1,190 @@ | |||
| 1 | + | //! A throwaway in-memory host, implementing all four traits. | |
| 2 | + | //! | |
| 3 | + | //! This is not a test of behavior. It is a test that the trait shapes can | |
| 4 | + | //! actually be satisfied: that the signatures compose, that the lifetimes work | |
| 5 | + | //! through `async_trait`, and that a host can carry its own state. The design | |
| 6 | + | //! calls for `ChatRooms` to be provably general rather than shaped around one | |
| 7 | + | //! consumer, and the only way to know that is to have a second implementor that | |
| 8 | + | //! is nothing like a community or a stream. | |
| 9 | + | //! | |
| 10 | + | //! When Multithreaded's real impl lands, this stays. If a signature change breaks | |
| 11 | + | //! it, that is the signal that the change leaked host-specific assumptions into | |
| 12 | + | //! the crate. | |
| 13 | + | ||
| 14 | + | use std::collections::HashMap; | |
| 15 | + | use std::sync::Mutex; | |
| 16 | + | use std::time::Duration; | |
| 17 | + | ||
| 18 | + | use async_trait::async_trait; | |
| 19 | + | use livechat::{ | |
| 20 | + | ChatAuthz, ChatError, ChatIdentity, ChatModeration, ChatRooms, DenyReason, Identity, MessageId, | |
| 21 | + | Retention, Room, RoomId, RoomState, UserId, WriteAccess, | |
| 22 | + | }; | |
| 23 | + | use uuid::Uuid; | |
| 24 | + | ||
| 25 | + | #[derive(Default)] | |
| 26 | + | struct Host { | |
| 27 | + | banned: Mutex<Vec<UserId>>, | |
| 28 | + | purged: Mutex<Vec<(RoomId, UserId)>>, | |
| 29 | + | log: Mutex<Vec<String>>, | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | fn room(state: RoomState) -> Room { | |
| 33 | + | Room { | |
| 34 | + | id: RoomId(Uuid::nil()), | |
| 35 | + | state, | |
| 36 | + | retention: Retention::forum_default(), | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | #[async_trait] | |
| 41 | + | impl ChatRooms for Host { | |
| 42 | + | async fn resolve(&self, key: &str) -> Result<Option<Room>, ChatError> { | |
| 43 | + | Ok(match key { | |
| 44 | + | "open" => Some(room(RoomState::Open)), | |
| 45 | + | "frozen" => Some(room(RoomState::ReadOnly)), | |
| 46 | + | "disabled" => Some(room(RoomState::Closed)), | |
| 47 | + | _ => None, | |
| 48 | + | }) | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | #[async_trait] | |
| 53 | + | impl ChatAuthz for Host { | |
| 54 | + | async fn can_read(&self, _viewer: Option<UserId>, room: &Room) -> Result<bool, ChatError> { | |
| 55 | + | Ok(room.state.allows_reads()) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | async fn can_write(&self, user: UserId, room: &Room) -> Result<WriteAccess, ChatError> { | |
| 59 | + | if !room.state.allows_writes() { | |
| 60 | + | return Ok(WriteAccess::Deny(DenyReason::NotAMember)); | |
| 61 | + | } | |
| 62 | + | if self.banned.lock().unwrap().contains(&user) { | |
| 63 | + | return Ok(WriteAccess::Deny(DenyReason::Banned)); | |
| 64 | + | } | |
| 65 | + | Ok(WriteAccess::Allow) | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | async fn is_moderator(&self, _user: UserId, _room: &Room) -> Result<bool, ChatError> { | |
| 69 | + | Ok(true) | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | #[async_trait] | |
| 74 | + | impl ChatIdentity for Host { | |
| 75 | + | async fn identify(&self, users: &[UserId]) -> Result<HashMap<UserId, Identity>, ChatError> { | |
| 76 | + | Ok(users | |
| 77 | + | .iter() | |
| 78 | + | .map(|u| { | |
| 79 | + | ( | |
| 80 | + | *u, | |
| 81 | + | Identity { | |
| 82 | + | display_name: u.to_string(), | |
| 83 | + | avatar_url: None, | |
| 84 | + | flair: None, | |
| 85 | + | }, | |
| 86 | + | ) | |
| 87 | + | }) | |
| 88 | + | .collect()) | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | #[async_trait] | |
| 93 | + | impl ChatModeration for Host { | |
| 94 | + | async fn delete_message( | |
| 95 | + | &self, | |
| 96 | + | _actor: UserId, | |
| 97 | + | _room: &Room, | |
| 98 | + | _message: MessageId, | |
| 99 | + | ) -> Result<(), ChatError> { | |
| 100 | + | Ok(()) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | async fn purge_user( | |
| 104 | + | &self, | |
| 105 | + | _actor: UserId, | |
| 106 | + | room: &Room, | |
| 107 | + | target: UserId, | |
| 108 | + | ) -> Result<u64, ChatError> { | |
| 109 | + | self.purged.lock().unwrap().push((room.id, target)); | |
| 110 | + | Ok(3) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | async fn timeout_user( | |
| 114 | + | &self, | |
| 115 | + | _actor: UserId, | |
| 116 | + | _room: &Room, | |
| 117 | + | _target: UserId, | |
| 118 | + | _duration: Duration, | |
| 119 | + | ) -> Result<(), ChatError> { | |
| 120 | + | Ok(()) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | async fn ban_user( | |
| 124 | + | &self, | |
| 125 | + | _actor: UserId, | |
| 126 | + | _room: &Room, | |
| 127 | + | target: UserId, | |
| 128 | + | _reason: Option<&str>, | |
| 129 | + | ) -> Result<(), ChatError> { | |
| 130 | + | self.banned.lock().unwrap().push(target); | |
| 131 | + | Ok(()) | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | async fn log_action( | |
| 135 | + | &self, | |
| 136 | + | _actor: UserId, | |
| 137 | + | _room_id: RoomId, | |
| 138 | + | action: &str, | |
| 139 | + | _detail: Option<&str>, | |
| 140 | + | ) -> Result<(), ChatError> { | |
| 141 | + | self.log.lock().unwrap().push(action.to_owned()); | |
| 142 | + | Ok(()) | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | /// The traits must be usable behind a trait object, not only as generics. Both | |
| 147 | + | /// hosts will hang these off application state that is cloned per request. | |
| 148 | + | fn as_dyn(host: &Host) -> (&dyn ChatRooms, &dyn ChatAuthz, &dyn ChatModeration) { | |
| 149 | + | (host, host, host) | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | #[tokio::test] | |
| 153 | + | async fn a_host_unlike_either_consumer_can_satisfy_every_trait() { | |
| 154 | + | let host = Host::default(); | |
| 155 | + | let (rooms, authz, moderation) = as_dyn(&host); | |
| 156 | + | let user = UserId(Uuid::new_v4()); | |
| 157 | + | ||
| 158 | + | assert!(rooms.resolve("nope").await.unwrap().is_none()); | |
| 159 | + | ||
| 160 | + | let open = rooms.resolve("open").await.unwrap().unwrap(); | |
| 161 | + | assert!(authz.can_write(user, &open).await.unwrap().is_allowed()); | |
| 162 | + | ||
| 163 | + | // A disabled room resolves as Closed rather than absent, so the host can tell | |
| 164 | + | // "chat is off here" apart from "no such room". | |
| 165 | + | let closed = rooms.resolve("disabled").await.unwrap().unwrap(); | |
| 166 | + | assert_eq!(closed.state, RoomState::Closed); | |
| 167 | + | assert!(!authz.can_read(Some(user), &closed).await.unwrap()); | |
| 168 | + | ||
| 169 | + | // Read-only rooms still serve their backlog. | |
| 170 | + | let frozen = rooms.resolve("frozen").await.unwrap().unwrap(); | |
| 171 | + | assert!(authz.can_read(Some(user), &frozen).await.unwrap()); | |
| 172 | + | assert!(!authz.can_write(user, &frozen).await.unwrap().is_allowed()); | |
| 173 | + | ||
| 174 | + | // Ban then purge, which is the pairing the design calls for. | |
| 175 | + | moderation.ban_user(user, &open, user, None).await.unwrap(); | |
| 176 | + | let removed = moderation.purge_user(user, &open, user).await.unwrap(); | |
| 177 | + | assert_eq!(removed, 3); | |
| 178 | + | assert_eq!( | |
| 179 | + | authz.can_write(user, &open).await.unwrap(), | |
| 180 | + | WriteAccess::Deny(DenyReason::Banned) | |
| 181 | + | ); | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | #[tokio::test] | |
| 185 | + | async fn identify_is_batched() { | |
| 186 | + | let host = Host::default(); | |
| 187 | + | let users: Vec<UserId> = (0..3).map(|_| UserId(Uuid::new_v4())).collect(); | |
| 188 | + | let resolved = host.identify(&users).await.unwrap(); | |
| 189 | + | assert_eq!(resolved.len(), 3); | |
| 190 | + | } |