1674 lines
63 KiB
Rust
1674 lines
63 KiB
Rust
//! 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 {
|
||
/// Built-in prefix modes held, as a bitfield of `PFX_*` (was six parallel bools:
|
||
/// oprefix `!`, owner `~`, admin `&`, op `@`, halfop `%`, voice `+`). Read/write
|
||
/// through the `op()`/`set_op()`-style accessors below.
|
||
pub prefixes: u8,
|
||
pub custom_prefixes: Vec<char>, // config-defined prefix mode letters held (customprefix)
|
||
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
|
||
}
|
||
|
||
/// Built-in prefix bits held in [`Member::prefixes`], high→low.
|
||
pub const PFX_OPER: u8 = 0b10_0000; // ! network staff (operprefix/ojoin)
|
||
pub const PFX_OWNER: u8 = 0b01_0000; // ~ +q
|
||
pub const PFX_ADMIN: u8 = 0b00_1000; // & +a
|
||
pub const PFX_OP: u8 = 0b00_0100; // @ +o
|
||
pub const PFX_HALFOP: u8 = 0b00_0010; // % +h
|
||
pub const PFX_VOICE: u8 = 0b00_0001; // + +v
|
||
|
||
/// 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 {
|
||
#[inline]
|
||
pub fn oprefix(&self) -> bool {
|
||
self.prefixes & PFX_OPER != 0
|
||
}
|
||
#[inline]
|
||
pub fn owner(&self) -> bool {
|
||
self.prefixes & PFX_OWNER != 0
|
||
}
|
||
#[inline]
|
||
pub fn admin(&self) -> bool {
|
||
self.prefixes & PFX_ADMIN != 0
|
||
}
|
||
#[inline]
|
||
pub fn op(&self) -> bool {
|
||
self.prefixes & PFX_OP != 0
|
||
}
|
||
#[inline]
|
||
pub fn halfop(&self) -> bool {
|
||
self.prefixes & PFX_HALFOP != 0
|
||
}
|
||
#[inline]
|
||
pub fn voice(&self) -> bool {
|
||
self.prefixes & PFX_VOICE != 0
|
||
}
|
||
#[inline]
|
||
fn set_bit(&mut self, bit: u8, on: bool) {
|
||
if on {
|
||
self.prefixes |= bit;
|
||
} else {
|
||
self.prefixes &= !bit;
|
||
}
|
||
}
|
||
pub fn set_oprefix(&mut self, on: bool) {
|
||
self.set_bit(PFX_OPER, on);
|
||
}
|
||
pub fn set_owner(&mut self, on: bool) {
|
||
self.set_bit(PFX_OWNER, on);
|
||
}
|
||
pub fn set_admin(&mut self, on: bool) {
|
||
self.set_bit(PFX_ADMIN, on);
|
||
}
|
||
pub fn set_op(&mut self, on: bool) {
|
||
self.set_bit(PFX_OP, on);
|
||
}
|
||
pub fn set_halfop(&mut self, on: bool) {
|
||
self.set_bit(PFX_HALFOP, on);
|
||
}
|
||
pub fn set_voice(&mut self, on: bool) {
|
||
self.set_bit(PFX_VOICE, on);
|
||
}
|
||
|
||
/// This member's numeric rank (0 = plain member).
|
||
/// Built-in tier rank from the fixed prefix bits (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.prefixes &= !(PFX_OWNER | PFX_ADMIN | PFX_OP | PFX_HALFOP | PFX_VOICE);
|
||
}
|
||
|
||
pub fn set_prefix(&mut self, letter: char, on: bool) {
|
||
match letter {
|
||
'y' => self.set_bit(PFX_OPER, on),
|
||
'q' => self.set_bit(PFX_OWNER, on),
|
||
'a' => self.set_bit(PFX_ADMIN, on),
|
||
'o' => self.set_bit(PFX_OP, on),
|
||
'h' => self.set_bit(PFX_HALFOP, on),
|
||
'v' => self.set_bit(PFX_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
|
||
/// `<letters>,<uuid>`, 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<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
|
||
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<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::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<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);
|
||
// 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 <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).
|
||
// 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 {
|
||
prefixes: if is_new { PFX_OP } else { 0 },
|
||
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());
|
||
u.invited.remove(&key); // invite consumed — drop it from the reverse index
|
||
}
|
||
// 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<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}"));
|
||
}
|
||
// 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));
|
||
}
|
||
|
||
/// A local user leaves `target` (original case): broadcast the PART, tell linked
|
||
/// servers, drop membership, and cull the channel if it's now empty. Returns
|
||
/// false (a no-op) if they weren't on it — the caller emits ERR_NOTONCHANNEL.
|
||
pub fn part(&mut self, uid: Uid, target: &str, reason: &str) -> bool {
|
||
let key = target.to_ascii_lowercase();
|
||
let on = self
|
||
.users
|
||
.get(&uid)
|
||
.map(|u| u.channels.contains(&key))
|
||
.unwrap_or(false);
|
||
if !on {
|
||
return false;
|
||
}
|
||
let prefix = self.users[&uid].prefix();
|
||
let line = if reason.is_empty() {
|
||
format!(":{prefix} PART {target}")
|
||
} else {
|
||
format!(":{prefix} PART {target} :{reason}")
|
||
};
|
||
// +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);
|
||
if hidden {
|
||
self.send(uid, line);
|
||
} else {
|
||
self.to_channel_vis(&key, &line, uid); // +u: only ops + self see the part
|
||
}
|
||
self.propagate_part(uid, target, reason); // tell linked servers
|
||
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());
|
||
self.events.push_back(Hook::Part(uid, key, reason.to_string()));
|
||
true
|
||
}
|
||
|
||
/// 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<String> {
|
||
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<Uid> = 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::<crate::modules::chathistory::History>() {
|
||
if let Some(v) = h.0.remove(oldkey) {
|
||
h.0.insert(newkey.to_string(), v);
|
||
}
|
||
}
|
||
if let Some(store) = self.ext.get_mut::<crate::modules::metadata::MetaStore>() {
|
||
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 `<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 toks: Vec<String> = 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<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_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<String> {
|
||
let n = now();
|
||
let dur = self.conf_num("nickflood_duration", 60u64);
|
||
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 + 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.
|
||
///
|
||
/// Runs on the message/ban/extban hot paths, so the common case — both operands
|
||
/// pure ASCII (hostmasks, IPs, cloaks) — takes a zero-allocation byte-wise path.
|
||
/// Only a non-ASCII mask or subject falls back to collecting Unicode-lowercased
|
||
/// chars. For ASCII input the two paths are identical (ASCII case-folding matches
|
||
/// `str::to_lowercase`, and one ASCII byte is one char so `?` still spans one char).
|
||
pub fn glob_match(pat: &str, s: &str) -> bool {
|
||
if pat.is_ascii() && s.is_ascii() {
|
||
return glob_bytes(pat.as_bytes(), s.as_bytes());
|
||
}
|
||
let p: Vec<char> = pat.to_lowercase().chars().collect();
|
||
let t: Vec<char> = s.to_lowercase().chars().collect();
|
||
glob_chars(&p, &t)
|
||
}
|
||
|
||
/// Greedy `*`/`?` glob over ASCII bytes, case-folded inline (no allocation).
|
||
fn glob_bytes(p: &[u8], t: &[u8]) -> bool {
|
||
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] == b'?' || p[pi].eq_ignore_ascii_case(&t[ti])) {
|
||
pi += 1;
|
||
ti += 1;
|
||
} else if pi < p.len() && p[pi] == b'*' {
|
||
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] == b'*' {
|
||
pi += 1;
|
||
}
|
||
pi == p.len()
|
||
}
|
||
|
||
/// The same greedy glob over pre-lowercased chars (non-ASCII fallback).
|
||
fn glob_chars(p: &[char], t: &[char]) -> bool {
|
||
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: 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::*;
|
||
|
||
#[test]
|
||
fn glob_basic() {
|
||
assert!(glob_match("*!*@*", "bob!user@host"));
|
||
assert!(glob_match("*.example.com", "a.b.EXAMPLE.com"));
|
||
assert!(glob_match("bob", "BOB"));
|
||
assert!(glob_match("b?b", "bXb"));
|
||
assert!(!glob_match("b?b", "bb"));
|
||
assert!(!glob_match("bob", "bobby"));
|
||
assert!(glob_match("a*z", "aXXXz"));
|
||
// non-ASCII takes the Unicode fallback and still folds case
|
||
assert!(glob_match("café*", "CAFÉ-bar"));
|
||
}
|
||
|
||
proptest! {
|
||
// The zero-alloc ASCII fast path must agree with the char-based algorithm
|
||
// on every ASCII input.
|
||
#[test]
|
||
fn glob_ascii_fastpath_agrees(
|
||
pat in "[a-zA-Z0-9?*._@!-]{0,12}",
|
||
s in "[a-zA-Z0-9._@!-]{0,12}",
|
||
) {
|
||
let plo: Vec<char> = pat.to_lowercase().chars().collect();
|
||
let tlo: Vec<char> = s.to_lowercase().chars().collect();
|
||
prop_assert_eq!(glob_match(&pat, &s), glob_chars(&plo, &tlo));
|
||
}
|
||
}
|
||
|
||
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.set_voice(true);
|
||
assert_eq!((m.rank(), m.prefix_char()), (RANK_VOICE, "+"));
|
||
m.set_halfop(true);
|
||
assert_eq!((m.rank(), m.prefix_char()), (RANK_HALFOP, "%"));
|
||
m.set_op(true);
|
||
assert_eq!((m.rank(), m.prefix_char()), (RANK_OP, "@"));
|
||
m.set_admin(true);
|
||
assert_eq!((m.rank(), m.prefix_char()), (RANK_ADMIN, "&"));
|
||
m.set_owner(true);
|
||
assert_eq!((m.rank(), m.prefix_char()), (RANK_OWNER, "~"));
|
||
}
|
||
}
|