echoIRCd/src/channels.rs

1182 lines
44 KiB
Rust

//! Channels: the `Channel` record, membership, channel modes, bans, invites and
//! JOIN/NAMES — the same job InspIRCd splits across channels/channelmanager, but
//! written from scratch in Rust (InspIRCd is a behaviour reference, not a source).
use std::collections::{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 owner: bool, // +q (~)
pub admin: bool, // +a (&)
pub op: bool, // +o (@)
pub halfop: bool, // +h (%)
pub voice: bool, // +v (+)
pub joined: u64, // unix ts this member joined (for +d delaymsg; 0 = unknown)
pub recent_msgs: Vec<String>, // +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.
pub const RANK_OWNER: u8 = 5;
pub const RANK_ADMIN: u8 = 4;
pub const RANK_OP: u8 = 3;
pub const RANK_HALFOP: u8 = 2;
pub const RANK_VOICE: u8 = 1;
impl Member {
/// This member's numeric rank (0 = plain member).
pub fn rank(&self) -> u8 {
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
}
}
/// Highest prefix char for NAMES (`""` for a plain member).
pub fn prefix_char(&self) -> &'static str {
if self.owner {
"~"
} else if self.admin {
"&"
} else if self.op {
"@"
} else if self.halfop {
"%"
} else if self.voice {
"+"
} else {
""
}
}
/// Set/clear a prefix mode by its letter (used by the S2S mode applier).
pub fn set_prefix(&mut self, letter: char, on: bool) {
match letter {
'q' => self.owner = on,
'a' => self.admin = on,
'o' => self.op = on,
'h' => self.halfop = on,
'v' => self.voice = on,
_ => {}
}
}
/// Every prefix char this member holds, high→low (for the `multi-prefix` cap).
pub fn all_prefixes(&self) -> String {
let mut s = String::new();
for (on, c) in [
(self.owner, '~'),
(self.admin, '&'),
(self.op, '@'),
(self.halfop, '%'),
(self.voice, '+'),
] {
if on {
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<u64>, // 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<String>, // +k <key>
pub limit: Option<u32>, // +l <n>
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<MsgFlood>, // +f
pub joinflood: Option<Rate>, // +j
pub nickflood: Option<Rate>, // +F
pub redirect: Option<String>, // +L <#target> — when full, send there
pub history: Option<(u32, u64)>, // +H <lines>:<secs> — replay recent messages to joiners
pub anticaps: Option<u8>, // +B <percent> — 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<u32>, // +J <secs> — block rejoin for N secs after a kick
pub opmoderated: bool, // +U — unprivileged users' messages go to ops only
pub delaymsg: Option<u32>, // +d <secs> — new joiners can't speak for N secs
pub repeat: Option<u32>, // +K <n> — block a line repeated within your last n
pub delayjoin: bool, // +D — hide JOINs until the user speaks/reveals
}
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,
_ => {}
}
}
/// `+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.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'),
] {
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 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}"));
}
}
s
}
}
pub struct Channel {
pub name: String, // display casing
pub topic: Option<Topic>,
pub members: HashMap<Uid, Member>,
pub rmembers: HashMap<String, Member>, // remote members, by network uuid (S2S)
pub modes: ChanModes,
pub bans: Vec<Ban>,
pub excepts: Vec<Ban>, // +e ban exceptions
pub invex: Vec<Ban>, // +I invite exceptions
pub filters: Vec<Ban>, // +g word/glob message filters (mask = the glob)
pub exemptchanops: Vec<Ban>, // +X exemptions (mask = "restriction:rankchar")
pub autoop: Vec<Ban>, // +w auto-status (mask = "prefixchar:hostmask")
pub invites: HashSet<Uid>, // uids allowed past +i
pub created: u64,
// --- ephemeral flood counters (not modes; never rendered or synced) -------
pub msgflood_hits: HashMap<Uid, Vec<u64>>, // +f per-user message times
pub joinflood_hits: Vec<u64>, // +j recent join times
pub joinflood_until: u64, // +j locked out until this unix ts
pub nickflood_hits: Vec<u64>, // +F recent nick-change times
pub nickflood_until: u64, // +F locked out until this unix ts
pub recent_kicks: HashMap<Uid, u64>, // +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::new(),
rmembers: HashMap::new(),
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::new(),
created: now(),
msgflood_hits: HashMap::new(),
joinflood_hits: Vec::new(),
joinflood_until: 0,
nickflood_hits: Vec::new(),
nickflood_until: 0,
recent_kicks: HashMap::new(),
}
}
/// 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()
}
/// Whether to keep this channel in the table: it has members, or it's +P
/// (permanent). The predicate every `channels.retain` prune uses.
pub fn keep_alive(&self) -> bool {
!self.is_empty() || self.modes.permanent
}
}
impl Server {
/// A member's channel rank (0 if not a member).
pub fn rank(&self, uid: Uid, key: &str) -> u8 {
// SAMODE/SAKICK run as the server: every access check keys off rank(),
// so a transient sudo makes them bypass the ladder cleanly.
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<Uid> = 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 <restriction>:<rankchar>` 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);
}
}
/// 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 (m_override); each
// bypass sets `overrode`, snoticed once the join succeeds (accountability).
let is_oper = self.users.get(&uid).map(|u| u.flags.oper).unwrap_or(false);
let mut overrode = false;
// 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 <secs> — 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)
if let Some(ch) = self.channels.get(&key) {
let full = ch.modes.limit.is_some_and(|l| ch.members.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(&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(&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<Uid> = 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}"));
}
self.send_names(uid, &key);
self.replay_chanhistory(uid, &key); // +H: replay recent messages to the joiner
self.propagate_join(uid, name); // tell linked servers this user joined
self.events.push_back(Hook::Join(uid, key));
}
/// +H chanhistory: replay a channel's recent messages to a user who just
/// joined — the last `<lines>` (within `<secs>`, 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<String> = match self.ext.get::<History>().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 names = String::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) {
names.push_str(&p);
let shown = if uhost { u.prefix() } else { u.nick.clone() };
names.push_str(&shown);
names.push(' ');
}
}
// 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()
};
names.push_str(&p);
let shown = if uhost { ru.prefix() } else { ru.nick.clone() };
names.push_str(&shown);
names.push(' ');
}
}
self.numeric(
uid,
RPL_NAMREPLY,
&format!("= {} :{}", ch.name, names.trim_end()),
);
self.numeric(
uid,
RPL_ENDOFNAMES,
&format!("{} :End of /NAMES list", ch.name),
);
}
/// True if `uid` is caught by an acting extban of type `kind` (`m`/`c`/`n`) on
/// `key` with no matching `kind:` exception in +e. The stored mask is
/// `kind:<hostmask>`; we glob the hostmask part against the user's prefix.
/// Whether any entry in `list` catches `uid`: a plain `nick!user@host` glob,
/// or the `g:<group>` security-group matching extban. 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'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..]),
_ => 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<Ban>| {
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<bool> {
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_from_user(uid, &format!("KICK {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 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 + 60; // lock the channel for 60s
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<String> {
let n = now();
let keys: Vec<String> = 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 + 60;
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<char> = pat.to_lowercase().chars().collect();
let t: Vec<char> = s.to_lowercase().chars().collect();
let (mut pi, mut ti) = (0usize, 0usize);
let mut star: Option<usize> = 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: g: (security group), y: (reputation
// score), r: (realname), j: (channel), s: (server name).
if matches!(b[0], b'g' | b'y' | b'r' | b'j' | b's' | b'G') {
return m.to_string();
}
return format!("{}:{}", &m[..1], normalize_mask(&m[2..]));
}
normalize_mask(m)
}
#[cfg(test)]
mod tests {
use super::*;
#[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, "~"));
}
}