Compare commits
6 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d9ec1e773 | ||
|
|
801c66d3b0 | ||
|
|
dd6865782a | ||
|
|
83125a7d3f | ||
|
|
db8aee2949 | ||
|
|
72c353cb5a |
17 changed files with 452 additions and 222 deletions
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
# Build output
|
||||||
|
/target
|
||||||
|
.devbox/
|
||||||
|
result
|
||||||
|
result-*
|
||||||
|
|
||||||
|
# The release job sources FORGEJO_TOKEN from here. Never commit it.
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Identity and feed state the daemon writes at runtime, and whatever the
|
||||||
|
# test rigs leave in a checkout.
|
||||||
|
*.key
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
state.json
|
||||||
|
smolboid.toml
|
||||||
4
Cargo.lock
generated
4
Cargo.lock
generated
|
|
@ -316,7 +316,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fumi-core"
|
name = "fumi-core"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chacha20poly1305",
|
"chacha20poly1305",
|
||||||
"data-encoding",
|
"data-encoding",
|
||||||
|
|
@ -652,7 +652,7 @@ checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "smolboid"
|
name = "smolboid"
|
||||||
version = "0.2.0"
|
version = "0.2.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fluent-bundle",
|
"fluent-bundle",
|
||||||
"fumi-core",
|
"fumi-core",
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ poll_secs = 60 # fetch interval
|
||||||
timeout = 30 # smolmail network timeout, seconds
|
timeout = 30 # smolmail network timeout, seconds
|
||||||
posts_per_day = 50 # per-feed daily quota
|
posts_per_day = 50 # per-feed daily quota
|
||||||
reposts_per_day = 50
|
reposts_per_day = 50
|
||||||
|
max_post_chars = 288 # longest post, in characters (code points)
|
||||||
cap_bytes = 1048576 # per-feed size cap, default 1 MiB
|
cap_bytes = 1048576 # per-feed size cap, default 1 MiB
|
||||||
registration = silent # open | closed | silent — see Admission
|
registration = silent # open | closed | silent — see Admission
|
||||||
registrations_per_hour = 10 # global cap on attempts that reach the network
|
registrations_per_hour = 10 # global cap on attempts that reach the network
|
||||||
|
|
@ -162,7 +163,7 @@ One command per mail. Two of them together, or one mixed with another, is refuse
|
||||||
fumi send twtxt@smol.place --header Twt-Delete:2026-10-01T12:47:00Z --body ""
|
fumi send twtxt@smol.place --header Twt-Delete:2026-10-01T12:47:00Z --body ""
|
||||||
```
|
```
|
||||||
|
|
||||||
The rules the daemon enforces: text is at most 160 Unicode code points and 640 bytes and may not contain TAB, LF, CR or U+2028; the timestamp is the mail's own time, pinned by the signature, so a same-second resend is rejected with a reply — wait a second and resend; at 90% of the size cap the daemon mails a warning naming the cap and both prune modes. Error replies are subject to the address's rules: at most ten per sender per hour, never twice for the same rejected payload.
|
The rules the daemon enforces: text is at most `max_post_chars` characters (288 by default; twtxt itself sets no hard limit) and may not contain control characters such as TAB, LF or CR, as twtxt requires, while U+2028 is allowed as the Multiline extension's line break; the timestamp is the mail's own time, pinned by the signature, so a same-second resend is rejected with a reply — wait a second and resend; at 90% of the size cap the daemon mails a warning naming the cap and both prune modes. Error replies are subject to the address's rules: at most ten per sender per hour, never twice for the same rejected payload.
|
||||||
|
|
||||||
## What a feed looks like
|
## What a feed looks like
|
||||||
|
|
||||||
|
|
|
||||||
2
audit.md
2
audit.md
|
|
@ -19,7 +19,7 @@ The reader of a feed verifies the author's own §5.3 payload signature, rebuilt
|
||||||
|
|
||||||
## Security properties, each with its enforcement point and proof
|
## Security properties, each with its enforcement point and proof
|
||||||
|
|
||||||
- **Feed-line injection is impossible.** Post and repost text is rejected on TAB, LF, CR or U+2028 (post rule 4, `rules.rs`), so a record can never introduce a second line; a text posing as `# smol:mail` or `# smol:repost` publishes only as timestamped post text. Proven by the pentest's injection and poser probes.
|
- **Feed-line injection is impossible.** Post and repost text is rejected on any control character, TAB, LF and CR included (post rule 3, `rules.rs`), so a record can never introduce a second line; a text posing as `# smol:mail` or `# smol:repost` publishes only as timestamped post text. Proven by the pentest's injection and poser probes.
|
||||||
- **The feed path cannot escape its directory.** A slug is validated before any filesystem access (`feedstore::valid_slug`), and the join is then checked to have stayed inside `feeds/` — so traversal, encoding, null-byte, unicode and dotfile forms all 404 without touching a file outside it. Proven by nine pentest path probes, a reject table and an HTTP property test.
|
- **The feed path cannot escape its directory.** A slug is validated before any filesystem access (`feedstore::valid_slug`), and the join is then checked to have stayed inside `feeds/` — so traversal, encoding, null-byte, unicode and dotfile forms all 404 without touching a file outside it. Proven by nine pentest path probes, a reject table and an HTTP property test.
|
||||||
|
|
||||||
The grammar widened when remote authors got host-namespaced slugs (`alice@other.org`), and that changed the guard's character rather than just its range: a path could not previously contain a dot, which is *why* traversal was unreachable. Dots are legal now, so `..`, adjacent separators, uppercase, `%`, `/` and empty DNS labels are each refused explicitly. The nick rule (`valid_username`, 1-30 bytes of `[a-z0-9-]`) is deliberately a separate function and was left untouched, because a nick reaches `# nick =` metadata that other twtxt clients parse; a test asserts the two have not been conflated.
|
The grammar widened when remote authors got host-namespaced slugs (`alice@other.org`), and that changed the guard's character rather than just its range: a path could not previously contain a dot, which is *why* traversal was unreachable. Dots are legal now, so `..`, adjacent separators, uppercase, `%`, `/` and empty DNS labels are each refused explicitly. The nick rule (`valid_username`, 1-30 bytes of `[a-z0-9-]`) is deliberately a separate function and was left untouched, because a nick reaches `# nick =` metadata that other twtxt clients parse; a test asserts the two have not been conflated.
|
||||||
|
|
|
||||||
|
|
@ -228,7 +228,7 @@ impl Feed {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Appends a record and its signature line, post rule 5's append-only
|
/// Appends a record and its signature line, post rule 4's append-only
|
||||||
/// half. The caller has validated the record and checked uniqueness.
|
/// half. The caller has validated the record and checked uniqueness.
|
||||||
pub fn append(&mut self, record: Record, mail: Option<MailLine>) {
|
pub fn append(&mut self, record: Record, mail: Option<MailLine>) {
|
||||||
let mail_item = mail.map(Item::Mail);
|
let mail_item = mail.map(Item::Mail);
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,6 @@ pub mod verify;
|
||||||
|
|
||||||
pub use envelope::{open, Opened};
|
pub use envelope::{open, Opened};
|
||||||
pub use feed::{Entry, Feed, MailLine, Record};
|
pub use feed::{Entry, Feed, MailLine, Record};
|
||||||
pub use rules::{validate_text, validate_timestamp, RuleError, MAX_BYTES, MAX_CODEPOINTS};
|
pub use rules::{validate_text, validate_timestamp, RuleError};
|
||||||
pub use time::{parse as parse_timestamp, render as render_timestamp};
|
pub use time::{parse as parse_timestamp, render as render_timestamp};
|
||||||
pub use verify::{reply_to_body, repost_body, signed_bytes, verify, Report, State};
|
pub use verify::{reply_to_body, repost_body, signed_bytes, verify, Report, State};
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,8 @@
|
||||||
//! Post rules 1, 3 and 4 from PLAN.md, enforced at receipt by the daemon and
|
//! Post rules 1 and 3 from PLAN.md, enforced at receipt by the daemon and
|
||||||
//! usable by any author-side tool.
|
//! usable by any author-side tool.
|
||||||
|
|
||||||
pub const MAX_CODEPOINTS: usize = 160;
|
|
||||||
pub const MAX_BYTES: usize = 640;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum RuleError {
|
pub enum RuleError {
|
||||||
TooManyCodepoints,
|
|
||||||
TooManyBytes,
|
|
||||||
ForbiddenChar,
|
ForbiddenChar,
|
||||||
BadTimestamp,
|
BadTimestamp,
|
||||||
DuplicateTimestamp,
|
DuplicateTimestamp,
|
||||||
|
|
@ -16,9 +11,7 @@ pub enum RuleError {
|
||||||
impl std::fmt::Display for RuleError {
|
impl std::fmt::Display for RuleError {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
let msg = match self {
|
let msg = match self {
|
||||||
RuleError::TooManyCodepoints => "more than 160 Unicode code points",
|
RuleError::ForbiddenChar => "contains a control character",
|
||||||
RuleError::TooManyBytes => "more than 640 bytes",
|
|
||||||
RuleError::ForbiddenChar => "contains TAB, LF, CR or U+2028",
|
|
||||||
RuleError::BadTimestamp => "not an RFC 3339 UTC timestamp at second precision",
|
RuleError::BadTimestamp => "not an RFC 3339 UTC timestamp at second precision",
|
||||||
RuleError::DuplicateTimestamp => "timestamp already exists in this feed",
|
RuleError::DuplicateTimestamp => "timestamp already exists in this feed",
|
||||||
};
|
};
|
||||||
|
|
@ -28,18 +21,13 @@ impl std::fmt::Display for RuleError {
|
||||||
|
|
||||||
impl std::error::Error for RuleError {}
|
impl std::error::Error for RuleError {}
|
||||||
|
|
||||||
/// Rules 3 and 4: at most 160 code points and 640 bytes, and no TAB, LF, CR
|
/// Rule 3: no control characters, as twtxt requires. TAB, LF and CR are among
|
||||||
/// or U+2028 — any of those would break the one-record-per-line invariant.
|
/// them, so a record can never break the one-record-per-line invariant. Length
|
||||||
/// The byte cap is mathematically implied by the codepoint cap over UTF-8 but
|
/// is an instance policy, so the daemon caps it, not this rule. U+2028
|
||||||
/// is kept as an explicit guard.
|
/// is not a control character and stays allowed: it is the twtxt Multiline
|
||||||
|
/// extension's line break.
|
||||||
pub fn validate_text(text: &str) -> Result<(), RuleError> {
|
pub fn validate_text(text: &str) -> Result<(), RuleError> {
|
||||||
if text.chars().count() > MAX_CODEPOINTS {
|
if text.chars().any(char::is_control) {
|
||||||
return Err(RuleError::TooManyCodepoints);
|
|
||||||
}
|
|
||||||
if text.len() > MAX_BYTES {
|
|
||||||
return Err(RuleError::TooManyBytes);
|
|
||||||
}
|
|
||||||
if text.contains(['\t', '\n', '\r', '\u{2028}']) {
|
|
||||||
return Err(RuleError::ForbiddenChar);
|
return Err(RuleError::ForbiddenChar);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -64,24 +52,14 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_over_length_text() {
|
fn accepts_long_and_multiline_text() {
|
||||||
let chars: String = std::iter::repeat('a').take(161).collect();
|
assert_eq!(validate_text(&"🙂".repeat(1000)), Ok(()));
|
||||||
assert_eq!(validate_text(&chars), Err(RuleError::TooManyCodepoints));
|
assert_eq!(validate_text("first\u{2028}second"), Ok(()));
|
||||||
|
|
||||||
// 160 four-byte code points sit exactly on both caps.
|
|
||||||
let four_byte: String = "🙂".repeat(160);
|
|
||||||
assert_eq!(validate_text(&four_byte), Ok(()));
|
|
||||||
let over: String = "🙂".repeat(161);
|
|
||||||
assert_eq!(
|
|
||||||
validate_text(&over),
|
|
||||||
Err(RuleError::TooManyCodepoints),
|
|
||||||
"codepoints are checked before bytes"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_record_breaking_characters() {
|
fn rejects_control_characters() {
|
||||||
for bad in ["a\tb", "a\nb", "a\rb", "a\u{2028}b"] {
|
for bad in ["a\tb", "a\nb", "a\rb", "a\u{0}b", "a\u{1b}[31mb", "a\u{85}b"] {
|
||||||
assert_eq!(validate_text(bad), Err(RuleError::ForbiddenChar));
|
assert_eq!(validate_text(bad), Err(RuleError::ForbiddenChar));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "smolboid"
|
name = "smolboid"
|
||||||
version = "0.2.0"
|
version = "0.2.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "smolboid daemon: twtxt feeds published by smolmail"
|
description = "smolboid daemon: twtxt feeds published by smolmail"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
|
|
|
||||||
|
|
@ -1,157 +1,200 @@
|
||||||
## Loaded once by `src/i18n.rs`; every string the daemon mails to an author
|
## Every string the daemon mails to an author. Operator CLI output is not
|
||||||
## lives here, grouped by the mail it belongs to. Operator-facing CLI output is
|
## here, deliberately: see the i18n module header.
|
||||||
## deliberately not here — see the module header.
|
|
||||||
##
|
##
|
||||||
## Keep the `-32` style placeables out: these bodies are read in plain-text
|
## House style, in short. Write to "you" and use contractions. Sentence case.
|
||||||
## mail clients and on phones, so they stay short lines and no markup.
|
## Start with a verb where you can. Say what happened and what to do next,
|
||||||
|
## without blaming anyone and without the words invalid, illegal or fatal.
|
||||||
|
## Use the words the clients use: "header", not frontmatter field; "Requests
|
||||||
|
## folder", not requests tier; "post", not record, twt or entry. No em dashes.
|
||||||
|
##
|
||||||
|
## These are read in plain-text mail clients and on phones, so keep lines
|
||||||
|
## short and leave markup out.
|
||||||
|
|
||||||
## Error replies — a mail the daemon could not apply
|
## Replies about a message we couldn't publish
|
||||||
|
|
||||||
reply-subject = twtxt: { $reason }
|
reply-subject = twtxt: { $reason }
|
||||||
reply-body =
|
reply-body =
|
||||||
Your mail could not be applied.
|
{ $reason }
|
||||||
|
|
||||||
Reason: { $reason }
|
Here's the message:
|
||||||
|
|
||||||
Quoted mail:
|
|
||||||
{ $quoted }
|
{ $quoted }
|
||||||
|
|
||||||
## Retention — the one-time warning as a feed approaches its size cap
|
## Reasons. Each one is a whole sentence: it becomes the subject line, and
|
||||||
|
## the body opens with it.
|
||||||
|
##
|
||||||
|
## Each MUST be a single line, however long. A reason goes into a mail header,
|
||||||
|
## and a line break there breaks the whole header block, so the reply arrives
|
||||||
|
## with no subject at all. Fluent keeps the newlines in a wrapped value, so
|
||||||
|
## there's no safe way to wrap these. `every_reason_is_a_single_line` checks
|
||||||
|
## it. When the fix needs more room, put it in a detail paragraph below.
|
||||||
|
|
||||||
cap-warning-subject = twtxt: feed at 90% of its size cap
|
reason-body-not-utf8 = We couldn't read the text in this message. Try sending it again.
|
||||||
|
reason-post-not-canonical = We couldn't rebuild this message exactly as you sent it, so we can't prove it's yours. Send the text on its own.
|
||||||
|
reason-repost-not-canonical = We couldn't rebuild this repost exactly as you sent it. Send it again with only the Twt-Repost header.
|
||||||
|
reason-fields-beyond-reply-to = This post carries extra headers. Send it again with just the text.
|
||||||
|
reason-nick-invalid = A nick can use a-z, 0-9 and hyphens, up to 30 characters.
|
||||||
|
reason-delete-needs-timestamp = Twt-Delete needs the time of the post to remove, like 2026-10-14T19:39:39Z.
|
||||||
|
reason-prune-mode-unknown = Twt-Prune takes fifo or off, not "{ $got }".
|
||||||
|
|
||||||
|
## Limits on what a post can contain
|
||||||
|
|
||||||
|
reason-too-long = A post can be up to { $max } characters. Shorten it and send it again.
|
||||||
|
reason-forbidden-char = A post has to fit on one line, without tabs or other control characters. Take them out and send it again.
|
||||||
|
reason-bad-timestamp = That isn't a time we can read. Use the form 2026-10-14T19:39:39Z.
|
||||||
|
reason-duplicate-timestamp = Your feed already has a post at that time.
|
||||||
|
|
||||||
|
## Quota and retention
|
||||||
|
|
||||||
|
reason-over-quota-post = You've reached today's limit for posts. Try again tomorrow.
|
||||||
|
reason-over-quota-repost = You've reached today's limit for reposts. Try again tomorrow.
|
||||||
|
reason-collision = Your feed already has a post at { $timestamp }. Wait a second, then send it again.
|
||||||
|
reason-feed-full = Your feed is full. Remove a post with Twt-Delete, or send Twt-Prune: fifo to drop the oldest ones from now on.
|
||||||
|
reason-reply-to-changed = This feed belongs to { $author }. Send your posts from that address.
|
||||||
|
reason-no-record-at = Your feed has no post at { $timestamp }.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
reason-one-command-per-mail = Send one command per message. Twt-Register, Twt-Help, Twt-Admit, Twt-Block and Twt-Registration each need their own, and feed commands need another.
|
||||||
|
reason-bad-address = "{ $got }" isn't a smolmail address.
|
||||||
|
reason-registration-mode-unknown = Twt-Registration takes open, closed or silent, not "{ $got }".
|
||||||
|
|
||||||
|
## An accept token in a post. The author didn't do anything wrong: their
|
||||||
|
## client added the token because they accepted this address. The fix depends
|
||||||
|
## on which client they use, so it goes in a detail paragraph.
|
||||||
|
|
||||||
|
reason-accept-field-on-post = A post can't carry the accept token your client attached.
|
||||||
|
reason-accept-field-on-first-post = Your first post carried an accept token, which we can't publish.
|
||||||
|
|
||||||
|
## Detail paragraphs, added to the end of the reply when one line isn't
|
||||||
|
## enough room for the fix. Most reasons don't need one.
|
||||||
|
|
||||||
|
reason-accept-field-on-post-detail =
|
||||||
|
|
||||||
|
We publish your post by rebuilding exactly what you signed, so anyone can
|
||||||
|
check it was you. A token can't be part of that.
|
||||||
|
|
||||||
|
We've got your token now, so send the same post again and it should go up.
|
||||||
|
|
||||||
|
If it's refused again, your client is adding the token to every message
|
||||||
|
instead of sending it once. Block this address in your client and send
|
||||||
|
the post again. Blocking only moves our replies back to your Requests
|
||||||
|
folder.
|
||||||
|
|
||||||
|
reason-accept-field-on-first-post-detail =
|
||||||
|
|
||||||
|
Your feed is fine. It's there and it's waiting for its first post.
|
||||||
|
|
||||||
|
We publish your post by rebuilding exactly what you signed, so anyone can
|
||||||
|
check it was you. A token can't be part of that.
|
||||||
|
|
||||||
|
We've got your token now, so send that post again and it should go up.
|
||||||
|
|
||||||
|
If it's refused again, your client is adding the token to every message
|
||||||
|
instead of sending it once. Block this address in your client and send
|
||||||
|
the post again. Blocking only moves our replies back to your Requests
|
||||||
|
folder.
|
||||||
|
|
||||||
|
## Warning as a feed fills up
|
||||||
|
|
||||||
|
cap-warning-subject = twtxt: your feed is nearly full
|
||||||
cap-warning-body =
|
cap-warning-body =
|
||||||
Your feed has reached 90% of the { $cap }-byte cap.
|
Your feed has reached 90% of its { $cap } limit.
|
||||||
|
|
||||||
In hard-limit mode (the default) a full feed rejects new records; delete
|
When it's full, new posts are refused. Remove some with Twt-Delete, or
|
||||||
some with Twt-Delete, or choose FIFO pruning with Twt-Prune: fifo, which
|
send Twt-Prune: fifo and we'll drop the oldest ones to make room.
|
||||||
drops the oldest records to make room.
|
|
||||||
|
|
||||||
## Admission — the mail that hands over the accept token
|
## The mail that hands over the accept token
|
||||||
|
|
||||||
welcome-subject = twtxt: your feed is ready
|
welcome-subject = twtxt: your feed is ready
|
||||||
welcome-body =
|
welcome-body =
|
||||||
Your smolboid feed is live: { $url }
|
Your feed is live: { $url }
|
||||||
|
|
||||||
Post by sending mail to this address with the twt as the body. Manage it
|
Send a message to this address and it becomes a post.
|
||||||
with the Twt-Nick, Twt-Delete, Twt-Repost and Twt-Prune frontmatter
|
|
||||||
fields, and send Twt-Help to get this list again.
|
|
||||||
|
|
||||||
## Rejection reasons — one per `reason::Reason` variant, rendered into the
|
To manage your feed, add one of these headers: Twt-Nick, Twt-Delete,
|
||||||
## reply subject and body above. Each is a sentence fragment completing
|
Twt-Repost or Twt-Prune. Send Twt-Help for what each one does.
|
||||||
## "your mail could not be applied because …", so they start lowercase and
|
|
||||||
## carry the fix where there is one.
|
|
||||||
##
|
|
||||||
## Each MUST be a single line, however long. These are interpolated into the
|
|
||||||
## reply's Subject, and a frontmatter value carrying a line break makes the
|
|
||||||
## whole block malformed — smolmail then fails closed toward display (SPEC.md
|
|
||||||
## sec 5.5) and the reply arrives with no subject at all. Fluent multiline
|
|
||||||
## values keep their newlines, so there is no safe way to wrap them here.
|
|
||||||
## `reason::tests::every_reason_is_a_single_line` enforces this.
|
|
||||||
|
|
||||||
reason-body-not-utf8 = body is not UTF-8
|
## First contact from someone who doesn't have a feed
|
||||||
reason-post-not-canonical = post mail is not in canonical form
|
|
||||||
reason-repost-not-canonical = repost mail is not in canonical form
|
|
||||||
reason-fields-beyond-reply-to = post mail carries fields beyond Reply-To (Subject or headers); resend the bare text
|
|
||||||
reason-nick-invalid = nick must be 1-30 bytes of a-z, 0-9 or hyphen
|
|
||||||
reason-delete-needs-timestamp = Twt-Delete needs an RFC 3339 UTC timestamp
|
|
||||||
reason-prune-mode-unknown = unknown prune mode `{ $got }`, want fifo or off
|
|
||||||
|
|
||||||
## Post rules, from core::rules::RuleError
|
|
||||||
|
|
||||||
reason-too-many-codepoints = more than 160 Unicode code points
|
|
||||||
reason-too-many-bytes = more than 640 bytes
|
|
||||||
reason-forbidden-char = contains TAB, LF, CR or U+2028
|
|
||||||
reason-bad-timestamp = not an RFC 3339 UTC timestamp at second precision
|
|
||||||
reason-duplicate-timestamp = timestamp already exists in this feed
|
|
||||||
|
|
||||||
## Retention and quota, from the apply step
|
|
||||||
|
|
||||||
reason-over-quota-post = daily post quota exceeded; try tomorrow
|
|
||||||
reason-over-quota-repost = daily repost quota exceeded; try tomorrow
|
|
||||||
reason-collision = timestamp { $timestamp } already exists; wait a second and resend
|
|
||||||
reason-feed-full = feed is at its size cap; delete records with Twt-Delete or choose Twt-Prune: fifo
|
|
||||||
reason-reply-to-changed = your Reply-To does not match the feed's bound author { $author }; send from that address or mail the operator
|
|
||||||
reason-no-record-at = no record at { $timestamp }
|
|
||||||
|
|
||||||
## Mailbox requests — register, help and the admin commands
|
|
||||||
|
|
||||||
reason-one-command-per-mail = one command per mail: send Twt-Register, Twt-Help, Twt-Admit, Twt-Block or Twt-Registration on its own, and feed commands in a separate mail
|
|
||||||
reason-bad-address = `{ $got }` is not a smolmail address
|
|
||||||
reason-registration-mode-unknown = unknown registration mode `{ $got }`, want open, closed or silent
|
|
||||||
|
|
||||||
## First contact — an unknown sender who may register
|
|
||||||
|
|
||||||
info-subject = twtxt: how to get a feed
|
info-subject = twtxt: how to get a feed
|
||||||
info-body =
|
info-body =
|
||||||
You can publish a twtxt feed here by sending mail.
|
You can publish a twtxt feed here by sending mail.
|
||||||
|
|
||||||
Ask for one, optionally naming the nick you want:
|
To get one, send a message to this address with this header:
|
||||||
|
|
||||||
fumi send { $mailbox } --header "Twt-Register: yournick"
|
Twt-Register: yournick
|
||||||
|
|
||||||
We will reply with your feed's address and the commands for managing it.
|
Headers sit next to the subject in most clients, often behind a button
|
||||||
Nothing is published until you ask, and this mail is the only one you get
|
marked "+ header". Leave the value empty and we'll use your username.
|
||||||
|
|
||||||
|
Our reply arrives in your Requests folder, not your inbox, because you
|
||||||
|
haven't accepted this address. We recommend leaving it that way.
|
||||||
|
|
||||||
|
We publish nothing until you ask, and this is the only mail you'll get
|
||||||
until you do.
|
until you do.
|
||||||
|
|
||||||
Our replies arrive in your requests tier, so look for them there:
|
|
||||||
|
|
||||||
fumi fetch && fumi list --requests
|
|
||||||
|
|
||||||
Do not accept this address in your client. If you do, it will attach an
|
|
||||||
accept token to everything you send, and a published record cannot carry
|
|
||||||
one and still be verifiable — so your posts would be refused.
|
|
||||||
|
|
||||||
## First contact while registration is closed
|
## First contact while registration is closed
|
||||||
|
|
||||||
info-closed-subject = twtxt: registration is closed
|
info-closed-subject = twtxt: we're not taking new feeds
|
||||||
info-closed-body =
|
info-closed-body =
|
||||||
Thanks for writing. This instance is not taking new feeds at the moment,
|
Thanks for writing. We're not taking new feeds right now, so there's
|
||||||
so there is nothing for you to do here yet.
|
nothing to do here yet.
|
||||||
|
|
||||||
Nothing was published and nothing was kept.
|
Nothing was published and nothing was kept.
|
||||||
|
|
||||||
## Twt-Help — the command reference
|
## Twt-Help
|
||||||
|
|
||||||
help-subject = twtxt: your commands
|
help-subject = twtxt: your commands
|
||||||
help-body =
|
help-body =
|
||||||
Your feed: { $url }
|
Your feed: { $url }
|
||||||
|
|
||||||
Post by sending mail to this address with the twt as the body. The body is
|
Send a message to this address and it becomes a post. We publish exactly
|
||||||
published byte for byte, at most 160 characters.
|
what you write, on one line.
|
||||||
|
|
||||||
Management fields, one per mail:
|
To manage your feed, add one of these headers. One per message.
|
||||||
|
|
||||||
Twt-Nick: yournick set the displayed nick
|
Twt-Nick: yournick change the name shown on your feed
|
||||||
Twt-Delete: <timestamp> remove one post or repost
|
Twt-Delete: <time> remove one post or repost
|
||||||
Twt-Repost: <mention> <#id> publish a repost
|
Twt-Repost: <mention> <#id> share someone else's post
|
||||||
Twt-Prune: fifo | off what a full feed does at its size cap
|
Twt-Prune: fifo | off what happens when your feed fills up
|
||||||
Twt-Help: send this list again
|
Twt-Help: send this list again
|
||||||
|
|
||||||
help-admin-section =
|
help-admin-section =
|
||||||
|
|
||||||
Admin, from your address:
|
You're an admin here, so you can also send:
|
||||||
|
|
||||||
Twt-Admit: user@host give someone a feed
|
Twt-Admit: user@host give someone a feed
|
||||||
Twt-Block: user@host withdraw someone's token
|
Twt-Block: user@host stop someone posting
|
||||||
Twt-Registration: open | closed | silent
|
Twt-Registration: open | closed | silent
|
||||||
|
|
||||||
## Admin notifications
|
## Admin notifications
|
||||||
|
|
||||||
admin-notify-subject = twtxt: new feed { $slug }
|
admin-notify-subject = twtxt: new feed { $slug }
|
||||||
admin-notify-body =
|
admin-notify-body =
|
||||||
A feed was created.
|
Someone registered a feed.
|
||||||
|
|
||||||
Address: { $address }
|
Address: { $address }
|
||||||
Feed: { $url }
|
Feed: { $url }
|
||||||
Fingerprint: { $fingerprint }
|
Fingerprint: { $fingerprint }
|
||||||
|
|
||||||
Verify that fingerprint out of band if you do not already know this
|
If you don't know this person, check that fingerprint with them some
|
||||||
author. Twt-Block withdraws their token.
|
other way. Send Twt-Block to stop them posting.
|
||||||
|
|
||||||
|
## What an admin command did. These are sentences on their own: they become
|
||||||
|
## the subject and open the body.
|
||||||
|
|
||||||
|
admin-admitted = Gave { $address } a feed at { $url }.
|
||||||
|
admin-admit-failed = Couldn't give { $address } a feed. { $detail }
|
||||||
|
admin-blocked = Blocked { $address }. Their posts won't go up from now on.
|
||||||
|
admin-block-unknown = { $address } wasn't accepted here, so there was nothing to block.
|
||||||
|
admin-block-failed = Couldn't block { $address }. { $detail }
|
||||||
|
admin-block-half-done = Blocked { $address } here, but the server still has their token, so their posts may still go up. { $detail }
|
||||||
|
admin-registration-open = Anyone can register a feed now.
|
||||||
|
admin-registration-closed = Registration is closed. People who write in are told so.
|
||||||
|
admin-registration-silent = Registration is closed and people who write in get no reply.
|
||||||
|
|
||||||
admin-ack-subject = twtxt: { $what }
|
admin-ack-subject = twtxt: { $what }
|
||||||
admin-ack-body =
|
admin-ack-body =
|
||||||
{ $what }
|
{ $what }
|
||||||
|
|
||||||
Sent because your address is listed as an admin of this instance.
|
You're getting this because you're an admin here.
|
||||||
|
|
||||||
reason-accept-field-on-post = your client attached an accept token because this address is in your contacts; a published record is verified by rebuilding the exact bytes you signed and cannot include a token, so withdraw the acceptance and resend
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,9 @@ pub struct Config {
|
||||||
pub data: PathBuf,
|
pub data: PathBuf,
|
||||||
pub posts_per_day: u32,
|
pub posts_per_day: u32,
|
||||||
pub reposts_per_day: u32,
|
pub reposts_per_day: u32,
|
||||||
|
/// Longest post or repost text, in Unicode code points. twtxt itself sets
|
||||||
|
/// no hard limit, so this is instance policy.
|
||||||
|
pub max_post_chars: usize,
|
||||||
/// Per-feed size cap in bytes (PLAN.md, Retention).
|
/// Per-feed size cap in bytes (PLAN.md, Retention).
|
||||||
pub cap_bytes: usize,
|
pub cap_bytes: usize,
|
||||||
pub poll_secs: u64,
|
pub poll_secs: u64,
|
||||||
|
|
@ -60,6 +63,7 @@ impl Default for Config {
|
||||||
data: PathBuf::from("data"),
|
data: PathBuf::from("data"),
|
||||||
posts_per_day: 50,
|
posts_per_day: 50,
|
||||||
reposts_per_day: 50,
|
reposts_per_day: 50,
|
||||||
|
max_post_chars: 288,
|
||||||
cap_bytes: 1_048_576,
|
cap_bytes: 1_048_576,
|
||||||
poll_secs: 60,
|
poll_secs: 60,
|
||||||
timeout: 30,
|
timeout: 30,
|
||||||
|
|
@ -100,6 +104,7 @@ impl Config {
|
||||||
"data" => cfg.data = PathBuf::from(value),
|
"data" => cfg.data = PathBuf::from(value),
|
||||||
"posts_per_day" => cfg.posts_per_day = value.parse().map_err(&bad)?,
|
"posts_per_day" => cfg.posts_per_day = value.parse().map_err(&bad)?,
|
||||||
"reposts_per_day" => cfg.reposts_per_day = value.parse().map_err(&bad)?,
|
"reposts_per_day" => cfg.reposts_per_day = value.parse().map_err(&bad)?,
|
||||||
|
"max_post_chars" => cfg.max_post_chars = value.parse().map_err(&bad)?,
|
||||||
"cap_bytes" => cfg.cap_bytes = value.parse().map_err(&bad)?,
|
"cap_bytes" => cfg.cap_bytes = value.parse().map_err(&bad)?,
|
||||||
"poll_secs" => cfg.poll_secs = value.parse().map_err(&bad)?,
|
"poll_secs" => cfg.poll_secs = value.parse().map_err(&bad)?,
|
||||||
"timeout" => cfg.timeout = value.parse().map_err(&bad)?,
|
"timeout" => cfg.timeout = value.parse().map_err(&bad)?,
|
||||||
|
|
|
||||||
|
|
@ -342,10 +342,6 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn post_rules_are_enforced_with_reasons() {
|
fn post_rules_are_enforced_with_reasons() {
|
||||||
use smolboid_core::rules::RuleError;
|
use smolboid_core::rules::RuleError;
|
||||||
assert_eq!(
|
|
||||||
dispatch(&incoming(&"a".repeat(161))).unwrap_err(),
|
|
||||||
Reason::Rule(RuleError::TooManyCodepoints)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
dispatch(&incoming("a\tb")).unwrap_err(),
|
dispatch(&incoming("a\tb")).unwrap_err(),
|
||||||
Reason::Rule(RuleError::ForbiddenChar)
|
Reason::Rule(RuleError::ForbiddenChar)
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,15 @@ mod tests {
|
||||||
"admin-notify-body",
|
"admin-notify-body",
|
||||||
"admin-ack-subject",
|
"admin-ack-subject",
|
||||||
"admin-ack-body",
|
"admin-ack-body",
|
||||||
"reason-accept-field-on-post",
|
"admin-admitted",
|
||||||
|
"admin-admit-failed",
|
||||||
|
"admin-blocked",
|
||||||
|
"admin-block-unknown",
|
||||||
|
"admin-block-failed",
|
||||||
|
"admin-block-half-done",
|
||||||
|
"admin-registration-open",
|
||||||
|
"admin-registration-closed",
|
||||||
|
"admin-registration-silent",
|
||||||
];
|
];
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -29,17 +29,48 @@ use std::sync::{Mutex, MutexGuard};
|
||||||
/// the next cycle rather than dropped.
|
/// the next cycle rather than dropped.
|
||||||
const DIALS_PER_POLL: usize = 4;
|
const DIALS_PER_POLL: usize = 4;
|
||||||
|
|
||||||
/// What became of one mail.
|
/// What became of one mail, and the line the operator reads about it.
|
||||||
|
///
|
||||||
|
/// Every branch carries its own phrase so that `poll` can log all of them in
|
||||||
|
/// one place: a new branch cannot be added without saying what it did.
|
||||||
|
///
|
||||||
|
/// The daemon stays silent toward *senders* deliberately — a reply would
|
||||||
|
/// confirm whether an address exists, and that silence is a security
|
||||||
|
/// property. The operator's journal is a different audience and reaches
|
||||||
|
/// nobody else. Do not "fix" a quiet drop by answering the sender.
|
||||||
#[derive(Debug, PartialEq, Eq)]
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
enum Outcome {
|
enum Outcome {
|
||||||
/// The feed or state changed.
|
/// The feed or state changed.
|
||||||
Applied,
|
Applied(String),
|
||||||
/// Handled, with nothing to change — including every silent drop.
|
/// A reply was queued, but nothing changed here.
|
||||||
Dropped,
|
Answered(String),
|
||||||
|
/// Nothing happened and the sender was told nothing.
|
||||||
|
Dropped(String),
|
||||||
/// Needed a dial the cycle could not afford. Left in the store.
|
/// Needed a dial the cycle could not afford. Left in the store.
|
||||||
Deferred,
|
Deferred,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Outcome {
|
||||||
|
fn line(&self) -> String {
|
||||||
|
match self {
|
||||||
|
Outcome::Applied(what) => format!("applied: {what}"),
|
||||||
|
Outcome::Answered(what) => format!("answered: {what}"),
|
||||||
|
Outcome::Dropped(why) => format!("dropped: {why}"),
|
||||||
|
Outcome::Deferred => "deferred: no dials left this cycle".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A byte count as something a reader can picture. Deliberately coarse: the
|
||||||
|
/// exact figure helps nobody deciding whether to prune their feed.
|
||||||
|
fn human_size(bytes: usize) -> String {
|
||||||
|
match bytes {
|
||||||
|
b if b >= 1 << 20 => format!("{} MB", b / (1 << 20)),
|
||||||
|
b if b >= 1 << 10 => format!("{} KB", b / (1 << 10)),
|
||||||
|
b => format!("{b} bytes"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether a request is one only an admin may run. Register and Help are not:
|
/// Whether a request is one only an admin may run. Register and Help are not:
|
||||||
/// an admin sending either means it literally, and routing them through the
|
/// an admin sending either means it literally, and routing them through the
|
||||||
/// admin branch would answer with silence.
|
/// admin branch would answer with silence.
|
||||||
|
|
@ -63,6 +94,7 @@ pub struct PendingRecord {
|
||||||
/// Failures that draw an error reply (PLAN.md, Management mail).
|
/// Failures that draw an error reply (PLAN.md, Management mail).
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum ApplyError {
|
pub enum ApplyError {
|
||||||
|
TooLong,
|
||||||
OverQuota(bool),
|
OverQuota(bool),
|
||||||
Collision(String),
|
Collision(String),
|
||||||
FeedFull,
|
FeedFull,
|
||||||
|
|
@ -71,7 +103,7 @@ pub enum ApplyError {
|
||||||
ReplyToChanged,
|
ReplyToChanged,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Applies a published record to the feed: quota, uniqueness, retention
|
/// Applies a published record to the feed: length, quota, uniqueness, retention
|
||||||
/// (pruning in Fifo mode, rejecting in Off mode), then the append.
|
/// (pruning in Fifo mode, rejecting in Off mode), then the append.
|
||||||
/// Returns the rendered size after appending, for the 90% warning.
|
/// Returns the rendered size after appending, for the 90% warning.
|
||||||
pub fn apply_record(
|
pub fn apply_record(
|
||||||
|
|
@ -81,6 +113,9 @@ pub fn apply_record(
|
||||||
record: PendingRecord,
|
record: PendingRecord,
|
||||||
now_unix: i64,
|
now_unix: i64,
|
||||||
) -> Result<(), ApplyError> {
|
) -> Result<(), ApplyError> {
|
||||||
|
if record.text.chars().count() > cfg.max_post_chars {
|
||||||
|
return Err(ApplyError::TooLong);
|
||||||
|
}
|
||||||
State::roll_counters(fs, now_unix);
|
State::roll_counters(fs, now_unix);
|
||||||
let (counter, limit) = if record.repost {
|
let (counter, limit) = if record.repost {
|
||||||
(&mut fs.reposts, cfg.reposts_per_day)
|
(&mut fs.reposts, cfg.reposts_per_day)
|
||||||
|
|
@ -226,6 +261,7 @@ impl Daemon {
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut applied = 0;
|
let mut applied = 0;
|
||||||
|
let mut answered = 0;
|
||||||
let mut dropped = 0;
|
let mut dropped = 0;
|
||||||
let mut replies: Vec<(String, String, String)> = Vec::new(); // (address, subject, body)
|
let mut replies: Vec<(String, String, String)> = Vec::new(); // (address, subject, body)
|
||||||
|
|
||||||
|
|
@ -233,6 +269,11 @@ impl Daemon {
|
||||||
let mut deferred = 0;
|
let mut deferred = 0;
|
||||||
for stored in &mail {
|
for stored in &mail {
|
||||||
let outcome = self.process(stored.id, &stored.envelope, &mut replies, &mut dials);
|
let outcome = self.process(stored.id, &stored.envelope, &mut replies, &mut dials);
|
||||||
|
// One line per mail, so a silent drop is still visible to whoever
|
||||||
|
// runs this. Without it every outcome looked the same in the log.
|
||||||
|
if let Ok(outcome) = &outcome {
|
||||||
|
println!("{}", outcome.line());
|
||||||
|
}
|
||||||
// A deferred mail keeps its row, so the next cycle sees it again.
|
// A deferred mail keeps its row, so the next cycle sees it again.
|
||||||
if outcome.as_ref().is_ok_and(|o| *o == Outcome::Deferred) {
|
if outcome.as_ref().is_ok_and(|o| *o == Outcome::Deferred) {
|
||||||
deferred += 1;
|
deferred += 1;
|
||||||
|
|
@ -240,14 +281,15 @@ impl Daemon {
|
||||||
}
|
}
|
||||||
let _ = self.store.delete_local("inbox", &stored.id);
|
let _ = self.store.delete_local("inbox", &stored.id);
|
||||||
match outcome {
|
match outcome {
|
||||||
Ok(Outcome::Applied) => applied += 1,
|
Ok(Outcome::Applied(_)) => applied += 1,
|
||||||
|
Ok(Outcome::Answered(_)) => answered += 1,
|
||||||
Ok(_) => dropped += 1,
|
Ok(_) => dropped += 1,
|
||||||
Err(e) => eprintln!("mail: {e}"),
|
Err(e) => eprintln!("mail: {e}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let failed = self.send_replies(replies);
|
let failed = self.send_replies(replies);
|
||||||
Ok(format!(
|
Ok(format!(
|
||||||
"{applied} applied, {dropped} dropped{}{}{}",
|
"{applied} applied, {answered} answered, {dropped} dropped{}{}{}",
|
||||||
match drained {
|
match drained {
|
||||||
Some(n) => format!(", {n} backlog discarded"),
|
Some(n) => format!(", {n} backlog discarded"),
|
||||||
None => String::new(),
|
None => String::new(),
|
||||||
|
|
@ -336,7 +378,7 @@ impl Daemon {
|
||||||
// Blocked: their token is withdrawn, so nothing they send applies and
|
// Blocked: their token is withdrawn, so nothing they send applies and
|
||||||
// they get no reply either.
|
// they get no reply either.
|
||||||
if !self.accepted(&address)? {
|
if !self.accepted(&address)? {
|
||||||
return Ok(Outcome::Dropped);
|
return Ok(Outcome::Dropped(format!("{address} is blocked")));
|
||||||
}
|
}
|
||||||
match dispatch::request(incoming) {
|
match dispatch::request(incoming) {
|
||||||
// An owner's admin command needs no resolve: the feed's address
|
// An owner's admin command needs no resolve: the feed's address
|
||||||
|
|
@ -350,14 +392,13 @@ impl Daemon {
|
||||||
// section for a non-admin.
|
// section for a non-admin.
|
||||||
Ok(Some(_)) => {
|
Ok(Some(_)) => {
|
||||||
self.queue_help(state, incoming, &address, index, replies);
|
self.queue_help(state, incoming, &address, index, replies);
|
||||||
Ok(Outcome::Dropped)
|
Ok(Outcome::Answered(format!("help for {address}")))
|
||||||
}
|
}
|
||||||
Ok(None) => self
|
Ok(None) => self.apply(incoming, index, state, replies),
|
||||||
.apply(incoming, index, state, replies)
|
|
||||||
.map(|changed| if changed { Outcome::Applied } else { Outcome::Dropped }),
|
|
||||||
Err(reason) => {
|
Err(reason) => {
|
||||||
|
let line = format!("{address}: {}", reason.text());
|
||||||
self.queue_reply(state, incoming, &address, &reason, replies);
|
self.queue_reply(state, incoming, &address, &reason, replies);
|
||||||
Ok(Outcome::Dropped)
|
Ok(Outcome::Answered(format!("refused {line}")))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -376,14 +417,16 @@ impl Daemon {
|
||||||
// replying would make this daemon a mail reflector for anyone holding
|
// replying would make this daemon a mail reflector for anyone holding
|
||||||
// a freshly minted keypair.
|
// a freshly minted keypair.
|
||||||
let Some(claim) = dispatch::claimed(incoming) else {
|
let Some(claim) = dispatch::claimed(incoming) else {
|
||||||
return Ok(Outcome::Dropped);
|
return Ok(Outcome::Dropped(
|
||||||
|
"no signed Reply-To, so there is no address to verify".into(),
|
||||||
|
));
|
||||||
};
|
};
|
||||||
let address = claim.short();
|
let address = claim.short();
|
||||||
|
|
||||||
// Before any network call, so a blocked author cannot spend our
|
// Before any network call, so a blocked author cannot spend our
|
||||||
// budget by re-registering.
|
// budget by re-registering.
|
||||||
if self.store.accepted_state(&address).unwrap_or(None) == Some(false) {
|
if self.store.accepted_state(&address).unwrap_or(None) == Some(false) {
|
||||||
return Ok(Outcome::Dropped);
|
return Ok(Outcome::Dropped(format!("{address} is blocked")));
|
||||||
}
|
}
|
||||||
|
|
||||||
let request = dispatch::request(incoming);
|
let request = dispatch::request(incoming);
|
||||||
|
|
@ -394,10 +437,15 @@ impl Daemon {
|
||||||
// even for a well-formed request — except for an admin, who would
|
// even for a well-formed request — except for an admin, who would
|
||||||
// otherwise have no way to turn registration back on.
|
// otherwise have no way to turn registration back on.
|
||||||
if mode == Mode::Silent && !is_admin_address {
|
if mode == Mode::Silent && !is_admin_address {
|
||||||
return Ok(Outcome::Dropped);
|
return Ok(Outcome::Dropped(format!(
|
||||||
|
"{address} wrote in, and registration is silent"
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
if !self.host_allowed(&claim) {
|
if !self.host_allowed(&claim) {
|
||||||
return Ok(Outcome::Dropped);
|
return Ok(Outcome::Dropped(format!(
|
||||||
|
"{} is not in register_hosts, so {address} cannot register",
|
||||||
|
claim.host
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
if *dials >= DIALS_PER_POLL {
|
if *dials >= DIALS_PER_POLL {
|
||||||
return Ok(Outcome::Deferred);
|
return Ok(Outcome::Deferred);
|
||||||
|
|
@ -410,14 +458,18 @@ impl Daemon {
|
||||||
Ok(Some(ref request)) if is_admin_address && is_admin_command(request) => {
|
Ok(Some(ref request)) if is_admin_address && is_admin_command(request) => {
|
||||||
*dials += 1;
|
*dials += 1;
|
||||||
if !self.holds_address(&claim, &incoming.sender)? {
|
if !self.holds_address(&claim, &incoming.sender)? {
|
||||||
return Ok(Outcome::Dropped);
|
return Ok(Outcome::Dropped(format!(
|
||||||
|
"{address} does not resolve to the key that signed an admin command"
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
self.run_admin(request, &address, state, replies, dials)
|
self.run_admin(request, &address, state, replies, dials)
|
||||||
}
|
}
|
||||||
Ok(Some(Request::Register { nick })) => {
|
Ok(Some(Request::Register { nick })) => {
|
||||||
if mode == Mode::Closed {
|
if mode == Mode::Closed {
|
||||||
self.queue_closed(state, incoming, &address, replies);
|
self.queue_closed(state, incoming, &address, replies);
|
||||||
return Ok(Outcome::Dropped);
|
return Ok(Outcome::Answered(format!(
|
||||||
|
"told {address} registration is closed"
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
// Counted before the dial, not after: a registration that
|
// Counted before the dial, not after: a registration that
|
||||||
// fails its key check costs the same outbound connection as
|
// fails its key check costs the same outbound connection as
|
||||||
|
|
@ -428,7 +480,10 @@ impl Daemon {
|
||||||
self.cfg.registrations_per_hour,
|
self.cfg.registrations_per_hour,
|
||||||
now(),
|
now(),
|
||||||
) {
|
) {
|
||||||
return Ok(Outcome::Dropped);
|
return Ok(Outcome::Dropped(format!(
|
||||||
|
"{address} tried to register, and this hour's limit of {} is spent",
|
||||||
|
self.cfg.registrations_per_hour
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
*dials += 1;
|
*dials += 1;
|
||||||
self.register(&claim, nick.as_deref(), incoming, state, replies)
|
self.register(&claim, nick.as_deref(), incoming, state, replies)
|
||||||
|
|
@ -439,10 +494,15 @@ impl Daemon {
|
||||||
_ => {
|
_ => {
|
||||||
if mode == Mode::Closed {
|
if mode == Mode::Closed {
|
||||||
self.queue_closed(state, incoming, &address, replies);
|
self.queue_closed(state, incoming, &address, replies);
|
||||||
return Ok(Outcome::Dropped);
|
return Ok(Outcome::Answered(format!(
|
||||||
|
"told {address} registration is closed"
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
if !global_allowed(state, Global::Informed, self.cfg.info_replies_per_hour, now()) {
|
if !global_allowed(state, Global::Informed, self.cfg.info_replies_per_hour, now()) {
|
||||||
return Ok(Outcome::Dropped);
|
return Ok(Outcome::Dropped(format!(
|
||||||
|
"{address} wrote in, and this hour's limit of {} info replies is spent",
|
||||||
|
self.cfg.info_replies_per_hour
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
*dials += 1;
|
*dials += 1;
|
||||||
// Resolving first is what makes the reply safe. Sending to the
|
// Resolving first is what makes the reply safe. Sending to the
|
||||||
|
|
@ -452,10 +512,12 @@ impl Daemon {
|
||||||
// without this check would mail the real owner on a stranger's
|
// without this check would mail the real owner on a stranger's
|
||||||
// behalf.
|
// behalf.
|
||||||
if !self.holds_address(&claim, &incoming.sender)? {
|
if !self.holds_address(&claim, &incoming.sender)? {
|
||||||
return Ok(Outcome::Dropped);
|
return Ok(Outcome::Dropped(format!(
|
||||||
|
"{address} does not resolve to the key that signed this mail"
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
self.queue_info(state, incoming, &address, replies);
|
self.queue_info(state, incoming, &address, replies);
|
||||||
Ok(Outcome::Dropped)
|
Ok(Outcome::Answered(format!("told {address} how to register")))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -523,8 +585,13 @@ impl Daemon {
|
||||||
// Includes the key-mismatch refusal, which must stay quiet:
|
// Includes the key-mismatch refusal, which must stay quiet:
|
||||||
// the sender is not the address holder, so there is nobody
|
// the sender is not the address holder, so there is nobody
|
||||||
// here to answer.
|
// here to answer.
|
||||||
eprintln!("register {}: {e}", claim.short());
|
// Includes the key-mismatch refusal. Quiet toward the
|
||||||
return Ok(Outcome::Dropped);
|
// sender, loud here: this is the line an operator needs when
|
||||||
|
// a registration does not appear.
|
||||||
|
return Ok(Outcome::Dropped(format!(
|
||||||
|
"could not register {}: {e}",
|
||||||
|
claim.short()
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -543,7 +610,10 @@ impl Daemon {
|
||||||
if self.cfg.admin_notify {
|
if self.cfg.admin_notify {
|
||||||
self.notify_admins(&admitted, claim, &url, replies);
|
self.notify_admins(&admitted, claim, &url, replies);
|
||||||
}
|
}
|
||||||
Ok(Outcome::Applied)
|
Ok(Outcome::Applied(format!(
|
||||||
|
"registered {} as {url}",
|
||||||
|
claim.short()
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn notify_admins(
|
fn notify_admins(
|
||||||
|
|
@ -605,37 +675,64 @@ impl Daemon {
|
||||||
crate::i18n::t1("welcome-body", "url", url.clone()),
|
crate::i18n::t1("welcome-body", "url", url.clone()),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
format!("admitted {target}: {url}")
|
let mut args = fluent_bundle::FluentArgs::new();
|
||||||
|
args.set("address", target.clone());
|
||||||
|
args.set("url", url);
|
||||||
|
crate::i18n::t_args("admin-admitted", Some(&args))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let mut args = fluent_bundle::FluentArgs::new();
|
||||||
|
args.set("address", target.clone());
|
||||||
|
args.set("detail", e);
|
||||||
|
crate::i18n::t_args("admin-admit-failed", Some(&args))
|
||||||
}
|
}
|
||||||
Err(e) => format!("could not admit {target}: {e}"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Request::Block(target) => match self.store.block(target) {
|
Request::Block(target) => {
|
||||||
Ok(true) => {
|
let said = |id: &str, detail: Option<String>| {
|
||||||
match client::push_tokens(&self.store, &self.account, None, self.cfg.timeout) {
|
let mut args = fluent_bundle::FluentArgs::new();
|
||||||
Ok(_) => format!("blocked {target}"),
|
args.set("address", target.clone());
|
||||||
Err(e) => format!("blocked {target} locally, but the server still holds their token: {e}"),
|
if let Some(detail) = detail {
|
||||||
|
args.set("detail", detail);
|
||||||
}
|
}
|
||||||
|
crate::i18n::t_args(id, Some(&args))
|
||||||
|
};
|
||||||
|
match self.store.block(target) {
|
||||||
|
Ok(true) => {
|
||||||
|
match client::push_tokens(&self.store, &self.account, None, self.cfg.timeout)
|
||||||
|
{
|
||||||
|
Ok(_) => said("admin-blocked", None),
|
||||||
|
Err(e) => said("admin-block-half-done", Some(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(false) => said("admin-block-unknown", None),
|
||||||
|
Err(e) => said("admin-block-failed", Some(e.to_string())),
|
||||||
}
|
}
|
||||||
Ok(false) => format!("{target} was never accepted"),
|
}
|
||||||
Err(e) => format!("could not block {target}: {e}"),
|
|
||||||
},
|
|
||||||
Request::Registration(mode) => {
|
Request::Registration(mode) => {
|
||||||
state.set_registration(*mode);
|
state.set_registration(*mode);
|
||||||
state
|
state
|
||||||
.save(&self.cfg.data.join("state.json"))
|
.save(&self.cfg.data.join("state.json"))
|
||||||
.map_err(|e| format!("state: {e}"))?;
|
.map_err(|e| format!("state: {e}"))?;
|
||||||
format!("registration is now {mode:?}").to_lowercase()
|
crate::i18n::t(match mode {
|
||||||
|
Mode::Open => "admin-registration-open",
|
||||||
|
Mode::Closed => "admin-registration-closed",
|
||||||
|
Mode::Silent => "admin-registration-silent",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
// Not an admin command; the caller routes these elsewhere.
|
// Not an admin command; the caller routes these elsewhere.
|
||||||
Request::Register { .. } | Request::Help => return Ok(Outcome::Dropped),
|
Request::Register { .. } | Request::Help => {
|
||||||
|
return Ok(Outcome::Dropped(
|
||||||
|
"an admin command that is not one; the caller routes these".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
};
|
};
|
||||||
replies.push((
|
replies.push((
|
||||||
address.to_owned(),
|
address.to_owned(),
|
||||||
crate::i18n::t1("admin-ack-subject", "what", what.clone()),
|
crate::i18n::t1("admin-ack-subject", "what", what.clone()),
|
||||||
crate::i18n::t1("admin-ack-body", "what", what),
|
crate::i18n::t1("admin-ack-body", "what", what.clone()),
|
||||||
));
|
));
|
||||||
Ok(Outcome::Applied)
|
Ok(Outcome::Applied(format!("admin {address}: {what}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The command reference, with the admin section only for an admin.
|
/// The command reference, with the admin section only for an admin.
|
||||||
|
|
@ -751,7 +848,7 @@ impl Daemon {
|
||||||
index: usize,
|
index: usize,
|
||||||
state: &mut MutexGuard<'_, State>,
|
state: &mut MutexGuard<'_, State>,
|
||||||
replies: &mut Vec<(String, String, String)>,
|
replies: &mut Vec<(String, String, String)>,
|
||||||
) -> Result<bool, String> {
|
) -> Result<Outcome, String> {
|
||||||
let username = state.feeds[index].username.clone();
|
let username = state.feeds[index].username.clone();
|
||||||
let address = state.feeds[index].address.clone();
|
let address = state.feeds[index].address.clone();
|
||||||
let mut feed = self.feeds.load(&username)?;
|
let mut feed = self.feeds.load(&username)?;
|
||||||
|
|
@ -772,17 +869,29 @@ impl Daemon {
|
||||||
}
|
}
|
||||||
Ok(Action::Commands(commands)) => {
|
Ok(Action::Commands(commands)) => {
|
||||||
match apply_commands(&mut feed, &mut state.feeds[index], &commands) {
|
match apply_commands(&mut feed, &mut state.feeds[index], &commands) {
|
||||||
Ok(n) => n > 0,
|
Ok(n) => Outcome::Applied(format!("{n} command(s) from {address}")),
|
||||||
Err(reason) => {
|
Err(reason) => {
|
||||||
|
let line = reason.text();
|
||||||
self.queue_reply(state, incoming, &address, &reason, replies);
|
self.queue_reply(state, incoming, &address, &reason, replies);
|
||||||
false
|
Outcome::Answered(format!("refused {address}: {line}"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(Action::Ignore) => false,
|
Ok(Action::Ignore) => {
|
||||||
|
Outcome::Dropped(format!("nothing to do in {address}'s mail"))
|
||||||
|
}
|
||||||
Err(reason) => {
|
Err(reason) => {
|
||||||
|
// Someone watching their first post be refused needs to hear
|
||||||
|
// that their feed is fine, not just what went wrong.
|
||||||
|
let reason = match reason {
|
||||||
|
Reason::AcceptFieldOnPost if feed.entries().is_empty() => {
|
||||||
|
Reason::AcceptFieldOnFirstPost
|
||||||
|
}
|
||||||
|
other => other,
|
||||||
|
};
|
||||||
|
let line = reason.text();
|
||||||
self.queue_reply(state, incoming, &address, &reason, replies);
|
self.queue_reply(state, incoming, &address, &reason, replies);
|
||||||
false
|
Outcome::Answered(format!("refused {address}: {line}"))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -807,7 +916,7 @@ impl Daemon {
|
||||||
incoming: &Incoming,
|
incoming: &Incoming,
|
||||||
replies: &mut Vec<(String, String, String)>,
|
replies: &mut Vec<(String, String, String)>,
|
||||||
record: PendingRecord,
|
record: PendingRecord,
|
||||||
) -> Result<bool, String> {
|
) -> Result<Outcome, String> {
|
||||||
let was_warned = state.feeds[index].warned;
|
let was_warned = state.feeds[index].warned;
|
||||||
match apply_record(feed, &mut state.feeds[index], &self.cfg, record, incoming.time) {
|
match apply_record(feed, &mut state.feeds[index], &self.cfg, record, incoming.time) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
|
|
@ -817,17 +926,14 @@ impl Daemon {
|
||||||
replies.push((
|
replies.push((
|
||||||
address.to_owned(),
|
address.to_owned(),
|
||||||
crate::i18n::t("cap-warning-subject"),
|
crate::i18n::t("cap-warning-subject"),
|
||||||
crate::i18n::t1(
|
crate::i18n::t1("cap-warning-body", "cap", human_size(self.cfg.cap_bytes)),
|
||||||
"cap-warning-body",
|
|
||||||
"cap",
|
|
||||||
self.cfg.cap_bytes.to_string(),
|
|
||||||
),
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Ok(true)
|
Ok(Outcome::Applied(format!("post by {address}")))
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let reason = match e {
|
let reason = match e {
|
||||||
|
ApplyError::TooLong => Reason::TooLong { max: self.cfg.max_post_chars },
|
||||||
ApplyError::OverQuota(repost) => Reason::OverQuota { repost },
|
ApplyError::OverQuota(repost) => Reason::OverQuota { repost },
|
||||||
ApplyError::Collision(timestamp) => Reason::Collision { timestamp },
|
ApplyError::Collision(timestamp) => Reason::Collision { timestamp },
|
||||||
ApplyError::FeedFull => Reason::FeedFull,
|
ApplyError::FeedFull => Reason::FeedFull,
|
||||||
|
|
@ -835,8 +941,9 @@ impl Daemon {
|
||||||
author: feed.author().unwrap_or_default().to_owned(),
|
author: feed.author().unwrap_or_default().to_owned(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
let line = reason.text();
|
||||||
self.queue_reply(state, incoming, address, &reason, replies);
|
self.queue_reply(state, incoming, address, &reason, replies);
|
||||||
Ok(false)
|
Ok(Outcome::Answered(format!("refused {address}: {line}")))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -857,14 +964,10 @@ impl Daemon {
|
||||||
let text = std::str::from_utf8(&incoming.body)
|
let text = std::str::from_utf8(&incoming.body)
|
||||||
.map(|t| t.chars().take(200).collect::<String>())
|
.map(|t| t.chars().take(200).collect::<String>())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let sentence = reason.text();
|
|
||||||
let mut args = fluent_bundle::FluentArgs::new();
|
|
||||||
args.set("reason", sentence.clone());
|
|
||||||
args.set("quoted", text);
|
|
||||||
replies.push((
|
replies.push((
|
||||||
address.to_owned(),
|
address.to_owned(),
|
||||||
crate::i18n::t1("reply-subject", "reason", sentence),
|
crate::i18n::t1("reply-subject", "reason", reason.text()),
|
||||||
crate::i18n::t_args("reply-body", Some(&args)),
|
reason.reply_body(&text),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -950,6 +1053,15 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sizes_read_as_sizes() {
|
||||||
|
assert_eq!(human_size(1_048_576), "1 MB");
|
||||||
|
assert_eq!(human_size(10 * 1_048_576), "10 MB");
|
||||||
|
assert_eq!(human_size(2048), "2 KB");
|
||||||
|
assert_eq!(human_size(1023), "1023 bytes");
|
||||||
|
assert_eq!(human_size(0), "0 bytes");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn posts_publish_and_verify() {
|
fn posts_publish_and_verify() {
|
||||||
let mut feed = smolboid_core::Feed::new();
|
let mut feed = smolboid_core::Feed::new();
|
||||||
|
|
@ -961,6 +1073,18 @@ mod tests {
|
||||||
assert_eq!(feed.entries().len(), 1);
|
assert_eq!(feed.entries().len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn length_cap_is_configurable() {
|
||||||
|
let mut feed = smolboid_core::Feed::new();
|
||||||
|
let mut fs = feed_state();
|
||||||
|
let cfg = Config { max_post_chars: 5, ..config(1 << 20) };
|
||||||
|
let five = record("2026-10-01T12:47:00Z", "🙂🙂🙂🙂🙂");
|
||||||
|
apply_record(&mut feed, &mut fs, &cfg, five, 0).unwrap();
|
||||||
|
let six = record("2026-10-01T12:47:01Z", "abcdef");
|
||||||
|
assert!(matches!(apply_record(&mut feed, &mut fs, &cfg, six, 0), Err(ApplyError::TooLong)));
|
||||||
|
assert_eq!(fs.posts, 1, "a refused post spends no quota");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reposts_publish_as_repost_records() {
|
fn reposts_publish_as_repost_records() {
|
||||||
let mut feed = smolboid_core::Feed::new();
|
let mut feed = smolboid_core::Feed::new();
|
||||||
|
|
@ -1029,7 +1153,7 @@ mod tests {
|
||||||
fn warning_flag_flips_at_ninety_percent() {
|
fn warning_flag_flips_at_ninety_percent() {
|
||||||
let mut feed = smolboid_core::Feed::new();
|
let mut feed = smolboid_core::Feed::new();
|
||||||
let mut fs = feed_state();
|
let mut fs = feed_state();
|
||||||
let cfg = config(1300);
|
let cfg = Config { max_post_chars: 700, ..config(1300) };
|
||||||
apply_record(&mut feed, &mut fs, &cfg, record("2026-10-01T12:47:00Z", "x"), 0).unwrap();
|
apply_record(&mut feed, &mut fs, &cfg, record("2026-10-01T12:47:00Z", "x"), 0).unwrap();
|
||||||
assert!(!fs.warned);
|
assert!(!fs.warned);
|
||||||
apply_record(&mut feed, &mut fs, &cfg, record("2026-10-01T12:47:01Z", &"y".repeat(700)), 0).unwrap();
|
apply_record(&mut feed, &mut fs, &cfg, record("2026-10-01T12:47:01Z", &"y".repeat(700)), 0).unwrap();
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ pub enum Reason {
|
||||||
/// maps it to copy and the library keeps its own `Display` for
|
/// maps it to copy and the library keeps its own `Display` for
|
||||||
/// author-side tools.
|
/// author-side tools.
|
||||||
Rule(RuleError),
|
Rule(RuleError),
|
||||||
|
/// Longer than the instance's `max_post_chars`.
|
||||||
|
TooLong { max: usize },
|
||||||
OverQuota { repost: bool },
|
OverQuota { repost: bool },
|
||||||
Collision { timestamp: String },
|
Collision { timestamp: String },
|
||||||
FeedFull,
|
FeedFull,
|
||||||
|
|
@ -43,6 +45,10 @@ pub enum Reason {
|
||||||
/// not a mistake the author made, it is one their client made for them,
|
/// not a mistake the author made, it is one their client made for them,
|
||||||
/// and the fix is to stop accepting this address.
|
/// and the fix is to stop accepting this address.
|
||||||
AcceptFieldOnPost,
|
AcceptFieldOnPost,
|
||||||
|
/// The same, on a feed that has published nothing yet — so the author is
|
||||||
|
/// watching their very first post be refused, and deserves to be told
|
||||||
|
/// that nothing is wrong with their feed.
|
||||||
|
AcceptFieldOnFirstPost,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<RuleError> for Reason {
|
impl From<RuleError> for Reason {
|
||||||
|
|
@ -63,9 +69,8 @@ impl Reason {
|
||||||
Reason::NickInvalid => "reason-nick-invalid",
|
Reason::NickInvalid => "reason-nick-invalid",
|
||||||
Reason::DeleteNeedsTimestamp => "reason-delete-needs-timestamp",
|
Reason::DeleteNeedsTimestamp => "reason-delete-needs-timestamp",
|
||||||
Reason::PruneModeUnknown { .. } => "reason-prune-mode-unknown",
|
Reason::PruneModeUnknown { .. } => "reason-prune-mode-unknown",
|
||||||
Reason::Rule(RuleError::TooManyCodepoints) => "reason-too-many-codepoints",
|
|
||||||
Reason::Rule(RuleError::TooManyBytes) => "reason-too-many-bytes",
|
|
||||||
Reason::Rule(RuleError::ForbiddenChar) => "reason-forbidden-char",
|
Reason::Rule(RuleError::ForbiddenChar) => "reason-forbidden-char",
|
||||||
|
Reason::TooLong { .. } => "reason-too-long",
|
||||||
Reason::Rule(RuleError::BadTimestamp) => "reason-bad-timestamp",
|
Reason::Rule(RuleError::BadTimestamp) => "reason-bad-timestamp",
|
||||||
Reason::Rule(RuleError::DuplicateTimestamp) => "reason-duplicate-timestamp",
|
Reason::Rule(RuleError::DuplicateTimestamp) => "reason-duplicate-timestamp",
|
||||||
Reason::OverQuota { repost: false } => "reason-over-quota-post",
|
Reason::OverQuota { repost: false } => "reason-over-quota-post",
|
||||||
|
|
@ -78,6 +83,7 @@ impl Reason {
|
||||||
Reason::BadAddress { .. } => "reason-bad-address",
|
Reason::BadAddress { .. } => "reason-bad-address",
|
||||||
Reason::RegistrationModeUnknown { .. } => "reason-registration-mode-unknown",
|
Reason::RegistrationModeUnknown { .. } => "reason-registration-mode-unknown",
|
||||||
Reason::AcceptFieldOnPost => "reason-accept-field-on-post",
|
Reason::AcceptFieldOnPost => "reason-accept-field-on-post",
|
||||||
|
Reason::AcceptFieldOnFirstPost => "reason-accept-field-on-first-post",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,16 +98,46 @@ impl Reason {
|
||||||
args.set("timestamp", timestamp.clone())
|
args.set("timestamp", timestamp.clone())
|
||||||
}
|
}
|
||||||
Reason::ReplyToChanged { author } => args.set("author", author.clone()),
|
Reason::ReplyToChanged { author } => args.set("author", author.clone()),
|
||||||
|
Reason::TooLong { max } => args.set("max", max.to_string()),
|
||||||
_ => return None,
|
_ => return None,
|
||||||
}
|
}
|
||||||
Some(args)
|
Some(args)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The author-facing sentence, used both as the reply's subject suffix and
|
/// The author-facing sentence, used both as the reply's subject suffix and
|
||||||
/// in its body.
|
/// in its body. One line, always: it goes into a frontmatter value, and a
|
||||||
|
/// line break there makes the whole block malformed.
|
||||||
pub fn text(&self) -> String {
|
pub fn text(&self) -> String {
|
||||||
crate::i18n::t_args(self.id(), self.args().as_ref())
|
crate::i18n::t_args(self.id(), self.args().as_ref())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The whole reply body: the template, the quoted mail, and the detail
|
||||||
|
/// paragraph if this reason has one.
|
||||||
|
///
|
||||||
|
/// Assembled here so there is one place that knows how the pieces join.
|
||||||
|
/// Rebuilding it at a call site is how the detail ended up glued to the
|
||||||
|
/// last line of the quoted mail.
|
||||||
|
pub fn reply_body(&self, quoted: &str) -> String {
|
||||||
|
let mut args = fluent_bundle::FluentArgs::new();
|
||||||
|
args.set("reason", self.text());
|
||||||
|
args.set("quoted", quoted.to_owned());
|
||||||
|
let mut body = crate::i18n::t_args("reply-body", Some(&args));
|
||||||
|
if let Some(detail) = self.detail() {
|
||||||
|
// The template ends on the quoted mail with no trailing newline.
|
||||||
|
body.push_str("\n\n");
|
||||||
|
body.push_str(&detail);
|
||||||
|
}
|
||||||
|
body
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An optional paragraph for the reply body, when one sentence cannot
|
||||||
|
/// carry the fix. Most reasons have none, and the subject says it all.
|
||||||
|
pub fn detail(&self) -> Option<String> {
|
||||||
|
let rendered = crate::i18n::t_args(&format!("{}-detail", self.id()), self.args().as_ref());
|
||||||
|
// The catalogue marks a missing id rather than failing, which is
|
||||||
|
// exactly how an absent detail reads.
|
||||||
|
(!rendered.starts_with("???")).then_some(rendered)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -120,9 +156,8 @@ mod tests {
|
||||||
Reason::NickInvalid,
|
Reason::NickInvalid,
|
||||||
Reason::DeleteNeedsTimestamp,
|
Reason::DeleteNeedsTimestamp,
|
||||||
Reason::PruneModeUnknown { got: "sideways".into() },
|
Reason::PruneModeUnknown { got: "sideways".into() },
|
||||||
Reason::Rule(RuleError::TooManyCodepoints),
|
|
||||||
Reason::Rule(RuleError::TooManyBytes),
|
|
||||||
Reason::Rule(RuleError::ForbiddenChar),
|
Reason::Rule(RuleError::ForbiddenChar),
|
||||||
|
Reason::TooLong { max: 288 },
|
||||||
Reason::Rule(RuleError::BadTimestamp),
|
Reason::Rule(RuleError::BadTimestamp),
|
||||||
Reason::Rule(RuleError::DuplicateTimestamp),
|
Reason::Rule(RuleError::DuplicateTimestamp),
|
||||||
Reason::OverQuota { repost: false },
|
Reason::OverQuota { repost: false },
|
||||||
|
|
@ -135,6 +170,7 @@ mod tests {
|
||||||
Reason::BadAddress { got: "not an address".into() },
|
Reason::BadAddress { got: "not an address".into() },
|
||||||
Reason::RegistrationModeUnknown { got: "sideways".into() },
|
Reason::RegistrationModeUnknown { got: "sideways".into() },
|
||||||
Reason::AcceptFieldOnPost,
|
Reason::AcceptFieldOnPost,
|
||||||
|
Reason::AcceptFieldOnFirstPost,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -172,6 +208,20 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A detail is optional, but one that exists must be reachable — a typo
|
||||||
|
/// in its id would otherwise silently drop the explanation.
|
||||||
|
#[test]
|
||||||
|
fn details_are_optional_but_never_half_present() {
|
||||||
|
assert!(Reason::AcceptFieldOnPost.detail().is_some());
|
||||||
|
assert!(Reason::AcceptFieldOnFirstPost.detail().is_some());
|
||||||
|
assert!(Reason::FeedFull.detail().is_none());
|
||||||
|
for reason in all_reasons() {
|
||||||
|
if let Some(detail) = reason.detail() {
|
||||||
|
assert!(!detail.is_empty(), "{reason:?} has an empty detail");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn interpolated_reasons_carry_their_value() {
|
fn interpolated_reasons_carry_their_value() {
|
||||||
assert!(Reason::PruneModeUnknown { got: "sideways".into() }
|
assert!(Reason::PruneModeUnknown { got: "sideways".into() }
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,7 @@
|
||||||
data = ${cfg.dataDir}
|
data = ${cfg.dataDir}
|
||||||
posts_per_day = ${toString cfg.postsPerDay}
|
posts_per_day = ${toString cfg.postsPerDay}
|
||||||
reposts_per_day = ${toString cfg.repostsPerDay}
|
reposts_per_day = ${toString cfg.repostsPerDay}
|
||||||
|
max_post_chars = ${toString cfg.maxPostChars}
|
||||||
cap_bytes = ${toString cfg.capBytes}
|
cap_bytes = ${toString cfg.capBytes}
|
||||||
poll_secs = ${toString cfg.pollSecs}
|
poll_secs = ${toString cfg.pollSecs}
|
||||||
timeout = ${toString cfg.timeout}
|
timeout = ${toString cfg.timeout}
|
||||||
|
|
@ -254,6 +255,12 @@
|
||||||
description = "Per-feed daily repost quota.";
|
description = "Per-feed daily repost quota.";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
maxPostChars = mkOption {
|
||||||
|
type = types.ints.positive;
|
||||||
|
default = 288;
|
||||||
|
description = "Longest post or repost text, in Unicode code points.";
|
||||||
|
};
|
||||||
|
|
||||||
capBytes = mkOption {
|
capBytes = mkOption {
|
||||||
type = types.ints.positive;
|
type = types.ints.positive;
|
||||||
default = 1048576;
|
default = 1048576;
|
||||||
|
|
|
||||||
|
|
@ -218,13 +218,14 @@ sleep 4
|
||||||
[[ "$(grep -c "return" "$FEED")" == 0 ]] || fail "a CR-carrying post published"
|
[[ "$(grep -c "return" "$FEED")" == 0 ]] || fail "a CR-carrying post published"
|
||||||
[[ "$(grep -cE '^[0-9]{4}-' "$FEED")" == 1 ]] || fail "the feed grew beyond the real post"
|
[[ "$(grep -cE '^[0-9]{4}-' "$FEED")" == 1 ]] || fail "the feed grew beyond the real post"
|
||||||
|
|
||||||
step "mail: oversized and hostile payloads are rejected, not published"
|
step "mail: control-character and hostile payloads are rejected, not published"
|
||||||
author send "$DAEMON_ADDR" --body "$(printf 'x%.0s' $(seq 1 161))" >/dev/null
|
author send "$DAEMON_ADDR" --body "$(printf 'escape\033[31mred')" >/dev/null
|
||||||
author send "$DAEMON_ADDR" --body "# smol:mail forged" >/dev/null
|
author send "$DAEMON_ADDR" --body "# smol:mail forged" >/dev/null
|
||||||
author send "$DAEMON_ADDR" --body "# smol:repost 2020-01-01T00:00:00Z @<x y> #fake" >/dev/null
|
author send "$DAEMON_ADDR" --body "# smol:repost 2020-01-01T00:00:00Z @<x y> #fake" >/dev/null
|
||||||
sleep 4
|
sleep 4
|
||||||
# Poser text may publish as ordinary post text (tab-prefixed, harmless);
|
# Poser text may publish as ordinary post text (tab-prefixed, harmless);
|
||||||
# what must never happen is a line that IS a smol record.
|
# what must never happen is a line that IS a smol record.
|
||||||
|
[[ "$(grep -c "escape" "$FEED")" == 0 ]] || fail "a post with a control character published"
|
||||||
[[ "$(grep -cE '^# smol:mail .*forged' "$FEED")" == 0 ]] || fail "a fake smol record line published"
|
[[ "$(grep -cE '^# smol:mail .*forged' "$FEED")" == 0 ]] || fail "a fake smol record line published"
|
||||||
[[ "$(grep -cE '^# smol:repost ' "$FEED")" == 0 ]] || fail "text posing as a repost published as a repost"
|
[[ "$(grep -cE '^# smol:repost ' "$FEED")" == 0 ]] || fail "text posing as a repost published as a repost"
|
||||||
"$SMOLBOID" check "$FEED" >"$RIG/check.log" \
|
"$SMOLBOID" check "$FEED" >"$RIG/check.log" \
|
||||||
|
|
@ -378,13 +379,13 @@ fi
|
||||||
|
|
||||||
step "mail: reply-flood is capped at 10 per hour"
|
step "mail: reply-flood is capped at 10 per hour"
|
||||||
for i in $(seq 1 15); do
|
for i in $(seq 1 15); do
|
||||||
author send "$DAEMON_ADDR" --body "$(printf 'y%.0s' $(seq 1 $((200 + i))))" >/dev/null
|
author send "$DAEMON_ADDR" --body "$(printf 'y%.0s' $(seq 1 "$i"))$(printf '\tz')" >/dev/null
|
||||||
done
|
done
|
||||||
sleep 5
|
sleep 5
|
||||||
author fetch >/dev/null
|
author fetch >/dev/null
|
||||||
# Fifteen distinct oversized payloads; at most ten answers, the rest silent.
|
# Fifteen distinct payloads carrying a TAB; at most ten answers, the rest silent.
|
||||||
# (Earlier probes may have drawn their own replies for other reasons.)
|
# (Earlier probes may have drawn their own replies for other reasons.)
|
||||||
author list --requests 2>/dev/null | grep -c "twtxt: more than 160" >"$RIG/replies.count" || true
|
author list --requests 2>/dev/null | grep -c "twtxt: A post has to fit on one line" >"$RIG/replies.count" || true
|
||||||
REPLIES=$(grep -oE '^[0-9]+' "$RIG/replies.count" | head -1 || echo 0)
|
REPLIES=$(grep -oE '^[0-9]+' "$RIG/replies.count" | head -1 || echo 0)
|
||||||
echo "flood answers: $REPLIES of 15"
|
echo "flood answers: $REPLIES of 15"
|
||||||
[[ "$REPLIES" -le 10 ]] || fail "reply flood drew $REPLIES answers, over the hourly cap"
|
[[ "$REPLIES" -le 10 ]] || fail "reply flood drew $REPLIES answers, over the hourly cap"
|
||||||
|
|
|
||||||
18
tests/rig.sh
18
tests/rig.sh
|
|
@ -144,7 +144,7 @@ for i in 1 2 3 4 5; do
|
||||||
continue # different seconds; try again
|
continue # different seconds; try again
|
||||||
fi
|
fi
|
||||||
fumi fetch >/dev/null
|
fumi fetch >/dev/null
|
||||||
if fumi list --requests 2>/dev/null | grep -q "timestamp .* already exists"; then
|
if fumi list --requests 2>/dev/null | grep -q "already has a post at"; then
|
||||||
collided=1
|
collided=1
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
|
|
@ -163,7 +163,7 @@ fumi send "$DAEMON_ADDR" --body "over the cap" >/dev/null
|
||||||
sleep 3
|
sleep 3
|
||||||
grep -q "over the cap" "$FEED" && fail "a capped feed published a record"
|
grep -q "over the cap" "$FEED" && fail "a capped feed published a record"
|
||||||
fumi fetch >/dev/null
|
fumi fetch >/dev/null
|
||||||
fumi list --requests 2>/dev/null | grep -q "feed is at its size cap" \
|
fumi list --requests 2>/dev/null | grep -q "Your feed is full" \
|
||||||
|| fail "no cap rejection reply"
|
|| fail "no cap rejection reply"
|
||||||
|
|
||||||
step "retention: fifo mode"
|
step "retention: fifo mode"
|
||||||
|
|
@ -173,7 +173,7 @@ fumi send "$DAEMON_ADDR" --body "fifo post" >/dev/null
|
||||||
wait_for "$FEED" "fifo post"
|
wait_for "$FEED" "fifo post"
|
||||||
[[ "$(grep -cE '^[0-9]' "$FEED")" == 1 ]] || fail "fifo did not prune down to one record"
|
[[ "$(grep -cE '^[0-9]' "$FEED")" == 1 ]] || fail "fifo did not prune down to one record"
|
||||||
fumi fetch >/dev/null
|
fumi fetch >/dev/null
|
||||||
fumi list --requests 2>/dev/null | grep -q "90% of its size cap" \
|
fumi list --requests 2>/dev/null | grep -q "your feed is nearly full" \
|
||||||
|| fail "no 90% warning reply"
|
|| fail "no 90% warning reply"
|
||||||
"$SMOLBOID" check "$FEED" >"$RIG/check.log" || { cat "$RIG/check.log"; fail "check"; }
|
"$SMOLBOID" check "$FEED" >"$RIG/check.log" || { cat "$RIG/check.log"; fail "check"; }
|
||||||
grep -q "1 verified, 0 unsigned, 0 invalid" "$RIG/check.log" \
|
grep -q "1 verified, 0 unsigned, 0 invalid" "$RIG/check.log" \
|
||||||
|
|
@ -289,9 +289,9 @@ done
|
||||||
MAIL="$(dana_mail)"
|
MAIL="$(dana_mail)"
|
||||||
[[ "$MAIL" == *"accept token"* ]] \
|
[[ "$MAIL" == *"accept token"* ]] \
|
||||||
|| { echo "$MAIL"; fail "no explanation for the refused post"; }
|
|| { echo "$MAIL"; fail "no explanation for the refused post"; }
|
||||||
# The whole sentence must survive as a subject: a line break in a frontmatter
|
# The whole sentence must survive as a subject: a line break in a mail header
|
||||||
# value makes the block malformed and strips the subject entirely.
|
# makes the block malformed and strips the subject entirely.
|
||||||
[[ "$MAIL" == *"withdraw the acceptance and resend"* ]] \
|
[[ "$MAIL" == *"your client attached"* ]] \
|
||||||
|| { echo "$MAIL"; fail "the reason was truncated in the subject"; }
|
|| { echo "$MAIL"; fail "the reason was truncated in the subject"; }
|
||||||
dana block "$DAEMON_ADDR" >/dev/null
|
dana block "$DAEMON_ADDR" >/dev/null
|
||||||
|
|
||||||
|
|
@ -319,11 +319,11 @@ sleep 4
|
||||||
[[ ! -f "$DATA/feeds/erin.txt" ]] || fail "registration is closed but erin got a feed"
|
[[ ! -f "$DATA/feeds/erin.txt" ]] || fail "registration is closed but erin got a feed"
|
||||||
for ((i = 0; i < 20; i++)); do
|
for ((i = 0; i < 20; i++)); do
|
||||||
erin fetch >/dev/null 2>&1 || true
|
erin fetch >/dev/null 2>&1 || true
|
||||||
[[ "$(erin list --requests 2>/dev/null || true)" == *"registration is closed"* ]] && break
|
[[ "$(erin list --requests 2>/dev/null || true)" == *"not taking new feeds"* ]] && break
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
[[ "$(erin list --requests 2>/dev/null || true)" == *"registration is closed"* ]] \
|
[[ "$(erin list --requests 2>/dev/null || true)" == *"not taking new feeds"* ]] \
|
||||||
|| fail "erin was not told registration is closed"
|
|| { erin list --requests || true; fail "erin was not told registration is closed"; }
|
||||||
|
|
||||||
step "rig passed"
|
step "rig passed"
|
||||||
echo "feeds: $FEED"
|
echo "feeds: $FEED"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue