Oregami
Repositories/oxedyne/fe2o3

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
32use crate::srv::publish::{
33 Markup,
34 ai,
35 parse_markup,
36};
37
38use oxedyne_fe2o3_core::prelude::*;
39use oxedyne_fe2o3_core::rand::Rand;
40use oxedyne_fe2o3_hash::hash::HashScheme;
41use oxedyne_fe2o3_iop_crypto::enc::Encrypter;
42use oxedyne_fe2o3_iop_db::api::{
43 Database,
44 ScanOpts,
45};
46use oxedyne_fe2o3_iop_hash::api::Hasher;
47use oxedyne_fe2o3_jdat::{
48 prelude::*,
49 id::NumIdDat,
50};
51
52use std::sync::{
53 Arc,
54 RwLock,
55};
56
57use 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.
63pub 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`.
67pub 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.
72pub const BODY_MAX: usize = 8_000;
73
74pub 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.
80pub 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.
88pub 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.
94pub const POST_MAX: usize = 1_000;
95
96const ID_LEN: usize = 16;
97const ID_ALPHABET: &str = "abcdefghijklmnopqrstuvwxyz0123456789";
98
99
100// ┌───────────────────────────────────────────────────────────────────────────┐
101// │ MODEL │
102// └───────────────────────────────────────────────────────────────────────────┘
103
104/// Where a comment stands.
105#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
106pub 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
116impl 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)]
156pub 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
173impl Default for Identity {
174 fn default() -> Self { Self::Anon }
175}
176
177impl 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)]
261pub 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
284impl 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)]
364pub 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
379impl 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.
425pub 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
432pub 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.
438pub 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
443pub fn mint_id() -> String {
444 Rand::generate_random_string(ID_LEN, ID_ALPHABET)
445}
446
447fn key_of(slug: &str, id: &str) -> Dat {
448 dat!(fmt!("{}{}/{}", KEY_PREFIX, slug, id))
449}
450
451fn post_prefix(slug: &str) -> String {
452 fmt!("{}{}/", KEY_PREFIX, slug)
453}
454
455fn 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.
469pub 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.
478pub 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.
484pub 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.
490pub 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
498pub 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.
504pub 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.
510pub 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
520fn 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
533fn 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.
546pub 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.
555pub 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)]
570pub 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
576impl 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".
615pub 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)]
624pub enum Moderator {
625 Rules(Rules), // what the sender proved, what they wrote, whether the site knows them
626}
627
628impl Default for Moderator {
629 fn default() -> Self { Self::Rules(Rules::default()) }
630}
631
632impl 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)]
643pub 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
650impl Default for Rules {
651 fn default() -> Self {
652 Self {
653 link_limit: 2,
654 trust_returning: true,
655 }
656 }
657}
658
659impl 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.
702pub 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)]
713pub enum Ranker {
714 #[default]
715 Chronological, // oldest first, the order a conversation happened in
716 Recent, // newest first
717}
718
719impl 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
734pub 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
752pub 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.
778pub 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.
822pub 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.
849pub 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
898pub 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.
919pub 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.
955pub 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
982pub 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.
1003pub 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
1041pub 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)]
1079pub 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.
1090pub 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
1114fn 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.
1141fn 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.
1158pub const PAGE_THREADS: usize = 25;
1159
1160/// One page of a threaded conversation, and how many pages there are.
1161pub 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
1169pub fn count_threads(threads: &[Thread]) -> usize {
1170 threads.iter().map(|t| 1 + count_threads(&t.replies)).sum()
1171}
1172
1173
1174#[cfg(test)]
1175mod 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 ![tracker](https://tracker.example/p.gif)");
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 /// &#x9;&#x9;&#x9;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.
1796pub 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
1802impl 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.
1821pub 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.
1840pub 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.
1875pub 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.
2083pub 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.
2090pub 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.
2096pub 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.
2111pub 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.
2126fn 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.
2157fn 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.
2173pub 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
2203const 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.
2211pub 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
2240pub 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
2258const 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.
2271pub 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.
2294pub 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
2359const SECRET_KEY: &str = "publish/comment-secret";
2360
2361const 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.
2372pub 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.
2403pub 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}