//! Channels: the `Channel` record, membership, channel modes, bans, invites and //! JOIN/NAMES. use crate::map::{HashMap, HashSet}; use crate::module::Hook; use crate::modules::chathistory::{HistMsg, History}; use crate::numeric::*; use crate::server::{iso_time, now, Server}; use crate::Uid; /// Per-member prefix modes (+q/+a/+o/+h/+v). Flag modes live in [`ChanModes`]. #[derive(Default)] pub struct Member { pub oprefix: bool, // operprefix/ojoin: server oper prefix (!), highest rank pub owner: bool, // +q (~) pub admin: bool, // +a (&) pub op: bool, // +o (@) pub halfop: bool, // +h (%) pub voice: bool, // +v (+) pub custom_prefixes: Vec, // config-defined prefix mode letters held (customprefix) pub joined: u64, // unix ts this member joined (for +d delaymsg; 0 = unknown) pub recent_msgs: Vec, // +K repeat: this member's last few lines here pub hidden: bool, // +D delayjoin: JOIN withheld until they reveal themselves } /// Prefix ranks, high→low — gate who may grant a prefix / kick whom. Spaced ×10 so /// config-defined custom prefixes (modules::customprefix) can slot in between. pub const RANK_OPER: u8 = 60; // operprefix/ojoin — above channel owner (network staff) pub const RANK_OWNER: u8 = 50; pub const RANK_ADMIN: u8 = 40; pub const RANK_OP: u8 = 30; pub const RANK_HALFOP: u8 = 20; pub const RANK_VOICE: u8 = 10; impl Member { /// This member's numeric rank (0 = plain member). /// Built-in tier rank from the fixed booleans (0 = none), ignoring custom prefixes. fn builtin_rank(&self) -> u8 { if self.oprefix { RANK_OPER } else if self.owner { RANK_OWNER } else if self.admin { RANK_ADMIN } else if self.op { RANK_OP } else if self.halfop { RANK_HALFOP } else if self.voice { RANK_VOICE } else { 0 } } pub fn rank(&self) -> u8 { let mut r = self.builtin_rank(); for &c in &self.custom_prefixes { if let Some(d) = crate::modules::customprefix::def_for_letter(c) { r = r.max(d.rank); } } r } /// Every (rank, sigil) prefix this member holds, high→low. Only allocates when /// custom prefixes are actually present. fn held(&self) -> Vec<(u8, &'static str)> { use crate::modules::customprefix::{def_for_letter, sigil}; let mut v: Vec<(u8, &'static str)> = Vec::new(); for (on, r, i) in [ (self.oprefix, RANK_OPER, 0), (self.owner, RANK_OWNER, 1), (self.admin, RANK_ADMIN, 2), (self.op, RANK_OP, 3), (self.halfop, RANK_HALFOP, 4), (self.voice, RANK_VOICE, 5), ] { if on { v.push((r, sigil(i))); } } for &c in &self.custom_prefixes { if let Some(d) = def_for_letter(c) { v.push((d.rank, d.sigil.as_str())); } } v.sort_by(|a, b| b.0.cmp(&a.0)); v } /// Highest prefix char for NAMES (`""` for a plain member). Sigils are /// config-overridable via [`crate::modules::customprefix`]. pub fn prefix_char(&self) -> &'static str { use crate::modules::customprefix::sigil; if self.custom_prefixes.is_empty() { // fast path: built-in tiers only if self.oprefix { sigil(0) } else if self.owner { sigil(1) } else if self.admin { sigil(2) } else if self.op { sigil(3) } else if self.halfop { sigil(4) } else if self.voice { sigil(5) } else { "" } } else { self.held().first().map(|(_, s)| *s).unwrap_or("") } } /// Set/clear a prefix mode by its letter — built-in booleans or, for a /// config-defined letter, the custom-prefix set (used by the S2S mode applier). /// Drop the standard status modes (q/a/o/h/v) — used when this side loses a /// channel-timestamp war and every member must be de-statused. pub fn clear_status(&mut self) { self.owner = false; self.admin = false; self.op = false; self.halfop = false; self.voice = false; } pub fn set_prefix(&mut self, letter: char, on: bool) { match letter { 'y' => self.oprefix = on, 'q' => self.owner = on, 'a' => self.admin = on, 'o' => self.op = on, 'h' => self.halfop = on, 'v' => self.voice = on, _ => { if crate::modules::customprefix::def_for_letter(letter).is_some() { self.custom_prefixes.retain(|&c| c != letter); if on { self.custom_prefixes.push(letter); } } } } } /// Every prefix char this member holds, high→low (for the `multi-prefix` cap). pub fn all_prefixes(&self) -> String { use crate::modules::customprefix::sigil; if self.custom_prefixes.is_empty() { let mut s = String::new(); for (on, i) in [ (self.oprefix, 0), (self.owner, 1), (self.admin, 2), (self.op, 3), (self.halfop, 4), (self.voice, 5), ] { if on { s.push_str(sigil(i)); } } s } else { self.held().iter().map(|(_, s)| *s).collect() } } /// Every status mode *letter* this member holds (`o`, `v`, custom letters), /// high→low. The server-to-server membership burst lists members as /// `,`, so this is the letter form of `all_prefixes`. pub fn mode_letters(&self) -> String { use crate::modules::customprefix::def_for_letter; let mut s = String::new(); for (on, l) in [ (self.oprefix, 'y'), (self.owner, 'q'), (self.admin, 'a'), (self.op, 'o'), (self.halfop, 'h'), (self.voice, 'v'), ] { if on { s.push(l); } } for &c in &self.custom_prefixes { if def_for_letter(c).is_some() { s.push(c); } } s } } pub struct Topic { pub text: String, pub setter: String, pub ts: u64, } /// A +b ban: a `nick!user@host` glob, who set it and when. pub struct Ban { pub mask: String, pub setter: String, pub ts: u64, pub expires: Option, // TBAN: unix ts to auto-lift at (None = permanent) } /// +f message flood: `[*]lines:secs` — kick past `lines` msgs in `secs` (and set /// a +b ban too when `ban`, from the leading `*`). #[derive(Clone)] pub struct MsgFlood { pub lines: u32, pub secs: u64, pub ban: bool, } /// A `count:secs` rate, shared by +j (join flood) and +F (nick-change flood). #[derive(Clone)] pub struct Rate { pub count: u32, pub secs: u64, } /// Channel modes other than the per-member prefixes. #[derive(Default)] pub struct ChanModes { pub moderated: bool, // +m — only +o/+v may speak pub topic_ops: bool, // +t — only ops may set the topic pub no_external: bool, // +n — must be a member to message it pub invite_only: bool, // +i pub secret: bool, // +s pub key: Option, // +k pub limit: Option, // +l pub secure_only: bool, // +z — only TLS-connected users may join pub private: bool, // +p — private (hidden from WHOIS channel list) pub oper_only: bool, // +O — only IRC operators may join pub no_nick: bool, // +N — members can't change nick while here pub no_ctcp: bool, // +C — block CTCP to the channel pub no_notice: bool, // +T — block NOTICEs to the channel pub no_color: bool, // +c — reject messages with formatting/colour pub strip_color: bool, // +S — strip formatting/colour from messages pub reg_only: bool, // +R — only logged-in (account) users may join pub reg_moderated: bool, // +M — only logged-in users may speak pub censor: bool, // +G — replace configured bad words pub auditorium: bool, // +u — hide non-ops from non-ops pub flood: Option, // +f pub joinflood: Option, // +j pub nickflood: Option, // +F pub redirect: Option, // +L <#target> — when full, send there pub history: Option<(u32, u64)>, // +H : — replay recent messages to joiners pub anticaps: Option, // +B — block messages that are mostly CAPS pub nokicks: bool, // +Q — KICK is disabled on the channel pub allowinvite: bool, // +A — any member (not just ops) may INVITE pub permanent: bool, // +P — channel persists with zero members pub kicknorejoin: Option, // +J — block rejoin for N secs after a kick pub opmoderated: bool, // +U — unprivileged users' messages go to ops only pub delaymsg: Option, // +d — new joiners can't speak for N secs pub repeat: Option, // +K — block a line repeated within your last n pub delayjoin: bool, // +D — hide JOINs until the user speaks/reveals pub registered: bool, // +r — set by services on a registered channel // (server/services-only; not user-settable) } impl ChanModes { /// Set/clear a no-parameter flag mode by its letter (the S2S mode applier). pub fn set_by_letter(&mut self, c: char, on: bool) { match c { 'm' => self.moderated = on, 'n' => self.no_external = on, 't' => self.topic_ops = on, 'i' => self.invite_only = on, 's' => self.secret = on, 'z' => self.secure_only = on, 'p' => self.private = on, 'O' => self.oper_only = on, 'N' => self.no_nick = on, 'C' => self.no_ctcp = on, 'T' => self.no_notice = on, 'c' => self.no_color = on, 'S' => self.strip_color = on, 'R' => self.reg_only = on, 'M' => self.reg_moderated = on, 'G' => self.censor = on, 'u' => self.auditorium = on, 'Q' => self.nokicks = on, 'A' => self.allowinvite = on, 'P' => self.permanent = on, 'U' => self.opmoderated = on, 'D' => self.delayjoin = on, 'r' => self.registered = on, _ => {} } } /// `+mnt`, or with `params` the +k/+l arguments too: `+ntkl secret 20`. pub fn render(&self, params: bool) -> String { let mut s = String::from("+"); for (on, ch) in [ (self.registered, 'r'), (self.invite_only, 'i'), (self.moderated, 'm'), (self.no_external, 'n'), (self.secret, 's'), (self.topic_ops, 't'), (self.secure_only, 'z'), (self.private, 'p'), (self.oper_only, 'O'), (self.no_nick, 'N'), (self.no_ctcp, 'C'), (self.no_notice, 'T'), (self.no_color, 'c'), (self.strip_color, 'S'), (self.reg_only, 'R'), (self.reg_moderated, 'M'), (self.censor, 'G'), (self.auditorium, 'u'), (self.permanent, 'P'), (self.nokicks, 'Q'), (self.allowinvite, 'A'), (self.opmoderated, 'U'), (self.delayjoin, 'D'), ] { if on { s.push(ch); } } if self.key.is_some() { s.push('k'); } if self.limit.is_some() { s.push('l'); } if self.flood.is_some() { s.push('f'); } if self.joinflood.is_some() { s.push('j'); } if self.nickflood.is_some() { s.push('F'); } if self.redirect.is_some() { s.push('L'); } if self.history.is_some() { s.push('H'); } if self.anticaps.is_some() { s.push('B'); } if self.kicknorejoin.is_some() { s.push('J'); } if self.delaymsg.is_some() { s.push('d'); } if self.repeat.is_some() { s.push('K'); } if params { if let Some(k) = &self.key { s.push(' '); s.push_str(k); } if let Some(l) = self.limit { s.push(' '); s.push_str(&l.to_string()); } if let Some(f) = &self.flood { let star = if f.ban { "*" } else { "" }; s.push_str(&format!(" {star}{}:{}", f.lines, f.secs)); } if let Some(j) = &self.joinflood { s.push_str(&format!(" {}:{}", j.count, j.secs)); } if let Some(n) = &self.nickflood { s.push_str(&format!(" {}:{}", n.count, n.secs)); } if let Some(t) = &self.redirect { s.push(' '); s.push_str(t); } if let Some((n, t)) = &self.history { s.push_str(&format!(" {n}:{t}")); } if let Some(p) = self.anticaps { s.push_str(&format!(" {p}")); } if let Some(secs) = self.kicknorejoin { s.push_str(&format!(" {secs}")); } if let Some(secs) = self.delaymsg { s.push_str(&format!(" {secs}")); } if let Some(n) = self.repeat { s.push_str(&format!(" {n}")); } } s } } pub struct Channel { pub name: String, // display casing pub topic: Option, pub members: HashMap, 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 exemptchanops: Vec, // +X exemptions (mask = "restriction:rankchar") pub autoop: Vec, // +w auto-status (mask = "prefixchar:hostmask") 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 pub joinflood_hits: Vec, // +j recent join times pub joinflood_until: u64, // +j locked out until this unix ts pub nickflood_hits: Vec, // +F recent nick-change times pub nickflood_until: u64, // +F locked out until this unix ts pub recent_kicks: HashMap, // +J uid -> unix ts of last kick (rejoin delay) } impl Channel { /// A fresh channel with the default `+nt` modes. pub fn new(name: &str) -> Channel { Channel { name: name.to_string(), topic: None, members: HashMap::default(), rmembers: HashMap::default(), modes: ChanModes { no_external: true, topic_ops: true, ..Default::default() }, bans: Vec::new(), excepts: Vec::new(), invex: Vec::new(), filters: Vec::new(), exemptchanops: Vec::new(), autoop: Vec::new(), invites: HashSet::default(), created: now(), msgflood_hits: HashMap::default(), joinflood_hits: Vec::new(), joinflood_until: 0, nickflood_hits: Vec::new(), nickflood_until: 0, recent_kicks: HashMap::default(), } } /// True once no local *and* no remote members remain (safe to drop). pub fn is_empty(&self) -> bool { self.members.is_empty() && self.rmembers.is_empty() } /// Keep this channel in the table: it has members, or it's +P (permanent). pub fn keep_alive(&self) -> bool { // A registered (+r) channel persists with zero members, like +P: otherwise a // sole user leaving destroys it, and their rejoin recreates it with a new TS — // which makes linked services re-assert the +r lock (and re-op) on every visit. !self.is_empty() || self.modes.permanent || self.modes.registered } } impl Server { /// A member's channel rank (0 if not a member). pub fn rank(&self, uid: Uid, key: &str) -> u8 { // SAMODE/SAKICK: mode_sudo makes every rank() check pass, bypassing the ladder. if self.mode_sudo { return RANK_OWNER; } self.channels .get(key) .and_then(|c| c.members.get(&uid)) .map(|m| m.rank()) .unwrap_or(0) } pub fn is_op(&self, uid: Uid, key: &str) -> bool { self.rank(uid, key) >= RANK_OP } pub fn is_member(&self, uid: Uid, key: &str) -> bool { self.channels .get(key) .map(|c| c.members.contains_key(&uid)) .unwrap_or(false) } /// +D delayjoin: announce a hidden member's withheld JOIN now (they revealed /// themselves by speaking / being opped / changing nick). No-op if not hidden. pub fn reveal_member(&mut self, uid: Uid, key: &str) { let hidden = self .channels .get(key) .and_then(|c| c.members.get(&uid)) .map(|m| m.hidden) .unwrap_or(false); if !hidden { return; } if let Some(m) = self .channels .get_mut(key) .and_then(|c| c.members.get_mut(&uid)) { m.hidden = false; } let name = self .channels .get(key) .map(|c| c.name.clone()) .unwrap_or_else(|| key.to_string()); let aud = self .channels .get(key) .map(|c| c.modes.auditorium) .unwrap_or(false); let (prefix, acct, realname) = match self.users.get(&uid) { Some(u) => ( u.prefix(), u.account.clone().unwrap_or_else(|| "*".to_string()), u.realname.clone(), ), None => return, }; let plain = format!(":{prefix} JOIN {name}"); let extended = format!(":{prefix} JOIN {name} {acct} :{realname}"); let members: Vec = self .channels .get(key) .map(|c| c.members.keys().copied().collect()) .unwrap_or_default(); for m in members { if m == uid { continue; // they already saw their own JOIN } if aud && self.rank(m, key) < RANK_OP { continue; // +u auditorium still hides them from non-ops } let ext = self .users .get(&m) .map(|u| u.caps.extended_join) .unwrap_or(false); self.send(m, if ext { extended.clone() } else { plain.clone() }); } } /// +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) { let now = now(); let mut expired: Vec<(String, String, String)> = Vec::new(); // (key, name, mask) for (key, ch) in &self.channels { for b in &ch.bans { if b.expires.is_some_and(|e| e <= now) { expired.push((key.clone(), ch.name.clone(), b.mask.clone())); } } } for (key, name, mask) in expired { if let Some(ch) = self.channels.get_mut(&key) { ch.bans.retain(|b| b.mask != mask); } self.to_channel(&key, &format!(":{} MODE {name} -b {mask}", self.name), None); // Propagate the removal to linked servers, else they keep the ban // forever (they stored the original +b without its expiry). let sid = self.sid.clone(); self.propagate_chan_mode(&sid, &name, "-b", &[mask]); } } /// Drop per-member flood state left behind by users who have left a channel /// (a slow leak on high-churn channels — Uids are never reused, so departed /// entries never get overwritten), and +J rejoin-block entries whose window has /// elapsed. Called from the background tick. pub fn purge_flood_state(&mut self) { let now = now(); for ch in self.channels.values_mut() { // +f message-time counters only matter for current members if !ch.msgflood_hits.is_empty() { let members = &ch.members; ch.msgflood_hits.retain(|uid, _| members.contains_key(uid)); } // +J recent-kick timestamps only matter within the (current) rejoin-block // window; once it's elapsed — or +J is off — the entry can't block anyone. if !ch.recent_kicks.is_empty() { let window = ch.modes.kicknorejoin.unwrap_or(0) as u64; ch.recent_kicks .retain(|_, ts| window > 0 && now.saturating_sub(*ts) < window); } } } /// Force `uid` out of `chan` (SAPART / SVSPART enforcement): announce the PART /// to the channel and to links, drop the membership, reap the channel if empty. /// No-op if the user isn't a member. pub fn force_part(&mut self, uid: Uid, chan: &str, reason: &str) { let key = chan.to_ascii_lowercase(); if !self.is_member(uid, &key) { return; } let prefix = self.users[&uid].prefix(); // +D delayjoin: a still-hidden member's PART is shown only to themselves let hidden = self .channels .get(&key) .and_then(|c| c.members.get(&uid)) .map(|m| m.hidden) .unwrap_or(false); let line = format!(":{prefix} PART {chan} :{reason}"); if hidden { self.send(uid, line); } else { self.to_channel(&key, &line, None); } self.propagate_part(uid, chan, reason); if let Some(ch) = self.channels.get_mut(&key) { ch.members.remove(&uid); } if let Some(u) = self.users.get_mut(&uid) { u.channels.remove(&key); } self.channels.retain(|_, c| c.keep_alive()); } /// Join a user to a channel (creating it if new, giving the creator +o), /// then broadcast JOIN and send TOPIC + NAMES. Queues the join hook. pub fn join(&mut self, uid: Uid, name: &str, key_arg: Option<&str>) { if !valid_chan(name, self.conf_num("maxchannel", 50usize)) { self.numeric(uid, ERR_NOSUCHCHANNEL, &format!("{name} :No such channel")); return; } let key = name.to_ascii_lowercase(); if self .users .get(&uid) .map(|u| u.channels.contains(&key)) .unwrap_or(true) { return; // unknown user, or already joined } // IRC operators override the join restrictions below; each bypass sets // `overrode`, snoticed once the join succeeds. let is_oper = self.users.get(&uid).map(|u| u.flags.oper).unwrap_or(false); let mut overrode = false; // connectclass max-channels cap (opers exempt) if !is_oper { if let Some(max) = crate::modules::connclass::max_chans(self, uid) { if self.users.get(&uid).map(|u| u.channels.len()).unwrap_or(0) >= max { self.numeric( uid, ERR_TOOMANYCHANNELS, &format!("{name} :You have joined too many channels"), ); return; } } } // CBAN — a forbidden channel name (opers bypass) if !is_oper { if let Some(reason) = self.matched_cban(&key) { self.numeric( uid, ERR_BADCHANNEL, &format!("{name} :Channel is CBAN'd: {reason}"), ); return; } } // denychans — a configured forbidden channel name (opers bypass per badchan) if crate::modules::denychans::intercept(self, uid, name, is_oper) { return; } // restrictchans — only opers may create new channels (unless whitelisted) if crate::modules::restrictchans::intercept(self, uid, name, is_oper) { return; } // channames — forbidden characters in new channel names if crate::modules::channames::intercept(self, uid, name) { return; } // an existing channel can refuse the join (+k / +b / +i / +z / +R / +J) if let Some(ch) = self.channels.get(&key) { if let Some(k) = &ch.modes.key { if key_arg != Some(k.as_str()) { if !is_oper { self.numeric( uid, ERR_BADCHANNELKEY, &format!("{name} :Cannot join channel (+k)"), ); return; } overrode = true; } } // +b — bans block even an invited user, unless a +e exception matches // (both honour the g: security-group extban) if self.ban_list_hit(uid, &ch.bans) && !self.ban_list_hit(uid, &ch.excepts) { if !is_oper { // banredirect: `+b mask$#chan` bounces the user into #chan (once) if let Some(t) = crate::modules::banredirect::redirect_target(self, uid, &key) { let tl = t.to_ascii_lowercase(); if !self.in_redirect && tl != key && !self.is_member(uid, &tl) { self.numeric( uid, ERR_LINKCHANNEL, &format!("{name} {t} :Cannot join channel (+b), redirecting"), ); self.in_redirect = true; self.join(uid, &t, None); self.in_redirect = false; return; } } self.numeric( uid, ERR_BANNEDFROMCHAN, &format!("{name} :Cannot join channel (+b)"), ); return; } overrode = true; } // +i — unless invited or matched by a +I invite exception if ch.modes.invite_only && !ch.invites.contains(&uid) && !self.ban_list_hit(uid, &ch.invex) { if !is_oper { self.numeric( uid, ERR_INVITEONLYCHAN, &format!("{name} :Cannot join channel (+i)"), ); return; } overrode = true; } // +z — TLS-connected users only if ch.modes.secure_only && !self.users.get(&uid).map(|u| u.secure).unwrap_or(false) { if !is_oper { self.numeric( uid, ERR_SECUREONLYCHAN, &format!("{name} :Cannot join channel; TLS users only (+z is set)"), ); return; } overrode = true; } // +O — IRC operators only (opers are allowed by definition) if ch.modes.oper_only && !is_oper { self.numeric( uid, ERR_CANTJOINOPERSONLY, &format!("{name} :Cannot join channel; IRC operators only (+O is set)"), ); return; } // +R — must be logged into an account (services-registered) if ch.modes.reg_only && self .users .get(&uid) .map(|u| u.account.is_none()) .unwrap_or(true) { if !is_oper { self.numeric( uid, ERR_NEEDREGGEDNICK, &format!("{name} :Cannot join channel; you must be logged in (+R is set)"), ); return; } overrode = true; } // +J — can't rejoin within N seconds of being kicked if let Some(secs) = ch.modes.kicknorejoin { if let Some(&kt) = ch.recent_kicks.get(&uid) { if now().saturating_sub(kt) < secs as u64 { if !is_oper { self.numeric( uid, ERR_DELAYREJOIN, &format!( "{name} :You must wait {secs}s after a kick to rejoin (+J)" ), ); return; } overrode = true; } } } } // +l full — with +L redirect, bounce the user to the target instead (opers override). // Count every member, local AND remote (services/other-server users), so the // limit reflects the channel's real network-wide size like InspIRCd. if let Some(ch) = self.channels.get(&key) { let full = ch .modes .limit .is_some_and(|l| (ch.members.len() + ch.rmembers.len()) as u32 >= l); let redirect = ch.modes.redirect.clone(); if full && is_oper { overrode = true; } else if full { match redirect { Some(t) if !self.in_redirect && t.to_ascii_lowercase() != key && !self.is_member(uid, &t.to_ascii_lowercase()) => { self.numeric( uid, ERR_LINKCHANNEL, &format!("{name} {t} :Cannot join channel (+l), redirecting"), ); self.in_redirect = true; self.join(uid, &t, None); self.in_redirect = false; return; } _ => { self.numeric( uid, ERR_CHANNELISFULL, &format!("{name} :Cannot join channel (+l)"), ); return; } } } } // +j join flood — once tripped, the channel locks new joins out for 60s (opers exempt) if !is_oper && self.channels.contains_key(&key) && self.joinflood_check(&key) { self.numeric( uid, ERR_UNAVAILRESOURCE, &format!("{name} :This channel is temporarily unavailable (+j join flood)"), ); return; } if overrode { let nick = self .users .get(&uid) .map(|u| u.nick.clone()) .unwrap_or_default(); self.snotice_c('v', &format!("{nick} used oper override to join {name}")); } let is_new = !self.channels.contains_key(&key); let ch = self .channels .entry(key.clone()) .or_insert_with(|| Channel::new(name)); ch.members.insert( uid, Member { op: is_new, joined: now(), ..Default::default() }, ); ch.invites.remove(&uid); // consume any pending invite ch.recent_kicks.remove(&uid); // they got back in; clear any +J rejoin timer if let Some(u) = self.users.get_mut(&uid) { u.channels.insert(key.clone()); } // chancreate: snotice when a brand-new channel comes into being if is_new && (self.conf_bool("chancreate", false) || self.conf_bool("announce_channels", false)) { let who = self .users .get(&uid) .map(|u| u.nick.clone()) .unwrap_or_default(); self.snotice_c('j', &format!("{who} created channel {name}")); } // JOIN broadcast — extended-join clients also get the account + realname let (prefix, acct, realname) = { let u = &self.users[&uid]; ( u.prefix(), u.account.clone().unwrap_or_else(|| "*".to_string()), u.realname.clone(), ) }; let plain = format!(":{prefix} JOIN {name}"); let extended = format!(":{prefix} JOIN {name} {acct} :{realname}"); let aud = self.channels[&key].modes.auditorium; // +D delayjoin: withhold the JOIN from everyone else until they speak/reveal let delayjoin = self.channels[&key].modes.delayjoin; if delayjoin { if let Some(m) = self .channels .get_mut(&key) .and_then(|c| c.members.get_mut(&uid)) { m.hidden = true; } } let members: Vec = self.channels[&key].members.keys().copied().collect(); for m in members { // +u auditorium: non-op members don't see other users join if aud && m != uid && self.rank(m, &key) < RANK_OP { continue; } // +D: only the joining user sees their own JOIN for now if delayjoin && m != uid { continue; } let ext = self .users .get(&m) .map(|u| u.caps.extended_join) .unwrap_or(false); self.send(m, if ext { extended.clone() } else { plain.clone() }); } if let Some(t) = self.channels[&key].topic.as_ref() { let text = t.text.clone(); self.numeric(uid, RPL_TOPIC, &format!("{name} :{text}")); } // no-implicit-names: a client that negotiated the cap doesn't want the // automatic NAMES burst after JOIN (it asks with NAMES when it needs it). if !self.users.get(&uid).map(|u| u.caps.no_implicit_names).unwrap_or(false) { self.send_names(uid, &key); } self.replay_chanhistory(uid, &key); // +H: replay recent messages to the joiner self.propagate_join(uid, name, is_new); // tell linked servers this user joined self.events.push_back(Hook::Join(uid, key)); } /// Rename channel `oldkey` (an existing lowercase key) to display name /// `newname`, preserving all state — membership, modes, topic, bans, TS. The /// channel object is rekeyed in the table and every local member's channel set /// is moved with it. Local members are then notified: a `RENAME` line for /// draft/channel-rename clients, a PART+JOIN(+topic+names) emulation for the /// rest (skipped when only the casing changed, per the spec). `src_prefix` is /// the nick!user@host — or server name — shown as the RENAME source. Returns /// the new lowercase key, or `None` if the move couldn't be made. Callers /// validate policy first (op rank, name validity, collision). pub fn rename_channel( &mut self, oldkey: &str, newname: &str, src_prefix: &str, reason: &str, ) -> Option { let newkey = newname.to_ascii_lowercase(); let case_only = newkey.as_str() == oldkey; if !self.channels.contains_key(oldkey) { return None; } if !case_only && self.channels.contains_key(&newkey) { return None; } let oldname = self.channels[oldkey].name.clone(); let members: Vec = self.channels[oldkey].members.keys().copied().collect(); if case_only { if let Some(ch) = self.channels.get_mut(oldkey) { ch.name = newname.to_string(); } } else { let mut ch = self.channels.remove(oldkey).unwrap(); ch.name = newname.to_string(); self.channels.insert(newkey.clone(), ch); for &m in &members { if let Some(u) = self.users.get_mut(&m) { u.channels.remove(oldkey); u.channels.insert(newkey.clone()); } } self.rekey_channel_state(oldkey, &newkey); } let renline = format!(":{src_prefix} RENAME {oldname} {newname} :{reason}"); for &m in &members { let has_cap = self .users .get(&m) .map(|u| u.caps.channel_rename) .unwrap_or(false); if has_cap { self.send(m, renline.clone()); } else if !case_only { self.emulate_rename_join(m, &oldname, newname, &newkey, reason); } // case-only + no cap: the spec says the PART/JOIN fallback SHOULD NOT // be used, so those clients simply keep the channel under its old case. } Some(newkey) } /// The PART-old + JOIN-new(+topic+names) fallback shown to one member that /// lacks draft/channel-rename, so their client follows the channel across a /// rename. Mirrors the JOIN broadcast (extended-join aware). fn emulate_rename_join(&self, m: Uid, oldname: &str, newname: &str, newkey: &str, reason: &str) { let Some(u) = self.users.get(&m) else { return; }; let prefix = u.prefix(); let joinline = if u.caps.extended_join { let acct = u.account.clone().unwrap_or_else(|| "*".to_string()); format!(":{prefix} JOIN {newname} {acct} :{}", u.realname) } else { format!(":{prefix} JOIN {newname}") }; let partline = if reason.is_empty() { format!(":{prefix} PART {oldname}") } else { format!(":{prefix} PART {oldname} :{reason}") }; self.send(m, partline); self.send(m, joinline); if let Some(t) = self.channels.get(newkey).and_then(|c| c.topic.as_ref()) { let text = t.text.clone(); self.numeric(m, RPL_TOPIC, &format!("{newname} :{text}")); } self.send_names(m, newkey); } /// Move a channel's auxiliary, name-keyed module state (recorded history, /// channel metadata) from `oldkey` to `newkey` on a rename, so a CHATHISTORY /// replay or a metadata read still finds it under the new name. fn rekey_channel_state(&mut self, oldkey: &str, newkey: &str) { if let Some(h) = self.ext.get_mut::() { if let Some(v) = h.0.remove(oldkey) { h.0.insert(newkey.to_string(), v); } } if let Some(store) = self.ext.get_mut::() { if let Some(v) = store.0.remove(oldkey) { store.0.insert(newkey.to_string(), v); } } } /// +H chanhistory: replay a channel's recent messages to a user who just /// joined — the last `` (within ``, 0 = no limit) from the store, /// wrapped in a `chathistory` batch for batch-capable clients. fn replay_chanhistory(&mut self, uid: Uid, key: &str) { let Some((lines, secs)) = self.channels.get(key).and_then(|c| c.modes.history) else { return; }; let Some(name) = self.channels.get(key).map(|c| c.name.clone()) else { return; }; let batch = self.users.get(&uid).map(|u| u.caps.batch).unwrap_or(false); let cutoff = if secs > 0 { now().saturating_sub(secs) } else { 0 }; let bref = if batch { Some(self.next_msgid().replace('-', "")) } else { None }; let out: Vec = match self.ext.get::().and_then(|h| h.0.get(key)) { Some(buf) => { let mut recent: Vec<&HistMsg> = buf.iter().filter(|m| m.ts >= cutoff).collect(); let start = recent.len().saturating_sub(lines as usize); recent.drain(..start); recent .iter() .map(|m| { let mut tags = format!("time={};msgid={}", iso_time(m.ts), m.msgid); if let Some(b) = &bref { tags.push_str(&format!(";batch={b}")); } format!("@{tags} :{} {} {name} :{}", m.prefix, m.verb, m.text) }) .collect() } None => return, }; if out.is_empty() { return; } if let Some(b) = &bref { self.send(uid, format!(":{} BATCH +{b} chathistory {name}", self.name)); } for l in out { self.send(uid, l); } if let Some(b) = &bref { self.send(uid, format!(":{} BATCH -{b}", self.name)); } } pub fn send_names(&self, uid: Uid, key: &str) { let Some(ch) = self.channels.get(key) else { self.numeric(uid, RPL_ENDOFNAMES, &format!("{key} :End of /NAMES list")); return; }; // +s (secret) / +p (private): members are hidden from non-members. Reply as // if the channel were empty (opers still see it). if (ch.modes.secret || ch.modes.private) && !ch.members.contains_key(&uid) && !self.is_oper(uid) { self.numeric( uid, RPL_ENDOFNAMES, &format!("{} :End of /NAMES list", ch.name), ); return; } // multi-prefix → all prefixes; userhost-in-names → full nick!user@host let (multi, uhost) = self .users .get(&uid) .map(|u| (u.caps.multi_prefix, u.caps.userhost_in_names)) .unwrap_or((false, false)); // +u auditorium: a non-op viewer only sees ops (plus themselves) let hide = ch.modes.auditorium && ch.members.get(&uid).map(|m| m.rank()).unwrap_or(0) < RANK_OP; let mut toks: Vec = Vec::new(); for (m, flags) in &ch.members { if hide && *m != uid && flags.rank() < RANK_OP { continue; } // +D delayjoin: a still-hidden member isn't shown to anyone but themselves if flags.hidden && *m != uid { continue; } let p = if multi { flags.all_prefixes() } else { flags.prefix_char().to_string() }; if let Some(u) = self.users.get(m) { let shown = if uhost { u.prefix() } else { u.nick.clone() }; toks.push(format!("{p}{shown}")); } } // remote members (users on linked servers) for (ruuid, mem) in &ch.rmembers { if hide && mem.rank() < RANK_OP { continue; } if let Some(ru) = self.remote_users.get(ruuid) { let p = if multi { mem.all_prefixes() } else { mem.prefix_char().to_string() }; let shown = if uhost { ru.prefix() } else { ru.nick.clone() }; toks.push(format!("{p}{shown}")); } } // visibility symbol: @ secret (+s), * private (+p), = public let vis = if ch.modes.secret { '@' } else if ch.modes.private { '*' } else { '=' }; // fold members across multiple 353 lines so a big channel stays under 512 bytes let askern = self.users.get(&uid).map(|u| u.nick.len()).unwrap_or(1); let budget = 500usize.saturating_sub(self.name.len() + askern + ch.name.len() + 12); let mut line = String::new(); for t in &toks { if !line.is_empty() && line.len() + 1 + t.len() > budget { self.numeric(uid, RPL_NAMREPLY, &format!("{vis} {} :{line}", ch.name)); line.clear(); } if !line.is_empty() { line.push(' '); } line.push_str(t); } if !line.is_empty() { self.numeric(uid, RPL_NAMREPLY, &format!("{vis} {} :{line}", ch.name)); } self.numeric( uid, RPL_ENDOFNAMES, &format!("{} :End of /NAMES list", ch.name), ); } /// Whether any entry in `list` catches `uid`: a plain `nick!user@host` glob, or /// a matching extban (`g:` group, `y:` reputation, `r:` realname, `j:` channel, /// `s:` server, `G:` geoip, `b:` banlist). Acting extbans (`m:`/`c:`/`n:`) never /// match here — they restrict actions, not join/ban membership. pub fn ban_list_hit(&self, uid: Uid, list: &[Ban]) -> bool { let who = self.users.get(&uid).map(|u| u.prefix()).unwrap_or_default(); list.iter().any(|b| { if b.mask.as_bytes().get(1) == Some(&b':') { match b.mask.as_bytes().first() { Some(b'a') => crate::modules::accountban::matches(self, uid, &b.mask[2..]), Some(b'g') => crate::modules::securitygroups::in_group(self, uid, &b.mask[2..]), Some(b'y') => { crate::modules::reputation::score_ban_match(self, uid, &b.mask[2..]) } Some(b'r') => crate::modules::realnameban::matches(self, uid, &b.mask[2..]), Some(b'j') => crate::modules::channelban::matches(self, uid, &b.mask[2..]), Some(b's') => crate::modules::serverban::matches(self, uid, &b.mask[2..]), Some(b'G') => crate::modules::geoip::geoban_match(self, uid, &b.mask[2..]), Some(b'b') => crate::modules::extbanbanlist::matches(self, uid, &b.mask[2..]), _ => false, } } else { // strip any `$#chan` banredirect suffix before matching the mask glob_match(crate::modules::banredirect::mask_part(&b.mask), &who) } }) } pub fn extban_active(&self, uid: Uid, key: &str, kind: char) -> bool { let Some(ch) = self.channels.get(key) else { return false; }; let Some(who) = self.users.get(&uid).map(|u| u.prefix()) else { return false; }; let pfx = format!("{kind}:"); let hit = |list: &Vec| { list.iter() .filter(|b| b.mask.starts_with(&pfx)) .any(|b| glob_match(&b.mask[2..], &who)) }; hit(&ch.bans) && !hit(&ch.excepts) } /// Broadcast a membership line, honouring +u auditorium: when set, non-op /// members other than `actor` don't see it. `actor` always receives it. pub fn to_channel_vis(&self, key: &str, line: &str, actor: Uid) { if let Some(ch) = self.channels.get(key) { let aud = ch.modes.auditorium; for (&uid, m) in &ch.members { if aud && uid != actor && m.rank() < RANK_OP { continue; } self.send(uid, line.to_string()); } } } /// +f: record a channel message from `uid`; `Some(ban)` when they just went /// over the limit (ban ⇒ also set a +b). Members at half-op+ and opers are /// exempt (checked by the caller). pub fn messageflood_hit(&mut self, uid: Uid, key: &str) -> Option { let n = now(); let ch = self.channels.get_mut(key)?; let f = ch.modes.flood.clone()?; let v = ch.msgflood_hits.entry(uid).or_default(); v.retain(|&t| n.saturating_sub(t) < f.secs); v.push(n); if v.len() as u32 > f.lines { ch.msgflood_hits.remove(&uid); Some(f.ban) } else { None } } /// Kick `uid` from `key` for flooding (optionally banning `*!*@host` first), /// broadcasting the KICK and queuing the part hook. pub fn flood_kick(&mut self, uid: Uid, key: &str, ban: bool) { let (nick, host) = match self.users.get(&uid) { Some(u) => (u.nick.clone(), u.host_display().to_string()), None => return, }; let cname = self .channels .get(key) .map(|c| c.name.clone()) .unwrap_or_else(|| key.to_string()); if ban { let mask = format!("*!*@{host}"); if let Some(c) = self.channels.get_mut(key) { if !c.bans.iter().any(|b| b.mask == mask) { c.bans.push(Ban { mask, setter: self.name.clone(), ts: now(), expires: None, }); } } } self.to_channel( key, &format!(":{} KICK {cname} {nick} :Flood", self.name), None, ); self.propagate_kick(uid, &cname, &nick, "Flood"); if let Some(c) = self.channels.get_mut(key) { c.members.remove(&uid); } if let Some(u) = self.users.get_mut(&uid) { u.channels.remove(key); } self.channels.retain(|_, c| c.keep_alive()); self.events .push_back(Hook::Part(uid, key.to_string(), "flood".to_string())); } /// +j: record a join attempt on `key`; true if joins are (now) locked out. pub fn joinflood_check(&mut self, key: &str) -> bool { let n = now(); let dur = self.conf_num("joinflood_duration", 60u64); let Some(ch) = self.channels.get_mut(key) else { return false; }; let Some(f) = ch.modes.joinflood.clone() else { return false; }; if n < ch.joinflood_until { return true; // still locked out } ch.joinflood_hits.retain(|&t| n.saturating_sub(t) < f.secs); ch.joinflood_hits.push(n); if ch.joinflood_hits.len() as u32 > f.count { ch.joinflood_until = n + dur; // lock joins for the configured window ch.joinflood_hits.clear(); return true; } false } /// +F: record a nick change and return the display name of any channel that /// is (now) locked out — the caller denies the change if so. Opers exempt. pub fn nickflood_blocked(&mut self, uid: Uid) -> Option { let n = now(); let dur = self.conf_num("nickflood_duration", 60u64); let keys: Vec = self .users .get(&uid) .map(|u| u.channels.iter().cloned().collect()) .unwrap_or_default(); let mut blocked = None; for key in keys { let Some(ch) = self.channels.get_mut(&key) else { continue; }; let Some(f) = ch.modes.nickflood.clone() else { continue; }; if n < ch.nickflood_until { blocked.get_or_insert_with(|| ch.name.clone()); continue; } ch.nickflood_hits.retain(|&t| n.saturating_sub(t) < f.secs); ch.nickflood_hits.push(n); if ch.nickflood_hits.len() as u32 > f.count { ch.nickflood_until = n + dur; ch.nickflood_hits.clear(); blocked.get_or_insert_with(|| ch.name.clone()); } } blocked } } /// A channel name starts with `#`, is ≤ 50 chars, and has no space/comma/control. pub fn valid_chan(name: &str, maxlen: usize) -> bool { name.starts_with('#') && name.len() > 1 && name.len() <= maxlen && !name .chars() .any(|c| c == ' ' || c == ',' || (c as u32) < 0x20) } /// Case-insensitive glob (`*` = any run, `?` = one char) — for +b mask matching. pub fn glob_match(pat: &str, s: &str) -> bool { let p: Vec = pat.to_lowercase().chars().collect(); let t: Vec = s.to_lowercase().chars().collect(); let (mut pi, mut ti) = (0usize, 0usize); let mut star: Option = None; let mut mark = 0usize; while ti < t.len() { if pi < p.len() && (p[pi] == '?' || p[pi] == t[ti]) { pi += 1; ti += 1; } else if pi < p.len() && p[pi] == '*' { star = Some(pi); mark = ti; pi += 1; } else if let Some(sp) = star { pi = sp + 1; mark += 1; ti = mark; } else { return false; } } while pi < p.len() && p[pi] == '*' { pi += 1; } pi == p.len() } /// Fill out a ban mask to `nick!user@host` form (`bob` → `bob!*@*`). pub fn normalize_mask(m: &str) -> String { match (m.contains('!'), m.contains('@')) { (true, true) => m.to_string(), (false, true) => format!("*!{m}"), (true, false) => format!("{m}@*"), (false, false) => format!("{m}!*@*"), } } /// Like [`normalize_mask`] but aware of extbans: `m:bob` normalises only the /// value after the `X:` prefix, so acting bans (`m:` mute, `c:` nocolor, /// `n:` nonick) keep their type while their hostmask is filled out. pub fn normalize_ban_mask(m: &str) -> String { let b = m.as_bytes(); if b.len() >= 2 && b[1] == b':' && (b[0] as char).is_ascii_alphabetic() { // These extbans carry a name / spec / channel / server, not a host mask, // so they must not be host-normalised: a: (account), g: (security group), // y: (reputation score), r: (realname), j: (channel), s: (server name), // G: (country), b: (banned-in-channel). if matches!(b[0], b'a' | b'g' | b'y' | b'r' | b'j' | b's' | b'G' | b'b') { return m.to_string(); } return format!("{}:{}", &m[..1], normalize_mask(&m[2..])); } normalize_mask(m) } #[cfg(test)] mod tests { use super::*; use proptest::prelude::*; proptest! { // Fuzz ban-mask normalisation: no input panics, and it's idempotent // (normalising an already-normalised mask changes nothing). #[test] fn normalize_ban_mask_no_panic_and_idempotent(m in ".*") { let once = normalize_ban_mask(&m); let twice = normalize_ban_mask(&once); prop_assert_eq!(once, twice); } } #[test] fn account_extban_mask_is_left_verbatim() { // a: carries an account name, not a host mask — must not gain !*@* assert_eq!(normalize_ban_mask("a:someacct*"), "a:someacct*"); // a bare nick is still host-normalised the usual way assert!(normalize_ban_mask("bob").contains('@')); } #[test] fn member_rank_and_prefix_char_take_the_highest() { let mut m = Member::default(); assert_eq!(m.rank(), 0); assert_eq!(m.prefix_char(), ""); m.voice = true; assert_eq!((m.rank(), m.prefix_char()), (RANK_VOICE, "+")); m.halfop = true; assert_eq!((m.rank(), m.prefix_char()), (RANK_HALFOP, "%")); m.op = true; assert_eq!((m.rank(), m.prefix_char()), (RANK_OP, "@")); m.admin = true; assert_eq!((m.rank(), m.prefix_char()), (RANK_ADMIN, "&")); m.owner = true; assert_eq!((m.rank(), m.prefix_char()), (RANK_OWNER, "~")); } }