From e206f4edbb1a5b298c161865fb20fc433b499cf3 Mon Sep 17 00:00:00 2001 From: Jean Date: Sat, 18 Jul 2026 01:23:37 +0000 Subject: [PATCH] operserv: REDACT deletes a message by its msgid (draft/message-redaction), relayed server-sourced so no ircd oper privilege is needed --- api/src/lib.rs | 9 +++++++++ modules/operserv/src/lib.rs | 3 +++ modules/operserv/src/redact.rs | 15 +++++++++++++++ modules/protocol/inspircd/src/lib.rs | 13 +++++++++++++ 4 files changed, 40 insertions(+) create mode 100644 modules/operserv/src/redact.rs diff --git a/api/src/lib.rs b/api/src/lib.rs index dcf47a6..9ac056d 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -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. diff --git a/modules/operserv/src/lib.rs b/modules/operserv/src/lib.rs index 9e64258..24fb340 100644 --- a/modules/operserv/src/lib.rs +++ b/modules/operserv/src/lib.rs @@ -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] | AKILL DEL | AKILL LIST [pattern]\x02\nA network-wide user@host ban." }, HelpEntry { cmd: "SPAMFILTER", summary: "content spam filter (ircd m_filter)", detail: "Syntax: \x02SPAMFILTER ADD [+expiry] | SPAMFILTER DEL | 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> [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 | FORBID DEL | 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] | SQLINE DEL | SQLINE LIST [pattern]\x02\nBans matching nicknames." }, HelpEntry { cmd: "SNLINE", summary: "ban a realname pattern", detail: "Syntax: \x02SNLINE ADD [+expiry] | SNLINE DEL | SNLINE LIST [pattern]\x02\nBans matching real names." }, diff --git a/modules/operserv/src/redact.rs b/modules/operserv/src/redact.rs new file mode 100644 index 0000000..1db02de --- /dev/null +++ b/modules/operserv/src/redact.rs @@ -0,0 +1,15 @@ +use echo_api::{Sender, ServiceCtx}; + +// REDACT <#channel|nick> [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> [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.")); +} diff --git a/modules/protocol/inspircd/src/lib.rs b/modules/protocol/inspircd/src/lib.rs index fe02bb1..6ffa180 100644 --- a/modules/protocol/inspircd/src/lib.rs +++ b/modules/protocol/inspircd/src/lib.rs @@ -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: : SERVER :. 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() });