From 80cc61e7f80ecef06efeb422adf8dd8f0ccf2658 Mon Sep 17 00:00:00 2001 From: reverse Date: Sun, 9 Aug 2026 07:34:42 +0000 Subject: [PATCH] exemptchanops +X: per-rank exemptions from flood/anticaps/repeat/censor/blockcolor/etc --- src/channels.rs | 36 ++++++++++++++++++++++++++++++++---- src/coremods/core_message.rs | 32 ++++++++++++++++++++------------ src/mode.rs | 21 +++++++++++++++++---- src/modules/reputation.rs | 1 + src/numeric.rs | 2 ++ src/users.rs | 4 ++-- 6 files changed, 74 insertions(+), 22 deletions(-) diff --git a/src/channels.rs b/src/channels.rs index a8eab69..5c39bb1 100644 --- a/src/channels.rs +++ b/src/channels.rs @@ -275,10 +275,11 @@ pub struct Channel { pub rmembers: HashMap, // remote members, by network uuid (S2S) pub modes: ChanModes, pub bans: Vec, - pub excepts: Vec, // +e ban exceptions - pub invex: Vec, // +I invite exceptions - pub filters: Vec, // +g word/glob message filters (mask = the glob) - pub invites: HashSet, // uids allowed past +i + pub excepts: Vec, // +e ban exceptions + pub invex: Vec, // +I invite exceptions + pub filters: Vec, // +g word/glob message filters (mask = the glob) + pub exemptchanops: Vec, // +X exemptions (mask = "restriction:rankchar") + pub invites: HashSet, // uids allowed past +i pub created: u64, // --- ephemeral flood counters (not modes; never rendered or synced) ------- pub msgflood_hits: HashMap>, // +f per-user message times @@ -306,6 +307,7 @@ impl Channel { excepts: Vec::new(), invex: Vec::new(), filters: Vec::new(), + exemptchanops: Vec::new(), invites: HashSet::new(), created: now(), msgflood_hits: HashMap::new(), @@ -355,6 +357,32 @@ impl Server { .unwrap_or(false) } + /// +X exemptchanops — is `uid` exempt from `restriction` in this channel? True + /// when a `+X :` entry names a rank they meet or exceed. + pub fn chanop_exempt(&self, uid: Uid, key: &str, restriction: &str) -> bool { + let Some(ch) = self.channels.get(key) else { + return false; + }; + let rank = self.rank(uid, key); + ch.exemptchanops.iter().any(|e| { + let Some((r, prefix)) = e.mask.split_once(':') else { + return false; + }; + if !r.eq_ignore_ascii_case(restriction) { + return false; + } + let need = match prefix.chars().next() { + Some('q') => RANK_OWNER, + Some('a') => RANK_ADMIN, + Some('o') => RANK_OP, + Some('h') => RANK_HALFOP, + Some('v') => RANK_VOICE, + _ => return false, + }; + rank >= need + }) + } + /// Lift any expired TBAN timed bans, announcing `MODE -b` to each channel. /// Called from the background tick. pub fn purge_tbans(&mut self) { diff --git a/src/coremods/core_message.rs b/src/coremods/core_message.rs index add74b0..32f220e 100644 --- a/src/coremods/core_message.rs +++ b/src/coremods/core_message.rs @@ -190,7 +190,11 @@ pub(crate) fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool) .get(&key) .map(|c| c.modes.moderated) .unwrap_or(false); - if moderated && s.rank(uid, &key) < RANK_VOICE && !op_only { + if moderated + && s.rank(uid, &key) < RANK_VOICE + && !op_only + && !s.chanop_exempt(uid, &key, "moderated") + { if !notice { s.numeric( uid, @@ -206,7 +210,11 @@ pub(crate) fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool) .get(&key) .map(|c| c.modes.reg_moderated) .unwrap_or(false); - if reg_moderated && s.rank(uid, &key) < RANK_VOICE && !s.is_logged_in(uid) { + if reg_moderated + && s.rank(uid, &key) < RANK_VOICE + && !s.is_logged_in(uid) + && !s.chanop_exempt(uid, &key, "regmoderated") + { if !notice { s.numeric( uid, @@ -218,7 +226,7 @@ pub(crate) fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool) } // +d delaymsg — a just-joined unprivileged user must wait before speaking if let Some(secs) = s.channels.get(&key).and_then(|c| c.modes.delaymsg) { - if s.rank(uid, &key) < RANK_VOICE { + if s.rank(uid, &key) < RANK_VOICE && !s.chanop_exempt(uid, &key, "delaymsg") { let joined = s .channels .get(&key) @@ -251,7 +259,7 @@ pub(crate) fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool) // +f message flood — ops/half-ops and opers are exempt; others get kicked let flood_exempt = s.rank(uid, &key) >= RANK_HALFOP || s.users.get(&uid).map(|u| u.flags.oper).unwrap_or(false); - if !flood_exempt { + if !flood_exempt && !s.chanop_exempt(uid, &key, "flood") { if let Some(ban) = s.messageflood_hit(uid, &key) { s.flood_kick(uid, &key, ban); return CmdResult::Fail; @@ -270,10 +278,10 @@ pub(crate) fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool) ) }) .unwrap_or_default(); - if notice && no_notice { + if notice && no_notice && !s.chanop_exempt(uid, &key, "nonotice") { return CmdResult::Fail; // +T — NOTICEs are silently dropped } - if no_ctcp && is_ctcp(text) && !is_action(text) { + if no_ctcp && is_ctcp(text) && !is_action(text) && !s.chanop_exempt(uid, &key, "noctcp") { if !notice { s.numeric( uid, @@ -283,7 +291,7 @@ pub(crate) fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool) } return CmdResult::Fail; } - if no_color && has_formatting(text) { + if no_color && has_formatting(text) && !s.chanop_exempt(uid, &key, "blockcolor") { if !notice { s.numeric( uid, @@ -310,7 +318,7 @@ pub(crate) fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool) .get(&key) .map(|c| c.filters.iter().any(|f| glob_match(&f.mask, text))) .unwrap_or(false); - if filtered { + if filtered && !s.chanop_exempt(uid, &key, "filter") { if !notice { s.numeric( uid, @@ -320,7 +328,7 @@ pub(crate) fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool) } return CmdResult::Fail; } - let mut body = if strip { + let mut body = if strip && !s.chanop_exempt(uid, &key, "stripcolor") { strip_formatting(text) } else { text.clone() @@ -331,7 +339,7 @@ pub(crate) fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool) .get(&key) .map(|c| c.modes.censor) .unwrap_or(false); - if censor_on && !s.censor.is_empty() { + if censor_on && !s.censor.is_empty() && !s.chanop_exempt(uid, &key, "censor") { match apply_censor(&body, &s.censor) { Some(b) => body = b, None => { @@ -347,7 +355,7 @@ pub(crate) fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool) } } // +B anticaps — reject a mostly-uppercase message (ops/opers exempt) - if !flood_exempt { + if !flood_exempt && !s.chanop_exempt(uid, &key, "anticaps") { if let Some(pct) = s.channels.get(&key).and_then(|c| c.modes.anticaps) { if caps_percent(&body).map(|p| p >= pct).unwrap_or(false) { if !notice { @@ -363,7 +371,7 @@ pub(crate) fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool) } // +K repeat — reject a line the sender just repeated; else remember it (ops exempt) if let Some(n) = s.channels.get(&key).and_then(|c| c.modes.repeat) { - if s.rank(uid, &key) < RANK_HALFOP { + if s.rank(uid, &key) < RANK_HALFOP && !s.chanop_exempt(uid, &key, "repeat") { let repeated = s .channels .get(&key) diff --git a/src/mode.rs b/src/mode.rs index e1fcf8d..614f013 100644 --- a/src/mode.rs +++ b/src/mode.rs @@ -92,6 +92,7 @@ static CHAN_MODES: &[&(dyn ChanMode + Sync)] = &[ &OPMODERATED, &DELAYMSG, &REPEAT, + &EXEMPTCHANOPS, ]; // --- prefix modes (+q/+a/+o/+h/+v): a per-member rank, needs a nick ---------- @@ -477,7 +478,8 @@ enum ListKind { Ban, Except, Invex, - Filter, // +g — message word/glob filters (not host masks) + Filter, // +g — message word/glob filters (not host masks) + ExemptChanOps, // +X — "restriction:rankchar" exemptions } impl ListKind { fn list<'a>(&self, c: &'a Channel) -> &'a Vec { @@ -486,6 +488,7 @@ impl ListKind { ListKind::Except => &c.excepts, ListKind::Invex => &c.invex, ListKind::Filter => &c.filters, + ListKind::ExemptChanOps => &c.exemptchanops, } } fn list_mut<'a>(&self, c: &'a mut Channel) -> &'a mut Vec { @@ -494,6 +497,7 @@ impl ListKind { ListKind::Except => &mut c.excepts, ListKind::Invex => &mut c.invex, ListKind::Filter => &mut c.filters, + ListKind::ExemptChanOps => &mut c.exemptchanops, } } /// (per-entry numeric, end-of-list numeric, name for the "End of …" line) @@ -503,12 +507,17 @@ impl ListKind { ListKind::Except => (RPL_EXCEPTLIST, RPL_ENDOFEXCEPTLIST, "exception list"), ListKind::Invex => (RPL_INVEXLIST, RPL_ENDOFINVEXLIST, "invite list"), ListKind::Filter => (RPL_SPAMFILTER, RPL_ENDOFSPAMFILTER, "spamfilter list"), + ListKind::ExemptChanOps => ( + RPL_EXEMPTIONLIST, + RPL_ENDOFEXEMPTIONLIST, + "exemptchanops list", + ), } } /// Ban-style lists hold host masks and get filled out to `nick!user@host`; - /// the +g filter list holds literal word/globs and is stored verbatim. + /// the +g filter and +X lists hold literal strings and are stored verbatim. fn normalizes(&self) -> bool { - !matches!(self, ListKind::Filter) + matches!(self, ListKind::Ban | ListKind::Except | ListKind::Invex) } } @@ -532,6 +541,10 @@ static FILTER: ListMode = ListMode { ch: 'g', kind: ListKind::Filter, }; +static EXEMPTCHANOPS: ListMode = ListMode { + ch: 'X', + kind: ListKind::ExemptChanOps, +}; impl ChanMode for ListMode { fn letter(&self) -> char { @@ -1234,7 +1247,7 @@ mod tests { #[test] fn registry_covers_all_channel_modes() { - for c in "qaohvbeIklmntiszpONCTcSRMfjFLgGuBQAPJUdK".chars() { + for c in "qaohvbeIklmntiszpONCTcSRMfjFLgGuBQAPJUdKX".chars() { assert!(chan_mode(c).is_some(), "missing handler for +{c}"); } assert!(chan_mode('y').is_none()); diff --git a/src/modules/reputation.rs b/src/modules/reputation.rs index 3427aed..4c5bac2 100644 --- a/src/modules/reputation.rs +++ b/src/modules/reputation.rs @@ -42,6 +42,7 @@ impl Module for ReputationMod { *e = (*e + 1).min(REP_CAP); } self.ticks += 1; + #[allow(clippy::manual_is_multiple_of)] // is_multiple_of is unstable on our MSRV if self.ticks % SAVE_EVERY == 0 { save(s); } diff --git a/src/numeric.rs b/src/numeric.rs index 6845e8a..47c841c 100644 --- a/src/numeric.rs +++ b/src/numeric.rs @@ -33,6 +33,8 @@ pub const ERR_DELAYREJOIN: u16 = 495; // +J — must wait before rejoining after pub const ERR_CANTSENDTOUSER: u16 = 531; // +c — no shared channel with the target pub const ERR_BADCHANNEL: u16 = 926; // CBAN — this channel name is forbidden pub const RPL_ENDOFSPAMFILTER: u16 = 940; // end of the +g word-filter list +pub const RPL_EXEMPTIONLIST: u16 = 954; // +X exemptchanops entry +pub const RPL_ENDOFEXEMPTIONLIST: u16 = 953; // end of the +X list pub const RPL_SPAMFILTER: u16 = 941; // one +g word-filter entry pub const RPL_KNOCK: u16 = 710; // channel gets the knock pub const RPL_KNOCKDLVR: u16 = 711; // knocker's ack diff --git a/src/users.rs b/src/users.rs index b47f04e..2ac1088 100644 --- a/src/users.rs +++ b/src/users.rs @@ -418,7 +418,7 @@ impl Server { uid, RPL_MYINFO, &format!( - "{} echoircd-{VERSION} iowxsgBDIHrRzWc qaohvbeIklimnpstzCTcSNORMfjFLgGuBQAPJUdK", + "{} echoircd-{VERSION} iowxsgBDIHrRzWc qaohvbeIklimnpstzCTcSNORMfjFLgGuBQAPJUdKX", self.name ), ); @@ -426,7 +426,7 @@ impl Server { uid, RPL_ISUPPORT, &format!( - "CHANTYPES=# PREFIX=(qaohv)~&@%+ CHANMODES=beIg,k,lfjFLHBJdK,ACGMNOPQRSTUcimnpstuz EXTBAN=,cmn WATCH=128 MONITOR=128 SILENCE=32 CALLERID=g WHOX CHATHISTORY=256 MSGREFTYPES=timestamp,msgid UTF8ONLY CASEMAPPING=ascii NICKLEN=30 CHANNELLEN=50 NETWORK={} :are supported by this server", + "CHANTYPES=# PREFIX=(qaohv)~&@%+ CHANMODES=beIgX,k,lfjFLHBJdK,ACGMNOPQRSTUcimnpstuz EXTBAN=,cmn WATCH=128 MONITOR=128 SILENCE=32 CALLERID=g WHOX CHATHISTORY=256 MSGREFTYPES=timestamp,msgid UTF8ONLY CASEMAPPING=ascii NICKLEN=30 CHANNELLEN=50 NETWORK={} :are supported by this server", self.network ), );