diff --git a/src/channels.rs b/src/channels.rs index 8133e86..e856862 100644 --- a/src/channels.rs +++ b/src/channels.rs @@ -964,7 +964,11 @@ impl Server { let text = t.text.clone(); self.numeric(uid, RPL_TOPIC, &format!("{name} :{text}")); } - self.send_names(uid, &key); + // 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)); @@ -1229,6 +1233,7 @@ impl Server { 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..]) diff --git a/src/modules/accountban.rs b/src/modules/accountban.rs new file mode 100644 index 0000000..48a178c --- /dev/null +++ b/src/modules/accountban.rs @@ -0,0 +1,16 @@ +//! The `a:` matching extban (IRCv3 account-extban): match a user by the services +//! account they are logged into. `+b a:spammer` bans the account `spammer` (glob), +//! `+e a:trusted` exempts one. A user with no account never matches. Dispatched +//! from the channel ban matcher; advertised via `ACCOUNTEXTBAN=a`. + +use crate::channels::glob_match; +use crate::server::Server; +use crate::Uid; + +/// Does `uid`'s logged-in account match the glob `mask` (the part after `a:`)? +pub fn matches(s: &Server, uid: Uid, mask: &str) -> bool { + match s.users.get(&uid).and_then(|u| u.account.as_deref()) { + Some(acct) => glob_match(mask, acct), + None => false, // not logged in — an account ban can't match + } +} diff --git a/src/modules/markread.rs b/src/modules/markread.rs index 91f99ce..3105383 100644 --- a/src/modules/markread.rs +++ b/src/modules/markread.rs @@ -140,11 +140,14 @@ impl Command for MarkReadCmd { .or_default() .insert(tkey, ts); let line = format!(":{} MARKREAD {target} timestamp={}", s.name, iso_time(ts)); + // Sync the new marker to every connection under this identity that + // negotiated draft/read-marker (multi-device); others never asked + // for read-marker traffic. let recips: Vec = s .users - .keys() - .copied() - .filter(|&p| marker_id(s, p) == id) + .iter() + .filter(|(&p, u)| u.caps.read_marker && marker_id(s, p) == id) + .map(|(&p, _)| p) .collect(); for p in recips { s.send(p, line.clone()); diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 8f8dbe9..c12c218 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -4,6 +4,7 @@ //! self-contained unit. pub mod account_registration; +pub mod accountban; pub mod antimixedutf8; pub mod antirandom; pub mod autodrop; diff --git a/src/server.rs b/src/server.rs index f359d3a..37c12ed 100644 --- a/src/server.rs +++ b/src/server.rs @@ -751,7 +751,7 @@ impl Server { let include_oper = self.conf_bool("operprefix", false) || self.conf_bool("ojoin", false); let prefix = crate::modules::customprefix::isupport(include_oper); let mut tokens: Vec = format!( - "CHANTYPES=# PREFIX={prefix} CHANMODES=beIgXw,k,lfjFLHBJdK,ACDGMNOPQRSTUcimnprstuz EXTBAN=,Gbcgjmnrsy WATCH={maxwatch} MONITOR={maxmon} SILENCE={maxsil} CALLERID=g WHOX CHATHISTORY={chathist} MSGREFTYPES=timestamp,msgid UTF8ONLY CASEMAPPING=ascii NICKLEN={maxnick} CHANNELLEN={maxchan} NETWORK={}", + "CHANTYPES=# PREFIX={prefix} CHANMODES=beIgXw,k,lfjFLHBJdK,ACDGMNOPQRSTUcimnprstuz EXTBAN=,aGbcgjmnrsy ACCOUNTEXTBAN=a BOT=B WATCH={maxwatch} MONITOR={maxmon} SILENCE={maxsil} CALLERID=g WHOX CHATHISTORY={chathist} MSGREFTYPES=timestamp,msgid UTF8ONLY CASEMAPPING=ascii NICKLEN={maxnick} CHANNELLEN={maxchan} NETWORK={}", self.network ) .split(' ') @@ -1395,6 +1395,43 @@ mod tests { assert!(!ann.iter().any(|l| l.contains("PART")), "no fallback on case-only: {ann:?}"); } + #[test] + fn isupport_advertises_bot_and_account_extban() { + let s = srv(); + let joined = s.isupport_lines().join(" "); + assert!(joined.contains("BOT=B"), "bot-mode letter: {joined}"); + assert!(joined.contains("ACCOUNTEXTBAN=a"), "account-extban token: {joined}"); + assert!(joined.contains("EXTBAN=,aG"), "'a' listed in EXTBAN: {joined}"); + } + + #[test] + fn account_extban_matches_by_account_glob() { + let mut s = srv(); + add_user(&mut s, 1, "ann"); + s.users.get_mut(&1).unwrap().account = Some("spammer".into()); + add_user(&mut s, 2, "bob"); // no account + assert!(crate::modules::accountban::matches(&s, 1, "spam*")); + assert!(!crate::modules::accountban::matches(&s, 1, "other")); + assert!(!crate::modules::accountban::matches(&s, 2, "*"), "no account never matches"); + } + + #[test] + fn no_implicit_names_suppresses_the_join_names_burst() { + let mut s = srv(); + let arx = add_user(&mut s, 1, "ann"); + s.users.get_mut(&1).unwrap().caps.no_implicit_names = true; + s.join(1, "#c", None); + let al: Vec = arx.try_iter().collect(); + assert!(al.iter().any(|l| l.contains("JOIN #c")), "still gets its JOIN"); + assert!(!al.iter().any(|l| l.contains(" 353 ")), "no NAMREPLY: {al:?}"); + assert!(!al.iter().any(|l| l.contains(" 366 ")), "no ENDOFNAMES"); + // a client without the cap still gets the implicit NAMES + let brx = add_user(&mut s, 2, "bob"); + s.join(2, "#c", None); + let bl: Vec = brx.try_iter().collect(); + assert!(bl.iter().any(|l| l.contains(" 353 ")) && bl.iter().any(|l| l.contains(" 366 "))); + } + #[test] fn nick_change_reindexes_and_notifies_channel() { let mut s = srv(); diff --git a/src/users.rs b/src/users.rs index 83017b4..b87c193 100644 --- a/src/users.rs +++ b/src/users.rs @@ -133,6 +133,8 @@ pub const SUPPORTED_CAPS: &[&str] = &[ "reverse.im/filehost", "draft/relaymsg", "draft/channel-rename", + "draft/read-marker", + "no-implicit-names", "cap-notify", ]; @@ -168,6 +170,8 @@ pub struct Caps { pub filehost: bool, // reverse.im/filehost — knows the file-host extension pub relaymsg: bool, // draft/relaymsg — may use RELAYMSG (bridge relaying) pub channel_rename: bool, // draft/channel-rename — receives RENAME (else PART+JOIN) + pub read_marker: bool, // draft/read-marker — MARKREAD sync across the identity + pub no_implicit_names: bool, // no-implicit-names — suppress the auto NAMES after JOIN pub cap_notify: bool, } @@ -237,6 +241,8 @@ impl Caps { "reverse.im/filehost" => self.filehost, "draft/relaymsg" => self.relaymsg, "draft/channel-rename" => self.channel_rename, + "draft/read-marker" => self.read_marker, + "no-implicit-names" => self.no_implicit_names, "cap-notify" => self.cap_notify, _ => false, } @@ -273,6 +279,8 @@ impl Caps { "reverse.im/filehost" => &mut self.filehost, "draft/relaymsg" => &mut self.relaymsg, "draft/channel-rename" => &mut self.channel_rename, + "draft/read-marker" => &mut self.read_marker, + "no-implicit-names" => &mut self.no_implicit_names, "cap-notify" => &mut self.cap_notify, _ => return false, };