From 57e45f22a923c0578f128769875cf781a999dd94 Mon Sep 17 00:00:00 2001 From: reverse Date: Sat, 8 Aug 2026 18:20:24 +0000 Subject: [PATCH] services interface: svsnick, svsjoin, svspart, svsmode (user +r/channel); dry force_part shared with sapart --- src/channels.rs | 20 +++++ src/coremods/core_mode.rs | 29 +++++++ src/coremods/core_oper.rs | 161 ++++++++++++++++++++++++++++++++++---- 3 files changed, 195 insertions(+), 15 deletions(-) diff --git a/src/channels.rs b/src/channels.rs index 6db56e1..a24306b 100644 --- a/src/channels.rs +++ b/src/channels.rs @@ -324,6 +324,26 @@ impl Server { .unwrap_or(false) } + /// 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(); + self.to_channel(&key, &format!(":{prefix} PART {chan} :{reason}"), 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.is_empty()); + } + /// 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>) { diff --git a/src/coremods/core_mode.rs b/src/coremods/core_mode.rs index bcc91ea..1a72202 100644 --- a/src/coremods/core_mode.rs +++ b/src/coremods/core_mode.rs @@ -22,6 +22,35 @@ fn emit(applied: &mut String, last: &mut char, sign: char, c: char) { applied.push(c); } +/// Apply user modes to `tuid` with services authority (SVSMODE): no "only your +/// own modes" restriction — that's the whole point. Reuses the per-mode handlers +/// and broadcasts the result to the target as `:nick MODE nick :`. +pub fn svs_set_user_modes(s: &mut Server, tuid: Uid, modestring: &str) { + let mut sign = '+'; + let mut applied = String::new(); + let mut last = ' '; + for c in modestring.chars() { + if c == '+' || c == '-' { + sign = c; + continue; + } + let adding = sign == '+'; + if let Some(handler) = user_mode(c) { + if handler.apply(s, tuid, adding) { + emit(&mut applied, &mut last, sign, c); + } + } + } + if !applied.is_empty() { + let nick = s + .users + .get(&tuid) + .map(|u| u.nick.clone()) + .unwrap_or_default(); + s.send(tuid, format!(":{nick} MODE {nick} :{applied}")); + } +} + struct Mode; impl Command for Mode { fn name(&self) -> &'static str { diff --git a/src/coremods/core_oper.rs b/src/coremods/core_oper.rs index 1ae141f..c365a57 100644 --- a/src/coremods/core_oper.rs +++ b/src/coremods/core_oper.rs @@ -3,7 +3,7 @@ use crate::channels::Topic; use crate::command::{CmdResult, Command}; -use crate::coremods::core_mode::apply_mode; +use crate::coremods::core_mode::{apply_mode, svs_set_user_modes}; use crate::module::Hook; use crate::numeric::*; use crate::server::{now, Server}; @@ -18,6 +18,10 @@ pub fn commands() -> Vec> { Box::new(Wallops), Box::new(SvsLogin), Box::new(SvsLogout), + Box::new(SvsNick), + Box::new(SvsJoin), + Box::new(SvsPart), + Box::new(SvsMode), Box::new(GlobOps), Box::new(SaJoin), Box::new(SaPart), @@ -296,24 +300,11 @@ impl Command for SaPart { ); return CmdResult::Fail; }; - let chan = ¶ms[1]; - let key = chan.to_ascii_lowercase(); let reason = params .get(2) .cloned() .unwrap_or_else(|| "Removed".to_string()); - if s.is_member(tuid, &key) { - let prefix = s.users[&tuid].prefix(); - s.to_channel(&key, &format!(":{prefix} PART {chan} :{reason}"), None); - s.propagate_part(tuid, chan, &reason); - if let Some(ch) = s.channels.get_mut(&key) { - ch.members.remove(&tuid); - } - if let Some(u) = s.users.get_mut(&tuid) { - u.channels.remove(&key); - } - s.channels.retain(|_, c| !c.is_empty()); - } + s.force_part(tuid, ¶ms[1], &reason); CmdResult::Ok } } @@ -363,6 +354,146 @@ impl Command for SaNick { } } +// --- SVS* : the services interface. Same enforcement as the SA* oper commands, +// under the names a services package speaks (like SVSLOGIN). Gated to opers/ +// services; a linked services pseudoserver drives these once S2S routes them. + +/// SVSNICK — force a nick change (nick-registration enforcement). An optional +/// third param is the new-nick TS, accepted and ignored (single-TS model). +struct SvsNick; +impl Command for SvsNick { + fn name(&self) -> &'static str { + "SVSNICK" + } + fn min_params(&self) -> usize { + 2 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + if !require_oper(s, uid) { + return CmdResult::Fail; + } + let Some(tuid) = s.find_nick(¶ms[0]) else { + s.numeric( + uid, + ERR_NOSUCHNICK, + &format!("{} :No such nick/channel", params[0]), + ); + return CmdResult::Fail; + }; + let newnick = ¶ms[1]; + if !valid_nick(newnick) { + s.numeric( + uid, + ERR_ERRONEUSNICKNAME, + &format!("{newnick} :Erroneous nickname"), + ); + return CmdResult::Fail; + } + if s.find_nick(newnick).is_some() + || s.remote_nick.contains_key(&newnick.to_ascii_lowercase()) + { + s.numeric( + uid, + ERR_NICKNAMEINUSE, + &format!("{newnick} :Nickname is already in use"), + ); + return CmdResult::Fail; + } + s.set_nick(tuid, newnick); + CmdResult::Ok + } +} + +/// SVSJOIN — force a user into a channel, bypassing +i/+k/+l/+b. +struct SvsJoin; +impl Command for SvsJoin { + fn name(&self) -> &'static str { + "SVSJOIN" + } + fn min_params(&self) -> usize { + 2 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + if !require_oper(s, uid) { + return CmdResult::Fail; + } + let Some(tuid) = s.find_nick(¶ms[0]) else { + s.numeric( + uid, + ERR_NOSUCHNICK, + &format!("{} :No such nick/channel", params[0]), + ); + return CmdResult::Fail; + }; + s.join(tuid, ¶ms[1], None); + CmdResult::Ok + } +} + +/// SVSPART — force a user out of a channel. +struct SvsPart; +impl Command for SvsPart { + fn name(&self) -> &'static str { + "SVSPART" + } + fn min_params(&self) -> usize { + 2 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + if !require_oper(s, uid) { + return CmdResult::Fail; + } + let Some(tuid) = s.find_nick(¶ms[0]) else { + s.numeric( + uid, + ERR_NOSUCHNICK, + &format!("{} :No such nick/channel", params[0]), + ); + return CmdResult::Fail; + }; + let reason = params + .get(2) + .cloned() + .unwrap_or_else(|| "Services forced part".to_string()); + s.force_part(tuid, ¶ms[1], &reason); + CmdResult::Ok + } +} + +/// SVSMODE — set modes on a user (e.g. `+r` registered) or a channel with services +/// authority, bypassing the "own modes only" / rank checks a normal MODE enforces. +struct SvsMode; +impl Command for SvsMode { + fn name(&self) -> &'static str { + "SVSMODE" + } + fn min_params(&self) -> usize { + 2 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + if !require_oper(s, uid) { + return CmdResult::Fail; + } + if params[0].starts_with('#') { + // channel modes with services authority — same path as SAMODE + s.mode_sudo = true; + let r = apply_mode(s, uid, params); + s.mode_sudo = false; + return r; + } + let Some(tuid) = s.find_nick(¶ms[0]) else { + s.numeric( + uid, + ERR_NOSUCHNICK, + &format!("{} :No such nick/channel", params[0]), + ); + return CmdResult::Fail; + }; + svs_set_user_modes(s, tuid, ¶ms[1]); + CmdResult::Ok + } +} + /// DIE — shut the server down (requires the server name as confirmation). struct Die; impl Command for Die {