From 6d19b6771bc432ac7d7dab319d77d9b629e53dd9 Mon Sep 17 00:00:00 2001 From: reverse Date: Sat, 8 Aug 2026 17:55:44 +0000 Subject: [PATCH] whox (354) + ircv3 standard-replies (fail/warn/note), account-tag, and +s/+p who/names member hiding --- src/channels.rs | 13 +++ src/coremods/core_channel.rs | 14 +-- src/coremods/core_info.rs | 177 +++++++++++++++++++++++++++++------ src/coremods/core_message.rs | 15 +-- src/numeric.rs | 1 + src/server.rs | 56 ++++++++++- src/users.rs | 10 +- 7 files changed, 233 insertions(+), 53 deletions(-) diff --git a/src/channels.rs b/src/channels.rs index 3b88fb2..6db56e1 100644 --- a/src/channels.rs +++ b/src/channels.rs @@ -507,6 +507,19 @@ impl Server { 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 diff --git a/src/coremods/core_channel.rs b/src/coremods/core_channel.rs index 9575090..1479245 100644 --- a/src/coremods/core_channel.rs +++ b/src/coremods/core_channel.rs @@ -39,17 +39,11 @@ impl Command for Knock { } let can = s.channels[&key].modes.invite_only && !s.is_member(uid, &key); if !can { - let nick = s - .users - .get(&uid) - .map(|u| u.nick.clone()) - .unwrap_or_default(); - s.send( + s.fail( uid, - format!( - ":{} NOTICE {nick} :Can't KNOCK on {chan} (not invite-only, or you're on it)", - s.name - ), + "KNOCK", + "CANNOT_KNOCK", + &format!("Can't KNOCK on {chan} (not invite-only, or you're on it)"), ); return CmdResult::Fail; } diff --git a/src/coremods/core_info.rs b/src/coremods/core_info.rs index 7cbdff1..8fff1c1 100644 --- a/src/coremods/core_info.rs +++ b/src/coremods/core_info.rs @@ -3,6 +3,7 @@ use crate::command::{CmdResult, Command}; use crate::numeric::*; use crate::server::{Server, VERSION}; +use crate::users::User; use crate::Uid; pub fn commands() -> Vec> { @@ -214,14 +215,34 @@ impl Command for Who { } fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { let target = ¶ms[0]; - if target.starts_with('#') { - let key = target.to_ascii_lowercase(); - let multi = s - .users - .get(&uid) - .map(|u| u.caps.multi_prefix) - .unwrap_or(false); - let rows: Vec<(Uid, String, String)> = match s.channels.get(&key) { + // WHOX: an options token containing '%' selects the reply fields (354). + // `WHO %[,]`, e.g. `WHO #c %cuhnat,152`. + let whox = params + .get(1) + .and_then(|o| o.split_once('%')) + .map(|(_, spec)| { + let (fields, qtype) = spec.split_once(',').unwrap_or((spec, "")); + (fields.to_string(), qtype.to_string()) + }); + let asker_oper = s.is_oper(uid); + let multi = s + .users + .get(&uid) + .map(|u| u.caps.multi_prefix) + .unwrap_or(false); + + // (uid, channel-name-or-"*", prefix-string) for each user to report + let rows: Vec<(Uid, String, String)> = if target.starts_with('#') { + match s.channels.get(&target.to_ascii_lowercase()) { + // +s/+p: don't reveal a secret/private channel's members to + // non-members (opers excepted) + Some(ch) + if (ch.modes.secret || ch.modes.private) + && !ch.members.contains_key(&uid) + && !asker_oper => + { + Vec::new() + } Some(ch) => { let name = ch.name.clone(); ch.members @@ -237,37 +258,131 @@ impl Command for Who { .collect() } None => Vec::new(), - }; - for (m, name, pfx) in rows { - if let Some(u) = s.users.get(&m) { - let row = format!( - "{name} {} {} {} {} H{pfx} :0 {}", - u.ident, - u.host_display(), - s.name, - u.nick, - u.realname - ); - s.numeric(uid, RPL_WHOREPLY, &row); - } } } else if let Some(tuid) = s.find_nick(target) { - let u = &s.users[&tuid]; - let row = format!( - "* {} {} {} {} H :0 {}", - u.ident, - u.host_display(), - s.name, - u.nick, - u.realname - ); - s.numeric(uid, RPL_WHOREPLY, &row); + vec![(tuid, "*".to_string(), String::new())] + } else { + Vec::new() + }; + + let now = crate::server::now(); + for (m, chan, pfx) in rows { + let (code, row) = { + let Some(u) = s.users.get(&m) else { continue }; + // flags: H (here) / G (gone/away), then * for opers, then prefixes + let mut flags = String::from(if u.flags.away.is_some() { "G" } else { "H" }); + if u.flags.oper && (!u.flags.hideoper || asker_oper) { + flags.push('*'); + } + flags.push_str(&pfx); + match &whox { + Some((fields, qtype)) => ( + RPL_WHOSPCRPL, + whox_row( + &s.name, + u, + &chan, + &flags, + fields, + qtype, + asker_oper, + m == uid, + now, + ), + ), + None => ( + RPL_WHOREPLY, + format!( + "{chan} {} {} {} {} {flags} :0 {}", + u.ident, + u.host_display(), + s.name, + u.nick, + u.realname + ), + ), + } + }; + s.numeric(uid, code, &row); } s.numeric(uid, RPL_ENDOFWHO, &format!("{target} :End of /WHO list")); CmdResult::Ok } } +/// Build a WHOX (354) reply body: the requested `fields` in their fixed output +/// order (never the request order), realname always last. Unknown field letters +/// are ignored. The real IP (`i`) is shown only to opers or to the user +/// themselves, so host-cloaking isn't defeated. +#[allow(clippy::too_many_arguments)] +fn whox_row( + server: &str, + u: &User, + chan: &str, + flags: &str, + fields: &str, + qtype: &str, + asker_oper: bool, + is_self: bool, + now: u64, +) -> String { + let has = |c: char| fields.contains(c); + let mut parts: Vec = Vec::new(); + if has('t') { + parts.push(if qtype.is_empty() { + "0".to_string() + } else { + qtype.to_string() + }); + } + if has('c') { + parts.push(chan.to_string()); + } + if has('u') { + parts.push(u.ident.clone()); + } + if has('i') { + parts.push(if asker_oper || is_self { + u.addr.ip().to_string() + } else { + "255.255.255.255".to_string() + }); + } + if has('h') { + parts.push(u.host_display().to_string()); + } + if has('s') { + parts.push(server.to_string()); + } + if has('n') { + parts.push(u.nick.clone()); + } + if has('f') { + parts.push(flags.to_string()); + } + if has('d') { + parts.push("0".to_string()); // hopcount (local users) + } + if has('l') { + parts.push(now.saturating_sub(u.last_active).to_string()); // idle seconds + } + if has('a') { + parts.push(u.account.clone().unwrap_or_else(|| "0".to_string())); + } + if has('o') { + parts.push("n/a".to_string()); // channel op-level + } + let mut row = parts.join(" "); + if has('r') { + if !row.is_empty() { + row.push(' '); + } + row.push(':'); + row.push_str(&u.realname); + } + row +} + struct Lusers; impl Command for Lusers { fn name(&self) -> &'static str { diff --git a/src/coremods/core_message.rs b/src/coremods/core_message.rs index 9f2460e..0c1e56d 100644 --- a/src/coremods/core_message.rs +++ b/src/coremods/core_message.rs @@ -317,7 +317,7 @@ fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool) -> CmdResu if m == uid || s.users.get(&m).map(|u| u.flags.deaf).unwrap_or(false) { continue; } - s.send_tagged(m, &ctags, &msgid, &line); + s.send_tagged(m, uid, &ctags, &msgid, &line); } // echo-message: give the sender their own copy if they asked for one if s.users @@ -325,7 +325,7 @@ fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool) -> CmdResu .map(|u| u.caps.echo_message) .unwrap_or(false) { - s.send_tagged(uid, &ctags, &msgid, &line); + s.send_tagged(uid, uid, &ctags, &msgid, &line); } // propagate to linked servers that have members in this channel s.send_channel_to_links(uid, &key, target, cmd, &body); @@ -427,14 +427,14 @@ fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool) -> CmdResu let ctags = s.line_ctags.clone(); let msgid = s.next_msgid(); if !silenced { - s.send_tagged(tuid, &ctags, &msgid, &pm); + s.send_tagged(tuid, uid, &ctags, &msgid, &pm); } if s.users .get(&uid) .map(|u| u.caps.echo_message) .unwrap_or(false) { - s.send_tagged(uid, &ctags, &msgid, &pm); + s.send_tagged(uid, uid, &ctags, &msgid, &pm); } // if the recipient is away, tell the sender (PRIVMSG only, not if silenced) if !notice && !silenced { @@ -467,6 +467,7 @@ fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool) -> CmdResu let ctags = s.line_ctags.clone(); let msgid = s.next_msgid(); s.send_tagged( + uid, uid, &ctags, &msgid, @@ -564,7 +565,7 @@ impl Command for TagMsg { .map(|u| u.caps.message_tags) .unwrap_or(false) { - s.send_tagged(m, &ctags, &msgid, &body); + s.send_tagged(m, uid, &ctags, &msgid, &body); } } } else if let Some(tuid) = s.find_nick(target) { @@ -573,14 +574,14 @@ impl Command for TagMsg { .map(|u| u.caps.message_tags) .unwrap_or(false) { - s.send_tagged(tuid, &ctags, &msgid, &body); + s.send_tagged(tuid, uid, &ctags, &msgid, &body); } if s.users .get(&uid) .map(|u| u.caps.echo_message) .unwrap_or(false) { - s.send_tagged(uid, &ctags, &msgid, &body); + s.send_tagged(uid, uid, &ctags, &msgid, &body); } } CmdResult::Ok diff --git a/src/numeric.rs b/src/numeric.rs index df654c6..f015677 100644 --- a/src/numeric.rs +++ b/src/numeric.rs @@ -96,6 +96,7 @@ pub const RPL_CREATIONTIME: u16 = 329; pub const RPL_NOTOPIC: u16 = 331; pub const RPL_TOPIC: u16 = 332; pub const RPL_WHOREPLY: u16 = 352; +pub const RPL_WHOSPCRPL: u16 = 354; // WHOX: field-selected WHO reply pub const RPL_NAMREPLY: u16 = 353; pub const RPL_ENDOFNAMES: u16 = 366; diff --git a/src/server.rs b/src/server.rs index dc0c84f..63d2389 100644 --- a/src/server.rs +++ b/src/server.rs @@ -446,6 +446,46 @@ impl Server { } } + /// IRCv3 standard reply (`FAIL`/`WARN`/`NOTE`): structured, machine-readable + /// command feedback. Sent in the `:server FAIL :` form + /// to clients that negotiated `standard-replies`; others get the description as + /// a plain server NOTICE so the human-readable text still reaches them. + pub fn fail(&self, uid: Uid, command: &str, code: &str, desc: &str) { + self.standard_reply(uid, "FAIL", command, code, desc); + } + pub fn warn(&self, uid: Uid, command: &str, code: &str, desc: &str) { + self.standard_reply(uid, "WARN", command, code, desc); + } + pub fn note(&self, uid: Uid, command: &str, code: &str, desc: &str) { + self.standard_reply(uid, "NOTE", command, code, desc); + } + fn standard_reply(&self, uid: Uid, kind: &str, command: &str, code: &str, desc: &str) { + let cap = self + .users + .get(&uid) + .map(|u| u.caps.standard_replies) + .unwrap_or(false); + if cap { + self.send( + uid, + format!(":{} {kind} {command} {code} :{desc}", self.name), + ); + } else { + let nick = self + .users + .get(&uid) + .map(|u| { + if u.nick.is_empty() { + "*".to_string() + } else { + u.nick.clone() + } + }) + .unwrap_or_else(|| "*".to_string()); + self.send(uid, format!(":{} NOTICE {nick} :{desc}", self.name)); + } + } + /// Send a line to every member of a channel, optionally skipping one uid. pub fn to_channel(&self, key: &str, line: &str, except: Option) { if let Some(ch) = self.channels.get(key) { @@ -457,15 +497,23 @@ impl Server { } } - /// Send a message body (`:prefix CMD …`) to `uid`, composing its IRCv3 tag - /// prefix from that client's caps: `time=` (server-time) plus the client-only - /// tags `ctags` (message-tags). Used for PRIVMSG / NOTICE / TAGMSG delivery. - pub fn send_tagged(&self, uid: Uid, ctags: &str, msgid: &str, body: &str) { + /// Send a message body (`:prefix CMD …`) from `src` to `uid`, composing its + /// IRCv3 tag prefix from *that recipient's* caps: `time=` (server-time), + /// `account=` (account-tag, from the sender's login) plus the client-only tags + /// `ctags` and `msgid` (message-tags). For PRIVMSG / NOTICE / TAGMSG delivery. + pub fn send_tagged(&self, uid: Uid, src: Uid, ctags: &str, msgid: &str, body: &str) { if let Some(u) = self.users.get(&uid) { let mut tags: Vec = Vec::new(); if u.caps.server_time { tags.push(format!("time={}", iso_time(now()))); } + // account-tag: label a message with the sender's services account, so + // recipients see who's authenticated without a separate WHOIS. + if u.caps.account_tag { + if let Some(acct) = self.users.get(&src).and_then(|su| su.account.as_deref()) { + tags.push(format!("account={acct}")); + } + } // msgid (IRCv3): a unique, server-assigned id per message so clients // can reference it (reactions, replies, redaction). Tag-only feature, // so it goes to message-tags clients alongside any client `+`-tags. diff --git a/src/users.rs b/src/users.rs index 2a8c441..73feb9b 100644 --- a/src/users.rs +++ b/src/users.rs @@ -93,6 +93,8 @@ pub const SUPPORTED_CAPS: &[&str] = &[ "invite-notify", "setname", "extended-monitor", + "account-tag", + "standard-replies", "cap-notify", ]; @@ -114,6 +116,8 @@ pub struct Caps { pub invite_notify: bool, pub setname: bool, pub extended_monitor: bool, // route away/account/chghost/setname for MONITOR targets + pub account_tag: bool, // prepend account= tag on messages from logged-in users + pub standard_replies: bool, // understands FAIL/WARN/NOTE structured replies pub cap_notify: bool, } @@ -152,6 +156,8 @@ impl Caps { "invite-notify" => self.invite_notify, "setname" => self.setname, "extended-monitor" => self.extended_monitor, + "account-tag" => self.account_tag, + "standard-replies" => self.standard_replies, "cap-notify" => self.cap_notify, _ => false, } @@ -173,6 +179,8 @@ impl Caps { "invite-notify" => &mut self.invite_notify, "setname" => &mut self.setname, "extended-monitor" => &mut self.extended_monitor, + "account-tag" => &mut self.account_tag, + "standard-replies" => &mut self.standard_replies, "cap-notify" => &mut self.cap_notify, _ => return false, }; @@ -370,7 +378,7 @@ impl Server { uid, RPL_ISUPPORT, &format!( - "CHANTYPES=# PREFIX=(qaohv)~&@%+ CHANMODES=beIg,k,lfjFL,CGMNORSTcimnpstuz EXTBAN=,cmn WATCH=128 MONITOR=128 SILENCE=32 CALLERID=g CASEMAPPING=ascii NICKLEN=30 CHANNELLEN=50 NETWORK={} :are supported by this server", + "CHANTYPES=# PREFIX=(qaohv)~&@%+ CHANMODES=beIg,k,lfjFL,CGMNORSTcimnpstuz EXTBAN=,cmn WATCH=128 MONITOR=128 SILENCE=32 CALLERID=g WHOX CASEMAPPING=ascii NICKLEN=30 CHANNELLEN=50 NETWORK={} :are supported by this server", self.network ), );