From aa384d56f2dc7cc02d9e067d6a011dee882fa253 Mon Sep 17 00:00:00 2001 From: reverse Date: Sun, 9 Aug 2026 20:13:07 +0000 Subject: [PATCH] modules: port customtitle (TITLE vanity-title command + WHOIS line + vhost) --- src/coremods/core_info.rs | 4 ++ src/modules/customtitle.rs | 87 ++++++++++++++++++++++++++++++++++++++ src/modules/mod.rs | 2 + 3 files changed, 93 insertions(+) create mode 100644 src/modules/customtitle.rs diff --git a/src/coremods/core_info.rs b/src/coremods/core_info.rs index ba98559..b8d5af4 100644 --- a/src/coremods/core_info.rs +++ b/src/coremods/core_info.rs @@ -253,6 +253,10 @@ impl Command for Whois { if let Some(line) = crate::modules::profilelink::line(s, &account) { s.numeric(uid, RPL_WHOISSPECIAL, &format!(":{line}")); } + // customtitle: a claimed vanity title + if let Some(line) = crate::modules::customtitle::line(s, tuid) { + s.numeric(uid, RPL_WHOISSPECIAL, &format!(":{line}")); + } // whoisport: the listener port — opers only if asker_oper { if let Some(line) = crate::modules::whoisport::line(s, tuid) { diff --git a/src/modules/customtitle.rs b/src/modules/customtitle.rs new file mode 100644 index 0000000..5ce523b --- /dev/null +++ b/src/modules/customtitle.rs @@ -0,0 +1,87 @@ +//! customtitle — `TITLE ` lets a user claim a configured vanity +//! title (shown in their WHOIS) and, optionally, a matching vhost — a lightweight +//! "mini-oper" identity without operator privileges. Config, one block per title: +//! +//! ```text +//! customtitle = +//! ``` +//! +//! The password is checked via [`crate::modules::password_hash`] so it may be +//! plaintext or a hash. The claimed title lives in the user's `ext`. +//! +//! Behaviour reference: InspIRCd's `m_customtitle`. Original native Rust. + +use crate::command::{CmdResult, Command}; +use crate::modules::password_hash; +use crate::server::Server; +use crate::Uid; + +/// The title a user has claimed, stored in `User.ext`. +struct Title(String); + +/// The WHOIS special line for `tuid`, if they've claimed a title. Called from core_info. +pub fn line(s: &Server, tuid: Uid) -> Option<String> { + s.users + .get(&tuid) + .and_then(|u| u.ext.get::<Title>()) + .map(|t| format!("is {}", t.0)) +} + +pub fn commands() -> Vec<Box<dyn Command>> { + vec![Box::new(TitleCmd)] +} + +struct TitleCmd; +impl Command for TitleCmd { + fn name(&self) -> &'static str { + "TITLE" + } + fn min_params(&self) -> usize { + 2 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + let (name, pass) = (params[0].clone(), params[1].clone()); + // find a matching <name> <password> <vhost> <title…> block + let found = s.conf_all("customtitle").iter().find_map(|line| { + let mut it = line.split_whitespace(); + let cname = it.next()?; + let cpass = it.next()?; + let cvhost = it.next()?; + let title = it.collect::<Vec<_>>().join(" "); + if cname == name && !title.is_empty() && password_hash::verify(cpass, &pass) { + Some((title, cvhost.to_string())) + } else { + None + } + }); + let nick = s + .users + .get(&uid) + .map(|u| u.nick.clone()) + .unwrap_or_default(); + let Some((title, vhost)) = found else { + s.send( + uid, + format!( + ":{} NOTICE {nick} :*** TITLE: invalid title name or password.", + s.name + ), + ); + return CmdResult::Fail; + }; + if let Some(u) = s.users.get_mut(&uid) { + u.ext.set(Title(title.clone())); + } + if vhost != "*" && !vhost.is_empty() { + s.change_host_ident(uid, None, Some(&vhost)); + } + s.send( + uid, + format!( + ":{} NOTICE {nick} :*** TITLE: you are now known as \"{title}\".", + s.name + ), + ); + CmdResult::Ok + } +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 4b28629..782625a 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -14,6 +14,7 @@ pub mod cloak; pub mod cloudflare_challenge; pub mod connectban; pub mod connflood; +pub mod customtitle; pub mod denychans; pub mod disable; pub mod dnsbl; @@ -111,5 +112,6 @@ pub fn module_commands() -> Vec<Box<dyn Command>> { .chain(extended_isupport::commands()) .chain(tline::commands()) .chain(rmode::commands()) + .chain(customtitle::commands()) .collect() }