//! Mode handlers. //! //! Channel modes implement [`ChanMode`] and user modes implement [`UserMode`]; //! the MODE command parses the modestring and dispatches to the handler for each //! letter, so adding a mode is a new handler + one line in a table — never an //! edit to the parser. The handler set is an ordinary slice of zero-sized //! `&'static` values: no fixed cap, no per-mode allocation, no mutable registry. use std::sync::OnceLock; use crate::channels::{ normalize_ban_mask, Ban, ChanModes, Channel, MsgFlood, Rate, RANK_ADMIN, RANK_HALFOP, RANK_OP, RANK_OWNER, RANK_VOICE, }; use crate::numeric::*; use crate::server::{now, Server}; use crate::users::UserFlags; use crate::Uid; /// Outcome of applying one mode letter. pub enum Applied { /// Nothing to echo (a no-op, a rejected change, or a list query). No, /// Echo this change back to the channel; `Some(param)` appends a parameter. Yes(Option), } pub trait ChanMode: Sync { fn letter(&self) -> char; /// Whether to consume an argument for this sign (taken only if one remains). fn wants_param(&self, adding: bool) -> bool; /// A list mode (+b/+e/+I/+g/+X/+w): a query with no argument is just viewing, /// so it needn't require channel-operator rank (unlike setting an entry). fn is_list(&self) -> bool { false } /// Apply `+`/`-` to channel `key` (display name `chan`) on behalf of `uid`. fn apply( &self, s: &mut Server, chan: &str, key: &str, uid: Uid, adding: bool, param: Option<&str>, ) -> Applied; } /// Dynamic handlers for config-defined custom prefix modes (see modules::customprefix). static CUSTOM_PREFIX_HANDLERS: OnceLock> = OnceLock::new(); /// Build the custom-prefix handlers once, at boot, after `customprefix::init`. pub fn init_custom_prefixes() { let v: Vec = crate::modules::customprefix::custom_defs() .iter() .map(|d| Prefix { ch: d.letter, rank: d.rank, }) .collect(); let _ = CUSTOM_PREFIX_HANDLERS.set(v); } /// Look up the handler for a channel-mode letter (built-in, then custom prefixes). pub fn chan_mode(c: char) -> Option<&'static (dyn ChanMode + Sync)> { if let Some(m) = CHAN_MODES.iter().copied().find(|m| m.letter() == c) { return Some(m); } CUSTOM_PREFIX_HANDLERS .get()? .iter() .find(|p| p.ch == c) .map(|p| p as &(dyn ChanMode + Sync)) } /// The registered channel modes. Add a mode by adding its handler here. static CHAN_MODES: &[&(dyn ChanMode + Sync)] = &[ &OWNER, &ADMIN, &OP, &HALFOP, &VOICE, &BAN, &EXCEPT, &INVEX, &KEY, &LIMIT, &MODERATED, &NOEXTERNAL, &TOPICLOCK, &INVITEONLY, &SECRET, &SECUREONLY, &PRIVATE, &OPERONLY, &NONICK, &NOCTCP, &NONOTICE, &NOCOLOR, &STRIPCOLOR, ®ONLY, ®MODERATED, &CENSOR, &AUDITORIUM, &FILTER, &MSGFLOOD, &JOINFLOOD, &NICKFLOOD, &REDIRECT, &CHANHISTORY, &ANTICAPS, &NOKICKS, &ALLOWINVITE, &PERMANENT, &KICKNOREJOIN, &OPMODERATED, &DELAYMSG, &REPEAT, &EXEMPTCHANOPS, &AUTOOP, &DELAYJOIN, ]; // --- prefix modes (+q/+a/+o/+h/+v): a per-member rank, needs a nick ---------- struct Prefix { ch: char, rank: u8, // the rank this prefix grants } static OWNER: Prefix = Prefix { ch: 'q', rank: RANK_OWNER, }; static ADMIN: Prefix = Prefix { ch: 'a', rank: RANK_ADMIN, }; static OP: Prefix = Prefix { ch: 'o', rank: RANK_OP, }; static HALFOP: Prefix = Prefix { ch: 'h', rank: RANK_HALFOP, }; static VOICE: Prefix = Prefix { ch: 'v', rank: RANK_VOICE, }; impl ChanMode for Prefix { fn letter(&self) -> char { self.ch } fn wants_param(&self, _adding: bool) -> bool { true } fn apply( &self, s: &mut Server, chan: &str, key: &str, uid: Uid, adding: bool, param: Option<&str>, ) -> Applied { let Some(pn) = param else { return Applied::No; }; let Some(tuid) = s.find_nick(pn) else { s.numeric(uid, ERR_NOSUCHNICK, &format!("{pn} :No such nick/channel")); return Applied::No; }; let target_rank = match s.channels.get(key).and_then(|c| c.members.get(&tuid)) { Some(m) => m.rank(), None => { s.numeric( uid, ERR_USERNOTINCHANNEL, &format!("{pn} {chan} :They aren't on that channel"), ); return Applied::No; } }; // customprefix depriv=no: a member may not remove this prefix from themselves if !adding && tuid == uid && !crate::modules::customprefix::can_depriv(self.ch) { s.numeric( uid, ERR_CHANOPRIVSNEEDED, &format!("{chan} :You may not remove +{} from yourself", self.ch), ); return Applied::No; } // must out-rank (or match) both the rank needed to set/unset this prefix // (customprefix ranktoset/ranktounset, default the prefix's own rank) and the // target's current top rank — no de-opping someone above you. let needed = if adding { crate::modules::customprefix::rank_to_set(self.ch).unwrap_or(self.rank) } else { crate::modules::customprefix::rank_to_unset(self.ch).unwrap_or(self.rank) }; let src = s.rank(uid, key); if src < needed || src < target_rank { s.numeric( uid, ERR_CHANOPRIVSNEEDED, &format!("{chan} :You lack the channel rank to set +{}", self.ch), ); return Applied::No; } if let Some(m) = s .channels .get_mut(key) .and_then(|c| c.members.get_mut(&tuid)) { m.set_prefix(self.ch, adding); // routes built-in booleans + custom prefixes } // +D delayjoin: gaining a prefix reveals a hidden member if adding { s.reveal_member(tuid, key); } Applied::Yes(Some(pn.to_string())) } } // --- simple flag modes (+m +n +t +i +s): one bool, no param ----------------- struct Flag { ch: char, set: fn(&mut ChanModes, bool), } fn set_moderated(m: &mut ChanModes, v: bool) { m.moderated = v; } fn set_noexternal(m: &mut ChanModes, v: bool) { m.no_external = v; } fn set_topiclock(m: &mut ChanModes, v: bool) { m.topic_ops = v; } fn set_inviteonly(m: &mut ChanModes, v: bool) { m.invite_only = v; } fn set_secret(m: &mut ChanModes, v: bool) { m.secret = v; } static MODERATED: Flag = Flag { ch: 'm', set: set_moderated, }; static NOEXTERNAL: Flag = Flag { ch: 'n', set: set_noexternal, }; static TOPICLOCK: Flag = Flag { ch: 't', set: set_topiclock, }; static INVITEONLY: Flag = Flag { ch: 'i', set: set_inviteonly, }; static SECRET: Flag = Flag { ch: 's', set: set_secret, }; fn set_private(m: &mut ChanModes, v: bool) { m.private = v; } fn set_operonly(m: &mut ChanModes, v: bool) { m.oper_only = v; } fn set_nonick(m: &mut ChanModes, v: bool) { m.no_nick = v; } fn set_noctcp(m: &mut ChanModes, v: bool) { m.no_ctcp = v; } fn set_nonotice(m: &mut ChanModes, v: bool) { m.no_notice = v; } fn set_nocolor(m: &mut ChanModes, v: bool) { m.no_color = v; } fn set_stripcolor(m: &mut ChanModes, v: bool) { m.strip_color = v; } static PRIVATE: Flag = Flag { ch: 'p', set: set_private, }; static OPERONLY: OperFlagChan = OperFlagChan { ch: 'O', set: set_operonly, }; static NONICK: Flag = Flag { ch: 'N', set: set_nonick, }; static NOCTCP: Flag = Flag { ch: 'C', set: set_noctcp, }; static NONOTICE: Flag = Flag { ch: 'T', set: set_nonotice, }; static NOCOLOR: Flag = Flag { ch: 'c', set: set_nocolor, }; static STRIPCOLOR: Flag = Flag { ch: 'S', set: set_stripcolor, }; fn set_regonly(m: &mut ChanModes, v: bool) { m.reg_only = v; } fn set_regmoderated(m: &mut ChanModes, v: bool) { m.reg_moderated = v; } static REGONLY: Flag = Flag { ch: 'R', set: set_regonly, }; static REGMODERATED: Flag = Flag { ch: 'M', set: set_regmoderated, }; fn set_censor(m: &mut ChanModes, v: bool) { m.censor = v; } fn set_auditorium(m: &mut ChanModes, v: bool) { m.auditorium = v; } static CENSOR: Flag = Flag { ch: 'G', set: set_censor, }; static AUDITORIUM: Flag = Flag { ch: 'u', set: set_auditorium, }; fn set_nokicks(m: &mut ChanModes, v: bool) { m.nokicks = v; } fn set_allowinvite(m: &mut ChanModes, v: bool) { m.allowinvite = v; } fn set_permanent(m: &mut ChanModes, v: bool) { m.permanent = v; } fn set_opmoderated(m: &mut ChanModes, v: bool) { m.opmoderated = v; } fn set_delayjoin(m: &mut ChanModes, v: bool) { m.delayjoin = v; } static NOKICKS: Flag = Flag { ch: 'Q', set: set_nokicks, }; static ALLOWINVITE: Flag = Flag { ch: 'A', set: set_allowinvite, }; static PERMANENT: OperFlagChan = OperFlagChan { ch: 'P', set: set_permanent, }; static OPMODERATED: Flag = Flag { ch: 'U', set: set_opmoderated, }; static DELAYJOIN: Flag = Flag { ch: 'D', set: set_delayjoin, }; impl ChanMode for Flag { fn letter(&self) -> char { self.ch } fn wants_param(&self, _adding: bool) -> bool { false } fn apply( &self, s: &mut Server, _chan: &str, key: &str, _uid: Uid, adding: bool, _param: Option<&str>, ) -> Applied { if let Some(c) = s.channels.get_mut(key) { (self.set)(&mut c.modes, adding); } Applied::Yes(None) } } /// A channel flag only an IRC operator may **set** — for modes that reach past a /// single channel (a network resource, or a staff-only policy) where chan-op rank /// isn't enough. Anyone with the usual rank may clear it. Server / services authority /// (`mode_sudo`, used by SAMODE and the RPC/S2S appliers) bypasses the oper check. struct OperFlagChan { ch: char, set: fn(&mut ChanModes, bool), } impl ChanMode for OperFlagChan { fn letter(&self) -> char { self.ch } fn wants_param(&self, _adding: bool) -> bool { false } fn apply( &self, s: &mut Server, chan: &str, key: &str, uid: Uid, adding: bool, _param: Option<&str>, ) -> Applied { if adding && !s.mode_sudo && !s.is_oper(uid) { s.numeric( uid, ERR_NOPRIVILEGES, &format!("{chan} :Only IRC operators may set channel mode +{}", self.ch), ); return Applied::No; } if let Some(c) = s.channels.get_mut(key) { (self.set)(&mut c.modes, adding); } Applied::Yes(None) } } // --- +k channel key --------------------------------------------------------- struct Key; static KEY: Key = Key; impl ChanMode for Key { fn letter(&self) -> char { 'k' } fn wants_param(&self, _adding: bool) -> bool { true // take the key on set; consume (and ignore) it on unset } fn apply( &self, s: &mut Server, _chan: &str, key: &str, _uid: Uid, adding: bool, param: Option<&str>, ) -> Applied { if adding { let Some(k) = param else { return Applied::No; }; if let Some(c) = s.channels.get_mut(key) { c.modes.key = Some(k.to_string()); } Applied::Yes(Some(k.to_string())) } else { if let Some(c) = s.channels.get_mut(key) { c.modes.key = None; } Applied::Yes(Some("*".to_string())) } } } // --- +l user limit ---------------------------------------------------------- struct Limit; static LIMIT: Limit = Limit; impl ChanMode for Limit { fn letter(&self) -> char { 'l' } fn wants_param(&self, adding: bool) -> bool { adding // +l takes a number; -l takes nothing } fn apply( &self, s: &mut Server, _chan: &str, key: &str, _uid: Uid, adding: bool, param: Option<&str>, ) -> Applied { if adding { let Some(n) = param.and_then(|p| p.parse::().ok()) else { return Applied::No; }; if let Some(c) = s.channels.get_mut(key) { c.modes.limit = Some(n); } Applied::Yes(Some(n.to_string())) } else { if let Some(c) = s.channels.get_mut(key) { c.modes.limit = None; } Applied::Yes(None) } } } // --- +H chanhistory: replay recent messages to joiners ---------------------- struct ChanHistory; static CHANHISTORY: ChanHistory = ChanHistory; impl ChanMode for ChanHistory { fn letter(&self) -> char { 'H' } fn wants_param(&self, adding: bool) -> bool { adding // +H [:]; -H takes nothing } fn apply( &self, s: &mut Server, _chan: &str, key: &str, _uid: Uid, adding: bool, param: Option<&str>, ) -> Applied { if adding { let Some(p) = param else { return Applied::No; }; let (lines_s, secs_s) = p.split_once(':').unwrap_or((p, "0")); let Some(lines) = lines_s.parse::().ok().filter(|&n| n > 0) else { return Applied::No; }; let lines = lines.min(crate::modules::chathistory::HISTORY_CAP as u32); let secs = secs_s.parse::().unwrap_or(0); if let Some(c) = s.channels.get_mut(key) { c.modes.history = Some((lines, secs)); } Applied::Yes(Some(format!("{lines}:{secs}"))) } else { if let Some(c) = s.channels.get_mut(key) { c.modes.history = None; } Applied::Yes(None) } } } // --- list modes (+b bans, +e ban exceptions, +I invite exceptions) ---------- #[derive(Clone, Copy)] enum ListKind { Ban, Except, Invex, Filter, // +g — message word/glob filters (not host masks) ExemptChanOps, // +X — "restriction:rankchar" exemptions AutoOp, // +w — "prefixchar:hostmask" auto-status on join } impl ListKind { fn list<'a>(&self, c: &'a Channel) -> &'a Vec { match self { ListKind::Ban => &c.bans, ListKind::Except => &c.excepts, ListKind::Invex => &c.invex, ListKind::Filter => &c.filters, ListKind::ExemptChanOps => &c.exemptchanops, ListKind::AutoOp => &c.autoop, } } fn list_mut<'a>(&self, c: &'a mut Channel) -> &'a mut Vec { match self { ListKind::Ban => &mut c.bans, ListKind::Except => &mut c.excepts, ListKind::Invex => &mut c.invex, ListKind::Filter => &mut c.filters, ListKind::ExemptChanOps => &mut c.exemptchanops, ListKind::AutoOp => &mut c.autoop, } } /// (per-entry numeric, end-of-list numeric, name for the "End of …" line) fn numerics(&self) -> (u16, u16, &'static str) { match self { ListKind::Ban => (RPL_BANLIST, RPL_ENDOFBANLIST, "ban list"), 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", ), ListKind::AutoOp => (RPL_AUTOOPLIST, RPL_ENDOFAUTOOP, "autoop list"), } } /// Ban-style lists hold host masks and get filled out to `nick!user@host`; /// the +g filter and +X lists hold literal strings and are stored verbatim. fn normalizes(&self) -> bool { matches!(self, ListKind::Ban | ListKind::Except | ListKind::Invex) } } struct ListMode { ch: char, kind: ListKind, } static BAN: ListMode = ListMode { ch: 'b', kind: ListKind::Ban, }; static EXCEPT: ListMode = ListMode { ch: 'e', kind: ListKind::Except, }; static INVEX: ListMode = ListMode { ch: 'I', kind: ListKind::Invex, }; static FILTER: ListMode = ListMode { ch: 'g', kind: ListKind::Filter, }; static EXEMPTCHANOPS: ListMode = ListMode { ch: 'X', kind: ListKind::ExemptChanOps, }; static AUTOOP: ListMode = ListMode { ch: 'w', kind: ListKind::AutoOp, }; impl ChanMode for ListMode { fn letter(&self) -> char { self.ch } fn is_list(&self) -> bool { true } fn wants_param(&self, _adding: bool) -> bool { true // a mask to add/remove; absent ⇒ list query } fn apply( &self, s: &mut Server, chan: &str, key: &str, uid: Uid, adding: bool, param: Option<&str>, ) -> Applied { let (entry_num, end_num, noun) = self.kind.numerics(); // no mask ⇒ list query let Some(mask) = param else { // hidelist: low-rank members may be barred from viewing this list if crate::modules::hidelist::denied(s, uid, key, self.ch) { s.numeric( uid, ERR_CHANOPRIVSNEEDED, &format!("{chan} :You do not have access to view the {noun}"), ); return Applied::No; } let rows: Vec<(String, String, u64)> = s .channels .get(key) .map(|c| { self.kind .list(c) .iter() .map(|b| (b.mask.clone(), b.setter.clone(), b.ts)) .collect() }) .unwrap_or_default(); for (m, setter, ts) in rows { s.numeric(uid, entry_num, &format!("{chan} {m} {setter} {ts}")); } s.numeric(uid, end_num, &format!("{chan} :End of channel {noun}")); return Applied::No; }; // autoop (+w) embeds a status prefix to grant on join, applied under server // authority — so adding an entry that grants a prefix the setter couldn't grant // by hand would let e.g. a half-op auto-op itself. Gate it by the rank the // prefix needs (SAMODE / services authority bypasses via mode_sudo). if adding && matches!(self.kind, ListKind::AutoOp) && !s.mode_sudo { let pfx = mask.split_once(':').and_then(|(p, _)| p.chars().next()); let needed = match pfx { Some('q') => RANK_OWNER, Some('a') => RANK_ADMIN, Some('o') => RANK_OP, Some('h') => RANK_HALFOP, Some('v') => RANK_VOICE, _ => 0, }; if s.rank(uid, key) < needed { s.numeric( uid, ERR_CHANOPRIVSNEEDED, &format!( "{chan} :You lack the channel rank to auto-grant +{}", pfx.unwrap_or('?') ), ); return Applied::No; } } let mask = if self.kind.normalizes() { normalize_ban_mask(mask) } else { mask.to_string() }; if adding { let setter = s .users .get(&uid) .map(|u| u.nick.clone()) .unwrap_or_default(); if let Some(c) = s.channels.get_mut(key) { let list = self.kind.list_mut(c); if list.iter().any(|b| b.mask == mask) { return Applied::No; // already present } list.push(Ban { mask: mask.clone(), setter, ts: now(), expires: None, }); } Applied::Yes(Some(mask)) } else { let mut removed = false; if let Some(c) = s.channels.get_mut(key) { let list = self.kind.list_mut(c); let before = list.len(); list.retain(|b| b.mask != mask); removed = list.len() < before; } if removed { Applied::Yes(Some(mask)) } else { Applied::No } } } } // --- +z secure-only --------------------------------------------------------- /// `+z` — only TLS-connected users may join. It can only be *set* when every /// current member is already on TLS (else `ERR_ALLMUSTSSL`); the join-time block /// for non-secure users lives in [`crate::channels::Server::join`]. struct SecureOnly; static SECUREONLY: SecureOnly = SecureOnly; impl ChanMode for SecureOnly { fn letter(&self) -> char { 'z' } fn wants_param(&self, _adding: bool) -> bool { false } fn apply( &self, s: &mut Server, chan: &str, key: &str, uid: Uid, adding: bool, _param: Option<&str>, ) -> Applied { if adding { let members: Vec = s .channels .get(key) .map(|c| c.members.keys().copied().collect()) .unwrap_or_default(); let total = members.len(); let nonssl = members .iter() .filter(|m| !s.users.get(m).map(|u| u.secure).unwrap_or(false)) .count(); if nonssl > 0 { s.numeric( uid, ERR_ALLMUSTSSL, &format!( "{chan} :All members of the channel must be connected using TLS ({nonssl}/{total} are non-TLS)" ), ); return Applied::No; } } if let Some(c) = s.channels.get_mut(key) { c.modes.secure_only = adding; } Applied::Yes(None) } } // --- flood / rate modes (+f message, +j join, +F nick) and +L redirect ------ /// Parse a `count:secs` rate; both parts must be positive. fn parse_rate(p: &str) -> Option { let (a, b) = p.split_once(':')?; let count = a.parse::().ok()?; let secs = b.parse::().ok()?; (count > 0 && secs > 0).then_some(Rate { count, secs }) } /// `+f [*]lines:secs` — kick a user who sends more than `lines` messages in /// `secs`; a leading `*` also sets a +b ban on them. Enforced in the PRIVMSG path. struct MsgFloodMode; static MSGFLOOD: MsgFloodMode = MsgFloodMode; impl ChanMode for MsgFloodMode { fn letter(&self) -> char { 'f' } fn wants_param(&self, adding: bool) -> bool { adding } fn apply( &self, s: &mut Server, _chan: &str, key: &str, _uid: Uid, adding: bool, param: Option<&str>, ) -> Applied { if adding { let Some(p) = param else { return Applied::No; }; let ban = p.starts_with('*'); let Some(r) = parse_rate(if ban { &p[1..] } else { p }) else { return Applied::No; }; if let Some(c) = s.channels.get_mut(key) { c.modes.flood = Some(MsgFlood { lines: r.count, secs: r.secs, ban, }); } Applied::Yes(Some(p.to_string())) } else { if let Some(c) = s.channels.get_mut(key) { c.modes.flood = None; } Applied::Yes(None) } } } /// `+j joins:secs` — after that many joins in the window, the channel locks new /// joins out for 60s. Enforced in [`crate::channels::Server::join`]. struct JoinFloodMode; static JOINFLOOD: JoinFloodMode = JoinFloodMode; impl ChanMode for JoinFloodMode { fn letter(&self) -> char { 'j' } fn wants_param(&self, adding: bool) -> bool { adding } fn apply( &self, s: &mut Server, _chan: &str, key: &str, _uid: Uid, adding: bool, param: Option<&str>, ) -> Applied { if adding { let Some(r) = param.and_then(parse_rate) else { return Applied::No; }; let echo = format!("{}:{}", r.count, r.secs); if let Some(c) = s.channels.get_mut(key) { c.modes.joinflood = Some(r); } Applied::Yes(Some(echo)) } else { if let Some(c) = s.channels.get_mut(key) { c.modes.joinflood = None; } Applied::Yes(None) } } } /// `+F changes:secs` — after that many nick changes in the window, nick changes /// on the channel are blocked for 60s. Enforced in the NICK path. struct NickFloodMode; static NICKFLOOD: NickFloodMode = NickFloodMode; impl ChanMode for NickFloodMode { fn letter(&self) -> char { 'F' } fn wants_param(&self, adding: bool) -> bool { adding } fn apply( &self, s: &mut Server, _chan: &str, key: &str, _uid: Uid, adding: bool, param: Option<&str>, ) -> Applied { if adding { let Some(r) = param.and_then(parse_rate) else { return Applied::No; }; let echo = format!("{}:{}", r.count, r.secs); if let Some(c) = s.channels.get_mut(key) { c.modes.nickflood = Some(r); } Applied::Yes(Some(echo)) } else { if let Some(c) = s.channels.get_mut(key) { c.modes.nickflood = None; } Applied::Yes(None) } } } /// `+L #target` — when the channel is full (+l) new joiners are sent to /// `#target` instead. Enforced in [`crate::channels::Server::join`]. struct RedirectMode; static REDIRECT: RedirectMode = RedirectMode; impl ChanMode for RedirectMode { fn letter(&self) -> char { 'L' } fn wants_param(&self, adding: bool) -> bool { adding } fn apply( &self, s: &mut Server, chan: &str, key: &str, _uid: Uid, adding: bool, param: Option<&str>, ) -> Applied { if adding { let Some(t) = param else { return Applied::No; }; if !t.starts_with('#') || t.eq_ignore_ascii_case(chan) { return Applied::No; // must be another channel } if let Some(c) = s.channels.get_mut(key) { c.modes.redirect = Some(t.to_string()); } Applied::Yes(Some(t.to_string())) } else { if let Some(c) = s.channels.get_mut(key) { c.modes.redirect = None; } Applied::Yes(None) } } } /// +B `` — reject channel messages that are at least `` uppercase. /// Enforced in the message path; ops are exempt. struct AntiCapsMode; static ANTICAPS: AntiCapsMode = AntiCapsMode; impl ChanMode for AntiCapsMode { fn letter(&self) -> char { 'B' } fn wants_param(&self, adding: bool) -> bool { adding } fn apply( &self, s: &mut Server, _chan: &str, key: &str, _uid: Uid, adding: bool, param: Option<&str>, ) -> Applied { if adding { let pct = param.and_then(|p| p.parse::().ok()).filter(|&p| p >= 1); let Some(pct) = pct.map(|p| p.min(100)) else { return Applied::No; // needs a 1..=100 percentage }; if let Some(c) = s.channels.get_mut(key) { c.modes.anticaps = Some(pct); } Applied::Yes(Some(pct.to_string())) } else { if let Some(c) = s.channels.get_mut(key) { c.modes.anticaps = None; } Applied::Yes(None) } } } /// +J `` — after being kicked, a user can't rejoin for `` seconds. /// Enforced in `Server::join`. struct KickNoRejoinMode; static KICKNOREJOIN: KickNoRejoinMode = KickNoRejoinMode; impl ChanMode for KickNoRejoinMode { fn letter(&self) -> char { 'J' } fn wants_param(&self, adding: bool) -> bool { adding } fn apply( &self, s: &mut Server, _chan: &str, key: &str, _uid: Uid, adding: bool, param: Option<&str>, ) -> Applied { if adding { let Some(secs) = param .and_then(|p| p.parse::().ok()) .filter(|&n| n >= 1) else { return Applied::No; // needs a positive seconds value }; let secs = secs.min(3600); if let Some(c) = s.channels.get_mut(key) { c.modes.kicknorejoin = Some(secs); } Applied::Yes(Some(secs.to_string())) } else { if let Some(c) = s.channels.get_mut(key) { c.modes.kicknorejoin = None; } Applied::Yes(None) } } } /// +d `` — a newly-joined member can't speak for `` seconds. /// Enforced in the message path; voiced-or-above are exempt. struct DelayMsgMode; static DELAYMSG: DelayMsgMode = DelayMsgMode; impl ChanMode for DelayMsgMode { fn letter(&self) -> char { 'd' } fn wants_param(&self, adding: bool) -> bool { adding } fn apply( &self, s: &mut Server, _chan: &str, key: &str, _uid: Uid, adding: bool, param: Option<&str>, ) -> Applied { if adding { let Some(secs) = param .and_then(|p| p.parse::().ok()) .filter(|&n| n >= 1) else { return Applied::No; }; let secs = secs.min(3600); if let Some(c) = s.channels.get_mut(key) { c.modes.delaymsg = Some(secs); } Applied::Yes(Some(secs.to_string())) } else { if let Some(c) = s.channels.get_mut(key) { c.modes.delaymsg = None; } Applied::Yes(None) } } } /// +K `` — block a message identical to one of the sender's previous `` /// lines in this channel. Ops are exempt. struct RepeatMode; static REPEAT: RepeatMode = RepeatMode; impl ChanMode for RepeatMode { fn letter(&self) -> char { 'K' } fn wants_param(&self, adding: bool) -> bool { adding } fn apply( &self, s: &mut Server, _chan: &str, key: &str, _uid: Uid, adding: bool, param: Option<&str>, ) -> Applied { if adding { let Some(n) = param .and_then(|p| p.parse::().ok()) .filter(|&n| n >= 1) else { return Applied::No; }; let n = n.min(20); if let Some(c) = s.channels.get_mut(key) { c.modes.repeat = Some(n); } Applied::Yes(Some(n.to_string())) } else { if let Some(c) = s.channels.get_mut(key) { c.modes.repeat = None; } Applied::Yes(None) } } } // === user modes ============================================================ /// A user mode (+i/+w/+o) — same handler-object shape as [`ChanMode`]: an /// unbounded slice of zero-sized `&'static` handlers. pub trait UserMode: Sync { fn letter(&self) -> char; /// Apply `+`/`-` to the user; return `true` if it took effect (echo it). fn apply(&self, s: &mut Server, uid: Uid, adding: bool) -> bool; } /// Look up the handler for a user-mode letter. pub fn user_mode(c: char) -> Option<&'static (dyn UserMode + Sync)> { USER_MODES.iter().copied().find(|m| m.letter() == c) } static USER_MODES: &[&(dyn UserMode + Sync)] = &[ &INVISIBLE, &WALLOPS, &HELPOP, &OPER, &CLOAK, &BOT, &DEAF, &HIDECHANS, &HIDEOPER, ®DEAF, ®ISTERED, &SSLPM, &SNOMASK, &CALLERID, &SHOWWHOIS, &COMMONCHANS, ]; /// A simple boolean user flag (+i / +w). struct UFlag { ch: char, set: fn(&mut UserFlags, bool), } fn set_invisible(f: &mut UserFlags, v: bool) { f.invisible = v; } fn set_wallops(f: &mut UserFlags, v: bool) { f.wallops = v; } static INVISIBLE: UFlag = UFlag { ch: 'i', set: set_invisible, }; static WALLOPS: UFlag = UFlag { ch: 'w', set: set_wallops, }; fn set_bot(f: &mut UserFlags, v: bool) { f.bot = v; } fn set_deaf(f: &mut UserFlags, v: bool) { f.deaf = v; } fn set_hidechans(f: &mut UserFlags, v: bool) { f.hidechans = v; } fn set_hideoper(f: &mut UserFlags, v: bool) { f.hideoper = v; } fn set_regdeaf(f: &mut UserFlags, v: bool) { f.reg_only_pm = v; } fn set_sslpm(f: &mut UserFlags, v: bool) { f.ssl_pm = v; } fn set_callerid(f: &mut UserFlags, v: bool) { f.callerid = v; } fn set_showwhois(f: &mut UserFlags, v: bool) { f.showwhois = v; } fn set_commonchans(f: &mut UserFlags, v: bool) { f.deny_uncommon = v; } static BOT: UFlag = UFlag { ch: 'B', set: set_bot, }; static DEAF: UFlag = UFlag { ch: 'D', set: set_deaf, }; static HIDECHANS: UFlag = UFlag { ch: 'I', set: set_hidechans, }; static HIDEOPER: OperFlag = OperFlag { ch: 'H', set: set_hideoper, }; static REGDEAF: UFlag = UFlag { ch: 'R', set: set_regdeaf, }; static SSLPM: UFlag = UFlag { ch: 'z', set: set_sslpm, }; static CALLERID: UFlag = UFlag { ch: 'g', set: set_callerid, }; static SHOWWHOIS: OperFlag = OperFlag { ch: 'W', set: set_showwhois, }; static COMMONCHANS: UFlag = UFlag { ch: 'c', set: set_commonchans, }; impl UserMode for UFlag { fn letter(&self) -> char { self.ch } fn apply(&self, s: &mut Server, uid: Uid, adding: bool) -> bool { if let Some(u) = s.users.get_mut(&uid) { (self.set)(&mut u.flags, adding); true } else { false } } } fn set_helpop(f: &mut UserFlags, v: bool) { f.helpop = v; } fn set_snomask(f: &mut UserFlags, v: bool) { f.snomask = v; } /// An oper-only boolean flag (+H / +W / +h / +s): only an operator may **set** it; /// anyone may clear it. A non-oper who tries to set it is told they aren't an /// operator (`ERR_NOPRIVILEGES`) and the flag is left off — it never silently /// "sticks" with no effect. struct OperFlag { ch: char, set: fn(&mut UserFlags, bool), } impl UserMode for OperFlag { fn letter(&self) -> char { self.ch } fn apply(&self, s: &mut Server, uid: Uid, adding: bool) -> bool { if adding && !s.is_oper(uid) { s.numeric( uid, ERR_NOPRIVILEGES, ":Permission Denied- You're not an IRC operator", ); return false; } if let Some(u) = s.users.get_mut(&uid) { (self.set)(&mut u.flags, adding); true } else { false } } } /// `+o` is granted only by OPER; a user may `-o` (de-oper) themselves. struct OperMode; static OPER: OperMode = OperMode; impl UserMode for OperMode { fn letter(&self) -> char { 'o' } fn apply(&self, s: &mut Server, uid: Uid, adding: bool) -> bool { if adding { return false; // never self-granted } if s.users.get(&uid).is_none() { return false; } if let Some(u) = s.users.get_mut(&uid) { u.flags.oper = false; } // operprefix: drop the ! prefix in every channel now that they're not staff crate::modules::operprefix::clear_all(s, uid); true } } /// `+h` — helpop: marks a user as available for help (shown in WHOIS). Oper-only to /// set; anyone may clear it. static HELPOP: OperFlag = OperFlag { ch: 'h', set: set_helpop, }; /// `+x` — host cloaking. The cloak string is computed once at connect by /// [`crate::modules::cloak`]; this handler only toggles whether it's shown. /// `-x` (revealing the real host) is oper-only, so +x can't be flipped to dodge /// a channel ban that was set on the cloak. struct CloakMode; static CLOAK: CloakMode = CloakMode; impl UserMode for CloakMode { fn letter(&self) -> char { 'x' } fn apply(&self, s: &mut Server, uid: Uid, adding: bool) -> bool { // No-op unless a cloak was actually computed (i.e. a cloak_key is set). let has_cloak = s .users .get(&uid) .map(|u| !u.cloak.is_empty()) .unwrap_or(false); if !has_cloak { return false; } if !adding && !s.is_oper(uid) { s.numeric( uid, ERR_NOPRIVILEGES, ":Only operators may remove the +x host cloak", ); return false; } let old_prefix = s.users.get(&uid).map(|u| u.prefix()).unwrap_or_default(); if let Some(u) = s.users.get_mut(&uid) { u.flags.cloak = adding; } let (ident, disp) = s .users .get(&uid) .map(|u| (u.ident.clone(), u.host_display().to_string())) .unwrap_or_default(); // chghost: tell capable peers the displayed host changed s.notify_peers(uid, &format!(":{old_prefix} CHGHOST {ident} {disp}"), |c| { c.chghost }); s.numeric( uid, RPL_HOSTHIDDEN, &format!("{disp} :is now your displayed host"), ); true } } /// `+r` — "logged into an account". Set/cleared only by **services** (via /// [`crate::server::Server::set_login`]); the user can never toggle it by hand. struct RegisteredMode; static REGISTERED: RegisteredMode = RegisteredMode; impl UserMode for RegisteredMode { fn letter(&self) -> char { 'r' } fn apply(&self, _s: &mut Server, _uid: Uid, _adding: bool) -> bool { false // services-managed; not user-settable } } /// `+s` — server-notice (snomask) receiver. Oper-only to set; anyone may drop it. static SNOMASK: OperFlag = OperFlag { ch: 's', set: set_snomask, }; #[cfg(test)] mod tests { use super::*; #[test] fn registry_covers_all_channel_modes() { for c in "qaohvbeIklmntiszpONCTcSRMfjFLgGuBQAPJUdKXwD".chars() { assert!(chan_mode(c).is_some(), "missing handler for +{c}"); } assert!(chan_mode('y').is_none()); } #[test] fn rate_parsing() { assert!(parse_rate("5:10").is_some()); assert!(parse_rate("0:10").is_none()); // zero count rejected assert!(parse_rate("5:0").is_none()); // zero window rejected assert!(parse_rate("bad").is_none()); } #[test] fn registry_covers_all_user_modes() { for c in "iwoxBDIHrRzsgWhc".chars() { assert!(user_mode(c).is_some(), "missing umode +{c}"); } assert!(user_mode('Q').is_none()); } #[test] fn masks_are_normalized() { assert_eq!(normalize_ban_mask("bob"), "bob!*@*"); assert_eq!(normalize_ban_mask("bob@evil.host"), "*!bob@evil.host"); // user@host assert_eq!(normalize_ban_mask("a!b@c"), "a!b@c"); // extbans keep their type; only the hostmask part is filled out assert_eq!(normalize_ban_mask("m:bob"), "m:bob!*@*"); assert_eq!(normalize_ban_mask("c:*@evil"), "c:*!*@evil"); } }