oxedyne/fe2o3/fe2o3_steel/src/srv/publish/comment.rs
88.5 KiB, 423 runs
created by r1870400018:16340, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! Comments on a post: what one is, where it is kept, and what decides whether it appears. |
| 2 | //! |
| 3 | //! # The shape of the thing |
| 4 | //! |
| 5 | //! A comment is prose by somebody who is not the author, attached to a post, possibly in reply to |
| 6 | //! another comment. It is written in the same Markdown the posts are, parsed to the same tree, and |
| 7 | //! rendered through [`fe2o3_text::doc::policy`](oxedyne_fe2o3_text::doc::policy) first -- which is |
| 8 | //! what makes a stranger's link safe to publish. Nothing here renders anything; that belongs to the |
| 9 | //! page, and this owns what is stored and what is decided. |
| 10 | //! |
| 11 | //! # Three seams, deliberately |
| 12 | //! |
| 13 | //! Two of them are not used yet and exist so that what comes later drops in rather than rewrites: |
| 14 | //! |
| 15 | //! - [`Identity`] is who a commenter is. Today that is a name and an optional address; the variant |
| 16 | //! for a network identity is present and unfilled. |
| 17 | //! - [`Moderator`] is what decides. Today that is [`Rules`](Moderator::Rules), which is arithmetic. |
| 18 | //! A moderator that asks a model is the same seam with a different arm. |
| 19 | //! - [`Ranker`] is what order comments come back in. Today chronological, oldest first, which is how |
| 20 | //! a conversation reads. A ranker that weighs something is the same seam with a different arm. |
| 21 | //! |
| 22 | //! # What a comment costs a reader |
| 23 | //! |
| 24 | //! Nothing. There is no third-party script, no avatar fetched from elsewhere, no image a commenter |
| 25 | //! can place (see the policy's reasoning), and no identifier stored about who *read* a thread. A |
| 26 | //! commenter's address, where they give one, is stored and **never rendered and never returned by |
| 27 | //! any endpoint** -- it exists to notify them of a reply and for nothing else. |
| 28 | //! |
| 29 | //! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\ |
| 30 | //! Anthropic Claude |
| 31 | |
| 32 | use crate::srv::publish::{ |
| 33 | Markup, |
| 34 | ai, |
| 35 | parse_markup, |
| 36 | }; |
| 37 | |
| 38 | use oxedyne_fe2o3_core::prelude::*; |
| 39 | use oxedyne_fe2o3_core::rand::Rand; |
| 40 | use oxedyne_fe2o3_hash::hash::HashScheme; |
| 41 | use oxedyne_fe2o3_iop_crypto::enc::Encrypter; |
| 42 | use oxedyne_fe2o3_iop_db::api::{ |
| 43 | Database, |
| 44 | ScanOpts, |
| 45 | }; |
| 46 | use oxedyne_fe2o3_iop_hash::api::Hasher; |
| 47 | use oxedyne_fe2o3_jdat::{ |
| 48 | prelude::*, |
| 49 | id::NumIdDat, |
| 50 | }; |
| 51 | |
| 52 | use std::sync::{ |
| 53 | Arc, |
| 54 | RwLock, |
| 55 | }; |
| 56 | |
| 57 | use tokio_rustls::rustls::ClientConfig; |
| 58 | |
| 59 | |
| 60 | // A comment's key carries its post's slug, so every comment on a post is one prefix scan and a |
| 61 | // read per comment -- the shape the posts themselves take, and for the same reason: nothing walks |
| 62 | // the whole database to draw one page. |
| 63 | pub const KEY_PREFIX: &str = "publish/comment/"; |
| 64 | |
| 65 | // A commenter is remembered only so that somebody already approved is not made to wait again. See |
| 66 | // `Commenter`. |
| 67 | pub const AUTHOR_PREFIX: &str = "publish/commenter/"; |
| 68 | |
| 69 | // The longest a comment may be, in bytes of source. Long enough for a considered reply and short |
| 70 | // enough that a page of them is a page. A limit that exists at all is the point; the number is a |
| 71 | // judgement. |
| 72 | pub const BODY_MAX: usize = 8_000; |
| 73 | |
| 74 | pub const NAME_MAX: usize = 64; |
| 75 | |
| 76 | // How deep a reply may nest. Three is the depth at which a thread is still a conversation and not |
| 77 | // a staircase. A reply deeper than this attaches to its grandparent instead of being refused: the |
| 78 | // person meant to reply to something, and losing their words to a structural rule would be the |
| 79 | // wrong answer. |
| 80 | pub const DEPTH_MAX: usize = 3; |
| 81 | |
| 82 | // How many comments may be waiting on one post before it stops taking more: the bound on what an |
| 83 | // unauthenticated write can cost. A comment that is held is storage somebody else chose to spend, |
| 84 | // and without a ceiling a machine that ignores the proof-of-work can spend it without limit. Once |
| 85 | // a post's queue is this full it takes nothing further until a person clears some -- a visible, |
| 86 | // recoverable state, unlike a disk that filled overnight. Approved comments are deliberately not |
| 87 | // counted: those are storage the site's own admin chose. |
| 88 | pub const PENDING_MAX: usize = 50; |
| 89 | |
| 90 | // How many comments one post will hold, in any state. Every comment on a post is read back |
| 91 | // whenever the post is viewed, so the store is not merely disk: it is work done on behalf of every |
| 92 | // reader, for ever. A thousand is far past any conversation worth having and well short of a page |
| 93 | // that will not serve. |
| 94 | pub const POST_MAX: usize = 1_000; |
| 95 | |
| 96 | const ID_LEN: usize = 16; |
| 97 | const ID_ALPHABET: &str = "abcdefghijklmnopqrstuvwxyz0123456789"; |
| 98 | |
| 99 | |
| 100 | // ┌───────────────────────────────────────────────────────────────────────────┐ |
| 101 | // │ MODEL │ |
| 102 | // └───────────────────────────────────────────────────────────────────────────┘ |
| 103 | |
| 104 | /// Where a comment stands. |
| 105 | #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] |
| 106 | pub enum CommentState { |
| 107 | #[default] |
| 108 | Pending, // waiting on a human, which every comment by an unknown author is |
| 109 | Approved, // published, and visible to a reader |
| 110 | // Judged spam. Kept rather than deleted, so a wrong judgement is recoverable and so a record of |
| 111 | // what arrives exists. |
| 112 | Spam, |
| 113 | Removed, // taken down after publication, by the site's author or the commenter |
| 114 | } |
| 115 | |
| 116 | impl CommentState { |
| 117 | |
| 118 | /// The word a record stores. |
| 119 | pub fn as_str(&self) -> &'static str { |
| 120 | match self { |
| 121 | Self::Pending => "pending", |
| 122 | Self::Approved => "approved", |
| 123 | Self::Spam => "spam", |
| 124 | Self::Removed => "removed", |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | /// The state a word names. |
| 129 | /// |
| 130 | /// **An unknown word is pending**, which is the safe reading for the same reason a subscriber's |
| 131 | /// is: a state this version cannot place must not thereby be published. A record written by a |
| 132 | /// later version showing up as awaiting review is a visible, harmless failure; showing up as |
| 133 | /// approved would be an invisible, harmful one. |
| 134 | pub fn of(s: &str) -> Self { |
| 135 | match s { |
| 136 | "approved" => Self::Approved, |
| 137 | "spam" => Self::Spam, |
| 138 | "removed" => Self::Removed, |
| 139 | _ => Self::Pending, |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | /// Is a comment in this state shown to a reader? |
| 144 | pub fn is_public(&self) -> bool { |
| 145 | matches!(self, Self::Approved) |
| 146 | } |
| 147 | } |
| 148 | |
| 149 | /// Who wrote a comment. |
| 150 | /// |
| 151 | /// The seam of row 42. Two arms are live and the third is the shape of what is coming: an identity |
| 152 | /// vouched for by a network rather than by an address the person typed. Nothing outside this module |
| 153 | /// matches on the variant to decide whether to *publish*; that is the moderator's business, and |
| 154 | /// keeping it there is what lets a new arm arrive without touching the pipeline. |
| 155 | #[derive(Clone, Debug, Eq, PartialEq)] |
| 156 | pub enum Identity { |
| 157 | // A name, and an address they chose to give. The address is never shown. |
| 158 | Local { |
| 159 | name: String, // what the reader sees |
| 160 | // Where a reply notification would go, if they asked for one. Never rendered, never |
| 161 | // returned by an endpoint, never given to a third party. |
| 162 | email: Option<String>, |
| 163 | }, |
| 164 | Anon, // no name given; shown as the site's word for a stranger |
| 165 | // An identity a network vouches for. Not yet issued by anything -- the arm exists so that |
| 166 | // storage, moderation and rendering already handle it when it is. |
| 167 | Vouched { |
| 168 | id: String, // the identifier the network knows them by |
| 169 | name: String, // what the reader sees |
| 170 | }, |
| 171 | } |
| 172 | |
| 173 | impl Default for Identity { |
| 174 | fn default() -> Self { Self::Anon } |
| 175 | } |
| 176 | |
| 177 | impl Identity { |
| 178 | |
| 179 | pub fn display_name(&self) -> &str { |
| 180 | match self { |
| 181 | Self::Local { name, .. } => name, |
| 182 | Self::Vouched { name, .. } => name, |
| 183 | Self::Anon => "Anonymous", |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | pub fn email(&self) -> Option<&str> { |
| 188 | match self { |
| 189 | Self::Local { email, .. } => email.as_deref(), |
| 190 | _ => None, |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | /// The stable handle by which this commenter is remembered between comments, if any. |
| 195 | /// |
| 196 | /// **An identity with no handle can never become trusted**, and that is the honest consequence of |
| 197 | /// letting people comment without identifying themselves: there is nothing to attach the trust |
| 198 | /// to. Such comments wait for a human every time. An address is the handle where one is given; a |
| 199 | /// vouched identity is its own. A bare name is deliberately *not* a handle -- anyone can type |
| 200 | /// somebody else's name, and treating that as identity would let a stranger inherit another |
| 201 | /// person's approval. |
| 202 | pub fn handle(&self) -> Option<String> { |
| 203 | match self { |
| 204 | Self::Local { email: Some(e), .. } if !e.trim().is_empty() |
| 205 | => Some(fmt!("email:{}", e.trim().to_lowercase())), |
| 206 | Self::Vouched { id, .. } |
| 207 | => Some(fmt!("vouched:{}", id)), |
| 208 | _ => None, |
| 209 | } |
| 210 | } |
| 211 | |
| 212 | pub fn to_dat(&self) -> Dat { |
| 213 | let mut m = DaticleMap::new(); |
| 214 | match self { |
| 215 | Self::Local { name, email } => { |
| 216 | m.insert(dat!("kind"), dat!("local".to_string())); |
| 217 | m.insert(dat!("name"), dat!(name.clone())); |
| 218 | if let Some(e) = email { |
| 219 | m.insert(dat!("email"), dat!(e.clone())); |
| 220 | } |
| 221 | } |
| 222 | Self::Anon => { |
| 223 | m.insert(dat!("kind"), dat!("anon".to_string())); |
| 224 | } |
| 225 | Self::Vouched { id, name } => { |
| 226 | m.insert(dat!("kind"), dat!("vouched".to_string())); |
| 227 | m.insert(dat!("id"), dat!(id.clone())); |
| 228 | m.insert(dat!("name"), dat!(name.clone())); |
| 229 | } |
| 230 | } |
| 231 | Dat::Map(m) |
| 232 | } |
| 233 | |
| 234 | /// The identity from a daticle. An unreadable one is anonymous rather than an error: a comment |
| 235 | /// whose author cannot be read is still a comment, and losing the words would be worse. |
| 236 | pub fn from_dat(d: &Dat) -> Self { |
| 237 | let m = match d { |
| 238 | Dat::Map(m) => m, |
| 239 | _ => return Self::Anon, |
| 240 | }; |
| 241 | let s = |k: &str| match m.get(&dat!(k)) { |
| 242 | Some(Dat::Str(v)) => Some(v.clone()), |
| 243 | _ => None, |
| 244 | }; |
| 245 | match s("kind").as_deref() { |
| 246 | Some("local") => Self::Local { |
| 247 | name: s("name").unwrap_or_default(), |
| 248 | email: s("email"), |
| 249 | }, |
| 250 | Some("vouched") => Self::Vouched { |
| 251 | id: s("id").unwrap_or_default(), |
| 252 | name: s("name").unwrap_or_default(), |
| 253 | }, |
| 254 | _ => Self::Anon, |
| 255 | } |
| 256 | } |
| 257 | } |
| 258 | |
| 259 | /// One comment, as the store keeps it. |
| 260 | #[derive(Clone, Debug, Default)] |
| 261 | pub struct Comment { |
| 262 | pub id: String, // the comment's own name, unguessable, minted once |
| 263 | pub slug: String, // the post it is attached to |
| 264 | pub parent: Option<String>, // the comment it replies to, where it replies to one |
| 265 | pub author: Identity, |
| 266 | // What they wrote, as written. The source is kept and never the rendering, for the same reason |
| 267 | // a post's is: the renderer improves, and a stored rendering is a photograph of an older one. |
| 268 | pub body: String, |
| 269 | pub created: String, // ISO timestamp |
| 270 | pub state: CommentState, |
| 271 | // Why it stands there, where something decided: the moderator's own words. Shown to the site's |
| 272 | // admin in the queue and never to a reader. |
| 273 | pub reason: Option<String>, |
| 274 | // Whether the site's own admin wrote this, rather than a visitor claiming to be them. A display |
| 275 | // name is whatever somebody typed, so it can never distinguish the site's author from a stranger |
| 276 | // who typed their name. This can: it is set only by the console, never by anything a form |
| 277 | // carries, and it is what the page marks. |
| 278 | pub by_site_author: bool, |
| 279 | // A salted hash of the address it came from -- not the address: enough to recognise a returning |
| 280 | // nuisance, not enough to reconstruct who they are, and never shown. |
| 281 | pub from: Option<String>, |
| 282 | } |
| 283 | |
| 284 | impl Comment { |
| 285 | |
| 286 | pub fn to_dat(&self) -> Dat { |
| 287 | let mut m = DaticleMap::new(); |
| 288 | m.insert(dat!("id"), dat!(self.id.clone())); |
| 289 | m.insert(dat!("slug"), dat!(self.slug.clone())); |
| 290 | m.insert(dat!("author"), self.author.to_dat()); |
| 291 | m.insert(dat!("body"), dat!(self.body.clone())); |
| 292 | m.insert(dat!("created"), dat!(self.created.clone())); |
| 293 | m.insert(dat!("state"), dat!(self.state.as_str().to_string())); |
| 294 | if self.by_site_author { |
| 295 | m.insert(dat!("by_site_author"), Dat::Bool(true)); |
| 296 | } |
| 297 | // Absent keys rather than empty ones, as everywhere else in this grammar: one way to say |
| 298 | // nothing is enough. |
| 299 | if let Some(p) = &self.parent { |
| 300 | m.insert(dat!("parent"), dat!(p.clone())); |
| 301 | } |
| 302 | if let Some(r) = &self.reason { |
| 303 | m.insert(dat!("reason"), dat!(r.clone())); |
| 304 | } |
| 305 | if let Some(f) = &self.from { |
| 306 | m.insert(dat!("from"), dat!(f.clone())); |
| 307 | } |
| 308 | Dat::Map(m) |
| 309 | } |
| 310 | |
| 311 | pub fn from_dat(d: &Dat) -> Outcome<Self> { |
| 312 | let m = match d { |
| 313 | Dat::Map(m) => m, |
| 314 | _ => return Err(err!( |
| 315 | "publish: a comment record must be a map, not {:?}.", d.kind(); |
| 316 | Invalid, Input, Mismatch)), |
| 317 | }; |
| 318 | let s = |k: &str| match m.get(&dat!(k)) { |
| 319 | Some(Dat::Str(v)) => Some(v.clone()), |
| 320 | _ => None, |
| 321 | }; |
| 322 | let id = match s("id") { |
| 323 | Some(v) if !v.is_empty() => v, |
| 324 | _ => return Err(err!( |
| 325 | "publish: a comment record names no id."; |
| 326 | Invalid, Input, Missing)), |
| 327 | }; |
| 328 | Ok(Self { |
| 329 | id, |
| 330 | slug: s("slug").unwrap_or_default(), |
| 331 | parent: s("parent"), |
| 332 | author: m.get(&dat!("author")).map(Identity::from_dat).unwrap_or(Identity::Anon), |
| 333 | body: s("body").unwrap_or_default(), |
| 334 | created: s("created").unwrap_or_default(), |
| 335 | state: CommentState::of(&s("state").unwrap_or_default()), |
| 336 | by_site_author: matches!(m.get(&dat!("by_site_author")), Some(Dat::Bool(true))), |
| 337 | reason: s("reason"), |
| 338 | from: s("from"), |
| 339 | }) |
| 340 | } |
| 341 | |
| 342 | /// The comment's prose as HTML, brought within what a site will publish from a stranger. |
| 343 | /// |
| 344 | /// **The only way a comment should ever reach a page.** The policy is applied to the tree before |
| 345 | /// rendering, so a `javascript:` destination, a remote image and a borrowed class name are gone |
| 346 | /// before any HTML exists -- see [`policy`](oxedyne_fe2o3_text::doc::policy) for why that is a |
| 347 | /// different and better thing than sanitising the output. |
| 348 | pub fn render(&self) -> Outcome<String> { |
| 349 | use oxedyne_fe2o3_text::doc::{html, policy}; |
| 350 | let doc = res!(parse_markup(&self.body, Markup::Markdown)); |
| 351 | // `nofollow ugc noopener`: a published comment otherwise lends the site's own standing to |
| 352 | // whatever it points at, which is the whole economic motive for comment spam. |
| 353 | let opts = html::Opts { link_rel: Some(fmt!("nofollow ugc noopener")) }; |
| 354 | Ok(html::render_with(&policy::apply(&doc, &policy::Policy::default()), &opts)) |
| 355 | } |
| 356 | } |
| 357 | |
| 358 | /// What is remembered about somebody who has commented before. |
| 359 | /// |
| 360 | /// The whole of "held once, then trusted": a commenter the site has approved is not made to wait |
| 361 | /// again. Nothing else is kept -- no history of what they said, no count of how often, no address |
| 362 | /// beyond the handle that is already derived from one. |
| 363 | #[derive(Clone, Debug, Default)] |
| 364 | pub struct Commenter { |
| 365 | pub handle: String, // from `Identity::handle` |
| 366 | // The salted address hash trust was granted to, where trust has been granted. An address in a |
| 367 | // form is not proof of anything: anyone may type an approved commenter's address and inherit |
| 368 | // their approval. Recording where the approved comment came from, and requiring a later comment |
| 369 | // to match it, makes that forgery cost the attacker the same vantage point as well as the |
| 370 | // address. Not proof either -- it is one more thing to have. |
| 371 | pub from: Option<String>, |
| 372 | pub trusted: bool, // whether an admin has approved something of theirs |
| 373 | // Whether an admin has decided the opposite. A blocked commenter's comments go straight to spam |
| 374 | // without troubling anybody. |
| 375 | pub blocked: bool, |
| 376 | pub first_seen: String, // when they were first seen |
| 377 | } |
| 378 | |
| 379 | impl Commenter { |
| 380 | |
| 381 | pub fn to_dat(&self) -> Dat { |
| 382 | let mut m = DaticleMap::new(); |
| 383 | m.insert(dat!("handle"), dat!(self.handle.clone())); |
| 384 | if let Some(f) = &self.from { |
| 385 | m.insert(dat!("from"), dat!(f.clone())); |
| 386 | } |
| 387 | m.insert(dat!("trusted"), Dat::Bool(self.trusted)); |
| 388 | m.insert(dat!("blocked"), Dat::Bool(self.blocked)); |
| 389 | m.insert(dat!("first_seen"), dat!(self.first_seen.clone())); |
| 390 | Dat::Map(m) |
| 391 | } |
| 392 | |
| 393 | pub fn from_dat(d: &Dat) -> Outcome<Self> { |
| 394 | let m = match d { |
| 395 | Dat::Map(m) => m, |
| 396 | _ => return Err(err!( |
| 397 | "publish: a commenter record must be a map, not {:?}.", d.kind(); |
| 398 | Invalid, Input, Mismatch)), |
| 399 | }; |
| 400 | let b = |k: &str| matches!(m.get(&dat!(k)), Some(Dat::Bool(true))); |
| 401 | let s = |k: &str| match m.get(&dat!(k)) { |
| 402 | Some(Dat::Str(v)) => Some(v.clone()), |
| 403 | _ => None, |
| 404 | }; |
| 405 | Ok(Self { |
| 406 | handle: s("handle").unwrap_or_default(), |
| 407 | from: s("from"), |
| 408 | trusted: b("trusted"), |
| 409 | blocked: b("blocked"), |
| 410 | first_seen: s("first_seen").unwrap_or_default(), |
| 411 | }) |
| 412 | } |
| 413 | } |
| 414 | |
| 415 | |
| 416 | // ┌───────────────────────────────────────────────────────────────────────────┐ |
| 417 | // │ VALIDITY │ |
| 418 | // └───────────────────────────────────────────────────────────────────────────┘ |
| 419 | |
| 420 | /// Whether a display name is one a person may wear. |
| 421 | /// |
| 422 | /// Length, and no control characters -- a name carrying a newline or a zero-width run is a name |
| 423 | /// chosen to do something other than name somebody. The name is escaped wherever it lands, so this is |
| 424 | /// not a safety check; it is a civility one. |
| 425 | pub fn valid_name(s: &str) -> bool { |
| 426 | let t = s.trim(); |
| 427 | !t.is_empty() |
| 428 | && t.len() <= NAME_MAX |
| 429 | && !t.chars().any(|c| c.is_control()) |
| 430 | } |
| 431 | |
| 432 | pub fn valid_body(s: &str) -> bool { |
| 433 | let t = s.trim(); |
| 434 | !t.is_empty() && t.len() <= BODY_MAX |
| 435 | } |
| 436 | |
| 437 | /// Whether a string is a name this module could have minted. |
| 438 | pub fn valid_id(s: &str) -> bool { |
| 439 | let t = s.trim(); |
| 440 | t.len() == ID_LEN && t.chars().all(|c| ID_ALPHABET.contains(c)) |
| 441 | } |
| 442 | |
| 443 | pub fn mint_id() -> String { |
| 444 | Rand::generate_random_string(ID_LEN, ID_ALPHABET) |
| 445 | } |
| 446 | |
| 447 | fn key_of(slug: &str, id: &str) -> Dat { |
| 448 | dat!(fmt!("{}{}/{}", KEY_PREFIX, slug, id)) |
| 449 | } |
| 450 | |
| 451 | fn post_prefix(slug: &str) -> String { |
| 452 | fmt!("{}{}/", KEY_PREFIX, slug) |
| 453 | } |
| 454 | |
| 455 | fn author_key(handle: &str) -> Dat { |
| 456 | dat!(fmt!("{}{}", AUTHOR_PREFIX, handle)) |
| 457 | } |
| 458 | |
| 459 | |
| 460 | // ┌───────────────────────────────────────────────────────────────────────────┐ |
| 461 | // │ PROOF OF WORK │ |
| 462 | // └───────────────────────────────────────────────────────────────────────────┘ |
| 463 | |
| 464 | // How many leading zero bits a comment's proof must show. The cost is paid by the sender's |
| 465 | // browser, once, in about a second at this width, and by a spammer once per attempt. It is not a |
| 466 | // wall -- anyone determined pays it -- it is a tax that makes posting ten thousand comments cost |
| 467 | // ten thousand seconds instead of nothing. Raise it if that stops being enough; every extra bit |
| 468 | // doubles the price. |
| 469 | pub const POW_BITS: u32 = 18; |
| 470 | |
| 471 | /// What a proof is computed over: the challenge the form was given, and the nonce the browser found. |
| 472 | /// |
| 473 | /// The challenge is a one-way function of the post, the site's secret and **the hour it was issued |
| 474 | /// in**, so a proof cannot be computed before the form is fetched, a proof for one post is not a |
| 475 | /// proof for another, and a proof does not last forever. Without the window a single solve served |
| 476 | /// every future comment on that post: the cost was paid once, not once per comment, which is not |
| 477 | /// what a tax is. |
| 478 | pub fn pow_challenge(slug: &str, secret: &[u8]) -> String { |
| 479 | pow_challenge_at(slug, secret, &pow_window(0)) |
| 480 | } |
| 481 | |
| 482 | // How long a challenge stands. An hour: long enough that a reader may write at length and still |
| 483 | // post, short enough that a solved nonce is not a permanent licence. |
| 484 | pub const POW_WINDOW_SECS: u64 = 3600; |
| 485 | |
| 486 | /// The window identifier, `back` windows ago. |
| 487 | /// |
| 488 | /// A verifier accepts the current window and the one before it, so a reader who opened the form at |
| 489 | /// 10:59 and posted at 11:01 is not refused for it. |
| 490 | pub fn pow_window(back: u64) -> String { |
| 491 | let now = std::time::SystemTime::now() |
| 492 | .duration_since(std::time::UNIX_EPOCH) |
| 493 | .map(|d| d.as_secs()) |
| 494 | .unwrap_or(0); |
| 495 | fmt!("{}", now.saturating_sub(back * POW_WINDOW_SECS) / POW_WINDOW_SECS) |
| 496 | } |
| 497 | |
| 498 | pub fn pow_challenge_at(slug: &str, secret: &[u8], window: &str) -> String { |
| 499 | let h = HashScheme::new_sha256().hash(&[slug.as_bytes(), b"comment-pow", secret, window.as_bytes()], []); |
| 500 | hex(&h.as_hashform().as_vec()) |
| 501 | } |
| 502 | |
| 503 | /// Whether a challenge is one this site issued, in a window still standing. |
| 504 | pub fn pow_challenge_current(challenge: &str, slug: &str, secret: &[u8]) -> bool { |
| 505 | challenge == pow_challenge_at(slug, secret, &pow_window(0)) |
| 506 | || challenge == pow_challenge_at(slug, secret, &pow_window(1)) |
| 507 | } |
| 508 | |
| 509 | /// Whether a nonce solves a challenge to the required width. |
| 510 | pub fn pow_verify(challenge: &str, nonce: &str, bits: u32) -> bool { |
| 511 | // SHA-256 and not SHA3, because the other side of this is `crypto.subtle.digest` in a browser |
| 512 | // and WebCrypto offers no SHA3. Getting this wrong does not fail loudly: the proof simply never |
| 513 | // verifies, the comment is refused before it is stored, and the reader is thanked for it. It was |
| 514 | // wrong exactly that way once -- see the test, which checks against a digest computed outside |
| 515 | // this program rather than against another call to the same function. |
| 516 | let h = HashScheme::new_sha256().hash(&[challenge.as_bytes(), nonce.as_bytes()], []); |
| 517 | leading_zero_bits(&h.as_hashform().as_vec()) >= bits |
| 518 | } |
| 519 | |
| 520 | fn leading_zero_bits(bytes: &[u8]) -> u32 { |
| 521 | let mut n = 0; |
| 522 | for b in bytes { |
| 523 | if *b == 0 { |
| 524 | n += 8; |
| 525 | continue; |
| 526 | } |
| 527 | n += b.leading_zeros(); |
| 528 | break; |
| 529 | } |
| 530 | n |
| 531 | } |
| 532 | |
| 533 | fn hex(bytes: &[u8]) -> String { |
| 534 | let mut s = String::with_capacity(bytes.len() * 2); |
| 535 | for b in bytes { |
| 536 | s.push_str(&fmt!("{:02x}", b)); |
| 537 | } |
| 538 | s |
| 539 | } |
| 540 | |
| 541 | /// A salted, one-way rendering of a caller's address. |
| 542 | /// |
| 543 | /// Stored instead of the address itself. It recognises a returning nuisance and reconstructs nobody: |
| 544 | /// the salt is per-site and never leaves the host, so the value is meaningless anywhere else, and the |
| 545 | /// address space being small enough to enumerate is exactly why the salt has to be there. |
| 546 | pub fn from_hash(addr: &str, salt: &[u8]) -> String { |
| 547 | hash_with(addr, b"comment-from", salt) |
| 548 | } |
| 549 | |
| 550 | /// As [`from_hash`], under a caller's own domain separator. |
| 551 | /// |
| 552 | /// The separator is what keeps two features' hashes of one address apart, so a value taken from one |
| 553 | /// counter says nothing about the other. A caller supplying its own gets the salting and the |
| 554 | /// truncation without having to restate either. |
| 555 | pub fn hash_with(addr: &str, domain: &[u8], salt: &[u8]) -> String { |
| 556 | let h = HashScheme::new_sha3_256().hash(&[addr.as_bytes(), domain, salt], []); |
| 557 | hex(&h.as_hashform().as_vec())[..32].to_string() |
| 558 | } |
| 559 | |
| 560 | |
| 561 | // ┌───────────────────────────────────────────────────────────────────────────┐ |
| 562 | // │ MODERATION │ |
| 563 | // └───────────────────────────────────────────────────────────────────────────┘ |
| 564 | |
| 565 | /// What a moderator decided, and why. |
| 566 | /// |
| 567 | /// The seam an AI moderator arrives behind. Three outcomes and no more: a moderator may publish, |
| 568 | /// defer to a person, or bin. It may not delete, and it may not edit. |
| 569 | #[derive(Clone, Debug, Eq, PartialEq)] |
| 570 | pub enum Verdict { |
| 571 | Allow, // publish it |
| 572 | Hold(String), // a person should look; the reason is for them, never for the commenter |
| 573 | Spam(String), // bin it, recoverably |
| 574 | } |
| 575 | |
| 576 | impl Verdict { |
| 577 | |
| 578 | pub fn state(&self) -> CommentState { |
| 579 | match self { |
| 580 | Self::Allow => CommentState::Approved, |
| 581 | Self::Hold(_) => CommentState::Pending, |
| 582 | Self::Spam(_) => CommentState::Spam, |
| 583 | } |
| 584 | } |
| 585 | |
| 586 | pub fn reason(&self) -> Option<String> { |
| 587 | match self { |
| 588 | Self::Allow => None, |
| 589 | Self::Hold(r) => Some(r.clone()), |
| 590 | Self::Spam(r) => Some(r.clone()), |
| 591 | } |
| 592 | } |
| 593 | |
| 594 | /// The stricter of two verdicts. |
| 595 | /// |
| 596 | /// How a chain of moderators combines: **the strictest wins, and no later moderator can loosen an |
| 597 | /// earlier one's refusal.** This is the rule that keeps a model from overturning arithmetic -- |
| 598 | /// asking one about a comment the rules already refused is both a waste of money and a way for a |
| 599 | /// persuasive comment to talk its way out of a proof it never did. |
| 600 | pub fn and_then(self, other: Verdict) -> Verdict { |
| 601 | match (&self, &other) { |
| 602 | (Self::Spam(_), _) => self, |
| 603 | (_, Self::Spam(_)) => other, |
| 604 | (Self::Hold(_), _) => self, |
| 605 | (_, Self::Hold(_)) => other, |
| 606 | _ => Verdict::Allow, |
| 607 | } |
| 608 | } |
| 609 | } |
| 610 | |
| 611 | // Why a comment claiming a known commenter from a new place is held. A const rather than a literal |
| 612 | // at the one place it is used, because a reason is shown to a person: it is worth being able to |
| 613 | // test that it reads as a sentence, which the wrapped literal it replaces did not -- the source |
| 614 | // indentation was inside the string, and the queue said "commented from before". |
| 615 | pub const REASON_MISMATCH: &str = "claims a commenter this site knows, but from somewhere they have \ |
| 616 | not commented from before -- worth checking it is them"; |
| 617 | |
| 618 | /// What decides whether a comment appears. |
| 619 | /// |
| 620 | /// An enum rather than a trait object, per the house rules, and the reason it is worth having at all |
| 621 | /// with only one arm filled: the pipeline that calls this is written once, and every later kind of |
| 622 | /// moderator is an arm here rather than a change to the pipeline. |
| 623 | #[derive(Clone, Debug)] |
| 624 | pub enum Moderator { |
| 625 | Rules(Rules), // what the sender proved, what they wrote, whether the site knows them |
| 626 | } |
| 627 | |
| 628 | impl Default for Moderator { |
| 629 | fn default() -> Self { Self::Rules(Rules::default()) } |
| 630 | } |
| 631 | |
| 632 | impl Moderator { |
| 633 | |
| 634 | pub fn judge(&self, c: &Comment, known: Option<&Commenter>) -> Verdict { |
| 635 | match self { |
| 636 | Self::Rules(r) => r.judge(c, known), |
| 637 | } |
| 638 | } |
| 639 | } |
| 640 | |
| 641 | /// The arithmetic moderator. |
| 642 | #[derive(Clone, Debug)] |
| 643 | pub struct Rules { |
| 644 | // How many links a comment may carry before it is held. A comment is prose with the occasional |
| 645 | // reference; a list of links is an advertisement. |
| 646 | pub link_limit: usize, |
| 647 | pub trust_returning: bool, |
| 648 | } |
| 649 | |
| 650 | impl Default for Rules { |
| 651 | fn default() -> Self { |
| 652 | Self { |
| 653 | link_limit: 2, |
| 654 | trust_returning: true, |
| 655 | } |
| 656 | } |
| 657 | } |
| 658 | |
| 659 | impl Rules { |
| 660 | |
| 661 | /// Judges a comment by what can be counted. |
| 662 | /// |
| 663 | /// The order matters and is deliberate: **blocked first** (nothing else about a blocked commenter |
| 664 | /// is interesting), then the things that are true of the comment whoever sent it, then trust. |
| 665 | /// Trust is last because it is the only thing that *lets a comment through*, and it should not be |
| 666 | /// able to carry one past a rule that would otherwise have caught it. |
| 667 | pub fn judge(&self, c: &Comment, known: Option<&Commenter>) -> Verdict { |
| 668 | if let Some(k) = known { |
| 669 | if k.blocked { |
| 670 | return Verdict::Spam(fmt!("the commenter is blocked")); |
| 671 | } |
| 672 | } |
| 673 | if !valid_body(&c.body) { |
| 674 | return Verdict::Spam(fmt!("the comment is empty or longer than {} bytes", BODY_MAX)); |
| 675 | } |
| 676 | let links = count_links(&c.body); |
| 677 | if links > self.link_limit { |
| 678 | return Verdict::Hold(fmt!("{} links, more than the {} a comment may carry", |
| 679 | links, self.link_limit)); |
| 680 | } |
| 681 | if self.trust_returning { |
| 682 | if let Some(k) = known { |
| 683 | if k.trusted { |
| 684 | return Verdict::Allow; |
| 685 | } |
| 686 | } |
| 687 | } |
| 688 | // Everybody else waits once. An identity with no handle waits every time, because there is |
| 689 | // nothing to remember them by -- see `Identity::handle`. |
| 690 | match c.author.handle() { |
| 691 | Some(_) => Verdict::Hold(fmt!("a first comment from this commenter")), |
| 692 | None => Verdict::Hold(fmt!("no address given, so the commenter cannot be recognised")), |
| 693 | } |
| 694 | } |
| 695 | } |
| 696 | |
| 697 | /// How many links a run of source carries. |
| 698 | /// |
| 699 | /// Counts both the Markdown form and a bare URL, because a spammer writes whichever works. It |
| 700 | /// over-counts a link written both ways in one comment, and over-counting sends a comment to a human |
| 701 | /// rather than to a reader, which is the direction an inexact count should err in. |
| 702 | pub fn count_links(body: &str) -> usize { |
| 703 | let markdown = body.matches("](").count(); |
| 704 | let bare = body.matches("http://").count() + body.matches("https://").count(); |
| 705 | markdown.max(bare) |
| 706 | } |
| 707 | |
| 708 | /// What order comments come back in. |
| 709 | /// |
| 710 | /// The seam of row 49. Chronological is how a conversation reads and is what is built; an arm that |
| 711 | /// weighs a comment by something other than when it arrived is the shape of what may come. |
| 712 | #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] |
| 713 | pub enum Ranker { |
| 714 | #[default] |
| 715 | Chronological, // oldest first, the order a conversation happened in |
| 716 | Recent, // newest first |
| 717 | } |
| 718 | |
| 719 | impl Ranker { |
| 720 | |
| 721 | pub fn rank(&self, items: &mut [Comment]) { |
| 722 | match self { |
| 723 | Self::Chronological => items.sort_by(|a, b| a.created.cmp(&b.created)), |
| 724 | Self::Recent => items.sort_by(|a, b| b.created.cmp(&a.created)), |
| 725 | } |
| 726 | } |
| 727 | } |
| 728 | |
| 729 | |
| 730 | // ┌───────────────────────────────────────────────────────────────────────────┐ |
| 731 | // │ STORE │ |
| 732 | // └───────────────────────────────────────────────────────────────────────────┘ |
| 733 | |
| 734 | pub fn put< |
| 735 | const UIDL: usize, |
| 736 | UID: NumIdDat<UIDL>, |
| 737 | ENC: Encrypter, |
| 738 | KH: Hasher, |
| 739 | DB: Database<UIDL, UID, ENC, KH>, |
| 740 | >( |
| 741 | db: &(Arc<RwLock<DB>>, UID), |
| 742 | c: &Comment, |
| 743 | ) |
| 744 | -> Outcome<()> |
| 745 | { |
| 746 | let (db_arc, user) = db; |
| 747 | let guard = lock_read!(db_arc); |
| 748 | res!(guard.insert(key_of(&c.slug, &c.id), c.to_dat(), *user, None)); |
| 749 | Ok(()) |
| 750 | } |
| 751 | |
| 752 | pub fn get< |
| 753 | const UIDL: usize, |
| 754 | UID: NumIdDat<UIDL>, |
| 755 | ENC: Encrypter, |
| 756 | KH: Hasher, |
| 757 | DB: Database<UIDL, UID, ENC, KH>, |
| 758 | >( |
| 759 | db: &(Arc<RwLock<DB>>, UID), |
| 760 | slug: &str, |
| 761 | id: &str, |
| 762 | ) |
| 763 | -> Outcome<Option<Comment>> |
| 764 | { |
| 765 | let (db_arc, _) = db; |
| 766 | let guard = lock_read!(db_arc); |
| 767 | match res!(guard.get(&key_of(slug, id), None)) { |
| 768 | Some((v, _)) => Ok(Some(res!(Comment::from_dat(&v)))), |
| 769 | None => Ok(None), |
| 770 | } |
| 771 | } |
| 772 | |
| 773 | /// Every comment on a post, whatever state it is in. |
| 774 | /// |
| 775 | /// The scan selects keys and the reads fetch values, which is not an optimisation to undo: scan v1 |
| 776 | /// answers `Dat::Empty` for every value whatever `include_values` asks of it, and says so in a log |
| 777 | /// line rather than an error. |
| 778 | pub fn list_for_post< |
| 779 | const UIDL: usize, |
| 780 | UID: NumIdDat<UIDL>, |
| 781 | ENC: Encrypter, |
| 782 | KH: Hasher, |
| 783 | DB: Database<UIDL, UID, ENC, KH>, |
| 784 | >( |
| 785 | db: &(Arc<RwLock<DB>>, UID), |
| 786 | slug: &str, |
| 787 | id: &str, |
| 788 | ) |
| 789 | -> Outcome<Vec<Comment>> |
| 790 | { |
| 791 | let (db_arc, _) = db; |
| 792 | let prefix = post_prefix(slug); |
| 793 | let found = { |
| 794 | let guard = lock_read!(db_arc); |
| 795 | let mut opts = ScanOpts::default(); |
| 796 | opts.prefix = Some(dat!(prefix.clone())); |
| 797 | opts.include_values = false; |
| 798 | res!(guard.scan(&opts, None)) |
| 799 | }; |
| 800 | let mut out = Vec::new(); |
| 801 | for (k, _, _) in &found { |
| 802 | let s = match k { |
| 803 | Dat::Str(s) => s, |
| 804 | _ => continue, |
| 805 | }; |
| 806 | let name = match s.strip_prefix(&prefix) { |
| 807 | Some(n) => n, |
| 808 | None => continue, |
| 809 | }; |
| 810 | // A key the scan offered and the read cannot make sense of costs that comment, not the page. |
| 811 | match get(db, slug, name) { |
| 812 | Ok(Some(c)) => out.push(c), |
| 813 | Ok(None) => {} |
| 814 | Err(e) => debug!("{}: publish: comment '{}/{}' will not read: {}", |
| 815 | id, slug, name, e), |
| 816 | } |
| 817 | } |
| 818 | Ok(out) |
| 819 | } |
| 820 | |
| 821 | /// The comments on a post that a reader may see, in the ranker's order. |
| 822 | pub fn public_for_post< |
| 823 | const UIDL: usize, |
| 824 | UID: NumIdDat<UIDL>, |
| 825 | ENC: Encrypter, |
| 826 | KH: Hasher, |
| 827 | DB: Database<UIDL, UID, ENC, KH>, |
| 828 | >( |
| 829 | db: &(Arc<RwLock<DB>>, UID), |
| 830 | slug: &str, |
| 831 | ranker: Ranker, |
| 832 | id: &str, |
| 833 | ) |
| 834 | -> Outcome<Vec<Comment>> |
| 835 | { |
| 836 | let mut items: Vec<Comment> = res!(list_for_post(db, slug, id)) |
| 837 | .into_iter() |
| 838 | .filter(|c| c.state.is_public()) |
| 839 | .collect(); |
| 840 | ranker.rank(&mut items); |
| 841 | Ok(items) |
| 842 | } |
| 843 | |
| 844 | /// Every comment awaiting a decision, across every post. |
| 845 | /// |
| 846 | /// What the moderation queue reads. A whole-prefix scan, which is the one place this module walks |
| 847 | /// more than one post's worth -- the queue is a page about the site rather than about a post, and |
| 848 | /// there is no cheaper way to ask "what is waiting" than to look. |
| 849 | pub fn queue< |
| 850 | const UIDL: usize, |
| 851 | UID: NumIdDat<UIDL>, |
| 852 | ENC: Encrypter, |
| 853 | KH: Hasher, |
| 854 | DB: Database<UIDL, UID, ENC, KH>, |
| 855 | >( |
| 856 | db: &(Arc<RwLock<DB>>, UID), |
| 857 | want: Option<CommentState>, |
| 858 | id: &str, |
| 859 | ) |
| 860 | -> Outcome<Vec<Comment>> |
| 861 | { |
| 862 | let (db_arc, _) = db; |
| 863 | let found = { |
| 864 | let guard = lock_read!(db_arc); |
| 865 | let mut opts = ScanOpts::default(); |
| 866 | opts.prefix = Some(dat!(KEY_PREFIX)); |
| 867 | opts.include_values = false; |
| 868 | res!(guard.scan(&opts, None)) |
| 869 | }; |
| 870 | let mut out = Vec::new(); |
| 871 | for (k, _, _) in &found { |
| 872 | let s = match k { |
| 873 | Dat::Str(s) => s, |
| 874 | _ => continue, |
| 875 | }; |
| 876 | let rest = match s.strip_prefix(KEY_PREFIX) { |
| 877 | Some(r) => r, |
| 878 | None => continue, |
| 879 | }; |
| 880 | let (slug, name) = match rest.split_once('/') { |
| 881 | Some(p) => p, |
| 882 | None => continue, |
| 883 | }; |
| 884 | match get(db, slug, name) { |
| 885 | Ok(Some(c)) => { |
| 886 | if want.map(|w| c.state == w).unwrap_or(true) { |
| 887 | out.push(c); |
| 888 | } |
| 889 | } |
| 890 | Ok(None) => {} |
| 891 | Err(e) => debug!("{}: publish: comment '{}' will not read: {}", id, rest, e), |
| 892 | } |
| 893 | } |
| 894 | out.sort_by(|a, b| b.created.cmp(&a.created)); |
| 895 | Ok(out) |
| 896 | } |
| 897 | |
| 898 | pub fn count_public< |
| 899 | const UIDL: usize, |
| 900 | UID: NumIdDat<UIDL>, |
| 901 | ENC: Encrypter, |
| 902 | KH: Hasher, |
| 903 | DB: Database<UIDL, UID, ENC, KH>, |
| 904 | >( |
| 905 | db: &(Arc<RwLock<DB>>, UID), |
| 906 | slug: &str, |
| 907 | id: &str, |
| 908 | ) |
| 909 | -> Outcome<usize> |
| 910 | { |
| 911 | Ok(res!(list_for_post(db, slug, id)).iter().filter(|c| c.state.is_public()).count()) |
| 912 | } |
| 913 | |
| 914 | /// Moves a comment to a state, recording why. |
| 915 | /// |
| 916 | /// Answers whether there was a comment there to move. Approving a comment **also trusts its author**, |
| 917 | /// where they have a handle: that is the whole of "held once, then trusted", and doing it here rather |
| 918 | /// than at the call site means every path that approves gets it. |
| 919 | pub fn set_state< |
| 920 | const UIDL: usize, |
| 921 | UID: NumIdDat<UIDL>, |
| 922 | ENC: Encrypter, |
| 923 | KH: Hasher, |
| 924 | DB: Database<UIDL, UID, ENC, KH>, |
| 925 | >( |
| 926 | db: &(Arc<RwLock<DB>>, UID), |
| 927 | slug: &str, |
| 928 | id: &str, |
| 929 | state: CommentState, |
| 930 | reason: Option<String>, |
| 931 | ) |
| 932 | -> Outcome<bool> |
| 933 | { |
| 934 | let mut c = match res!(get(db, slug, id)) { |
| 935 | Some(c) => c, |
| 936 | None => return Ok(false), |
| 937 | }; |
| 938 | c.state = state; |
| 939 | c.reason = reason; |
| 940 | res!(put(db, &c)); |
| 941 | |
| 942 | if state == CommentState::Approved { |
| 943 | if let Some(h) = c.author.handle() { |
| 944 | res!(set_trust(db, &h, true, c.from.as_deref(), &c.created)); |
| 945 | } |
| 946 | } |
| 947 | Ok(true) |
| 948 | } |
| 949 | |
| 950 | /// Deletes a comment outright. |
| 951 | /// |
| 952 | /// Distinct from [`CommentState::Removed`], which is a comment taken down and still on file. This is |
| 953 | /// for erasing something that should not be kept at all -- what somebody asking to be forgotten is |
| 954 | /// owed, and what a piece of abuse deserves. |
| 955 | pub fn erase< |
| 956 | const UIDL: usize, |
| 957 | UID: NumIdDat<UIDL>, |
| 958 | ENC: Encrypter, |
| 959 | KH: Hasher, |
| 960 | DB: Database<UIDL, UID, ENC, KH>, |
| 961 | >( |
| 962 | db: &(Arc<RwLock<DB>>, UID), |
| 963 | slug: &str, |
| 964 | id: &str, |
| 965 | ) |
| 966 | -> Outcome<bool> |
| 967 | { |
| 968 | if res!(get(db, slug, id)).is_none() { |
| 969 | return Ok(false); |
| 970 | } |
| 971 | let (db_arc, user) = db; |
| 972 | let guard = lock_read!(db_arc); |
| 973 | res!(guard.delete(&key_of(slug, id), *user, None)); |
| 974 | Ok(true) |
| 975 | } |
| 976 | |
| 977 | |
| 978 | // ┌───────────────────────────────────────────────────────────────────────────┐ |
| 979 | // │ COMMENTERS │ |
| 980 | // └───────────────────────────────────────────────────────────────────────────┘ |
| 981 | |
| 982 | pub fn commenter< |
| 983 | const UIDL: usize, |
| 984 | UID: NumIdDat<UIDL>, |
| 985 | ENC: Encrypter, |
| 986 | KH: Hasher, |
| 987 | DB: Database<UIDL, UID, ENC, KH>, |
| 988 | >( |
| 989 | db: &(Arc<RwLock<DB>>, UID), |
| 990 | handle: &str, |
| 991 | ) |
| 992 | -> Outcome<Option<Commenter>> |
| 993 | { |
| 994 | let (db_arc, _) = db; |
| 995 | let guard = lock_read!(db_arc); |
| 996 | match res!(guard.get(&author_key(handle), None)) { |
| 997 | Some((v, _)) => Ok(Some(res!(Commenter::from_dat(&v)))), |
| 998 | None => Ok(None), |
| 999 | } |
| 1000 | } |
| 1001 | |
| 1002 | /// Sets whether a commenter is trusted, remembering them if they are new. |
| 1003 | pub fn set_trust< |
| 1004 | const UIDL: usize, |
| 1005 | UID: NumIdDat<UIDL>, |
| 1006 | ENC: Encrypter, |
| 1007 | KH: Hasher, |
| 1008 | DB: Database<UIDL, UID, ENC, KH>, |
| 1009 | >( |
| 1010 | db: &(Arc<RwLock<DB>>, UID), |
| 1011 | handle: &str, |
| 1012 | trusted: bool, |
| 1013 | from: Option<&str>, |
| 1014 | now: &str, |
| 1015 | ) |
| 1016 | -> Outcome<()> |
| 1017 | { |
| 1018 | let mut rec = res!(commenter(db, handle)).unwrap_or_else(|| Commenter { |
| 1019 | handle: handle.to_string(), |
| 1020 | from: None, |
| 1021 | trusted: false, |
| 1022 | blocked: false, |
| 1023 | first_seen: now.to_string(), |
| 1024 | }); |
| 1025 | rec.trusted = trusted; |
| 1026 | // Trust is granted to a commenter *as seen*, so a later comment must arrive the same way. |
| 1027 | if trusted { |
| 1028 | rec.from = from.map(|f| f.to_string()); |
| 1029 | } |
| 1030 | // Trusting somebody who was blocked unblocks them: the admin's later decision is the operative |
| 1031 | // one, and leaving both flags set would be a record that contradicts itself. |
| 1032 | if trusted { |
| 1033 | rec.blocked = false; |
| 1034 | } |
| 1035 | let (db_arc, user) = db; |
| 1036 | let guard = lock_read!(db_arc); |
| 1037 | res!(guard.insert(author_key(handle), rec.to_dat(), *user, None)); |
| 1038 | Ok(()) |
| 1039 | } |
| 1040 | |
| 1041 | pub fn set_blocked< |
| 1042 | const UIDL: usize, |
| 1043 | UID: NumIdDat<UIDL>, |
| 1044 | ENC: Encrypter, |
| 1045 | KH: Hasher, |
| 1046 | DB: Database<UIDL, UID, ENC, KH>, |
| 1047 | >( |
| 1048 | db: &(Arc<RwLock<DB>>, UID), |
| 1049 | handle: &str, |
| 1050 | blocked: bool, |
| 1051 | now: &str, |
| 1052 | ) |
| 1053 | -> Outcome<()> |
| 1054 | { |
| 1055 | let mut rec = res!(commenter(db, handle)).unwrap_or_else(|| Commenter { |
| 1056 | handle: handle.to_string(), |
| 1057 | from: None, |
| 1058 | trusted: false, |
| 1059 | blocked: false, |
| 1060 | first_seen: now.to_string(), |
| 1061 | }); |
| 1062 | rec.blocked = blocked; |
| 1063 | if blocked { |
| 1064 | rec.trusted = false; |
| 1065 | } |
| 1066 | let (db_arc, user) = db; |
| 1067 | let guard = lock_read!(db_arc); |
| 1068 | res!(guard.insert(author_key(handle), rec.to_dat(), *user, None)); |
| 1069 | Ok(()) |
| 1070 | } |
| 1071 | |
| 1072 | |
| 1073 | // ┌───────────────────────────────────────────────────────────────────────────┐ |
| 1074 | // │ THREADING │ |
| 1075 | // └───────────────────────────────────────────────────────────────────────────┘ |
| 1076 | |
| 1077 | /// A comment and the replies beneath it. |
| 1078 | #[derive(Clone, Debug)] |
| 1079 | pub struct Thread { |
| 1080 | pub comment: Comment, |
| 1081 | pub replies: Vec<Thread>, // in the same order the ranker gave |
| 1082 | } |
| 1083 | |
| 1084 | /// Arranges a flat run of comments into threads. |
| 1085 | /// |
| 1086 | /// A reply whose parent is not in the run -- because it was never approved, or was erased -- is |
| 1087 | /// **raised to the top level rather than dropped**: the words were addressed to somebody, and losing |
| 1088 | /// them because the thing they answered is gone would lose a side of a conversation. It reads a |
| 1089 | /// little oddly and says everything that was said, which is the right way round. |
| 1090 | pub fn thread(items: Vec<Comment>) -> Vec<Thread> { |
| 1091 | let present: std::collections::HashSet<String> = |
| 1092 | items.iter().map(|c| c.id.clone()).collect(); |
| 1093 | |
| 1094 | // Children by parent, and the roots in the order they arrived. |
| 1095 | let mut children: std::collections::HashMap<String, Vec<Comment>> = |
| 1096 | std::collections::HashMap::new(); |
| 1097 | let mut roots: Vec<Comment> = Vec::new(); |
| 1098 | for c in items { |
| 1099 | match &c.parent { |
| 1100 | Some(p) if present.contains(p) => children.entry(p.clone()).or_default().push(c), |
| 1101 | _ => roots.push(c), |
| 1102 | } |
| 1103 | } |
| 1104 | // A cycle -- two comments naming each other -- leaves no roots, and every comment in it would |
| 1105 | // simply vanish from the page with nothing said. Anything still held after the roots are taken |
| 1106 | // is raised, on the same reasoning an orphan is: words that were written should be readable. |
| 1107 | if roots.is_empty() && !children.is_empty() { |
| 1108 | let orphaned: Vec<Comment> = children.drain().flat_map(|(_, v)| v).collect(); |
| 1109 | return orphaned.into_iter().map(|c| Thread { comment: c, replies: Vec::new() }).collect(); |
| 1110 | } |
| 1111 | roots.into_iter().map(|r| build(r, &mut children, 0)).collect() |
| 1112 | } |
| 1113 | |
| 1114 | fn build( |
| 1115 | c: Comment, |
| 1116 | children: &mut std::collections::HashMap<String, Vec<Comment>>, |
| 1117 | depth: usize, |
| 1118 | ) |
| 1119 | -> Thread |
| 1120 | { |
| 1121 | let kids = children.remove(&c.id).unwrap_or_default(); |
| 1122 | // At the floor every descendant, however deep, is flattened onto this node rather than nested |
| 1123 | // further. Draining the whole subtree is the point: handling only the next level or two would |
| 1124 | // silently lose anything below, which is exactly the bug the depth test was written to catch. |
| 1125 | let replies = if depth + 1 >= DEPTH_MAX { |
| 1126 | let mut flat = Vec::new(); |
| 1127 | for k in kids { |
| 1128 | flat.extend(drain(k, children)); |
| 1129 | } |
| 1130 | flat.into_iter().map(|x| Thread { comment: x, replies: Vec::new() }).collect() |
| 1131 | } else { |
| 1132 | kids.into_iter().map(|k| build(k, children, depth + 1)).collect() |
| 1133 | }; |
| 1134 | Thread { comment: c, replies } |
| 1135 | } |
| 1136 | |
| 1137 | /// A comment and every descendant it has, in reading order, flat. |
| 1138 | /// |
| 1139 | /// Used at the depth floor. Recursive over the remaining children, so a chain of any length comes out |
| 1140 | /// whole: the depth rule bounds how deeply a thread is *drawn*, never how much of it is kept. |
| 1141 | fn drain( |
| 1142 | c: Comment, |
| 1143 | children: &mut std::collections::HashMap<String, Vec<Comment>>, |
| 1144 | ) |
| 1145 | -> Vec<Comment> |
| 1146 | { |
| 1147 | let kids = children.remove(&c.id).unwrap_or_default(); |
| 1148 | let mut out = vec![c]; |
| 1149 | for k in kids { |
| 1150 | out.extend(drain(k, children)); |
| 1151 | } |
| 1152 | out |
| 1153 | } |
| 1154 | |
| 1155 | // How many top-level threads a page of a conversation shows. Counted in threads rather than |
| 1156 | // comments: a reply belongs with what it answers, and splitting a thread across a page boundary |
| 1157 | // would leave an answer on one page and its question on another. |
| 1158 | pub const PAGE_THREADS: usize = 25; |
| 1159 | |
| 1160 | /// One page of a threaded conversation, and how many pages there are. |
| 1161 | pub fn page_of(threads: Vec<Thread>, page: usize) -> (Vec<Thread>, usize, usize) { |
| 1162 | let pages = threads.len().div_ceil(PAGE_THREADS).max(1); |
| 1163 | let at = page.max(1).min(pages); |
| 1164 | let from = (at - 1) * PAGE_THREADS; |
| 1165 | let upto = (from + PAGE_THREADS).min(threads.len()); |
| 1166 | (threads[from..upto].to_vec(), at, pages) |
| 1167 | } |
| 1168 | |
| 1169 | pub fn count_threads(threads: &[Thread]) -> usize { |
| 1170 | threads.iter().map(|t| 1 + count_threads(&t.replies)).sum() |
| 1171 | } |
| 1172 | |
| 1173 | |
| 1174 | #[cfg(test)] |
| 1175 | mod tests { |
| 1176 | use super::*; |
| 1177 | |
| 1178 | fn c(id: &str, body: &str) -> Comment { |
| 1179 | Comment { |
| 1180 | id: fmt!("{}", id), |
| 1181 | slug: fmt!("a-post"), |
| 1182 | body: fmt!("{}", body), |
| 1183 | created: fmt!("2026-07-20T10:00:00Z"), |
| 1184 | ..Default::default() |
| 1185 | } |
| 1186 | } |
| 1187 | fn named(id: &str, email: Option<&str>) -> Comment { |
| 1188 | let mut x = c(id, "a perfectly ordinary remark"); |
| 1189 | x.author = Identity::Local { |
| 1190 | name: fmt!("Ada"), |
| 1191 | email: email.map(|e| fmt!("{}", e)), |
| 1192 | }; |
| 1193 | x |
| 1194 | } |
| 1195 | |
| 1196 | /// An unknown state reads as pending, never as approved: a record a later version wrote must not |
| 1197 | /// publish itself by being unreadable. |
| 1198 | #[test] |
| 1199 | fn test_an_unknown_state_is_pending_00() -> Outcome<()> { |
| 1200 | assert_eq!(CommentState::of("approved"), CommentState::Approved); |
| 1201 | assert_eq!(CommentState::of("spam"), CommentState::Spam); |
| 1202 | assert_eq!(CommentState::of("removed"), CommentState::Removed); |
| 1203 | for unknown in ["", "published", "live", "APPROVED", "whatever-comes-next"] { |
| 1204 | assert_eq!(CommentState::of(unknown), CommentState::Pending, |
| 1205 | "'{}' was not read as pending", unknown); |
| 1206 | assert!(!CommentState::of(unknown).is_public(), "'{}' would have been shown", unknown); |
| 1207 | } |
| 1208 | Ok(()) |
| 1209 | } |
| 1210 | |
| 1211 | /// Only an address or a vouched id is a handle. A typed name is not, or a stranger could inherit |
| 1212 | /// somebody else's approval by typing their name. |
| 1213 | #[test] |
| 1214 | fn test_a_name_is_not_an_identity_01() -> Outcome<()> { |
| 1215 | let anon = Identity::Anon; |
| 1216 | assert_eq!(anon.handle(), None); |
| 1217 | |
| 1218 | let just_a_name = Identity::Local { name: fmt!("Ada"), email: None }; |
| 1219 | assert_eq!(just_a_name.handle(), None, "a bare name was taken for an identity"); |
| 1220 | |
| 1221 | let with_email = Identity::Local { name: fmt!("Ada"), email: Some(fmt!(" Ada@Example.COM ")) }; |
| 1222 | assert_eq!(with_email.handle(), Some(fmt!("email:ada@example.com")), |
| 1223 | "an address was not normalised into a stable handle"); |
| 1224 | |
| 1225 | let blank = Identity::Local { name: fmt!("Ada"), email: Some(fmt!(" ")) }; |
| 1226 | assert_eq!(blank.handle(), None, "an empty address became a handle"); |
| 1227 | |
| 1228 | let vouched = Identity::Vouched { id: fmt!("abc"), name: fmt!("Ada") }; |
| 1229 | assert_eq!(vouched.handle(), Some(fmt!("vouched:abc"))); |
| 1230 | Ok(()) |
| 1231 | } |
| 1232 | |
| 1233 | /// A commenter's address never reaches what a reader sees. |
| 1234 | #[test] |
| 1235 | fn test_an_address_is_never_displayed_02() -> Outcome<()> { |
| 1236 | let x = named("a", Some("ada@example.com")); |
| 1237 | assert_eq!(x.author.display_name(), "Ada"); |
| 1238 | assert!(!x.author.display_name().contains('@'), "the display name carries an address"); |
| 1239 | // It round-trips through storage, because a reply notification needs it. |
| 1240 | let back = res!(Comment::from_dat(&x.to_dat())); |
| 1241 | assert_eq!(back.author.email(), Some("ada@example.com")); |
| 1242 | Ok(()) |
| 1243 | } |
| 1244 | |
| 1245 | /// A comment round-trips through the store's daticle form. |
| 1246 | #[test] |
| 1247 | fn test_a_comment_round_trips_03() -> Outcome<()> { |
| 1248 | let mut x = named("abc", Some("ada@example.com")); |
| 1249 | x.parent = Some(fmt!("parent-id")); |
| 1250 | x.state = CommentState::Approved; |
| 1251 | x.reason = Some(fmt!("because")); |
| 1252 | x.from = Some(fmt!("deadbeef")); |
| 1253 | let back = res!(Comment::from_dat(&x.to_dat())); |
| 1254 | assert_eq!(back.id, x.id); |
| 1255 | assert_eq!(back.slug, x.slug); |
| 1256 | assert_eq!(back.parent, x.parent); |
| 1257 | assert_eq!(back.body, x.body); |
| 1258 | assert_eq!(back.state, x.state); |
| 1259 | assert_eq!(back.reason, x.reason); |
| 1260 | assert_eq!(back.from, x.from); |
| 1261 | assert_eq!(back.author, x.author); |
| 1262 | Ok(()) |
| 1263 | } |
| 1264 | |
| 1265 | /// A record with no id is refused rather than stored under a name it does not have. |
| 1266 | #[test] |
| 1267 | fn test_a_record_without_an_id_is_refused_04() -> Outcome<()> { |
| 1268 | let mut m = DaticleMap::new(); |
| 1269 | m.insert(dat!("slug"), dat!("a-post".to_string())); |
| 1270 | assert!(Comment::from_dat(&Dat::Map(m)).is_err()); |
| 1271 | assert!(Comment::from_dat(&dat!("not a map".to_string())).is_err()); |
| 1272 | Ok(()) |
| 1273 | } |
| 1274 | |
| 1275 | /// The first comment waits; the same person, once approved, does not wait again. |
| 1276 | #[test] |
| 1277 | fn test_held_once_then_trusted_05() -> Outcome<()> { |
| 1278 | let m = Moderator::default(); |
| 1279 | let x = named("a", Some("ada@example.com")); |
| 1280 | |
| 1281 | // Nobody knows them yet. |
| 1282 | assert!(matches!(m.judge(&x, None), Verdict::Hold(_)), "a first comment was not held"); |
| 1283 | |
| 1284 | // Approved once. |
| 1285 | let known = Commenter { |
| 1286 | handle: fmt!("email:ada@example.com"), from: None, trusted: true, blocked: false, |
| 1287 | first_seen: fmt!("2026-07-01T00:00:00Z"), |
| 1288 | }; |
| 1289 | assert_eq!(m.judge(&x, Some(&known)), Verdict::Allow, "a trusted commenter was still held"); |
| 1290 | |
| 1291 | // Blocked beats everything. |
| 1292 | let blocked = Commenter { trusted: true, blocked: true, ..known.clone() }; |
| 1293 | assert!(matches!(m.judge(&x, Some(&blocked)), Verdict::Spam(_)), |
| 1294 | "a blocked commenter got through"); |
| 1295 | Ok(()) |
| 1296 | } |
| 1297 | |
| 1298 | /// Somebody who gives no address is held every time, because there is nothing to remember. |
| 1299 | #[test] |
| 1300 | fn test_an_anonymous_commenter_is_always_held_06() -> Outcome<()> { |
| 1301 | let m = Moderator::default(); |
| 1302 | let x = named("a", None); |
| 1303 | match m.judge(&x, None) { |
| 1304 | Verdict::Hold(r) => assert!(r.contains("recognised"), "unhelpful reason: {}", r), |
| 1305 | other => panic!("an anonymous comment was not held: {:?}", other), |
| 1306 | } |
| 1307 | Ok(()) |
| 1308 | } |
| 1309 | |
| 1310 | /// Trust does not carry a comment past a rule that would otherwise catch it. |
| 1311 | #[test] |
| 1312 | fn test_trust_does_not_overrule_the_counting_07() -> Outcome<()> { |
| 1313 | let m = Moderator::default(); |
| 1314 | let known = Commenter { |
| 1315 | handle: fmt!("email:ada@example.com"), from: None, trusted: true, blocked: false, |
| 1316 | first_seen: fmt!("2026-07-01T00:00:00Z"), |
| 1317 | }; |
| 1318 | let mut spammy = named("a", Some("ada@example.com")); |
| 1319 | spammy.body = fmt!("buy https://a.example buy https://b.example buy https://c.example"); |
| 1320 | assert!(matches!(m.judge(&spammy, Some(&known)), Verdict::Hold(_)), |
| 1321 | "a trusted commenter's link-stuffed comment went straight through"); |
| 1322 | |
| 1323 | // And an empty one is refused whoever sent it. |
| 1324 | let mut empty = named("b", Some("ada@example.com")); |
| 1325 | empty.body = fmt!(" "); |
| 1326 | assert!(matches!(m.judge(&empty, Some(&known)), Verdict::Spam(_))); |
| 1327 | Ok(()) |
| 1328 | } |
| 1329 | |
| 1330 | /// Links are counted in both the forms a spammer writes them in. |
| 1331 | #[test] |
| 1332 | fn test_links_are_counted_either_way_08() -> Outcome<()> { |
| 1333 | assert_eq!(count_links("no links here"), 0); |
| 1334 | assert_eq!(count_links("one [a](https://x.example) link"), 1); |
| 1335 | assert_eq!(count_links("bare https://x.example and https://y.example"), 2); |
| 1336 | assert_eq!(count_links("[a](x) [b](y) [c](z)"), 3); |
| 1337 | Ok(()) |
| 1338 | } |
| 1339 | |
| 1340 | /// The strictest verdict wins, and nothing later can loosen an earlier refusal. |
| 1341 | #[test] |
| 1342 | fn test_the_strictest_verdict_wins_09() -> Outcome<()> { |
| 1343 | let allow = Verdict::Allow; |
| 1344 | let hold = Verdict::Hold(fmt!("h")); |
| 1345 | let spam = Verdict::Spam(fmt!("s")); |
| 1346 | |
| 1347 | assert_eq!(allow.clone().and_then(allow.clone()), Verdict::Allow); |
| 1348 | assert!(matches!(allow.clone().and_then(hold.clone()), Verdict::Hold(_))); |
| 1349 | assert!(matches!(hold.clone().and_then(allow.clone()), Verdict::Hold(_)), |
| 1350 | "a later Allow overturned a Hold"); |
| 1351 | assert!(matches!(spam.clone().and_then(allow.clone()), Verdict::Spam(_)), |
| 1352 | "a later Allow overturned a Spam"); |
| 1353 | assert!(matches!(hold.clone().and_then(spam.clone()), Verdict::Spam(_))); |
| 1354 | assert!(matches!(spam.clone().and_then(hold.clone()), Verdict::Spam(_))); |
| 1355 | Ok(()) |
| 1356 | } |
| 1357 | |
| 1358 | /// A verdict maps to the state it should, and carries its reason. |
| 1359 | #[test] |
| 1360 | fn test_a_verdict_names_a_state_10() -> Outcome<()> { |
| 1361 | assert_eq!(Verdict::Allow.state(), CommentState::Approved); |
| 1362 | assert_eq!(Verdict::Hold(fmt!("r")).state(), CommentState::Pending); |
| 1363 | assert_eq!(Verdict::Spam(fmt!("r")).state(), CommentState::Spam); |
| 1364 | assert_eq!(Verdict::Allow.reason(), None); |
| 1365 | assert_eq!(Verdict::Hold(fmt!("r")).reason(), Some(fmt!("r"))); |
| 1366 | Ok(()) |
| 1367 | } |
| 1368 | |
| 1369 | /// The proof verifies a digest computed **outside this program**. |
| 1370 | /// |
| 1371 | /// The value below came from python's hashlib, not from another call to `pow_verify`. That |
| 1372 | /// matters: the first version of this hashed SHA3-256 on the server while the browser hashed |
| 1373 | /// SHA-256, so no proof ever verified, every comment from a reader with scripting was refused |
| 1374 | /// before it was stored, and each of those readers was thanked for it. The test that was supposed |
| 1375 | /// to catch it checked SHA3 against SHA3 and passed happily. An oracle from outside is the only |
| 1376 | /// kind that could have failed. |
| 1377 | #[test] |
| 1378 | fn test_the_proof_agrees_with_an_outside_digest_20() -> Outcome<()> { |
| 1379 | // sha256("0123456789abcdef"*4 + "4494") begins 0x0009..., which is 12 leading zero bits. |
| 1380 | let challenge = "0123456789abcdef".repeat(4); |
| 1381 | assert!(pow_verify(&challenge, "4494", 12), |
| 1382 | "a proof computed by hashlib did not verify: the server is not using SHA-256"); |
| 1383 | assert!(!pow_verify(&challenge, "4494", 20), |
| 1384 | "a 12-bit proof passed at 20 bits"); |
| 1385 | assert!(!pow_verify(&challenge, "4493", 12), "a wrong nonce passed"); |
| 1386 | Ok(()) |
| 1387 | } |
| 1388 | |
| 1389 | /// A challenge stands for its window and not for ever. |
| 1390 | #[test] |
| 1391 | fn test_a_proof_expires_21() -> Outcome<()> { |
| 1392 | let secret = b"a-site-secret"; |
| 1393 | let now = pow_challenge_at("a-post", secret, &pow_window(0)); |
| 1394 | let prev = pow_challenge_at("a-post", secret, &pow_window(1)); |
| 1395 | let ancient = pow_challenge_at("a-post", secret, "1"); |
| 1396 | |
| 1397 | assert_ne!(now, prev, "two windows share a challenge, so a solve never expires"); |
| 1398 | assert!(pow_challenge_current(&now, "a-post", secret)); |
| 1399 | // The window before is accepted, so a form opened at 10:59 still posts at 11:01. |
| 1400 | assert!(pow_challenge_current(&prev, "a-post", secret)); |
| 1401 | assert!(!pow_challenge_current(&ancient, "a-post", secret), "an old proof still stands"); |
| 1402 | assert!(!pow_challenge_current(&now, "another-post", secret), "a proof travelled between posts"); |
| 1403 | Ok(()) |
| 1404 | } |
| 1405 | |
| 1406 | /// A parent is an id this module minted, or it is nothing at all. |
| 1407 | #[test] |
| 1408 | fn test_a_parent_is_an_id_22() -> Outcome<()> { |
| 1409 | assert!(valid_id(&mint_id())); |
| 1410 | assert!(!valid_id("")); |
| 1411 | assert!(!valid_id("short")); |
| 1412 | assert!(!valid_id(&"a".repeat(ID_LEN + 1))); |
| 1413 | assert!(!valid_id(&"A".repeat(ID_LEN)), "an off-alphabet id was accepted"); |
| 1414 | assert!(!valid_id(&"!".repeat(ID_LEN))); |
| 1415 | // The field that had no bound at all: megabytes of anything. |
| 1416 | assert!(!valid_id(&"a".repeat(8_000_000)), "an unbounded parent was accepted"); |
| 1417 | Ok(()) |
| 1418 | } |
| 1419 | |
| 1420 | /// Trust is honoured only where the sender matches the one it was granted to. |
| 1421 | #[test] |
| 1422 | fn test_trust_does_not_travel_23() -> Outcome<()> { |
| 1423 | let m = Moderator::default(); |
| 1424 | let mut x = named("a", Some("ada@example.com")); |
| 1425 | x.from = Some(fmt!("the-place-ada-comments-from")); |
| 1426 | |
| 1427 | let granted = Commenter { |
| 1428 | handle: fmt!("email:ada@example.com"), |
| 1429 | from: Some(fmt!("the-place-ada-comments-from")), |
| 1430 | trusted: true, |
| 1431 | blocked: false, |
| 1432 | first_seen: fmt!("2026-07-01T00:00:00Z"), |
| 1433 | }; |
| 1434 | // Ada, from where Ada comments: allowed. |
| 1435 | assert_eq!(m.judge(&x, Some(&granted)), Verdict::Allow); |
| 1436 | |
| 1437 | // Somebody else typing Ada's address, from elsewhere: the trust must not follow the address. |
| 1438 | // (The filtering happens in `receive`; this asserts the record carries what that needs.) |
| 1439 | assert_eq!(granted.from.as_deref(), Some("the-place-ada-comments-from")); |
| 1440 | let forged_from = Some(fmt!("somewhere-else")); |
| 1441 | assert_ne!(granted.from, forged_from, "trust would have been inherited by an address alone"); |
| 1442 | Ok(()) |
| 1443 | } |
| 1444 | |
| 1445 | /// The rate limit switches off cleanly, and a site that says nothing gets the defaults. |
| 1446 | #[test] |
| 1447 | fn test_the_rate_limit_is_policy_26() -> Outcome<()> { |
| 1448 | use crate::srv::publish::PublishConfig; |
| 1449 | |
| 1450 | // A site that says nothing is rate limited, because most sites should be. |
| 1451 | let cfg = res!(PublishConfig::from_datmap(&DaticleMap::new())); |
| 1452 | assert_eq!(cfg.comment_rate_secs, 30); |
| 1453 | assert_eq!(cfg.comment_rate_hourly, 10); |
| 1454 | |
| 1455 | // A site behind a shared address turns it off, and is taken at its word. |
| 1456 | // Written as a bare zero, which the grammar types as narrowly as it can -- the shape an |
| 1457 | // operator actually writes, and the one a match on `Dat::U64` alone would refuse. |
| 1458 | let mut m = DaticleMap::new(); |
| 1459 | m.insert(dat!("comment_rate_secs"), dat!(0u8)); |
| 1460 | m.insert(dat!("comment_rate_hourly"), dat!(0u8)); |
| 1461 | let cfg = res!(PublishConfig::from_datmap(&m)); |
| 1462 | assert_eq!(cfg.comment_rate_secs, 0); |
| 1463 | assert_eq!(cfg.comment_rate_hourly, 0); |
| 1464 | |
| 1465 | // And a value that is not a count is refused rather than guessed at. |
| 1466 | let mut m = DaticleMap::new(); |
| 1467 | m.insert(dat!("comment_rate_secs"), dat!("often".to_string())); |
| 1468 | assert!(PublishConfig::from_datmap(&m).is_err()); |
| 1469 | Ok(()) |
| 1470 | } |
| 1471 | |
| 1472 | /// A submission cannot claim to be the site's author, whatever it sends. |
| 1473 | #[test] |
| 1474 | fn test_a_submission_cannot_claim_authorship_25() -> Outcome<()> { |
| 1475 | // The struct's own default is false, and `receive` sets it explicitly rather than taking it |
| 1476 | // from anything a form carried -- there is no field on `Submission` that could reach it. |
| 1477 | let x = Comment::default(); |
| 1478 | assert!(!x.by_site_author); |
| 1479 | |
| 1480 | // It survives a round trip when the console does set it. |
| 1481 | let mut y = named("a", Some("me@example.com")); |
| 1482 | y.by_site_author = true; |
| 1483 | assert!(res!(Comment::from_dat(&y.to_dat())).by_site_author); |
| 1484 | |
| 1485 | // And a record that says nothing about it is not the author's. |
| 1486 | let mut m = DaticleMap::new(); |
| 1487 | m.insert(dat!("id"), dat!("abcdefghijklmnop".to_string())); |
| 1488 | assert!(!res!(Comment::from_dat(&Dat::Map(m))).by_site_author); |
| 1489 | Ok(()) |
| 1490 | } |
| 1491 | |
| 1492 | /// An edit token names one comment and cannot be guessed from another. |
| 1493 | #[test] |
| 1494 | fn test_an_edit_token_names_one_comment_28() -> Outcome<()> { |
| 1495 | let secret = b"a-site-secret"; |
| 1496 | let a = edit_token("aaaaaaaaaaaaaaaa", secret); |
| 1497 | let b = edit_token("bbbbbbbbbbbbbbbb", secret); |
| 1498 | assert_ne!(a, b, "two comments share an edit token"); |
| 1499 | assert!(edit_token_ok("aaaaaaaaaaaaaaaa", secret, &a)); |
| 1500 | assert!(!edit_token_ok("aaaaaaaaaaaaaaaa", secret, &b), "another comment's token was taken"); |
| 1501 | assert!(!edit_token_ok("aaaaaaaaaaaaaaaa", secret, ""), "an empty token was taken"); |
| 1502 | assert!(!edit_token_ok("aaaaaaaaaaaaaaaa", b"another-secret", &a), |
| 1503 | "a token from another site was taken"); |
| 1504 | Ok(()) |
| 1505 | } |
| 1506 | |
| 1507 | /// The window closes, and an unreadable stamp is not editable. |
| 1508 | #[test] |
| 1509 | fn test_the_edit_window_closes_29() -> Outcome<()> { |
| 1510 | let mut c = c("aaaaaaaaaaaaaaaa", "words"); |
| 1511 | c.created = fmt!("2026-07-20T10:00:00Z"); |
| 1512 | let at = match parse_stamp_secs(&c.created) { |
| 1513 | Some(t) => t, |
| 1514 | None => panic!("the test's own stamp will not parse"), |
| 1515 | }; |
| 1516 | assert!(editable(&c, at), "a comment was not editable the moment it was written"); |
| 1517 | assert!(editable(&c, at + EDIT_WINDOW_SECS - 1), "the window closed early"); |
| 1518 | assert!(!editable(&c, at + EDIT_WINDOW_SECS), "the window did not close"); |
| 1519 | assert!(!editable(&c, at + 86_400), "a day-old comment was still editable"); |
| 1520 | |
| 1521 | // A stamp that will not read is not editable: an unreadable time cannot be shown to be |
| 1522 | // recent, and guessing permissively would make the window unbounded. |
| 1523 | for bad in ["not a time at all", "", "2026-07-20", "20260720T100000Z", "yyyy-mm-ddThh:mm:ss"] { |
| 1524 | c.created = fmt!("{}", bad); |
| 1525 | assert!(!editable(&c, at), "'{}' granted an unbounded window", bad); |
| 1526 | } |
| 1527 | Ok(()) |
| 1528 | } |
| 1529 | |
| 1530 | /// The stamp reader agrees with known instants, and refuses what is not one. |
| 1531 | #[test] |
| 1532 | fn test_the_stamp_reader_is_strict_30() -> Outcome<()> { |
| 1533 | // Values from `date -u -d "<stamp>" +%s`, not from this function and not from memory. The |
| 1534 | // first draft of this test had one of them three days out, written from recall; the code |
| 1535 | // was right and the expectation was wrong. |
| 1536 | assert_eq!(parse_stamp_secs("1970-01-01T00:00:00Z"), Some(0)); |
| 1537 | assert_eq!(parse_stamp_secs("2000-01-01T00:00:00Z"), Some(946_684_800)); |
| 1538 | assert_eq!(parse_stamp_secs("2026-07-20T03:00:00Z"), Some(1_784_516_400)); |
| 1539 | // A leap day, which a naive day count gets wrong. |
| 1540 | assert_eq!(parse_stamp_secs("2024-02-29T00:00:00Z"), Some(1_709_164_800)); |
| 1541 | // And the shapes that are not a stamp. |
| 1542 | for bad in ["", "not a time", "2026-07-20", "2026-13-01T00:00:00Z", |
| 1543 | "2026-07-32T00:00:00Z", "2026-07-20T25:00:00Z", "2026-07-20T00:60:00Z", |
| 1544 | "20xx-07-20T00:00:00Z"] { |
| 1545 | assert_eq!(parse_stamp_secs(bad), None, "'{}' was read as a time", bad); |
| 1546 | } |
| 1547 | Ok(()) |
| 1548 | } |
| 1549 | |
| 1550 | /// A comment claiming a known commenter from a new place is held and says so. |
| 1551 | #[test] |
| 1552 | fn test_a_claim_from_elsewhere_is_flagged_27() -> Outcome<()> { |
| 1553 | // The record `receive` consults, and what it would compare against. |
| 1554 | let granted = Commenter { |
| 1555 | handle: fmt!("email:ada@example.com"), |
| 1556 | from: Some(fmt!("where-ada-comments-from")), |
| 1557 | trusted: true, |
| 1558 | blocked: false, |
| 1559 | first_seen: fmt!("2026-07-01T00:00:00Z"), |
| 1560 | }; |
| 1561 | // Somebody typing Ada's address from somewhere else: the mismatch is detectable, which is |
| 1562 | // what `receive` turns into a held comment carrying a reason. |
| 1563 | let forged = Some(fmt!("somewhere-ada-has-never-been")); |
| 1564 | let is_mismatch = granted.trusted |
| 1565 | && granted.from.is_some() |
| 1566 | && granted.from != forged; |
| 1567 | assert!(is_mismatch, "an impersonation attempt would not have been noticed"); |
| 1568 | |
| 1569 | // And Ada herself, from her usual place, is not flagged. |
| 1570 | let hers = granted.from.clone(); |
| 1571 | assert!(!(granted.trusted && granted.from.is_some() && granted.from != hers), |
| 1572 | "a regular commenter was flagged as an impostor"); |
| 1573 | Ok(()) |
| 1574 | } |
| 1575 | |
| 1576 | /// A cycle does not swallow the comments in it. |
| 1577 | #[test] |
| 1578 | fn test_a_cycle_loses_nothing_24() -> Outcome<()> { |
| 1579 | let mut a = c("aaaaaaaaaaaaaaaa", "one"); |
| 1580 | let mut b = c("bbbbbbbbbbbbbbbb", "two"); |
| 1581 | a.parent = Some(b.id.clone()); |
| 1582 | b.parent = Some(a.id.clone()); |
| 1583 | let threads = thread(vec![a, b]); |
| 1584 | assert_eq!(count_threads(&threads), 2, "a cycle swallowed its comments"); |
| 1585 | Ok(()) |
| 1586 | } |
| 1587 | |
| 1588 | /// The proof is over the challenge, so a proof for one post is not a proof for another, and a |
| 1589 | /// wrong nonce does not pass. |
| 1590 | #[test] |
| 1591 | fn test_a_proof_is_bound_to_its_challenge_11() -> Outcome<()> { |
| 1592 | let secret = b"a-per-process-secret"; |
| 1593 | let a = pow_challenge_at("post-one", secret, "w"); |
| 1594 | let b = pow_challenge_at("post-two", secret, "w"); |
| 1595 | assert_ne!(a, b, "two posts share a challenge"); |
| 1596 | assert_eq!(a, pow_challenge_at("post-one", secret, "w"), "a challenge is not stable"); |
| 1597 | |
| 1598 | // Find a real proof at a width cheap enough for a test, then check it does not travel. |
| 1599 | let bits = 8; |
| 1600 | let mut nonce = 0u64; |
| 1601 | let solved = loop { |
| 1602 | if pow_verify(&a, &fmt!("{}", nonce), bits) { break fmt!("{}", nonce); } |
| 1603 | nonce += 1; |
| 1604 | assert!(nonce < 1_000_000, "no proof found at {} bits", bits); |
| 1605 | }; |
| 1606 | assert!(pow_verify(&a, &solved, bits)); |
| 1607 | assert!(!pow_verify(&b, &solved, bits), "a proof for one post solved another"); |
| 1608 | assert!(!pow_verify(&a, "0", bits + 24), "a proof passed at a width it cannot have met"); |
| 1609 | Ok(()) |
| 1610 | } |
| 1611 | |
| 1612 | /// The address a comment came from is stored one-way and salted. |
| 1613 | #[test] |
| 1614 | fn test_an_address_is_stored_one_way_12() -> Outcome<()> { |
| 1615 | let h = from_hash("203.0.113.7", b"site-salt"); |
| 1616 | assert!(!h.contains("203"), "the address survived in its own hash: {}", h); |
| 1617 | assert_eq!(h, from_hash("203.0.113.7", b"site-salt"), "the hash is not stable"); |
| 1618 | assert_ne!(h, from_hash("203.0.113.8", b"site-salt"), "two addresses collided"); |
| 1619 | assert_ne!(h, from_hash("203.0.113.7", b"other-salt"), |
| 1620 | "the salt does not change the hash, so it is portable between sites"); |
| 1621 | Ok(()) |
| 1622 | } |
| 1623 | |
| 1624 | /// Replies nest under what they answer, and the order within a level is the ranker's. |
| 1625 | #[test] |
| 1626 | fn test_replies_nest_13() -> Outcome<()> { |
| 1627 | let mut a = c("a", "root one"); |
| 1628 | a.created = fmt!("2026-07-20T10:00:00Z"); |
| 1629 | let mut b = c("b", "reply to a"); |
| 1630 | b.parent = Some(fmt!("a")); |
| 1631 | b.created = fmt!("2026-07-20T10:01:00Z"); |
| 1632 | let mut d = c("d", "root two"); |
| 1633 | d.created = fmt!("2026-07-20T10:02:00Z"); |
| 1634 | |
| 1635 | let threads = thread(vec![a, b, d]); |
| 1636 | assert_eq!(threads.len(), 2, "replies did not nest: {:?}", threads.len()); |
| 1637 | assert_eq!(threads[0].comment.id, "a"); |
| 1638 | assert_eq!(threads[0].replies.len(), 1); |
| 1639 | assert_eq!(threads[0].replies[0].comment.id, "b"); |
| 1640 | assert_eq!(threads[1].comment.id, "d"); |
| 1641 | assert_eq!(count_threads(&threads), 3); |
| 1642 | Ok(()) |
| 1643 | } |
| 1644 | |
| 1645 | /// A reply whose parent is gone is raised rather than dropped: one side of a conversation is |
| 1646 | /// still worth reading. |
| 1647 | #[test] |
| 1648 | fn test_an_orphan_is_raised_not_dropped_14() -> Outcome<()> { |
| 1649 | let mut orphan = c("b", "answering something that was removed"); |
| 1650 | orphan.parent = Some(fmt!("a-comment-that-is-not-here")); |
| 1651 | let threads = thread(vec![orphan]); |
| 1652 | assert_eq!(threads.len(), 1, "an orphaned reply was dropped"); |
| 1653 | assert_eq!(threads[0].comment.id, "b"); |
| 1654 | assert_eq!(count_threads(&threads), 1); |
| 1655 | Ok(()) |
| 1656 | } |
| 1657 | |
| 1658 | /// Nothing is lost to the depth rule: a reply below the floor is kept, flattened. |
| 1659 | #[test] |
| 1660 | fn test_depth_loses_nothing_15() -> Outcome<()> { |
| 1661 | let mut items = vec![c("c0", "root")]; |
| 1662 | for i in 1..8 { |
| 1663 | let mut x = c(&fmt!("c{}", i), "deeper"); |
| 1664 | x.parent = Some(fmt!("c{}", i - 1)); |
| 1665 | x.created = fmt!("2026-07-20T10:0{}:00Z", i); |
| 1666 | items.push(x); |
| 1667 | } |
| 1668 | let threads = thread(items); |
| 1669 | assert_eq!(count_threads(&threads), 8, "the depth rule lost comments"); |
| 1670 | Ok(()) |
| 1671 | } |
| 1672 | |
| 1673 | /// The ranker orders a level, both ways. |
| 1674 | #[test] |
| 1675 | fn test_the_ranker_orders_16() -> Outcome<()> { |
| 1676 | let mut a = c("a", "first"); a.created = fmt!("2026-07-20T10:00:00Z"); |
| 1677 | let mut b = c("b", "second"); b.created = fmt!("2026-07-20T11:00:00Z"); |
| 1678 | let mut items = vec![b.clone(), a.clone()]; |
| 1679 | |
| 1680 | Ranker::Chronological.rank(&mut items); |
| 1681 | assert_eq!(items[0].id, "a", "chronological did not put the oldest first"); |
| 1682 | |
| 1683 | Ranker::Recent.rank(&mut items); |
| 1684 | assert_eq!(items[0].id, "b", "recent did not put the newest first"); |
| 1685 | Ok(()) |
| 1686 | } |
| 1687 | |
| 1688 | /// A name is words, not control characters, and a body has bounds. |
| 1689 | #[test] |
| 1690 | fn test_what_is_accepted_17() -> Outcome<()> { |
| 1691 | assert!(valid_name("Ada")); |
| 1692 | assert!(valid_name(" Ada Lovelace ")); |
| 1693 | assert!(!valid_name("")); |
| 1694 | assert!(!valid_name(" ")); |
| 1695 | assert!(!valid_name("Ada\nLovelace"), "a newline in a name was accepted"); |
| 1696 | assert!(!valid_name("Ada\u{0}"), "a NUL in a name was accepted"); |
| 1697 | assert!(!valid_name(&"a".repeat(NAME_MAX + 1))); |
| 1698 | |
| 1699 | assert!(valid_body("a remark")); |
| 1700 | assert!(!valid_body("")); |
| 1701 | assert!(!valid_body(" ")); |
| 1702 | assert!(!valid_body(&"a".repeat(BODY_MAX + 1))); |
| 1703 | Ok(()) |
| 1704 | } |
| 1705 | |
| 1706 | /// A comment's prose reaches HTML through the policy, so a stranger's script does not run. |
| 1707 | #[test] |
| 1708 | fn test_a_comment_renders_within_the_policy_18() -> Outcome<()> { |
| 1709 | let mut x = c("a", ""); |
| 1710 | x.body = fmt!("Nice post. [click me](javascript:alert(1)) and <script>steal()</script>\n\n\ |
| 1711 | "); |
| 1712 | let html = res!(x.render()); |
| 1713 | assert!(!html.contains("javascript:"), "a script destination reached the page: {}", html); |
| 1714 | assert!(!html.contains("<script>"), "a script tag reached the page: {}", html); |
| 1715 | assert!(!html.contains("<img"), "a remote image reached the page: {}", html); |
| 1716 | assert!(!html.contains("tracker.example"), "a tracker's address reached the page: {}", html); |
| 1717 | // And the words survive. |
| 1718 | assert!(html.contains("Nice post."), "the prose was lost: {}", html); |
| 1719 | assert!(html.contains("click me"), "the link's words were lost: {}", html); |
| 1720 | |
| 1721 | // A link a stranger did get to keep carries rel, so the site lends it nothing. |
| 1722 | let mut y = c("b", ""); |
| 1723 | y.body = fmt!("See [this](https://example.com/x)."); |
| 1724 | let html = res!(y.render()); |
| 1725 | assert!(html.contains("rel=\"nofollow ugc noopener\""), |
| 1726 | "a commenter's link carried no rel: {}", html); |
| 1727 | Ok(()) |
| 1728 | } |
| 1729 | |
| 1730 | /// Ids are unguessable and do not repeat. |
| 1731 | #[test] |
| 1732 | fn test_ids_are_minted_19() -> Outcome<()> { |
| 1733 | let mut seen = std::collections::HashSet::new(); |
| 1734 | for _ in 0..256 { |
| 1735 | let id = mint_id(); |
| 1736 | assert_eq!(id.len(), ID_LEN); |
| 1737 | assert!(id.chars().all(|c| ID_ALPHABET.contains(c)), "'{}' is off the alphabet", id); |
| 1738 | assert!(seen.insert(id), "a minted id repeated within 256"); |
| 1739 | } |
| 1740 | Ok(()) |
| 1741 | } |
| 1742 | |
| 1743 | /// Every reason a moderator gives reads as a sentence, because a person reads it in the queue. |
| 1744 | /// |
| 1745 | /// The one that did not was a literal wrapped across two lines without the continuation, so the |
| 1746 | /// source's own indentation was inside the string and the queue said "commented from |
| 1747 | /// 			before". Nothing about the moderation was wrong, and nothing tested the words. |
| 1748 | #[test] |
| 1749 | fn test_a_reason_reads_as_a_sentence_31() -> Outcome<()> { |
| 1750 | let m = Moderator::default(); |
| 1751 | let anon = named("a", None); |
| 1752 | let mut spammy = named("b", Some("ada@example.com")); |
| 1753 | spammy.body = fmt!("buy https://a.example buy https://b.example buy https://c.example"); |
| 1754 | let mut empty = named("c", Some("ada@example.com")); |
| 1755 | empty.body = fmt!(" "); |
| 1756 | let blocked = Commenter { |
| 1757 | handle: fmt!("email:ada@example.com"), from: None, trusted: false, blocked: true, |
| 1758 | first_seen: fmt!("2026-07-01T00:00:00Z"), |
| 1759 | }; |
| 1760 | |
| 1761 | let mut reasons = vec![fmt!("{}", REASON_MISMATCH)]; |
| 1762 | for v in [ |
| 1763 | m.judge(&anon, None), |
| 1764 | m.judge(&named("d", Some("ada@example.com")), None), |
| 1765 | m.judge(&spammy, None), |
| 1766 | m.judge(&empty, None), |
| 1767 | m.judge(&anon, Some(&blocked)), |
| 1768 | ] { |
| 1769 | if let Some(r) = v.reason() { |
| 1770 | reasons.push(r); |
| 1771 | } |
| 1772 | } |
| 1773 | assert!(reasons.len() >= 6, "the reasons were not all gathered: {:?}", reasons); |
| 1774 | for r in &reasons { |
| 1775 | assert!(!r.contains('\t'), "a reason carries a tab: {:?}", r); |
| 1776 | assert!(!r.contains('\n'), "a reason carries a newline: {:?}", r); |
| 1777 | assert!(!r.contains(" "), "a reason carries a run of spaces: {:?}", r); |
| 1778 | assert_eq!(r.trim(), r.as_str(), "a reason is padded: {:?}", r); |
| 1779 | assert!(!r.is_empty(), "a reason says nothing"); |
| 1780 | } |
| 1781 | Ok(()) |
| 1782 | } |
| 1783 | } |
| 1784 | |
| 1785 | |
| 1786 | // ┌───────────────────────────────────────────────────────────────────────────┐ |
| 1787 | // │ RECEIVING │ |
| 1788 | // └───────────────────────────────────────────────────────────────────────────┘ |
| 1789 | |
| 1790 | /// What a submitted comment turned into. |
| 1791 | /// |
| 1792 | /// A caller answers a reader with the same page either way; this says what to tell them, as a **code** |
| 1793 | /// that the page turns into words -- never as text that travels in a URL. It never says *which rule* |
| 1794 | /// refused, deliberately: a spammer tuning against a precise reason is being given a test suite, and a |
| 1795 | /// reader who wrote something ordinary does not need to know the machinery. |
| 1796 | pub enum Received { |
| 1797 | Published, // stored and published at once, because the site already knows the commenter |
| 1798 | Held, // stored and waiting for the author to see it |
| 1799 | Refused(String), // not stored; the reader is told the same thing as `Held` |
| 1800 | } |
| 1801 | |
| 1802 | impl Received { |
| 1803 | |
| 1804 | /// What a reader is told. |
| 1805 | /// |
| 1806 | /// **A refusal reads like a hold.** Anything else is an oracle: a machine that is told "your proof |
| 1807 | /// was wrong" retries with a better proof, and one told "held for review" learns nothing about |
| 1808 | /// whether it worked. The cost is that a genuine reader whose comment was binned is told it is |
| 1809 | /// waiting; the alternative is a tuning signal for everybody, which is worse. |
| 1810 | pub fn tell_reader(&self) -> &'static str { |
| 1811 | match self { |
| 1812 | Self::Published => "published", |
| 1813 | Self::Held |
| 1814 | | Self::Refused(_) |
| 1815 | => "held", |
| 1816 | } |
| 1817 | } |
| 1818 | } |
| 1819 | |
| 1820 | /// Everything a submitted comment arrives with. |
| 1821 | pub struct Submission<'a> { |
| 1822 | pub slug: &'a str, // the post being commented on |
| 1823 | pub parent: Option<String>, // the comment being replied to, where one is |
| 1824 | pub name: String, // the name given |
| 1825 | pub email: Option<String>, // the address given, where one was |
| 1826 | pub body: String, // the prose |
| 1827 | pub honeypot: String, // anything in it and the sender is not a person |
| 1828 | pub challenge: String, // the challenge the form carried |
| 1829 | pub nonce: String, // the nonce the browser found, where it found one |
| 1830 | pub from: Option<String>, // who sent it, for the salted hash |
| 1831 | pub now: String, // when it arrived |
| 1832 | } |
| 1833 | |
| 1834 | /// Renders a comment's prose as the reader would see it, storing nothing. |
| 1835 | /// |
| 1836 | /// A preview is a rendering service offered to anybody, which is why it is bounded rather than |
| 1837 | /// merely offered: the body is capped as a comment's is, and a sender is held to the same interval |
| 1838 | /// as a comment. It counts against **its own** budget, not the comment budget -- previewing twice |
| 1839 | /// then posting should not find the post refused, which is what sharing one counter would do. |
| 1840 | pub fn preview< |
| 1841 | const UIDL: usize, |
| 1842 | UID: NumIdDat<UIDL>, |
| 1843 | ENC: Encrypter, |
| 1844 | KH: Hasher, |
| 1845 | DB: Database<UIDL, UID, ENC, KH>, |
| 1846 | >( |
| 1847 | db: &(Arc<RwLock<DB>>, UID), |
| 1848 | body: &str, |
| 1849 | from: Option<&str>, |
| 1850 | salt: &[u8], |
| 1851 | interval: u64, |
| 1852 | ) |
| 1853 | -> Outcome<Option<String>> |
| 1854 | { |
| 1855 | if !valid_body(body) { |
| 1856 | return Ok(None); |
| 1857 | } |
| 1858 | if let Some(addr) = from { |
| 1859 | let key = fmt!("preview:{}", from_hash(addr, salt)); |
| 1860 | if !res!(rate_allows(db, &key, interval, 0)) { |
| 1861 | return Ok(None); |
| 1862 | } |
| 1863 | } |
| 1864 | let c = Comment { body: body.to_string(), ..Default::default() }; |
| 1865 | Ok(Some(res!(c.render()))) |
| 1866 | } |
| 1867 | |
| 1868 | /// Takes a submitted comment: checks it, judges it, stores it. |
| 1869 | /// |
| 1870 | /// The order is the point. **What can be decided without touching the database is decided first** -- |
| 1871 | /// the honeypot and the shape of the fields cost nothing and refuse most of what arrives. The proof |
| 1872 | /// is checked next, and is *not* a condition of being heard: a browser with no scripting sends no |
| 1873 | /// nonce, and that comment is held for a person rather than refused, because a reader without |
| 1874 | /// JavaScript is still a reader. Only then does anything read from or write to the store. |
| 1875 | pub async fn receive< |
| 1876 | const UIDL: usize, |
| 1877 | UID: NumIdDat<UIDL>, |
| 1878 | ENC: Encrypter, |
| 1879 | KH: Hasher, |
| 1880 | DB: Database<UIDL, UID, ENC, KH>, |
| 1881 | >( |
| 1882 | db: &(Arc<RwLock<DB>>, UID), |
| 1883 | moderator: &Moderator, |
| 1884 | // The site's AI, where it has one, and the connection to reach it. Consulted on a comment the |
| 1885 | // rules would make wait, and never on one they already refused -- asking a model about a comment |
| 1886 | // the arithmetic binned is both a waste and a way for a persuasive comment to talk its way out. |
| 1887 | ai_settings: Option<&ai::AiSettings>, |
| 1888 | tls: &Option<Arc<ClientConfig>>, |
| 1889 | rate: (u64, u32), |
| 1890 | sub: Submission<'_>, |
| 1891 | salt: &[u8], |
| 1892 | secret: &[u8], |
| 1893 | id: &str, |
| 1894 | ) |
| 1895 | -> Outcome<(Received, Option<String>)> |
| 1896 | { |
| 1897 | // The honeypot. A field no person can see, so anything in it was put there by something filling |
| 1898 | // every field it found. Nothing is stored and nothing is logged beyond the count. |
| 1899 | if !sub.honeypot.trim().is_empty() { |
| 1900 | info!("{}: publish: a comment on '{}' filled the honeypot", id, sub.slug); |
| 1901 | return Ok((Received::Refused(fmt!("honeypot")), None)); |
| 1902 | } |
| 1903 | |
| 1904 | let name = sub.name.trim().to_string(); |
| 1905 | let body = sub.body.trim().to_string(); |
| 1906 | if !valid_body(&body) { |
| 1907 | return Ok((Received::Refused(fmt!("the comment is empty or too long")), None)); |
| 1908 | } |
| 1909 | // A name is optional in the sense that a reader may leave it blank and be anonymous; a name that |
| 1910 | // is *given* must be a name. |
| 1911 | if !name.is_empty() && !valid_name(&name) { |
| 1912 | return Ok((Received::Refused(fmt!("that is not a name")), None)); |
| 1913 | } |
| 1914 | |
| 1915 | // The proof, where one was sent. A wrong proof is a refusal -- it was attempted and failed, which |
| 1916 | // a browser does not do by accident. No proof at all is not: see the note above. |
| 1917 | let proved = if sub.nonce.trim().is_empty() { |
| 1918 | false |
| 1919 | } else { |
| 1920 | if !pow_challenge_current(&sub.challenge, sub.slug, secret) { |
| 1921 | return Ok((Received::Refused(fmt!("the proof answers a challenge this site did not set")), None)); |
| 1922 | } |
| 1923 | if !pow_verify(&sub.challenge, sub.nonce.trim(), POW_BITS) { |
| 1924 | return Ok((Received::Refused(fmt!("the proof does not meet the width")), None)); |
| 1925 | } |
| 1926 | true |
| 1927 | }; |
| 1928 | |
| 1929 | let email = sub.email |
| 1930 | .map(|e| e.trim().to_lowercase()) |
| 1931 | .filter(|e| !e.is_empty()); |
| 1932 | // An address that is given must look like one; one that is not given is fine. It is never shown, |
| 1933 | // so a malformed address is only ever a reply that will not arrive -- worth refusing at the door |
| 1934 | // rather than storing something useless. |
| 1935 | if let Some(e) = &email { |
| 1936 | if !crate::srv::publish::subscribe::valid_email(e) { |
| 1937 | return Ok((Received::Refused(fmt!("that is not an address")), None)); |
| 1938 | } |
| 1939 | } |
| 1940 | |
| 1941 | let author = if name.is_empty() && email.is_none() { |
| 1942 | Identity::Anon |
| 1943 | } else { |
| 1944 | Identity::Local { |
| 1945 | name: if name.is_empty() { fmt!("Anonymous") } else { name }, |
| 1946 | email: email, |
| 1947 | } |
| 1948 | }; |
| 1949 | |
| 1950 | let c = Comment { |
| 1951 | id: mint_id(), |
| 1952 | slug: sub.slug.to_string(), |
| 1953 | // A parent is an id this module minted or it is nothing. Unchecked, it was the one field |
| 1954 | // with no bound at all: a caller could store megabytes of their own choosing per request, |
| 1955 | // since the field went straight into the record. |
| 1956 | parent: sub.parent.filter(|p| valid_id(p)), |
| 1957 | author, |
| 1958 | body, |
| 1959 | created: sub.now.clone(), |
| 1960 | state: CommentState::Pending, |
| 1961 | reason: None, |
| 1962 | // Never from the form. A submission cannot claim this, whatever it sends. |
| 1963 | by_site_author: false, |
| 1964 | from: sub.from.as_deref().map(|a| from_hash(a, salt)), |
| 1965 | }; |
| 1966 | |
| 1967 | // What is already waiting on this post. Read before anything is written, so a full queue costs a |
| 1968 | // read rather than a row. |
| 1969 | // What the sender is allowed, before the post's own ceilings are read. A refusal here costs one |
| 1970 | // read and writes nothing, which is the point of putting it first. |
| 1971 | if let Some(addr) = &sub.from { |
| 1972 | let hashed = from_hash(addr, salt); |
| 1973 | if !res!(rate_allows(db, &hashed, rate.0, rate.1)) { |
| 1974 | info!("{}: publish: a sender is commenting faster than this site allows", id); |
| 1975 | return Ok((Received::Refused(fmt!("too many comments from one sender")), None)); |
| 1976 | } |
| 1977 | } |
| 1978 | |
| 1979 | let held = res!(list_for_post(db, sub.slug, id)); |
| 1980 | // A ceiling on the whole thread, not only on what is waiting. Every comment on a post is read |
| 1981 | // back on every public view of it, so an unbounded store is an unbounded cost on every reader -- |
| 1982 | // the write is cheap and permanent and the reading of it is neither. |
| 1983 | if held.len() >= POST_MAX { |
| 1984 | info!("{}: publish: '{}' holds {} comments and is taking no more", id, sub.slug, held.len()); |
| 1985 | return Ok((Received::Refused(fmt!("the post has all the comments it will take")), None)); |
| 1986 | } |
| 1987 | let waiting = held.iter().filter(|x| x.state == CommentState::Pending).count(); |
| 1988 | if waiting >= PENDING_MAX { |
| 1989 | info!("{}: publish: '{}' has {} comments waiting and is taking no more", |
| 1990 | id, sub.slug, waiting); |
| 1991 | return Ok((Received::Refused(fmt!("the post's queue is full")), None)); |
| 1992 | } |
| 1993 | |
| 1994 | // What the site already knows about this commenter, where there is anything to know. |
| 1995 | let known = match c.author.handle() { |
| 1996 | Some(h) => res!(commenter(db, &h)), |
| 1997 | None => None, |
| 1998 | }; |
| 1999 | |
| 2000 | // Trust attaches to a handle, and a handle is an address somebody typed: nothing has proved they |
| 2001 | // own it. So it is only honoured where the sender also matches the one the trust was granted to. |
| 2002 | // An attacker who knows an approved address still has to arrive from the same place. This is a |
| 2003 | // weaker claim than a confirmed address would be, and it is stated rather than hidden: see |
| 2004 | // `Commenter::from`. |
| 2005 | let mismatch = known.as_ref().map(|k: &Commenter| { |
| 2006 | k.trusted && k.from.is_some() && k.from.as_deref() != c.from.as_deref() |
| 2007 | }).unwrap_or(false); |
| 2008 | let known = known.filter(|k| { |
| 2009 | !k.trusted || k.from.is_none() || k.from.as_deref() == c.from.as_deref() |
| 2010 | }); |
| 2011 | |
| 2012 | let mut verdict = moderator.judge(&c, known.as_ref()); |
| 2013 | |
| 2014 | // The model refines an ordinary hold, and only that. A comment the rules would publish (a trusted |
| 2015 | // commenter) or bin (a blocked one, a shape that fails) is already decided; the model is asked |
| 2016 | // only about the comment that would otherwise sit in the queue -- a stranger's first say -- and it |
| 2017 | // may publish it, bin it, or leave it waiting. Its decision replaces the rules' hold, but the |
| 2018 | // security holds below are applied *after* and strictest-wins, so the model can never carry a |
| 2019 | // comment past a missing proof of work or a trusted address arriving from a new place. A model |
| 2020 | // that is not configured, or will not answer, changes nothing: the comment simply waits, which is |
| 2021 | // what it would have done without any AI at all. |
| 2022 | if let Verdict::Hold(_) = &verdict { |
| 2023 | if let (Some(settings), Some(tls_arc)) = (ai_settings, tls.as_ref()) { |
| 2024 | if settings.ready() { |
| 2025 | match ai::judge_comment(settings, tls_arc.clone(), &c.body).await { |
| 2026 | Some(ai::CommentVerdict::Approve) => verdict = Verdict::Allow, |
| 2027 | Some(ai::CommentVerdict::Spam) => |
| 2028 | verdict = Verdict::Spam(fmt!("judged spam by the site's model")), |
| 2029 | Some(ai::CommentVerdict::Hold) => |
| 2030 | verdict = Verdict::Hold(fmt!("held for a person by the site's model")), |
| 2031 | // The model could not be reached; the comment keeps the hold it already had. |
| 2032 | None => {} |
| 2033 | } |
| 2034 | } |
| 2035 | } |
| 2036 | } |
| 2037 | |
| 2038 | // A comment claiming a commenter this site trusts, arriving from somewhere that commenter has |
| 2039 | // never used. Usually innocent -- people travel, and addresses change -- but it is also exactly |
| 2040 | // what impersonating a regular looks like, and it is the one case where a name in a queue is |
| 2041 | // worth a second look. Said plainly in the queue rather than left for a moderator to spot. |
| 2042 | if mismatch { |
| 2043 | verdict = verdict.and_then(Verdict::Hold(fmt!("{}", REASON_MISMATCH))); |
| 2044 | } |
| 2045 | // A comment with no proof is held even where the moderator would have allowed it. The proof is |
| 2046 | // what distinguishes a reader who has a browser from something that posts to a URL, and a trusted |
| 2047 | // commenter's address is exactly what a spammer would forge to skip the queue. |
| 2048 | if !proved { |
| 2049 | verdict = verdict.and_then(Verdict::Hold(fmt!("no proof of work was sent"))); |
| 2050 | } |
| 2051 | |
| 2052 | let mut stored = c; |
| 2053 | stored.state = verdict.state(); |
| 2054 | stored.reason = verdict.reason(); |
| 2055 | |
| 2056 | // Spam is stored rather than dropped: a wrong judgement must be recoverable, and what arrives is |
| 2057 | // worth being able to look at. |
| 2058 | res!(put(db, &stored)); |
| 2059 | |
| 2060 | // A new commenter with a handle is remembered now, unapproved, so the queue can show that this is |
| 2061 | // their first and so blocking them later has something to attach to. |
| 2062 | if let Some(h) = stored.author.handle() { |
| 2063 | if known.is_none() { |
| 2064 | res!(set_trust(db, &h, false, None, &sub.now)); |
| 2065 | } |
| 2066 | } |
| 2067 | |
| 2068 | // The id goes back with the answer so the caller can hand its author a token: this is the only |
| 2069 | // moment anybody can prove they wrote this, and there is no second chance to say so. Spam gets |
| 2070 | // none -- there is nothing to correct. |
| 2071 | let told = match stored.state { |
| 2072 | CommentState::Approved => Received::Published, |
| 2073 | CommentState::Spam => Received::Refused(fmt!("judged spam")), |
| 2074 | _ => Received::Held, |
| 2075 | }; |
| 2076 | let editable = stored.state != CommentState::Spam; |
| 2077 | Ok((told, if editable { Some(stored.id) } else { None })) |
| 2078 | } |
| 2079 | |
| 2080 | |
| 2081 | // How long a commenter may correct what they just wrote. Long enough to notice a typo and fix it, |
| 2082 | // short enough that the right to edit does not outlive the moment of writing. |
| 2083 | pub const EDIT_WINDOW_SECS: u64 = 900; |
| 2084 | |
| 2085 | /// A token proving the holder wrote a particular comment. |
| 2086 | /// |
| 2087 | /// One-way from the comment's id and the site's secret, so it cannot be computed by somebody who |
| 2088 | /// did not receive it, and it names exactly one comment. Handed back once, in a cookie, when the |
| 2089 | /// comment is taken. |
| 2090 | pub fn edit_token(id: &str, secret: &[u8]) -> String { |
| 2091 | let h = HashScheme::new_sha256().hash(&[id.as_bytes(), b"comment-edit", secret], []); |
| 2092 | hex(&h.as_hashform().as_vec())[..32].to_string() |
| 2093 | } |
| 2094 | |
| 2095 | /// Whether a token is the one for this comment, compared without leaking where it differs. |
| 2096 | pub fn edit_token_ok(id: &str, secret: &[u8], given: &str) -> bool { |
| 2097 | let want = edit_token(id, secret); |
| 2098 | if want.len() != given.len() { |
| 2099 | return false; |
| 2100 | } |
| 2101 | // Constant time in the length compared: a token is a secret, and an early return on the first |
| 2102 | // wrong character tells whoever is guessing how much of their guess was right. |
| 2103 | let mut diff = 0u8; |
| 2104 | for (a, b) in want.bytes().zip(given.bytes()) { |
| 2105 | diff |= a ^ b; |
| 2106 | } |
| 2107 | diff == 0 |
| 2108 | } |
| 2109 | |
| 2110 | /// Whether a comment is still within the window its author may correct it in. |
| 2111 | pub fn editable(c: &Comment, now_secs: u64) -> bool { |
| 2112 | // A comment whose stamp will not read is not editable: an unreadable time cannot be shown to be |
| 2113 | // recent, and guessing in the permissive direction would make the window unbounded. |
| 2114 | match parse_stamp_secs(&c.created) { |
| 2115 | Some(t) => now_secs.saturating_sub(t) < EDIT_WINDOW_SECS, |
| 2116 | None => false, |
| 2117 | } |
| 2118 | } |
| 2119 | |
| 2120 | /// Unix seconds from a stamp this module wrote, where it reads as one. |
| 2121 | /// |
| 2122 | /// **Strict on purpose, and not `CalClock::parse_iso`.** That delegates to a general datetime parser |
| 2123 | /// which is lenient by design -- it reads "not a time at all" as *some* time, which was caught by the |
| 2124 | /// test below. A permissive read here would hand an unbounded edit window to any comment whose stamp |
| 2125 | /// was unreadable, so this accepts exactly the shape [`now_stamp`] writes and nothing else. |
| 2126 | fn parse_stamp_secs(s: &str) -> Option<u64> { |
| 2127 | let b = s.as_bytes(); |
| 2128 | if b.len() < 19 || b[4] != b'-' || b[7] != b'-' || b[13] != b':' || b[16] != b':' { |
| 2129 | return None; |
| 2130 | } |
| 2131 | if b[10] != b'T' && b[10] != b' ' { |
| 2132 | return None; |
| 2133 | } |
| 2134 | let num = |from: usize, to: usize| -> Option<i64> { |
| 2135 | let part = s.get(from..to)?; |
| 2136 | if !part.bytes().all(|c| c.is_ascii_digit()) { |
| 2137 | return None; |
| 2138 | } |
| 2139 | part.parse::<i64>().ok() |
| 2140 | }; |
| 2141 | let (y, mo, d) = (num(0, 4)?, num(5, 7)?, num(8, 10)?); |
| 2142 | let (h, mi, sec) = (num(11, 13)?, num(14, 16)?, num(17, 19)?); |
| 2143 | if !(1..=12).contains(&mo) || !(1..=31).contains(&d) |
| 2144 | || h > 23 || mi > 59 || sec > 60 |
| 2145 | { |
| 2146 | return None; |
| 2147 | } |
| 2148 | let days = days_from_civil(y, mo, d); |
| 2149 | let secs = days * 86_400 + h * 3600 + mi * 60 + sec; |
| 2150 | if secs < 0 { None } else { Some(secs as u64) } |
| 2151 | } |
| 2152 | |
| 2153 | /// Days from the Unix epoch to a civil date, by Howard Hinnant's algorithm. |
| 2154 | /// |
| 2155 | /// Shifts the year to start in March so the leap day falls at the end of a four-century cycle, which |
| 2156 | /// is what makes the whole thing arithmetic rather than a table. |
| 2157 | fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { |
| 2158 | let y = if m <= 2 { y - 1 } else { y }; |
| 2159 | let era = if y >= 0 { y } else { y - 399 } / 400; |
| 2160 | let yoe = y - era * 400; |
| 2161 | let mp = (m + 9) % 12; |
| 2162 | let doy = (153 * mp + 2) / 5 + d - 1; |
| 2163 | let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; |
| 2164 | era * 146_097 + doe - 719_468 |
| 2165 | } |
| 2166 | |
| 2167 | /// Replaces what a comment says, keeping who wrote it and when. |
| 2168 | /// |
| 2169 | /// **An edit to a published comment returns it to the queue.** Otherwise the edit window is a |
| 2170 | /// bait-and-switch: write something agreeable, be approved, then change it to whatever you liked, |
| 2171 | /// with the site's endorsement already attached. A comment still waiting is edited in place, since |
| 2172 | /// nobody has seen it and nothing has been endorsed. |
| 2173 | pub fn edit< |
| 2174 | const UIDL: usize, |
| 2175 | UID: NumIdDat<UIDL>, |
| 2176 | ENC: Encrypter, |
| 2177 | KH: Hasher, |
| 2178 | DB: Database<UIDL, UID, ENC, KH>, |
| 2179 | >( |
| 2180 | db: &(Arc<RwLock<DB>>, UID), |
| 2181 | slug: &str, |
| 2182 | id: &str, |
| 2183 | body: &str, |
| 2184 | ) |
| 2185 | -> Outcome<bool> |
| 2186 | { |
| 2187 | let mut c = match res!(get(db, slug, id)) { |
| 2188 | Some(c) => c, |
| 2189 | None => return Ok(false), |
| 2190 | }; |
| 2191 | if !valid_body(body) { |
| 2192 | return Ok(false); |
| 2193 | } |
| 2194 | c.body = body.trim().to_string(); |
| 2195 | if c.state == CommentState::Approved { |
| 2196 | c.state = CommentState::Pending; |
| 2197 | c.reason = Some(fmt!("edited by its author after it was published")); |
| 2198 | } |
| 2199 | res!(put(db, &c)); |
| 2200 | Ok(true) |
| 2201 | } |
| 2202 | |
| 2203 | const OPEN_KEY: &str = "publish/comments-open"; |
| 2204 | |
| 2205 | /// Whether this site is taking comments, as the site itself has decided. |
| 2206 | /// |
| 2207 | /// The config's `comments` is the **starting position**, not the standing one: an operator sets it |
| 2208 | /// once when a site is built, and after that the person running the site opens and closes comments |
| 2209 | /// from the console without touching a file or restarting anything. A site that has never decided |
| 2210 | /// takes the config's word. |
| 2211 | pub fn comments_open< |
| 2212 | const UIDL: usize, |
| 2213 | UID: NumIdDat<UIDL>, |
| 2214 | ENC: Encrypter, |
| 2215 | KH: Hasher, |
| 2216 | DB: Database<UIDL, UID, ENC, KH>, |
| 2217 | >( |
| 2218 | db: Option<&(Arc<RwLock<DB>>, UID)>, |
| 2219 | from_config: bool, |
| 2220 | ) |
| 2221 | -> bool |
| 2222 | { |
| 2223 | let db = match db { |
| 2224 | Some(d) => d, |
| 2225 | None => return from_config, |
| 2226 | }; |
| 2227 | let (db_arc, _) = db; |
| 2228 | let guard = match db_arc.read() { |
| 2229 | Ok(g) => g, |
| 2230 | // A lock this cannot take is not a reason to open comments on a site that wanted them |
| 2231 | // shut, so the config's answer stands. |
| 2232 | Err(_) => return from_config, |
| 2233 | }; |
| 2234 | match guard.get(&dat!(OPEN_KEY), None) { |
| 2235 | Ok(Some((Dat::Bool(b), _))) => b, |
| 2236 | _ => from_config, |
| 2237 | } |
| 2238 | } |
| 2239 | |
| 2240 | pub fn set_comments_open< |
| 2241 | const UIDL: usize, |
| 2242 | UID: NumIdDat<UIDL>, |
| 2243 | ENC: Encrypter, |
| 2244 | KH: Hasher, |
| 2245 | DB: Database<UIDL, UID, ENC, KH>, |
| 2246 | >( |
| 2247 | db: &(Arc<RwLock<DB>>, UID), |
| 2248 | open: bool, |
| 2249 | ) |
| 2250 | -> Outcome<()> |
| 2251 | { |
| 2252 | let (db_arc, user) = db; |
| 2253 | let guard = lock_read!(db_arc); |
| 2254 | res!(guard.insert(dat!(OPEN_KEY), Dat::Bool(open), *user, None)); |
| 2255 | Ok(()) |
| 2256 | } |
| 2257 | |
| 2258 | const RATE_PREFIX: &str = "publish/comment-rate/"; |
| 2259 | |
| 2260 | |
| 2261 | /// Whether a sender may comment now, and the record of their having done so. |
| 2262 | /// |
| 2263 | /// Keyed on the **salted address hash**, not on the address they typed: an attacker varies the |
| 2264 | /// address freely and cannot as easily vary where they are. This is the one durable signal about a |
| 2265 | /// sender, and it was collected and ignored until an adversarial review pointed that out. |
| 2266 | /// |
| 2267 | /// Two bounds, because they stop different things. The interval stops a flood; the hourly count |
| 2268 | /// stops a slow drip that would otherwise never trip an interval at all. A sender with no address |
| 2269 | /// hash -- which should not happen, since the caller supplies one -- is not rate limited here, and |
| 2270 | /// is bounded by the per-post caps instead. |
| 2271 | pub fn rate_allows< |
| 2272 | const UIDL: usize, |
| 2273 | UID: NumIdDat<UIDL>, |
| 2274 | ENC: Encrypter, |
| 2275 | KH: Hasher, |
| 2276 | DB: Database<UIDL, UID, ENC, KH>, |
| 2277 | >( |
| 2278 | db: &(Arc<RwLock<DB>>, UID), |
| 2279 | from: &str, |
| 2280 | interval: u64, |
| 2281 | hourly: u32, |
| 2282 | ) |
| 2283 | -> Outcome<bool> |
| 2284 | { |
| 2285 | rate_allows_at(db, RATE_PREFIX, from, interval, hourly) |
| 2286 | } |
| 2287 | |
| 2288 | /// As [`rate_allows`], under a caller's own key prefix. |
| 2289 | /// |
| 2290 | /// One counter per thing being limited. The newsletter's sign-ups and a post's comments are |
| 2291 | /// different acts at different costs, and a reader who has just commented has not thereby spent |
| 2292 | /// their sign-up: sharing one bucket between them would make each limit depend on the other's |
| 2293 | /// traffic. |
| 2294 | pub fn rate_allows_at< |
| 2295 | const UIDL: usize, |
| 2296 | UID: NumIdDat<UIDL>, |
| 2297 | ENC: Encrypter, |
| 2298 | KH: Hasher, |
| 2299 | DB: Database<UIDL, UID, ENC, KH>, |
| 2300 | >( |
| 2301 | db: &(Arc<RwLock<DB>>, UID), |
| 2302 | prefix: &str, |
| 2303 | from: &str, |
| 2304 | interval: u64, |
| 2305 | hourly: u32, |
| 2306 | ) |
| 2307 | -> Outcome<bool> |
| 2308 | { |
| 2309 | // Both off: the site has decided its readers share addresses, or that the per-post ceilings are |
| 2310 | // bound enough. Nothing is read and nothing is written. |
| 2311 | if interval == 0 && hourly == 0 { |
| 2312 | return Ok(true); |
| 2313 | } |
| 2314 | let now = std::time::SystemTime::now() |
| 2315 | .duration_since(std::time::UNIX_EPOCH) |
| 2316 | .map(|d| d.as_secs()) |
| 2317 | .unwrap_or(0); |
| 2318 | let key = dat!(fmt!("{}{}", prefix, from)); |
| 2319 | |
| 2320 | let (db_arc, user) = db; |
| 2321 | let (last, count, window) = { |
| 2322 | let guard = lock_read!(db_arc); |
| 2323 | match res!(guard.get(&key, None)) { |
| 2324 | Some((Dat::List(v), _)) if v.len() == 3 => { |
| 2325 | let n = |i: usize| match v.get(i) { |
| 2326 | Some(Dat::U64(x)) => *x, |
| 2327 | _ => 0, |
| 2328 | }; |
| 2329 | (n(0), n(1) as u32, n(2)) |
| 2330 | } |
| 2331 | _ => (0, 0, 0), |
| 2332 | } |
| 2333 | }; |
| 2334 | |
| 2335 | // A new hour resets the count. The window is the hour the first of them landed in, not a |
| 2336 | // rolling one: a rolling window needs every timestamp kept, and this needs three numbers. |
| 2337 | let (count, window) = if now.saturating_sub(window) >= 3600 { |
| 2338 | (0, now) |
| 2339 | } else { |
| 2340 | (count, window) |
| 2341 | }; |
| 2342 | |
| 2343 | if (interval > 0 && now.saturating_sub(last) < interval) |
| 2344 | || (hourly > 0 && count >= hourly) |
| 2345 | { |
| 2346 | return Ok(false); |
| 2347 | } |
| 2348 | |
| 2349 | let guard = lock_read!(db_arc); |
| 2350 | res!(guard.insert( |
| 2351 | key, |
| 2352 | Dat::List(vec![dat!(now), dat!((count + 1) as u64), dat!(window)]), |
| 2353 | *user, |
| 2354 | None, |
| 2355 | )); |
| 2356 | Ok(true) |
| 2357 | } |
| 2358 | |
| 2359 | const SECRET_KEY: &str = "publish/comment-secret"; |
| 2360 | |
| 2361 | const SECRET_LEN: usize = 32; |
| 2362 | |
| 2363 | /// The site's own comment secret, made once and kept. |
| 2364 | /// |
| 2365 | /// Used for two things that must not be guessable and must be *stable*: the proof-of-work challenge, |
| 2366 | /// and the salt a sender's address is hashed with. Domain-separated at each use, so the same bytes |
| 2367 | /// serve both without either becoming an oracle for the other. |
| 2368 | /// |
| 2369 | /// **Stored rather than per-process** for a plain reason: a challenge that changed on restart would |
| 2370 | /// refuse every comment written against a form fetched before it, and the reader would have done the |
| 2371 | /// work for nothing. A site's secret outlives its process. |
| 2372 | pub fn site_secret< |
| 2373 | const UIDL: usize, |
| 2374 | UID: NumIdDat<UIDL>, |
| 2375 | ENC: Encrypter, |
| 2376 | KH: Hasher, |
| 2377 | DB: Database<UIDL, UID, ENC, KH>, |
| 2378 | >( |
| 2379 | db: &(Arc<RwLock<DB>>, UID), |
| 2380 | ) |
| 2381 | -> Outcome<Vec<u8>> |
| 2382 | { |
| 2383 | let (db_arc, user) = db; |
| 2384 | { |
| 2385 | let guard = lock_read!(db_arc); |
| 2386 | if let Some((Dat::BU8(bytes), _)) = res!(guard.get(&dat!(SECRET_KEY), None)) { |
| 2387 | if bytes.len() == SECRET_LEN { |
| 2388 | return Ok(bytes); |
| 2389 | } |
| 2390 | } |
| 2391 | } |
| 2392 | let mut fresh = vec![0u8; SECRET_LEN]; |
| 2393 | Rand::fill_u8(&mut fresh); |
| 2394 | let guard = lock_read!(db_arc); |
| 2395 | res!(guard.insert(dat!(SECRET_KEY), Dat::BU8(fresh.clone()), *user, None)); |
| 2396 | Ok(fresh) |
| 2397 | } |
| 2398 | |
| 2399 | /// An ISO timestamp for now. |
| 2400 | /// |
| 2401 | /// A comment's arrival is a real instant rather than a date somebody chose, so it is stamped from the |
| 2402 | /// clock here rather than taken from anything a sender supplied. |
| 2403 | pub fn now_stamp() -> String { |
| 2404 | use oxedyne_fe2o3_datime::time::CalClock; |
| 2405 | match CalClock::now_utc() { |
| 2406 | Ok(t) => t.to_string(), |
| 2407 | // A clock that will not read is not a reason to lose a comment. An empty stamp sorts first |
| 2408 | // and is visibly wrong in the queue, which is the right way for this to fail. |
| 2409 | Err(_) => String::new(), |
| 2410 | } |
| 2411 | } |