import echoircd — from-scratch irc daemon in native rust
This commit is contained in:
commit
9b12791774
38 changed files with 9757 additions and 0 deletions
792
src/channels.rs
Normal file
792
src/channels.rs
Normal file
|
|
@ -0,0 +1,792 @@
|
|||
//! 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::numeric::*;
|
||||
use crate::server::{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 (+)
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// +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
|
||||
}
|
||||
|
||||
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,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// `+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 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);
|
||||
}
|
||||
}
|
||||
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 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
|
||||
}
|
||||
|
||||
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(),
|
||||
invites: HashSet::new(),
|
||||
created: now(),
|
||||
msgflood_hits: HashMap::new(),
|
||||
joinflood_hits: Vec::new(),
|
||||
joinflood_until: 0,
|
||||
nickflood_hits: Vec::new(),
|
||||
nickflood_until: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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.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
|
||||
}
|
||||
// an existing channel can refuse the join (+k / +b / +i / +l)
|
||||
if let Some(ch) = self.channels.get(&key) {
|
||||
if let Some(k) = &ch.modes.key {
|
||||
if key_arg != Some(k.as_str()) {
|
||||
self.numeric(
|
||||
uid,
|
||||
ERR_BADCHANNELKEY,
|
||||
&format!("{name} :Cannot join channel (+k)"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// +b — bans block even an invited user, unless a +e exception matches
|
||||
let mask = self.users.get(&uid).map(|u| u.prefix()).unwrap_or_default();
|
||||
if ch.bans.iter().any(|b| glob_match(&b.mask, &mask))
|
||||
&& !ch.excepts.iter().any(|e| glob_match(&e.mask, &mask))
|
||||
{
|
||||
self.numeric(
|
||||
uid,
|
||||
ERR_BANNEDFROMCHAN,
|
||||
&format!("{name} :Cannot join channel (+b)"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// +i — unless invited or matched by a +I invite exception
|
||||
if ch.modes.invite_only
|
||||
&& !ch.invites.contains(&uid)
|
||||
&& !ch.invex.iter().any(|e| glob_match(&e.mask, &mask))
|
||||
{
|
||||
self.numeric(
|
||||
uid,
|
||||
ERR_INVITEONLYCHAN,
|
||||
&format!("{name} :Cannot join channel (+i)"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// +z — TLS-connected users only
|
||||
if ch.modes.secure_only && !self.users.get(&uid).map(|u| u.secure).unwrap_or(false) {
|
||||
self.numeric(
|
||||
uid,
|
||||
ERR_SECUREONLYCHAN,
|
||||
&format!("{name} :Cannot join channel; TLS users only (+z is set)"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// +O — IRC operators only
|
||||
if ch.modes.oper_only && !self.users.get(&uid).map(|u| u.flags.oper).unwrap_or(false) {
|
||||
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)
|
||||
{
|
||||
self.numeric(
|
||||
uid,
|
||||
ERR_NEEDREGGEDNICK,
|
||||
&format!("{name} :Cannot join channel; you must be logged in (+R is set)"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// +l full — with +L redirect, bounce the user to the target instead
|
||||
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 {
|
||||
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
|
||||
if 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;
|
||||
}
|
||||
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,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
ch.invites.remove(&uid); // consume any pending invite
|
||||
if let Some(u) = self.users.get_mut(&uid) {
|
||||
u.channels.insert(key.clone());
|
||||
}
|
||||
|
||||
// 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;
|
||||
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;
|
||||
}
|
||||
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.propagate_join(uid, name); // tell linked servers this user joined
|
||||
self.events.push_back(Hook::Join(uid, key));
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
// 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;
|
||||
}
|
||||
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.
|
||||
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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
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.is_empty());
|
||||
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) -> bool {
|
||||
name.starts_with('#')
|
||||
&& name.len() > 1
|
||||
&& name.len() <= 50
|
||||
&& !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() {
|
||||
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, "~"));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue