feat: add smolboid daemon: mail loop, feed store and HTTP listener
This commit is contained in:
parent
fd3f9bcf24
commit
8330b65ff9
8 changed files with 1981 additions and 0 deletions
290
daemon/src/dispatch.rs
Normal file
290
daemon/src/dispatch.rs
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
//! Mail-to-action translation (PLAN.md, Management mail): a post mail is
|
||||
//! bare text or a block whose only field is Reply-To; a command mail opens a
|
||||
//! §5.5 block. Pure — the caller owns admission, retention and error replies.
|
||||
|
||||
use smolboid_core::feed::MailLine;
|
||||
use smolboid_core::rules::validate_text;
|
||||
use smolboid_core::time;
|
||||
use smolboid_core::{repost_body, reply_to_body};
|
||||
|
||||
use crate::state::PruneMode;
|
||||
|
||||
/// One fetched mail, opened and verified by the caller.
|
||||
pub struct Incoming {
|
||||
pub sender: [u8; 32],
|
||||
pub epk: [u8; 32],
|
||||
pub sig: [u8; 64],
|
||||
pub time: i64,
|
||||
pub body: Vec<u8>,
|
||||
}
|
||||
|
||||
pub enum Action {
|
||||
Post {
|
||||
timestamp: String,
|
||||
text: String,
|
||||
/// The Reply-To value the body carried, when the ordinary-send block
|
||||
/// form was used; bound in the feed's `smol:reply_to` metadata.
|
||||
reply_to: Option<String>,
|
||||
mail: MailLine,
|
||||
},
|
||||
Repost {
|
||||
timestamp: String,
|
||||
args: String,
|
||||
reply_to: Option<String>,
|
||||
mail: MailLine,
|
||||
},
|
||||
Commands(Vec<Command>),
|
||||
/// Nothing to do: recognized by the rules as benign, never published.
|
||||
Ignore,
|
||||
}
|
||||
|
||||
pub enum Command {
|
||||
Nick(String),
|
||||
Delete(String),
|
||||
Prune(PruneMode),
|
||||
}
|
||||
|
||||
/// The `Twt-` frontmatter fields a command mail may carry; unknown keys are
|
||||
/// preserved upstream and never alter behaviour (SPEC.md §5.5).
|
||||
const F_NICK: &str = "twt-nick";
|
||||
const F_DELETE: &str = "twt-delete";
|
||||
const F_REPOST: &str = "twt-repost";
|
||||
const F_PRUNE: &str = "twt-prune";
|
||||
const F_REPLY_TO: &str = "reply-to";
|
||||
|
||||
/// Fails closed: a block with unreconstructible fields is never published.
|
||||
pub fn dispatch(incoming: &Incoming) -> Result<Action, String> {
|
||||
let text = std::str::from_utf8(&incoming.body).map_err(|_| "body is not UTF-8".to_owned())?;
|
||||
|
||||
if !text.starts_with("---\n") {
|
||||
// The bare post form: the body is the twt, byte for byte.
|
||||
return post(incoming, text, None);
|
||||
}
|
||||
|
||||
let (fields, rest) = fumi_core::message::parse_frontmatter(text);
|
||||
if fields.is_empty() {
|
||||
// An empty or malformed block: nothing reliable, nothing published.
|
||||
return Ok(Action::Ignore);
|
||||
}
|
||||
|
||||
let reply_to = fields.get(F_REPLY_TO).cloned();
|
||||
let beyond_reply_to: Vec<&String> = fields
|
||||
.keys()
|
||||
.filter(|k| k.as_str() != F_REPLY_TO)
|
||||
.collect();
|
||||
|
||||
// A post in the ordinary-send form: a block whose only field is Reply-To.
|
||||
if beyond_reply_to.is_empty() {
|
||||
if let Some(reply_to) = &reply_to {
|
||||
if text != reply_to_body(reply_to, &rest) {
|
||||
return Err("post mail is not in canonical form".to_owned());
|
||||
}
|
||||
return post(incoming, &rest, Some(reply_to.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
// A repost: the Twt-Repost field, optionally with the same Reply-To an
|
||||
// ordinary send carries; both must reconstruct byte-exactly.
|
||||
if fields.len() <= 2 {
|
||||
if let Some(args) = fields.get(F_REPOST) {
|
||||
let canonical_no = repost_body(args, None);
|
||||
let canonical_yes = repost_body(args, reply_to.as_deref());
|
||||
if text == canonical_no || text == canonical_yes {
|
||||
validate_text(args).map_err(|e| e.to_string())?;
|
||||
return Ok(Action::Repost {
|
||||
timestamp: time::render(incoming.time),
|
||||
args: args.clone(),
|
||||
reply_to,
|
||||
mail: MailLine {
|
||||
sender: incoming.sender,
|
||||
epk: incoming.epk,
|
||||
sig: incoming.sig,
|
||||
},
|
||||
});
|
||||
}
|
||||
return Err("repost mail is not in canonical form".to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
// A block with extra fields and no Twt field: the author plainly meant to
|
||||
// publish, and silence would hide the problem.
|
||||
if !fields.contains_key(F_NICK)
|
||||
&& !fields.contains_key(F_DELETE)
|
||||
&& !fields.contains_key(F_PRUNE)
|
||||
&& !fields.contains_key(F_REPOST)
|
||||
{
|
||||
return Err(
|
||||
"post mail carries fields beyond Reply-To (Subject, headers or an Accept token); \
|
||||
resend the bare text"
|
||||
.to_owned(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut commands = Vec::new();
|
||||
if let Some(nick) = fields.get(F_NICK) {
|
||||
if !crate::feedstore::valid_username(nick) {
|
||||
return Err("nick must be 1-30 bytes of a-z, 0-9 or hyphen".to_owned());
|
||||
}
|
||||
commands.push(Command::Nick(nick.clone()));
|
||||
}
|
||||
if let Some(timestamp) = fields.get(F_DELETE) {
|
||||
if time::parse(timestamp).is_none() {
|
||||
return Err("Twt-Delete needs an RFC 3339 UTC timestamp".to_owned());
|
||||
}
|
||||
commands.push(Command::Delete(timestamp.clone()));
|
||||
}
|
||||
if let Some(mode) = fields.get(F_PRUNE) {
|
||||
commands.push(Command::Prune(match mode.as_str() {
|
||||
"fifo" => PruneMode::Fifo,
|
||||
"off" => PruneMode::Off,
|
||||
other => return Err(format!("unknown prune mode `{other}`, want fifo or off")),
|
||||
}));
|
||||
}
|
||||
// A mixed mail is a command mail; the body is ignored, never posted.
|
||||
if commands.is_empty() {
|
||||
Ok(Action::Ignore)
|
||||
} else {
|
||||
Ok(Action::Commands(commands))
|
||||
}
|
||||
}
|
||||
|
||||
fn post(incoming: &Incoming, text: &str, reply_to: Option<String>) -> Result<Action, String> {
|
||||
validate_text(text).map_err(|e| e.to_string())?;
|
||||
Ok(Action::Post {
|
||||
timestamp: time::render(incoming.time),
|
||||
text: text.to_owned(),
|
||||
reply_to,
|
||||
mail: MailLine {
|
||||
sender: incoming.sender,
|
||||
epk: incoming.epk,
|
||||
sig: incoming.sig,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn incoming(body: &str) -> Incoming {
|
||||
Incoming {
|
||||
sender: [1; 32],
|
||||
epk: [2; 32],
|
||||
sig: [3; 64],
|
||||
time: 1_760_000_000,
|
||||
body: body.as_bytes().to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_body_is_a_post() {
|
||||
let Action::Post { timestamp, text, reply_to, mail } = dispatch(&incoming("hello smolverse")).unwrap() else {
|
||||
panic!("expected a post");
|
||||
};
|
||||
assert_eq!(timestamp, "2025-10-09T08:53:20Z");
|
||||
assert_eq!(text, "hello smolverse");
|
||||
assert!(reply_to.is_none());
|
||||
assert_eq!(mail.sender, [1; 32]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_send_form_is_a_post() {
|
||||
// What `fumi send --body x` emits: a block with only Reply-To.
|
||||
let body = "---\nReply-To: smol://tobias@smol.place/k\n---\nhello smolverse";
|
||||
let Action::Post { text, reply_to, .. } = dispatch(&incoming(body)).unwrap() else {
|
||||
panic!("expected a post");
|
||||
};
|
||||
assert_eq!(text, "hello smolverse");
|
||||
assert_eq!(reply_to.as_deref(), Some("smol://tobias@smol.place/k"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dashed_body_is_still_a_post() {
|
||||
// A single line can never open a block, so nothing is escaped.
|
||||
let Action::Post { text, .. } = dispatch(&incoming("--- dashed opening")).unwrap() else {
|
||||
panic!("expected a post");
|
||||
};
|
||||
assert_eq!(text, "--- dashed opening");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_rules_are_enforced_with_reasons() {
|
||||
assert!(dispatch(&incoming(&"a".repeat(161))).is_err());
|
||||
assert!(dispatch(&incoming("a\tb")).is_err());
|
||||
let bad = Incoming { body: vec![0xff, 0xfe], ..incoming("") };
|
||||
assert!(dispatch(&bad).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_fields_make_a_rejected_post() {
|
||||
// A Subject, or the Accept token of an accepted recipient.
|
||||
assert!(dispatch(&incoming("---\nReply-To: smol://a/b\nSubject: hi\n---\nbody\n")).is_err());
|
||||
assert!(dispatch(&incoming("---\nAccept: token\n---\nbody\n")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_repost_command_publishes() {
|
||||
let args = "@<dana https://smol.place/twtxt/dana.txt> #abc1234";
|
||||
let no_reply_to = format!("---\nTwt-Repost: {args}\n---\n");
|
||||
let Action::Repost { args: got, reply_to, mail, .. } =
|
||||
dispatch(&incoming(&no_reply_to)).unwrap() else {
|
||||
panic!("expected a repost");
|
||||
};
|
||||
assert_eq!(got, args);
|
||||
assert!(reply_to.is_none());
|
||||
assert_eq!(mail.sig, [3; 64]);
|
||||
|
||||
// The ordinary-send form, Twt-Repost before Reply-To as fumi emits.
|
||||
let with_reply_to = format!("---\nTwt-Repost: {args}\nReply-To: smol://tobias@s/k\n---\n");
|
||||
let Action::Repost { args: got, reply_to, .. } =
|
||||
dispatch(&incoming(&with_reply_to)).unwrap() else {
|
||||
panic!("expected a repost");
|
||||
};
|
||||
assert_eq!(got, args);
|
||||
assert_eq!(reply_to.as_deref(), Some("smol://tobias@s/k"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noncanonical_reposts_are_rejected() {
|
||||
let args = "@<d u> #abc";
|
||||
assert!(dispatch(&incoming(&format!("---\nTwt-Repost: {args} \n---\n"))).is_err());
|
||||
assert!(dispatch(&incoming(&format!("---\nTwt-Repost: {args}\n---\nextra\n"))).is_err());
|
||||
assert!(dispatch(&incoming(&format!(
|
||||
"---\nTwt-Repost: {args}\nTwt-Nick: x\n---\n"
|
||||
)))
|
||||
.is_err());
|
||||
// Reply-To after the closing marker, wrong order relative to the
|
||||
// canonical forms.
|
||||
assert!(dispatch(&incoming(&format!(
|
||||
"---\nReply-To: smol://a/b\nTwt-Repost: {args}\n---\n"
|
||||
)))
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_mails_apply_all_recognized_fields() {
|
||||
let body = "---\nTwt-Nick: toby\nTwt-Prune: fifo\nSubject: ignored\n---\nignored body\n";
|
||||
let Action::Commands(cmds) = dispatch(&incoming(body)).unwrap() else {
|
||||
panic!("expected commands");
|
||||
};
|
||||
assert_eq!(cmds.len(), 2);
|
||||
assert!(matches!(cmds[0], Command::Nick(ref n) if n == "toby"));
|
||||
assert!(matches!(cmds[1], Command::Prune(PruneMode::Fifo)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_values_are_validated() {
|
||||
assert!(dispatch(&incoming("---\nTwt-Nick: Bad Nick\n---\n")).is_err());
|
||||
assert!(dispatch(&incoming("---\nTwt-Delete: not-a-date\n---\n")).is_err());
|
||||
assert!(dispatch(&incoming("---\nTwt-Prune: sometimes\n---\n")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odd_mail_shapes_fail_closed_to_ignore() {
|
||||
// Malformed block, empty escape block, only unknown fields with a
|
||||
// Twt- field absent — but pure unknown-field blocks are rejected
|
||||
// as posts, so the ignore set is the malformed and empty ones.
|
||||
assert!(matches!(dispatch(&incoming("---\nno colon here\n---\n")), Ok(Action::Ignore)));
|
||||
assert!(matches!(dispatch(&incoming("---\n---\n")), Ok(Action::Ignore)));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue