diff --git a/src/channels.rs b/src/channels.rs index b223f3c..2ae18d3 100644 --- a/src/channels.rs +++ b/src/channels.rs @@ -103,6 +103,7 @@ pub struct Ban { pub mask: String, pub setter: String, pub ts: u64, + pub expires: Option, // TBAN: unix ts to auto-lift at (None = permanent) } /// +f message flood: `[*]lines:secs` — kick past `lines` msgs in `secs` (and set @@ -348,6 +349,26 @@ impl Server { .unwrap_or(false) } + /// 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. @@ -758,6 +779,7 @@ impl Server { mask, setter: self.name.clone(), ts: now(), + expires: None, }); } } diff --git a/src/coremods/core_channel.rs b/src/coremods/core_channel.rs index b70b113..cb0fd88 100644 --- a/src/coremods/core_channel.rs +++ b/src/coremods/core_channel.rs @@ -1,10 +1,11 @@ //! core_channel — channel membership commands: JOIN, PART, KICK, TOPIC, NAMES. -use crate::channels::{Topic, RANK_HALFOP}; +use crate::channels::{normalize_ban_mask, Ban, Topic, RANK_HALFOP}; use crate::command::{CmdResult, Command}; use crate::module::Hook; use crate::numeric::*; use crate::server::{now, Server}; +use crate::xline::parse_duration; use crate::Uid; pub fn commands() -> Vec> { @@ -19,9 +20,74 @@ pub fn commands() -> Vec> { Box::new(Knock), Box::new(Cycle), Box::new(Remove), + Box::new(Tban), ] } +/// TBAN — set a +b ban that lifts itself after a duration (InspIRCd `m_timedbans`). +/// `TBAN <#chan> `; needs half-op or above. The background tick +/// removes it and announces `MODE -b` when it expires. +struct Tban; +impl Command for Tban { + fn name(&self) -> &'static str { + "TBAN" + } + fn min_params(&self) -> usize { + 3 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + let chan = ¶ms[0]; + let key = chan.to_ascii_lowercase(); + if !s.channels.contains_key(&key) { + s.numeric(uid, ERR_NOSUCHCHANNEL, &format!("{chan} :No such channel")); + return CmdResult::Fail; + } + if s.rank(uid, &key) < RANK_HALFOP { + s.numeric( + uid, + ERR_CHANOPRIVSNEEDED, + &format!("{chan} :You're not a channel operator"), + ); + return CmdResult::Fail; + } + let Some(dur) = parse_duration(¶ms[1]).filter(|&d| d > 0) else { + s.fail( + uid, + "TBAN", + "INVALID_DURATION", + "TBAN needs a positive duration", + ); + return CmdResult::Fail; + }; + let mask = normalize_ban_mask(¶ms[2]); + let (nick, prefix) = { + let u = &s.users[&uid]; + (u.nick.clone(), u.prefix()) + }; + if s.channels[&key].bans.iter().any(|b| b.mask == mask) { + s.send( + uid, + format!( + ":{} NOTICE {nick} :{mask} is already banned on {chan}", + s.name + ), + ); + return CmdResult::Fail; + } + if let Some(c) = s.channels.get_mut(&key) { + c.bans.push(Ban { + mask: mask.clone(), + setter: nick, + ts: now(), + expires: Some(now() + dur), + }); + } + s.to_channel(&key, &format!(":{prefix} MODE {chan} +b {mask}"), None); + s.propagate_from_user(uid, &format!("MODE {chan} +b {mask}")); + CmdResult::Ok + } +} + /// KNOCK — ask for an invite to an invite-only channel. struct Knock; impl Command for Knock { diff --git a/src/ircd.rs b/src/ircd.rs index 414b043..3b72957 100644 --- a/src/ircd.rs +++ b/src/ircd.rs @@ -353,6 +353,7 @@ impl Ircd { fn on_tick(&mut self) { self.server.ping_links(); // keepalive on every server link self.server.purge_xlines(); // drop expired server bans + self.server.purge_tbans(); // lift expired timed channel bans (TBAN) let now = crate::server::now(); let (to_ping, to_quit) = self.server.idle_check(now); for uid in to_ping { diff --git a/src/link.rs b/src/link.rs index 270f79e..7c7f670 100644 --- a/src/link.rs +++ b/src/link.rs @@ -1131,6 +1131,7 @@ impl Server { mask, setter, ts: now(), + expires: None, }); } } else { diff --git a/src/mode.rs b/src/mode.rs index d353fbf..c946698 100644 --- a/src/mode.rs +++ b/src/mode.rs @@ -579,6 +579,7 @@ impl ChanMode for ListMode { mask: mask.clone(), setter, ts: now(), + expires: None, }); } Applied::Yes(Some(mask))