feat: follow upstream twtxt post rules: reject control characters, allow U+2028, make the length cap configurable
This commit is contained in:
parent
dd6865782a
commit
801c66d3b0
12 changed files with 63 additions and 58 deletions
|
|
@ -38,9 +38,8 @@ reason-prune-mode-unknown = Twt-Prune takes fifo or off, not "{ $got }".
|
|||
|
||||
## Limits on what a post can contain
|
||||
|
||||
reason-too-many-codepoints = A post can be up to 160 characters. Shorten it and send it again.
|
||||
reason-too-many-bytes = A post can be up to 640 bytes, and accents and emoji use several each. Shorten it and send it again.
|
||||
reason-forbidden-char = A post has to fit on one line. Take out the line breaks and tabs, then send it again.
|
||||
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.
|
||||
|
||||
|
|
@ -150,7 +149,7 @@ help-body =
|
|||
Your feed: { $url }
|
||||
|
||||
Send a message to this address and it becomes a post. We publish exactly
|
||||
what you write, up to 160 characters, on one line.
|
||||
what you write, on one line.
|
||||
|
||||
To manage your feed, add one of these headers. One per message.
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ pub struct Config {
|
|||
pub data: PathBuf,
|
||||
pub posts_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).
|
||||
pub cap_bytes: usize,
|
||||
pub poll_secs: u64,
|
||||
|
|
@ -60,6 +63,7 @@ impl Default for Config {
|
|||
data: PathBuf::from("data"),
|
||||
posts_per_day: 50,
|
||||
reposts_per_day: 50,
|
||||
max_post_chars: 288,
|
||||
cap_bytes: 1_048_576,
|
||||
poll_secs: 60,
|
||||
timeout: 30,
|
||||
|
|
@ -100,6 +104,7 @@ impl Config {
|
|||
"data" => cfg.data = PathBuf::from(value),
|
||||
"posts_per_day" => cfg.posts_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)?,
|
||||
"poll_secs" => cfg.poll_secs = value.parse().map_err(&bad)?,
|
||||
"timeout" => cfg.timeout = value.parse().map_err(&bad)?,
|
||||
|
|
|
|||
|
|
@ -342,10 +342,6 @@ mod tests {
|
|||
#[test]
|
||||
fn post_rules_are_enforced_with_reasons() {
|
||||
use smolboid_core::rules::RuleError;
|
||||
assert_eq!(
|
||||
dispatch(&incoming(&"a".repeat(161))).unwrap_err(),
|
||||
Reason::Rule(RuleError::TooManyCodepoints)
|
||||
);
|
||||
assert_eq!(
|
||||
dispatch(&incoming("a\tb")).unwrap_err(),
|
||||
Reason::Rule(RuleError::ForbiddenChar)
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ pub struct PendingRecord {
|
|||
/// Failures that draw an error reply (PLAN.md, Management mail).
|
||||
#[derive(Debug)]
|
||||
pub enum ApplyError {
|
||||
TooLong,
|
||||
OverQuota(bool),
|
||||
Collision(String),
|
||||
FeedFull,
|
||||
|
|
@ -102,7 +103,7 @@ pub enum ApplyError {
|
|||
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.
|
||||
/// Returns the rendered size after appending, for the 90% warning.
|
||||
pub fn apply_record(
|
||||
|
|
@ -112,6 +113,9 @@ pub fn apply_record(
|
|||
record: PendingRecord,
|
||||
now_unix: i64,
|
||||
) -> Result<(), ApplyError> {
|
||||
if record.text.chars().count() > cfg.max_post_chars {
|
||||
return Err(ApplyError::TooLong);
|
||||
}
|
||||
State::roll_counters(fs, now_unix);
|
||||
let (counter, limit) = if record.repost {
|
||||
(&mut fs.reposts, cfg.reposts_per_day)
|
||||
|
|
@ -929,6 +933,7 @@ impl Daemon {
|
|||
}
|
||||
Err(e) => {
|
||||
let reason = match e {
|
||||
ApplyError::TooLong => Reason::TooLong { max: self.cfg.max_post_chars },
|
||||
ApplyError::OverQuota(repost) => Reason::OverQuota { repost },
|
||||
ApplyError::Collision(timestamp) => Reason::Collision { timestamp },
|
||||
ApplyError::FeedFull => Reason::FeedFull,
|
||||
|
|
@ -1068,6 +1073,18 @@ mod tests {
|
|||
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]
|
||||
fn reposts_publish_as_repost_records() {
|
||||
let mut feed = smolboid_core::Feed::new();
|
||||
|
|
@ -1136,7 +1153,7 @@ mod tests {
|
|||
fn warning_flag_flips_at_ninety_percent() {
|
||||
let mut feed = smolboid_core::Feed::new();
|
||||
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();
|
||||
assert!(!fs.warned);
|
||||
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
|
||||
/// author-side tools.
|
||||
Rule(RuleError),
|
||||
/// Longer than the instance's `max_post_chars`.
|
||||
TooLong { max: usize },
|
||||
OverQuota { repost: bool },
|
||||
Collision { timestamp: String },
|
||||
FeedFull,
|
||||
|
|
@ -67,9 +69,8 @@ impl Reason {
|
|||
Reason::NickInvalid => "reason-nick-invalid",
|
||||
Reason::DeleteNeedsTimestamp => "reason-delete-needs-timestamp",
|
||||
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::TooLong { .. } => "reason-too-long",
|
||||
Reason::Rule(RuleError::BadTimestamp) => "reason-bad-timestamp",
|
||||
Reason::Rule(RuleError::DuplicateTimestamp) => "reason-duplicate-timestamp",
|
||||
Reason::OverQuota { repost: false } => "reason-over-quota-post",
|
||||
|
|
@ -97,6 +98,7 @@ impl Reason {
|
|||
args.set("timestamp", timestamp.clone())
|
||||
}
|
||||
Reason::ReplyToChanged { author } => args.set("author", author.clone()),
|
||||
Reason::TooLong { max } => args.set("max", max.to_string()),
|
||||
_ => return None,
|
||||
}
|
||||
Some(args)
|
||||
|
|
@ -154,9 +156,8 @@ mod tests {
|
|||
Reason::NickInvalid,
|
||||
Reason::DeleteNeedsTimestamp,
|
||||
Reason::PruneModeUnknown { got: "sideways".into() },
|
||||
Reason::Rule(RuleError::TooManyCodepoints),
|
||||
Reason::Rule(RuleError::TooManyBytes),
|
||||
Reason::Rule(RuleError::ForbiddenChar),
|
||||
Reason::TooLong { max: 288 },
|
||||
Reason::Rule(RuleError::BadTimestamp),
|
||||
Reason::Rule(RuleError::DuplicateTimestamp),
|
||||
Reason::OverQuota { repost: false },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue