operserv: REDACT deletes a message by its msgid (draft/message-redaction), relayed server-sourced so no ircd oper privilege is needed
All checks were successful
CI / check (push) Successful in 4m6s

This commit is contained in:
Jean Chevronnet 2026-07-18 01:23:37 +00:00
parent e6f05a3ede
commit e206f4edbb
No known key found for this signature in database
4 changed files with 40 additions and 0 deletions

View file

@ -156,6 +156,9 @@ pub enum NetAction {
DelLine { kind: String, mask: String },
// Disconnect a user from the network, sourced from pseudoclient `from`.
KillUser { from: String, uid: String, reason: String },
// Redact (delete) a message by its msgid, sourced from a pseudoclient. `target`
// is a channel or a uid; `reason` is optional (empty = none). draft/message-redaction.
Redact { from: String, target: String, msgid: String, reason: String },
// Introduce a juped server (a fake server holding a name so the real one
// can't link), sourced from our server. `sid` is its allocated server id.
JupeServer { name: String, sid: String, reason: String },
@ -617,6 +620,12 @@ impl ServiceCtx {
self.actions.push(NetAction::DelLine { kind: kind.wire().to_string(), mask: mask.to_string() });
}
// Redact (delete) a message by `msgid` in `target` (a channel or nick), sourced
// from pseudoclient `from`. `reason` may be empty. draft/message-redaction.
pub fn redact(&mut self, from: &str, target: &str, msgid: &str, reason: &str) {
self.actions.push(NetAction::Redact { from: from.to_string(), target: target.to_string(), msgid: msgid.to_string(), reason: reason.to_string() });
}
// Push a spam filter to the ircd's m_filter via a broadcast `METADATA * filter`.
// `value` is the encoded filter (see `encode_filter`). m_filter accepts adds
// this way; there is no matching metadata delete.

View file

@ -8,6 +8,7 @@ use echo_api::{HelpEntry, NetView, Sender, Service, ServiceCtx, Store};
#[path = "xline.rs"]
mod xline;
mod spamfilter;
mod redact;
#[path = "forbid.rs"]
mod forbid;
#[path = "global.rs"]
@ -78,6 +79,7 @@ impl Service for OperServ {
Some(cmd) if cmd.eq_ignore_ascii_case("SHUN") => xline::SHUN.handle(me, from, args, ctx, db),
Some(cmd) if cmd.eq_ignore_ascii_case("CBAN") => xline::CBAN.handle(me, from, args, ctx, db),
Some(cmd) if cmd.eq_ignore_ascii_case("SPAMFILTER") => spamfilter::handle(me, from, args, ctx, db),
Some(cmd) if cmd.eq_ignore_ascii_case("REDACT") => redact::handle(me, from, args, ctx),
Some(cmd) if cmd.eq_ignore_ascii_case("FORBID") => forbid::handle(me, from, args, ctx, db),
Some(cmd) if cmd.eq_ignore_ascii_case("GLOBAL") => global::handle(me, from, args, ctx),
Some(cmd) if cmd.eq_ignore_ascii_case("KILL") => kill::handle(me, from, args, ctx, net),
@ -112,6 +114,7 @@ const BLURB: &str = "OperServ holds the network operator tools. Every command is
const TOPICS: &[HelpEntry] = &[
HelpEntry { cmd: "AKILL", summary: "network-ban a user@host", detail: "Syntax: \x02AKILL ADD [+expiry] <user@host> <reason> | AKILL DEL <mask|number> | AKILL LIST [pattern]\x02\nA network-wide user@host ban." },
HelpEntry { cmd: "SPAMFILTER", summary: "content spam filter (ircd m_filter)", detail: "Syntax: \x02SPAMFILTER ADD <action> [+expiry] <regex> <reason> | SPAMFILTER DEL <regex|number> | SPAMFILTER LIST [pattern]\x02\nMatches message content (a regular expression, e.g. \x02.*free.*bitcoin.*\x02) and acts on it. Actions: gline, zline, block, silent, kill, shun, warn, none. echo persists filters and re-applies them to the ircd at each link. Admin only." },
HelpEntry { cmd: "REDACT", summary: "delete a message by its msgid", detail: "Syntax: \x02REDACT <#channel|nick> <msgid> [reason]\x02\nDeletes a specific message (by its IRCv3 \x02msgid\x02, which your client shows) from everyone who received it. draft/message-redaction." },
HelpEntry { cmd: "FORBID", summary: "ban a nick/chan/email from registration", detail: "Syntax: \x02FORBID ADD <NICK|CHAN|EMAIL> <mask> <reason> | FORBID DEL <NICK|CHAN|EMAIL> <mask> | FORBID LIST\x02\nStops a nick, channel, or email pattern from being registered." },
HelpEntry { cmd: "SQLINE", summary: "ban a nick pattern", detail: "Syntax: \x02SQLINE ADD [+expiry] <nick> <reason> | SQLINE DEL <mask|number> | SQLINE LIST [pattern]\x02\nBans matching nicknames." },
HelpEntry { cmd: "SNLINE", summary: "ban a realname pattern", detail: "Syntax: \x02SNLINE ADD [+expiry] <realname> <reason> | SNLINE DEL <mask|number> | SNLINE LIST [pattern]\x02\nBans matching real names." },

View file

@ -0,0 +1,15 @@
use echo_api::{Sender, ServiceCtx};
// REDACT <#channel|nick> <msgid> [reason]: delete a message by its IRCv3 msgid.
// The msgid comes from the reporting oper's client (which saw the tagged message);
// echo relays it as a server-trusted redaction, so no ircd oper privilege is needed
// (draft/message-redaction). One-shot — nothing is stored.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx) {
let (Some(&target), Some(&msgid)) = (args.get(1), args.get(2)) else {
ctx.notice(me, from.uid, "Syntax: REDACT <#channel|nick> <msgid> [reason]");
return;
};
let reason = if args.len() > 3 { args[3..].join(" ") } else { String::new() };
ctx.redact(me, target, msgid, &reason);
ctx.notice(me, from.uid, format!("Redacted message \x02{msgid}\x02 in \x02{target}\x02."));
}

View file

@ -475,6 +475,11 @@ impl Protocol for InspIrcd {
}
NetAction::DelLine { kind, mask } => vec![self.sourced(format!("DELLINE {} {}", kind, mask))],
NetAction::KillUser { from, uid, reason } => vec![format!(":{} KILL {} :{}", from, uid, reason)],
NetAction::Redact { from, target, msgid, reason } => vec![if reason.is_empty() {
format!(":{from} REDACT {target} {msgid}")
} else {
format!(":{from} REDACT {target} {msgid} :{reason}")
}],
// Introduce a server behind us: :<our-sid> SERVER <name> <sid> :<desc>.
NetAction::JupeServer { name, sid, reason } => {
vec![self.sourced(format!("SERVER {} {} :JUPED: {}", name, sid, reason))]
@ -768,6 +773,14 @@ mod tests {
assert_eq!(lines, vec![":42S ENCAP 0IR CHGIDENT 0IRAAAAAB cool".to_string()]);
}
#[test]
fn serializes_redact() {
let with = proto().serialize(&NetAction::Redact { from: "42SAAAAAA".into(), target: "#c".into(), msgid: "abc".into(), reason: "spam".into() });
assert_eq!(with, vec![":42SAAAAAA REDACT #c abc :spam".to_string()]);
let without = proto().serialize(&NetAction::Redact { from: "42SAAAAAA".into(), target: "alice".into(), msgid: "xyz".into(), reason: String::new() });
assert_eq!(without, vec![":42SAAAAAA REDACT alice xyz".to_string()], "no trailing when reason is empty");
}
#[test]
fn serializes_svspart() {
let lines = proto().serialize(&NetAction::ForcePart { uid: "0IRAAAAAB".into(), channel: "#chan".into(), reason: "bye".into() });