feat: answer unknown senders, gated on proving they hold their address
process now splits by whether the sender owns a feed. The owner path runs first, so a rotated owner is never mistaken for a registration, and behaves exactly as before for ordinary mail. The stranger path is the new one, and every branch of it is gated. No signed Reply-To means no address to verify or answer, so it stays a silent drop. The blocked check runs before anything touches the network, so Twt-Block cannot be sidestepped by re-registering. Silent mode stays silent, which is how every version before this behaved — except for an admin, who would otherwise have no way to turn registration back on. Then the gate the whole change rests on: before any outbound mail, the claimed address is resolved on its own server and the resolved key must equal the key that signed the request. Without it a signed Reply-To naming someone else's address would be enough to take their feed, and replying to the claimed smol:// URI would pin the sender's key as verified under that address — a permanent denial of service against whoever really owns it. Every reply is queued against the short form for the same reason; nothing hands a self-certifying URI to send. Registration attempts are counted before the dial rather than after, since one that fails its key check costs the same connection. A per-cycle dial budget bounds how long the state lock is held, and a mail over budget keeps its row instead of being dropped. Admin commands are recognised only from an admin address, and only Admit, Block and Registration count as admin commands — an admin's Twt-Help means Twt-Help. A non-admin sending one gets the ordinary answer and is never told admin commands exist. Rig still passes, so the owner path is unchanged in practice as well as in intent.
This commit is contained in:
parent
77d822cebe
commit
f1084c1056
3 changed files with 519 additions and 26 deletions
|
|
@ -33,8 +33,8 @@ welcome-body =
|
|||
Your smolboid feed is live: { $url }
|
||||
|
||||
Post by sending mail to this address with the twt as the body. Manage it
|
||||
with Twt-Nick, Twt-Delete, Twt-Repost and Twt-Prune frontmatter fields
|
||||
(PLAN.md).
|
||||
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
|
||||
## reply subject and body above. Each is a sentence fragment completing
|
||||
|
|
@ -80,3 +80,75 @@ reason-one-command-per-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-body =
|
||||
You can publish a twtxt feed here by sending mail.
|
||||
|
||||
Two steps. First accept this address in your client, so our replies reach
|
||||
your inbox instead of your requests tier:
|
||||
|
||||
fumi accept { $mailbox }
|
||||
|
||||
Then ask for a feed, optionally naming the nick you want:
|
||||
|
||||
fumi send { $mailbox } --header "Twt-Register: yournick"
|
||||
|
||||
We will reply with your feed's address and the commands for managing it.
|
||||
Nothing is published until you ask for a feed, and this mail is the only
|
||||
one you get until you do.
|
||||
|
||||
## First contact while registration is closed
|
||||
|
||||
info-closed-subject = twtxt: registration is closed
|
||||
info-closed-body =
|
||||
Thanks for writing. This instance is not taking new feeds at the moment,
|
||||
so there is nothing for you to do here yet.
|
||||
|
||||
Nothing was published and nothing was kept.
|
||||
|
||||
## Twt-Help — the command reference
|
||||
|
||||
help-subject = twtxt: your commands
|
||||
help-body =
|
||||
Your feed: { $url }
|
||||
|
||||
Post by sending mail to this address with the twt as the body. The body is
|
||||
published byte for byte, at most 160 characters.
|
||||
|
||||
Management fields, one per mail:
|
||||
|
||||
Twt-Nick: yournick set the displayed nick
|
||||
Twt-Delete: <timestamp> remove one post or repost
|
||||
Twt-Repost: <mention> <#id> publish a repost
|
||||
Twt-Prune: fifo | off what a full feed does at its size cap
|
||||
Twt-Help: send this list again
|
||||
|
||||
help-admin-section =
|
||||
|
||||
Admin, from your address:
|
||||
|
||||
Twt-Admit: user@host give someone a feed
|
||||
Twt-Block: user@host withdraw someone's token
|
||||
Twt-Registration: open | closed | silent
|
||||
|
||||
## Admin notifications
|
||||
|
||||
admin-notify-subject = twtxt: new feed { $slug }
|
||||
admin-notify-body =
|
||||
A feed was created.
|
||||
|
||||
Address: { $address }
|
||||
Feed: { $url }
|
||||
Fingerprint: { $fingerprint }
|
||||
|
||||
Verify that fingerprint out of band if you do not already know this
|
||||
author. Twt-Block withdraws their token.
|
||||
|
||||
admin-ack-subject = twtxt: { $what }
|
||||
admin-ack-body =
|
||||
{ $what }
|
||||
|
||||
Sent because your address is listed as an admin of this instance.
|
||||
|
|
|
|||
|
|
@ -97,6 +97,17 @@ mod tests {
|
|||
"cap-warning-body",
|
||||
"welcome-subject",
|
||||
"welcome-body",
|
||||
"info-subject",
|
||||
"info-body",
|
||||
"info-closed-subject",
|
||||
"info-closed-body",
|
||||
"help-subject",
|
||||
"help-body",
|
||||
"help-admin-section",
|
||||
"admin-notify-subject",
|
||||
"admin-notify-body",
|
||||
"admin-ack-subject",
|
||||
"admin-ack-body",
|
||||
];
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -11,13 +11,45 @@ use smolboid_core::feed::{Feed, MailLine, Record};
|
|||
use smolboid_core::envelope;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::dispatch::{self, Action, Command, Incoming};
|
||||
use crate::dispatch::{self, Action, Command, Incoming, Request};
|
||||
use crate::feedstore::FeedStore;
|
||||
use crate::reason::Reason;
|
||||
use crate::state::{payload_key, reply_allowed, FeedState, PruneMode, State};
|
||||
use crate::register;
|
||||
use crate::state::{
|
||||
global_allowed, payload_key, reply_allowed, FeedState, Global, Mode, PruneMode, State,
|
||||
};
|
||||
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
|
||||
/// How many mails per cycle may make an outbound connection.
|
||||
///
|
||||
/// `poll` holds the state lock across each mail and a dial can block for
|
||||
/// `timeout` seconds, so an unbounded batch of registrations would stall mail
|
||||
/// processing for minutes. Anything over the budget is left in the store for
|
||||
/// the next cycle rather than dropped.
|
||||
const DIALS_PER_POLL: usize = 4;
|
||||
|
||||
/// What became of one mail.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum Outcome {
|
||||
/// The feed or state changed.
|
||||
Applied,
|
||||
/// Handled, with nothing to change — including every silent drop.
|
||||
Dropped,
|
||||
/// Needed a dial the cycle could not afford. Left in the store.
|
||||
Deferred,
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// admin branch would answer with silence.
|
||||
fn is_admin_command(request: &Request) -> bool {
|
||||
matches!(
|
||||
request,
|
||||
Request::Admit(_) | Request::Block(_) | Request::Registration(_)
|
||||
)
|
||||
}
|
||||
|
||||
/// Everything needed to publish one record, before retention gets a say.
|
||||
pub struct PendingRecord {
|
||||
pub timestamp: String,
|
||||
|
|
@ -197,22 +229,30 @@ impl Daemon {
|
|||
let mut dropped = 0;
|
||||
let mut replies: Vec<(String, String, String)> = Vec::new(); // (address, subject, body)
|
||||
|
||||
let mut dials = 0;
|
||||
let mut deferred = 0;
|
||||
for stored in &mail {
|
||||
let outcome = self.process(stored.id, &stored.envelope, &mut replies);
|
||||
let outcome = self.process(stored.id, &stored.envelope, &mut replies, &mut dials);
|
||||
// A deferred mail keeps its row, so the next cycle sees it again.
|
||||
if outcome.as_ref().is_ok_and(|o| *o == Outcome::Deferred) {
|
||||
deferred += 1;
|
||||
continue;
|
||||
}
|
||||
let _ = self.store.delete_local("inbox", &stored.id);
|
||||
match outcome {
|
||||
Ok(true) => applied += 1,
|
||||
Ok(false) => dropped += 1,
|
||||
Ok(Outcome::Applied) => applied += 1,
|
||||
Ok(_) => dropped += 1,
|
||||
Err(e) => eprintln!("mail: {e}"),
|
||||
}
|
||||
}
|
||||
let failed = self.send_replies(replies);
|
||||
Ok(format!(
|
||||
"{applied} applied, {dropped} dropped{}{}",
|
||||
"{applied} applied, {dropped} dropped{}{}{}",
|
||||
match drained {
|
||||
Some(n) => format!(", {n} backlog discarded"),
|
||||
None => String::new(),
|
||||
},
|
||||
if deferred > 0 { format!(", {deferred} deferred") } else { String::new() },
|
||||
if failed > 0 { format!(", {failed} replies undeliverable") } else { String::new() }
|
||||
))
|
||||
}
|
||||
|
|
@ -248,14 +288,19 @@ impl Daemon {
|
|||
Ok(Some(requests.len()))
|
||||
}
|
||||
|
||||
/// Processes one stored envelope end to end. Ok(true) when the feed or
|
||||
/// state changed.
|
||||
/// Processes one stored envelope end to end.
|
||||
///
|
||||
/// Ordering matters twice over. The owner path runs first, so a mail from
|
||||
/// a rotated owner is never mistaken for a registration. And on the
|
||||
/// no-feed path the blocked check runs before anything touches the
|
||||
/// network, so Twt-Block cannot be worked around by re-registering.
|
||||
fn process(
|
||||
&self,
|
||||
id: [u8; 32],
|
||||
envelope: &[u8],
|
||||
replies: &mut Vec<(String, String, String)>,
|
||||
) -> Result<bool, String> {
|
||||
dials: &mut usize,
|
||||
) -> Result<Outcome, String> {
|
||||
let opened = envelope::open(self.account.keys(), envelope, now())
|
||||
.map_err(|e| format!("open {}: {e}", &fumi_core::crypto::b32(&id)[..8]))?;
|
||||
let incoming = Incoming {
|
||||
|
|
@ -267,23 +312,388 @@ impl Daemon {
|
|||
};
|
||||
|
||||
let mut state = self.state.lock().unwrap();
|
||||
// Admission: a feed must exist and its owner must still be accepted.
|
||||
// Requests-tier or blocked mail has neither and is dropped without a
|
||||
// reply (PLAN.md, Admission and Error replies) — main-tier inbox rows
|
||||
// are all `mail("inbox")` ever returns.
|
||||
let index = match state.feed_of(&opened.sender) {
|
||||
Some(index) => index,
|
||||
None => match self.follow_rotation(&incoming, &mut state)? {
|
||||
Some(index) => index,
|
||||
None => return Ok(false),
|
||||
},
|
||||
let owner = match state.feed_of(&opened.sender) {
|
||||
Some(index) => Some(index),
|
||||
None => self.follow_rotation(&incoming, &mut state)?,
|
||||
};
|
||||
let feed_state = state.feeds[index].clone();
|
||||
if !self.accepted(&feed_state.address)? {
|
||||
return Ok(false);
|
||||
|
||||
match owner {
|
||||
Some(index) => self.owner_mail(&incoming, index, &mut state, replies, dials),
|
||||
None => self.stranger_mail(&incoming, &mut state, replies, dials),
|
||||
}
|
||||
}
|
||||
|
||||
/// A mail from someone who already has a feed.
|
||||
fn owner_mail(
|
||||
&self,
|
||||
incoming: &Incoming,
|
||||
index: usize,
|
||||
state: &mut MutexGuard<'_, State>,
|
||||
replies: &mut Vec<(String, String, String)>,
|
||||
dials: &mut usize,
|
||||
) -> Result<Outcome, String> {
|
||||
let address = state.feeds[index].address.clone();
|
||||
// Blocked: their token is withdrawn, so nothing they send applies and
|
||||
// they get no reply either.
|
||||
if !self.accepted(&address)? {
|
||||
return Ok(Outcome::Dropped);
|
||||
}
|
||||
match dispatch::request(incoming) {
|
||||
// An owner's admin command needs no resolve: the feed's address
|
||||
// was verified when it was created and only a signed rotation
|
||||
// chain can move it, so the key on file is enough.
|
||||
Ok(Some(ref request)) if self.is_admin(&address) && is_admin_command(request) => {
|
||||
self.run_admin(request, &address, state, replies, dials)
|
||||
}
|
||||
// Help, or a request only an admin may send — either way the
|
||||
// reference is the useful answer, and it quietly omits the admin
|
||||
// section for a non-admin.
|
||||
Ok(Some(_)) => {
|
||||
self.queue_help(state, incoming, &address, index, replies);
|
||||
Ok(Outcome::Dropped)
|
||||
}
|
||||
Ok(None) => self
|
||||
.apply(incoming, index, state, replies)
|
||||
.map(|changed| if changed { Outcome::Applied } else { Outcome::Dropped }),
|
||||
Err(reason) => {
|
||||
self.queue_reply(state, incoming, &address, &reason, replies);
|
||||
Ok(Outcome::Dropped)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A mail from someone with no feed. Everything here is gated on proving
|
||||
/// they hold the address they claim.
|
||||
fn stranger_mail(
|
||||
&self,
|
||||
incoming: &Incoming,
|
||||
state: &mut MutexGuard<'_, State>,
|
||||
replies: &mut Vec<(String, String, String)>,
|
||||
dials: &mut usize,
|
||||
) -> Result<Outcome, String> {
|
||||
// No signed Reply-To, no address to answer or to register. Dropped in
|
||||
// silence, as before: without one there is nothing to verify and
|
||||
// replying would make this daemon a mail reflector for anyone holding
|
||||
// a freshly minted keypair.
|
||||
let Some(claim) = dispatch::claimed(incoming) else {
|
||||
return Ok(Outcome::Dropped);
|
||||
};
|
||||
let address = claim.short();
|
||||
|
||||
// Before any network call, so a blocked author cannot spend our
|
||||
// budget by re-registering.
|
||||
if self.store.accepted_state(&address).unwrap_or(None) == Some(false) {
|
||||
return Ok(Outcome::Dropped);
|
||||
}
|
||||
|
||||
self.apply(&incoming, index, &mut state, replies)
|
||||
let request = dispatch::request(incoming);
|
||||
let is_admin_address = self.is_admin(&address);
|
||||
let mode = state.registration(self.cfg.registration);
|
||||
|
||||
// Silent is the pre-self-service behaviour, and it must stay silent
|
||||
// even for a well-formed request — except for an admin, who would
|
||||
// otherwise have no way to turn registration back on.
|
||||
if mode == Mode::Silent && !is_admin_address {
|
||||
return Ok(Outcome::Dropped);
|
||||
}
|
||||
if !self.host_allowed(&claim) {
|
||||
return Ok(Outcome::Dropped);
|
||||
}
|
||||
if *dials >= DIALS_PER_POLL {
|
||||
return Ok(Outcome::Deferred);
|
||||
}
|
||||
|
||||
match request {
|
||||
// An admin with no feed still has to prove they hold the address,
|
||||
// because a signed Reply-To naming an admin proves nothing: anyone
|
||||
// can mint a key and sign a URI carrying someone else's address.
|
||||
Ok(Some(ref request)) if is_admin_address && is_admin_command(request) => {
|
||||
*dials += 1;
|
||||
if !self.holds_address(&claim, &incoming.sender)? {
|
||||
return Ok(Outcome::Dropped);
|
||||
}
|
||||
self.run_admin(request, &address, state, replies, dials)
|
||||
}
|
||||
Ok(Some(Request::Register { nick })) => {
|
||||
if mode == Mode::Closed {
|
||||
self.queue_closed(state, incoming, &address, replies);
|
||||
return Ok(Outcome::Dropped);
|
||||
}
|
||||
// Counted before the dial, not after: a registration that
|
||||
// fails its key check costs the same outbound connection as
|
||||
// one that succeeds.
|
||||
if !global_allowed(
|
||||
state,
|
||||
Global::Attempted,
|
||||
self.cfg.registrations_per_hour,
|
||||
now(),
|
||||
) {
|
||||
return Ok(Outcome::Dropped);
|
||||
}
|
||||
*dials += 1;
|
||||
self.register(&claim, nick.as_deref(), incoming, state, replies)
|
||||
}
|
||||
// Help, an admin command from a non-admin, no request at all, or a
|
||||
// malformed one: all the same answer, which is how to get a feed.
|
||||
// A non-admin is never told that admin commands exist.
|
||||
_ => {
|
||||
if mode == Mode::Closed {
|
||||
self.queue_closed(state, incoming, &address, replies);
|
||||
return Ok(Outcome::Dropped);
|
||||
}
|
||||
if !global_allowed(state, Global::Informed, self.cfg.info_replies_per_hour, now()) {
|
||||
return Ok(Outcome::Dropped);
|
||||
}
|
||||
*dials += 1;
|
||||
// Resolving first is what makes the reply safe. Sending to the
|
||||
// claimed smol:// URI would pin the sender's key as verified
|
||||
// under that address — a permanent denial of service against
|
||||
// whoever really owns it — and sending to the short form
|
||||
// without this check would mail the real owner on a stranger's
|
||||
// behalf.
|
||||
if !self.holds_address(&claim, &incoming.sender)? {
|
||||
return Ok(Outcome::Dropped);
|
||||
}
|
||||
self.queue_info(state, incoming, &address, replies);
|
||||
Ok(Outcome::Dropped)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `claim` resolves, on its own server, to the key that signed the
|
||||
/// mail. This is the check that turns "someone claims this address" into
|
||||
/// "the address holder wrote to us".
|
||||
///
|
||||
/// A resolve failure is not an error worth surfacing: the address may
|
||||
/// simply not exist, which is the answer.
|
||||
fn holds_address(&self, claim: &Address, sender: &[u8; 32]) -> Result<bool, String> {
|
||||
let mut session = match client::connect(&self.store, claim, false, self.cfg.timeout) {
|
||||
Ok(session) => session,
|
||||
Err(e) => {
|
||||
eprintln!("claim {}: {e}", claim.short());
|
||||
return Ok(false);
|
||||
}
|
||||
};
|
||||
let resolved = client::trust_key(&self.store, session.transport(), claim);
|
||||
session.close();
|
||||
match resolved {
|
||||
Ok((key, _)) => Ok(&key == sender),
|
||||
Err(e) => {
|
||||
eprintln!("claim {}: {e}", claim.short());
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_admin(&self, address: &str) -> bool {
|
||||
self.cfg.admins.iter().any(|a| a == address)
|
||||
}
|
||||
|
||||
/// An empty allowlist means any host, and a non-empty one is the only way
|
||||
/// to stop the daemon dialling a host a stranger chose.
|
||||
fn host_allowed(&self, claim: &Address) -> bool {
|
||||
self.cfg.register_hosts.is_empty()
|
||||
|| self.cfg.register_hosts.iter().any(|h| h == &claim.host)
|
||||
}
|
||||
|
||||
/// Creates the feed and hands over the token. `expect` is the signing key,
|
||||
/// so admission refuses an address that resolves to anyone else.
|
||||
fn register(
|
||||
&self,
|
||||
claim: &Address,
|
||||
nick: Option<&str>,
|
||||
incoming: &Incoming,
|
||||
state: &mut MutexGuard<'_, State>,
|
||||
replies: &mut Vec<(String, String, String)>,
|
||||
) -> Result<Outcome, String> {
|
||||
let admitted = match register::admit(
|
||||
register::Context {
|
||||
cfg: &self.cfg,
|
||||
store: &self.store,
|
||||
account: &self.account,
|
||||
feeds: &self.feeds,
|
||||
},
|
||||
state,
|
||||
claim,
|
||||
nick,
|
||||
Some(&incoming.sender),
|
||||
) {
|
||||
Ok(admitted) => admitted,
|
||||
Err(e) => {
|
||||
// Includes the key-mismatch refusal, which must stay quiet:
|
||||
// the sender is not the address holder, so there is nobody
|
||||
// here to answer.
|
||||
eprintln!("register {}: {e}", claim.short());
|
||||
return Ok(Outcome::Dropped);
|
||||
}
|
||||
};
|
||||
|
||||
let url = register::feed_url(&self.cfg, &admitted.slug);
|
||||
replies.push((
|
||||
claim.short(),
|
||||
crate::i18n::t("welcome-subject"),
|
||||
crate::i18n::t1("welcome-body", "url", url.clone()),
|
||||
));
|
||||
if !admitted.synced {
|
||||
eprintln!(
|
||||
"register {}: the accept token did not reach the server",
|
||||
claim.short()
|
||||
);
|
||||
}
|
||||
if self.cfg.admin_notify {
|
||||
self.notify_admins(&admitted, claim, &url, replies);
|
||||
}
|
||||
Ok(Outcome::Applied)
|
||||
}
|
||||
|
||||
fn notify_admins(
|
||||
&self,
|
||||
admitted: ®ister::Admitted,
|
||||
claim: &Address,
|
||||
url: &str,
|
||||
replies: &mut Vec<(String, String, String)>,
|
||||
) {
|
||||
let mut args = fluent_bundle::FluentArgs::new();
|
||||
args.set("address", claim.short());
|
||||
args.set("url", url.to_owned());
|
||||
args.set("fingerprint", fumi_core::crypto::fingerprint(&admitted.key));
|
||||
let body = crate::i18n::t_args("admin-notify-body", Some(&args));
|
||||
let subject = crate::i18n::t1("admin-notify-subject", "slug", admitted.slug.clone());
|
||||
for admin in &self.cfg.admins {
|
||||
replies.push((admin.clone(), subject.clone(), body.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs an admin command. The caller has already established that the
|
||||
/// sender holds an admin address.
|
||||
fn run_admin(
|
||||
&self,
|
||||
request: &Request,
|
||||
address: &str,
|
||||
state: &mut MutexGuard<'_, State>,
|
||||
replies: &mut Vec<(String, String, String)>,
|
||||
dials: &mut usize,
|
||||
) -> Result<Outcome, String> {
|
||||
let what = match request {
|
||||
Request::Admit(target) => {
|
||||
if *dials >= DIALS_PER_POLL {
|
||||
return Ok(Outcome::Deferred);
|
||||
}
|
||||
*dials += 1;
|
||||
let target_addr =
|
||||
Address::parse(target).map_err(|e| format!("admit target: {e}"))?;
|
||||
// expect = None: the target has not written in, so there is no
|
||||
// signing key to match. The admin vouches, as on the CLI.
|
||||
match register::admit(
|
||||
register::Context {
|
||||
cfg: &self.cfg,
|
||||
store: &self.store,
|
||||
account: &self.account,
|
||||
feeds: &self.feeds,
|
||||
},
|
||||
state,
|
||||
&target_addr,
|
||||
None,
|
||||
None,
|
||||
) {
|
||||
Ok(admitted) => {
|
||||
let url = register::feed_url(&self.cfg, &admitted.slug);
|
||||
if admitted.created {
|
||||
replies.push((
|
||||
target_addr.short(),
|
||||
crate::i18n::t("welcome-subject"),
|
||||
crate::i18n::t1("welcome-body", "url", url.clone()),
|
||||
));
|
||||
}
|
||||
format!("admitted {target}: {url}")
|
||||
}
|
||||
Err(e) => format!("could not admit {target}: {e}"),
|
||||
}
|
||||
}
|
||||
Request::Block(target) => match self.store.block(target) {
|
||||
Ok(true) => {
|
||||
match client::push_tokens(&self.store, &self.account, None, self.cfg.timeout) {
|
||||
Ok(_) => format!("blocked {target}"),
|
||||
Err(e) => format!("blocked {target} locally, but the server still holds their token: {e}"),
|
||||
}
|
||||
}
|
||||
Ok(false) => format!("{target} was never accepted"),
|
||||
Err(e) => format!("could not block {target}: {e}"),
|
||||
},
|
||||
Request::Registration(mode) => {
|
||||
state.set_registration(*mode);
|
||||
state
|
||||
.save(&self.cfg.data.join("state.json"))
|
||||
.map_err(|e| format!("state: {e}"))?;
|
||||
format!("registration is now {mode:?}").to_lowercase()
|
||||
}
|
||||
// Not an admin command; the caller routes these elsewhere.
|
||||
Request::Register { .. } | Request::Help => return Ok(Outcome::Dropped),
|
||||
};
|
||||
replies.push((
|
||||
address.to_owned(),
|
||||
crate::i18n::t1("admin-ack-subject", "what", what.clone()),
|
||||
crate::i18n::t1("admin-ack-body", "what", what),
|
||||
));
|
||||
Ok(Outcome::Applied)
|
||||
}
|
||||
|
||||
/// The command reference, with the admin section only for an admin.
|
||||
fn queue_help(
|
||||
&self,
|
||||
state: &mut MutexGuard<'_, State>,
|
||||
incoming: &Incoming,
|
||||
address: &str,
|
||||
index: usize,
|
||||
replies: &mut Vec<(String, String, String)>,
|
||||
) {
|
||||
let key = payload_key(&incoming.sender, incoming.time, &incoming.body);
|
||||
if !reply_allowed(state, &incoming.sender, &key, now()) {
|
||||
return;
|
||||
}
|
||||
let slug = state.feeds[index].username.clone();
|
||||
let mut body = crate::i18n::t1("help-body", "url", register::feed_url(&self.cfg, &slug));
|
||||
if self.is_admin(address) {
|
||||
body.push_str(&crate::i18n::t("help-admin-section"));
|
||||
}
|
||||
replies.push((address.to_owned(), crate::i18n::t("help-subject"), body));
|
||||
}
|
||||
|
||||
fn queue_info(
|
||||
&self,
|
||||
state: &mut MutexGuard<'_, State>,
|
||||
incoming: &Incoming,
|
||||
address: &str,
|
||||
replies: &mut Vec<(String, String, String)>,
|
||||
) {
|
||||
let key = payload_key(&incoming.sender, incoming.time, &incoming.body);
|
||||
if !reply_allowed(state, &incoming.sender, &key, now()) {
|
||||
return;
|
||||
}
|
||||
let mailbox = format!("{}@{}", self.cfg.mailbox, self.cfg.smolmail_host);
|
||||
replies.push((
|
||||
address.to_owned(),
|
||||
crate::i18n::t("info-subject"),
|
||||
crate::i18n::t1("info-body", "mailbox", mailbox),
|
||||
));
|
||||
}
|
||||
|
||||
fn queue_closed(
|
||||
&self,
|
||||
state: &mut MutexGuard<'_, State>,
|
||||
incoming: &Incoming,
|
||||
address: &str,
|
||||
replies: &mut Vec<(String, String, String)>,
|
||||
) {
|
||||
let key = payload_key(&incoming.sender, incoming.time, &incoming.body);
|
||||
if !reply_allowed(state, &incoming.sender, &key, now()) {
|
||||
return;
|
||||
}
|
||||
replies.push((
|
||||
address.to_owned(),
|
||||
crate::i18n::t("info-closed-subject"),
|
||||
crate::i18n::t("info-closed-body"),
|
||||
));
|
||||
}
|
||||
|
||||
fn accepted(&self, address: &str) -> Result<bool, String> {
|
||||
|
|
@ -441,7 +851,7 @@ impl Daemon {
|
|||
replies: &mut Vec<(String, String, String)>,
|
||||
) {
|
||||
let key = payload_key(&incoming.sender, incoming.time, &incoming.body);
|
||||
if !reply_allowed(&mut **state, &incoming.sender, &key, now()) {
|
||||
if !reply_allowed(state, &incoming.sender, &key, now()) {
|
||||
return;
|
||||
}
|
||||
let text = std::str::from_utf8(&incoming.body)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue