import echoircd — from-scratch irc daemon in native rust
This commit is contained in:
commit
9b12791774
38 changed files with 9757 additions and 0 deletions
471
src/coremods/core_channel.rs
Normal file
471
src/coremods/core_channel.rs
Normal file
|
|
@ -0,0 +1,471 @@
|
|||
//! core_channel — channel membership commands: JOIN, PART, KICK, TOPIC, NAMES.
|
||||
|
||||
use crate::channels::{Topic, RANK_HALFOP};
|
||||
use crate::command::{CmdResult, Command};
|
||||
use crate::module::Hook;
|
||||
use crate::numeric::*;
|
||||
use crate::server::{now, Server};
|
||||
use crate::Uid;
|
||||
|
||||
pub fn commands() -> Vec<Box<dyn Command>> {
|
||||
vec![
|
||||
Box::new(Join),
|
||||
Box::new(Part),
|
||||
Box::new(Kick),
|
||||
Box::new(TopicCmd),
|
||||
Box::new(Names),
|
||||
Box::new(Invite),
|
||||
Box::new(Knock),
|
||||
Box::new(Cycle),
|
||||
Box::new(Remove),
|
||||
]
|
||||
}
|
||||
|
||||
/// KNOCK — ask for an invite to an invite-only channel.
|
||||
struct Knock;
|
||||
impl Command for Knock {
|
||||
fn name(&self) -> &'static str {
|
||||
"KNOCK"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
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;
|
||||
}
|
||||
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(
|
||||
uid,
|
||||
format!(
|
||||
":{} NOTICE {nick} :Can't KNOCK on {chan} (not invite-only, or you're on it)",
|
||||
s.name
|
||||
),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let reason = params
|
||||
.get(1)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "requesting an invite".to_string());
|
||||
let who = s.users[&uid].prefix();
|
||||
s.to_channel(
|
||||
&key,
|
||||
&format!(
|
||||
":{} NOTICE {chan} :[Knock] {who} is knocking: {reason}",
|
||||
s.name
|
||||
),
|
||||
None,
|
||||
);
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_KNOCKDLVR,
|
||||
&format!("{chan} :Your KNOCK has been delivered"),
|
||||
);
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
/// CYCLE — part and immediately rejoin a channel.
|
||||
struct Cycle;
|
||||
impl Command for Cycle {
|
||||
fn name(&self) -> &'static str {
|
||||
"CYCLE"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let chan = ¶ms[0];
|
||||
let key = chan.to_ascii_lowercase();
|
||||
if !s.is_member(uid, &key) {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NOTONCHANNEL,
|
||||
&format!("{chan} :You're not on that channel"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let prefix = s.users[&uid].prefix();
|
||||
s.to_channel(&key, &format!(":{prefix} PART {chan} :cycling"), None);
|
||||
s.propagate_part(uid, chan, "cycling");
|
||||
if let Some(ch) = s.channels.get_mut(&key) {
|
||||
ch.members.remove(&uid);
|
||||
}
|
||||
if let Some(u) = s.users.get_mut(&uid) {
|
||||
u.channels.remove(&key);
|
||||
}
|
||||
s.channels.retain(|_, c| !c.is_empty());
|
||||
s.join(uid, chan, None);
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
/// REMOVE — like KICK, but the target sees a PART (a softer removal).
|
||||
struct Remove;
|
||||
impl Command for Remove {
|
||||
fn name(&self) -> &'static str {
|
||||
"REMOVE"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
2
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let (chan, victim) = (¶ms[0], ¶ms[1]);
|
||||
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(tuid) = s.find_nick(victim) else {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NOSUCHNICK,
|
||||
&format!("{victim} :No such nick/channel"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
};
|
||||
if !s.channels[&key].members.contains_key(&tuid) {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_USERNOTINCHANNEL,
|
||||
&format!("{victim} {chan} :They aren't on that channel"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
if s.rank(uid, &key) < s.rank(tuid, &key) {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_CHANOPRIVSNEEDED,
|
||||
&format!("{chan} :You cannot remove a user of higher rank"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let by = s.users[&uid].nick.clone();
|
||||
let reason = match params.get(2) {
|
||||
Some(r) => format!("Removed by {by}: {r}"),
|
||||
None => format!("Removed by {by}"),
|
||||
};
|
||||
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());
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct Invite;
|
||||
impl Command for Invite {
|
||||
fn name(&self) -> &'static str {
|
||||
"INVITE"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
2
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let (tnick, chan) = (¶ms[0], ¶ms[1]);
|
||||
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.is_member(uid, &key) {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NOTONCHANNEL,
|
||||
&format!("{chan} :You're not on that channel"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
// only ops may invite into an +i channel
|
||||
if s.channels[&key].modes.invite_only && !s.is_op(uid, &key) {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_CHANOPRIVSNEEDED,
|
||||
&format!("{chan} :You're not a channel operator"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let Some(tuid) = s.find_nick(tnick) else {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NOSUCHNICK,
|
||||
&format!("{tnick} :No such nick/channel"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
};
|
||||
if s.channels[&key].members.contains_key(&tuid) {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_USERONCHANNEL,
|
||||
&format!("{tnick} {chan} :is already on channel"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
if let Some(ch) = s.channels.get_mut(&key) {
|
||||
ch.invites.insert(tuid);
|
||||
}
|
||||
let who = s.users[&tuid].nick.clone();
|
||||
s.numeric(uid, RPL_INVITING, &format!("{who} {chan}"));
|
||||
let prefix = s.users[&uid].prefix();
|
||||
s.send(tuid, format!(":{prefix} INVITE {who} :{chan}"));
|
||||
// invite-notify: tell capable channel members about the invite
|
||||
let notify = format!(":{prefix} INVITE {who} {chan}");
|
||||
let members: Vec<Uid> = s.channels[&key].members.keys().copied().collect();
|
||||
for m in members {
|
||||
if m != uid
|
||||
&& s.users
|
||||
.get(&m)
|
||||
.map(|u| u.caps.invite_notify)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
s.send(m, notify.clone());
|
||||
}
|
||||
}
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct Join;
|
||||
impl Command for Join {
|
||||
fn name(&self) -> &'static str {
|
||||
"JOIN"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let keys: Vec<&str> = params
|
||||
.get(1)
|
||||
.map(|k| k.split(',').collect())
|
||||
.unwrap_or_default();
|
||||
for (i, name) in params[0].split(',').filter(|x| !x.is_empty()).enumerate() {
|
||||
s.join(uid, name, keys.get(i).copied());
|
||||
}
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct Part;
|
||||
impl Command for Part {
|
||||
fn name(&self) -> &'static str {
|
||||
"PART"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let reason = params.get(1).cloned().unwrap_or_default();
|
||||
for target in params[0].split(',').filter(|x| !x.is_empty()) {
|
||||
let key = target.to_ascii_lowercase();
|
||||
let on = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.channels.contains(&key))
|
||||
.unwrap_or(false);
|
||||
if !on {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NOTONCHANNEL,
|
||||
&format!("{target} :You're not on that channel"),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let prefix = s.users[&uid].prefix();
|
||||
let line = if reason.is_empty() {
|
||||
format!(":{prefix} PART {target}")
|
||||
} else {
|
||||
format!(":{prefix} PART {target} :{reason}")
|
||||
};
|
||||
s.to_channel_vis(&key, &line, uid); // +u: only ops + self see the part
|
||||
s.propagate_part(uid, target, &reason); // tell linked servers
|
||||
if let Some(ch) = s.channels.get_mut(&key) {
|
||||
ch.members.remove(&uid);
|
||||
}
|
||||
if let Some(u) = s.users.get_mut(&uid) {
|
||||
u.channels.remove(&key);
|
||||
}
|
||||
s.channels.retain(|_, c| !c.is_empty());
|
||||
s.events.push_back(Hook::Part(uid, key, reason.clone()));
|
||||
}
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct Kick;
|
||||
impl Command for Kick {
|
||||
fn name(&self) -> &'static str {
|
||||
"KICK"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
2
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let (chan, victim) = (¶ms[0], ¶ms[1]);
|
||||
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(tuid) = s.find_nick(victim) else {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NOSUCHNICK,
|
||||
&format!("{victim} :No such nick/channel"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
};
|
||||
if !s.channels[&key].members.contains_key(&tuid) {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_USERNOTINCHANNEL,
|
||||
&format!("{victim} {chan} :They aren't on that channel"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
// can't kick someone who out-ranks you
|
||||
if s.rank(uid, &key) < s.rank(tuid, &key) {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_CHANOPRIVSNEEDED,
|
||||
&format!("{chan} :You cannot kick a user of higher rank"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let kicker = s.users[&uid].nick.clone();
|
||||
let reason = params.get(2).cloned().unwrap_or(kicker);
|
||||
let prefix = s.users[&uid].prefix();
|
||||
s.to_channel(
|
||||
&key,
|
||||
&format!(":{prefix} KICK {chan} {victim} :{reason}"),
|
||||
None,
|
||||
);
|
||||
s.propagate_from_user(uid, &format!("KICK {chan} {victim} :{reason}")); // tell links
|
||||
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.events
|
||||
.push_back(Hook::Part(tuid, key, "kicked".to_string()));
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct TopicCmd;
|
||||
impl Command for TopicCmd {
|
||||
fn name(&self) -> &'static str {
|
||||
"TOPIC"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let target = ¶ms[0];
|
||||
let key = target.to_ascii_lowercase();
|
||||
if !s.channels.contains_key(&key) {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NOSUCHCHANNEL,
|
||||
&format!("{target} :No such channel"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
if params.len() < 2 {
|
||||
match s.channels[&key].topic.as_ref() {
|
||||
Some(t) => {
|
||||
let text = t.text.clone();
|
||||
s.numeric(uid, RPL_TOPIC, &format!("{target} :{text}"));
|
||||
}
|
||||
None => s.numeric(uid, RPL_NOTOPIC, &format!("{target} :No topic is set")),
|
||||
}
|
||||
return CmdResult::Ok;
|
||||
}
|
||||
let on = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.channels.contains(&key))
|
||||
.unwrap_or(false);
|
||||
if !on {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NOTONCHANNEL,
|
||||
&format!("{target} :You're not on that channel"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
// +t: only ops may set the topic
|
||||
if s.channels[&key].modes.topic_ops && !s.is_op(uid, &key) {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_CHANOPRIVSNEEDED,
|
||||
&format!("{target} :You're not a channel operator"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let text = params[1].clone();
|
||||
let (prefix, setter) = {
|
||||
let u = &s.users[&uid];
|
||||
(u.prefix(), u.nick.clone())
|
||||
};
|
||||
if let Some(ch) = s.channels.get_mut(&key) {
|
||||
ch.topic = Some(Topic {
|
||||
text: text.clone(),
|
||||
setter,
|
||||
ts: now(),
|
||||
});
|
||||
}
|
||||
s.to_channel(&key, &format!(":{prefix} TOPIC {target} :{text}"), None);
|
||||
s.propagate_from_user(uid, &format!("TOPIC {target} :{text}")); // tell links
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct Names;
|
||||
impl Command for Names {
|
||||
fn name(&self) -> &'static str {
|
||||
"NAMES"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
for target in params[0].split(',').filter(|x| !x.is_empty()) {
|
||||
s.send_names(uid, &target.to_ascii_lowercase());
|
||||
}
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
287
src/coremods/core_extra.rs
Normal file
287
src/coremods/core_extra.rs
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
//! core_extra — the standard informational / utility commands a full ircd is
|
||||
//! expected to answer: LIST, WHOWAS, USERHOST, ISON, TIME, ADMIN, INFO, STATS, MAP.
|
||||
|
||||
use crate::command::{CmdResult, Command};
|
||||
use crate::numeric::*;
|
||||
use crate::server::{iso_time, now, Server, VERSION};
|
||||
use crate::xline::XKind;
|
||||
use crate::Uid;
|
||||
|
||||
pub fn commands() -> Vec<Box<dyn Command>> {
|
||||
vec![
|
||||
Box::new(List),
|
||||
Box::new(Whowas),
|
||||
Box::new(UserHost),
|
||||
Box::new(IsOn),
|
||||
Box::new(Time),
|
||||
Box::new(Admin),
|
||||
Box::new(Info),
|
||||
Box::new(Stats),
|
||||
Box::new(Map),
|
||||
]
|
||||
}
|
||||
|
||||
struct List;
|
||||
impl Command for List {
|
||||
fn name(&self) -> &'static str {
|
||||
"LIST"
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, _params: &[String]) -> CmdResult {
|
||||
s.numeric(uid, RPL_LISTSTART, "Channel :Users Name");
|
||||
let keys: Vec<String> = s.channels.keys().cloned().collect();
|
||||
for key in keys {
|
||||
let ch = &s.channels[&key];
|
||||
// hide secret / private channels from non-members
|
||||
if (ch.modes.secret || ch.modes.private) && !ch.members.contains_key(&uid) {
|
||||
continue;
|
||||
}
|
||||
let count = ch.members.len() + ch.rmembers.len();
|
||||
let topic = ch
|
||||
.topic
|
||||
.as_ref()
|
||||
.map(|t| t.text.clone())
|
||||
.unwrap_or_default();
|
||||
let name = ch.name.clone();
|
||||
s.numeric(uid, RPL_LIST, &format!("{name} {count} :{topic}"));
|
||||
}
|
||||
s.numeric(uid, RPL_LISTEND, ":End of /LIST");
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct Whowas;
|
||||
impl Command for Whowas {
|
||||
fn name(&self) -> &'static str {
|
||||
"WHOWAS"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let target = ¶ms[0];
|
||||
let want = target.to_ascii_lowercase();
|
||||
let limit = params
|
||||
.get(1)
|
||||
.and_then(|c| c.parse::<usize>().ok())
|
||||
.unwrap_or(8);
|
||||
let hits: Vec<(String, String, String, String, u64)> = s
|
||||
.whowas
|
||||
.iter()
|
||||
.filter(|e| e.nick.to_ascii_lowercase() == want)
|
||||
.take(limit)
|
||||
.map(|e| {
|
||||
(
|
||||
e.nick.clone(),
|
||||
e.ident.clone(),
|
||||
e.host.clone(),
|
||||
e.realname.clone(),
|
||||
e.ts,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
if hits.is_empty() {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_WASNOSUCHNICK,
|
||||
&format!("{target} :There was no such nickname"),
|
||||
);
|
||||
}
|
||||
for (nick, ident, host, realname, ts) in hits {
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_WHOWASUSER,
|
||||
&format!("{nick} {ident} {host} * :{realname}"),
|
||||
);
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_WHOISSERVER,
|
||||
&format!("{nick} {} :{}", s.name, iso_time(ts)),
|
||||
);
|
||||
}
|
||||
s.numeric(uid, RPL_ENDOFWHOWAS, &format!("{target} :End of WHOWAS"));
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct UserHost;
|
||||
impl Command for UserHost {
|
||||
fn name(&self) -> &'static str {
|
||||
"USERHOST"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
for nick in params.iter().take(5) {
|
||||
if let Some(tuid) = s.find_nick(nick) {
|
||||
if let Some(u) = s.users.get(&tuid) {
|
||||
let star = if u.flags.oper { "*" } else { "" };
|
||||
let here = if u.flags.away.is_some() { "-" } else { "+" };
|
||||
parts.push(format!(
|
||||
"{}{star}={here}{}@{}",
|
||||
u.nick,
|
||||
u.ident,
|
||||
u.host_display()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
s.numeric(uid, RPL_USERHOST, &format!(":{}", parts.join(" ")));
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct IsOn;
|
||||
impl Command for IsOn {
|
||||
fn name(&self) -> &'static str {
|
||||
"ISON"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let on: Vec<String> = params
|
||||
.iter()
|
||||
.flat_map(|p| p.split_whitespace())
|
||||
.filter(|n| s.find_nick(n).is_some() || s.find_remote(n).is_some())
|
||||
.map(|n| n.to_string())
|
||||
.collect();
|
||||
s.numeric(uid, RPL_ISON, &format!(":{}", on.join(" ")));
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct Time;
|
||||
impl Command for Time {
|
||||
fn name(&self) -> &'static str {
|
||||
"TIME"
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, _params: &[String]) -> CmdResult {
|
||||
s.numeric(uid, RPL_TIME, &format!("{} :{}", s.name, iso_time(now())));
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct Admin;
|
||||
impl Command for Admin {
|
||||
fn name(&self) -> &'static str {
|
||||
"ADMIN"
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, _params: &[String]) -> CmdResult {
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_ADMINME,
|
||||
&format!("{} :Administrative info", s.name),
|
||||
);
|
||||
s.numeric(uid, RPL_ADMINLOC1, &format!(":{} IRC network", s.network));
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_ADMINLOC2,
|
||||
":echoIRCd — a from-scratch ircd in Rust",
|
||||
);
|
||||
s.numeric(uid, RPL_ADMINEMAIL, &format!(":admin@{}", s.name));
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct Info;
|
||||
impl Command for Info {
|
||||
fn name(&self) -> &'static str {
|
||||
"INFO"
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, _params: &[String]) -> CmdResult {
|
||||
for line in [
|
||||
format!("echoircd-{VERSION} — a from-scratch IRC daemon in Rust"),
|
||||
"Modeled on InspIRCd's API; #![forbid(unsafe_code)]".to_string(),
|
||||
format!("Running the {} network", s.network),
|
||||
] {
|
||||
s.numeric(uid, RPL_INFO, &format!(":{line}"));
|
||||
}
|
||||
s.numeric(uid, RPL_ENDOFINFO, ":End of /INFO list");
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct Stats;
|
||||
impl Command for Stats {
|
||||
fn name(&self) -> &'static str {
|
||||
"STATS"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let letter = params[0].chars().next().unwrap_or(' ');
|
||||
match letter {
|
||||
'u' => {
|
||||
let up = now().saturating_sub(s.created);
|
||||
let (d, h, m, sec) = (up / 86400, (up % 86400) / 3600, (up % 3600) / 60, up % 60);
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_STATSUPTIME,
|
||||
&format!(":Server Up {d} days {h:02}:{m:02}:{sec:02}"),
|
||||
);
|
||||
}
|
||||
'o' => {
|
||||
let opers: Vec<String> = s.opers.iter().map(|(n, _)| n.clone()).collect();
|
||||
for n in opers {
|
||||
s.numeric(uid, RPL_STATSOLINE, &format!("O * * {n} :oper"));
|
||||
}
|
||||
}
|
||||
'k' | 'g' | 'z' => {
|
||||
let kind = match letter {
|
||||
'k' => XKind::Kline,
|
||||
'g' => XKind::Gline,
|
||||
_ => XKind::Zline,
|
||||
};
|
||||
let rows: Vec<String> = s
|
||||
.xlines
|
||||
.iter()
|
||||
.filter(|x| x.kind == kind)
|
||||
.map(|x| {
|
||||
format!(
|
||||
"{} {} {} {} :{}",
|
||||
x.kind.tag(),
|
||||
x.mask,
|
||||
x.expires,
|
||||
x.setter,
|
||||
x.reason
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
for r in rows {
|
||||
s.numeric(uid, RPL_STATSXLINE, &r);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_ENDOFSTATS,
|
||||
&format!("{letter} :End of /STATS report"),
|
||||
);
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct Map;
|
||||
impl Command for Map {
|
||||
fn name(&self) -> &'static str {
|
||||
"MAP"
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, _params: &[String]) -> CmdResult {
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_MAP,
|
||||
&format!("{} ({} users)", s.name, s.users.len()),
|
||||
);
|
||||
let mut peers: Vec<String> = s.servers.values().map(|sv| sv.name.clone()).collect();
|
||||
peers.sort();
|
||||
for name in peers {
|
||||
s.numeric(uid, RPL_MAP, &format!("`- {name}"));
|
||||
}
|
||||
s.numeric(uid, RPL_MAPEND, ":End of /MAP");
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
312
src/coremods/core_info.rs
Normal file
312
src/coremods/core_info.rs
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
//! core_info — informational commands: WHOIS, WHO, LUSERS, MOTD, VERSION.
|
||||
|
||||
use crate::command::{CmdResult, Command};
|
||||
use crate::numeric::*;
|
||||
use crate::server::{Server, VERSION};
|
||||
use crate::Uid;
|
||||
|
||||
pub fn commands() -> Vec<Box<dyn Command>> {
|
||||
vec![
|
||||
Box::new(Whois),
|
||||
Box::new(Who),
|
||||
Box::new(Lusers),
|
||||
Box::new(Motd),
|
||||
Box::new(VersionCmd),
|
||||
Box::new(Links),
|
||||
]
|
||||
}
|
||||
|
||||
/// LINKS — the servers this one knows about (itself + every linked peer).
|
||||
struct Links;
|
||||
impl Command for Links {
|
||||
fn name(&self) -> &'static str {
|
||||
"LINKS"
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, _params: &[String]) -> CmdResult {
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_LINKS,
|
||||
&format!("{} {} :0 {}", s.name, s.name, s.server_desc),
|
||||
);
|
||||
let mut rows: Vec<(String, String)> = s
|
||||
.servers
|
||||
.values()
|
||||
.map(|sv| (sv.name.clone(), sv.desc.clone()))
|
||||
.collect();
|
||||
rows.sort();
|
||||
for (name, desc) in rows {
|
||||
s.numeric(uid, RPL_LINKS, &format!("{name} {} :1 {desc}", s.name));
|
||||
}
|
||||
s.numeric(uid, RPL_ENDOFLINKS, "* :End of /LINKS list");
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct Whois;
|
||||
impl Command for Whois {
|
||||
fn name(&self) -> &'static str {
|
||||
"WHOIS"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let tnick = params[0]
|
||||
.split(',')
|
||||
.next()
|
||||
.unwrap_or(¶ms[0])
|
||||
.to_string();
|
||||
let Some(tuid) = s.find_nick(&tnick) else {
|
||||
// maybe they're on another server
|
||||
if let Some((uuid, _)) = s.find_remote(&tnick) {
|
||||
if let Some(ru) = s.remote_users.get(&uuid) {
|
||||
let srv = s
|
||||
.servers
|
||||
.get(&ru.sid)
|
||||
.map(|sv| sv.name.clone())
|
||||
.unwrap_or_else(|| ru.sid.clone());
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_WHOISUSER,
|
||||
&format!("{} {} {} * :{}", ru.nick, ru.ident, ru.host, ru.realname),
|
||||
);
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_WHOISSERVER,
|
||||
&format!("{} {srv} :remote user", ru.nick),
|
||||
);
|
||||
if let Some(a) = &ru.account {
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_WHOISACCOUNT,
|
||||
&format!("{} {a} :is logged in as", ru.nick),
|
||||
);
|
||||
}
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_ENDOFWHOIS,
|
||||
&format!("{} :End of /WHOIS list", ru.nick),
|
||||
);
|
||||
return CmdResult::Ok;
|
||||
}
|
||||
}
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NOSUCHNICK,
|
||||
&format!("{tnick} :No such nick/channel"),
|
||||
);
|
||||
s.numeric(uid, RPL_ENDOFWHOIS, &format!("{tnick} :End of /WHOIS list"));
|
||||
return CmdResult::Fail;
|
||||
};
|
||||
let asker_oper = s.is_oper(uid);
|
||||
let is_self = tuid == uid;
|
||||
let keys: Vec<String> = s.users[&tuid].channels.iter().cloned().collect();
|
||||
let (
|
||||
nick,
|
||||
ident,
|
||||
disp,
|
||||
realname,
|
||||
realhost,
|
||||
realip,
|
||||
secure,
|
||||
oper,
|
||||
bot,
|
||||
hideoper,
|
||||
hidechans,
|
||||
account,
|
||||
last_active,
|
||||
signon,
|
||||
) = {
|
||||
let u = &s.users[&tuid];
|
||||
(
|
||||
u.nick.clone(),
|
||||
u.ident.clone(),
|
||||
u.host_display().to_string(),
|
||||
u.realname.clone(),
|
||||
u.host.clone(),
|
||||
u.addr.ip().to_string(),
|
||||
u.secure,
|
||||
u.flags.oper,
|
||||
u.flags.bot,
|
||||
u.flags.hideoper,
|
||||
u.flags.hidechans,
|
||||
u.account.clone(),
|
||||
u.last_active,
|
||||
u.signon,
|
||||
)
|
||||
};
|
||||
let chans: Vec<String> = keys
|
||||
.iter()
|
||||
.filter_map(|k| s.channels.get(k).map(|c| c.name.clone()))
|
||||
.collect();
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_WHOISUSER,
|
||||
&format!("{nick} {ident} {disp} * :{realname}"),
|
||||
);
|
||||
if bot {
|
||||
s.numeric(uid, RPL_WHOISBOT, &format!("{nick} :is a bot"));
|
||||
}
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_WHOISSERVER,
|
||||
&format!("{nick} {} :echoIRCd", s.name),
|
||||
);
|
||||
// +I hides the channel list from everyone but the user themselves + opers
|
||||
if !chans.is_empty() && (is_self || asker_oper || !hidechans) {
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_WHOISCHANNELS,
|
||||
&format!("{nick} :{}", chans.join(" ")),
|
||||
);
|
||||
}
|
||||
// 313: is an IRC operator (hidden by +H unless the asker is an oper)
|
||||
if oper && (!hideoper || asker_oper) {
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_WHOISOPERATOR,
|
||||
&format!("{nick} :is an IRC operator"),
|
||||
);
|
||||
}
|
||||
// opers can see through the cloak to the real host/ip
|
||||
if asker_oper && disp != realhost {
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_WHOISHOST,
|
||||
&format!("{nick} :is connecting from {ident}@{realhost} {realip}"),
|
||||
);
|
||||
}
|
||||
// 330: logged in to a services account
|
||||
if let Some(acct) = &account {
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_WHOISACCOUNT,
|
||||
&format!("{nick} {acct} :is logged in as"),
|
||||
);
|
||||
}
|
||||
// sslinfo: advertise a secure (TLS) connection
|
||||
if secure {
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_WHOISSECURE,
|
||||
&format!("{nick} :is using a secure connection"),
|
||||
);
|
||||
}
|
||||
// 317: idle time + signon time
|
||||
let idle = crate::server::now().saturating_sub(last_active);
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_WHOISIDLE,
|
||||
&format!("{nick} {idle} {signon} :seconds idle, signon time"),
|
||||
);
|
||||
s.numeric(uid, RPL_ENDOFWHOIS, &format!("{nick} :End of /WHOIS list"));
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct Who;
|
||||
impl Command for Who {
|
||||
fn name(&self) -> &'static str {
|
||||
"WHO"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
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) {
|
||||
Some(ch) => {
|
||||
let name = ch.name.clone();
|
||||
ch.members
|
||||
.iter()
|
||||
.map(|(&m, mem)| {
|
||||
let p = if multi {
|
||||
mem.all_prefixes()
|
||||
} else {
|
||||
mem.prefix_char().to_string()
|
||||
};
|
||||
(m, name.clone(), p)
|
||||
})
|
||||
.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);
|
||||
}
|
||||
s.numeric(uid, RPL_ENDOFWHO, &format!("{target} :End of /WHO list"));
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct Lusers;
|
||||
impl Command for Lusers {
|
||||
fn name(&self) -> &'static str {
|
||||
"LUSERS"
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, _params: &[String]) -> CmdResult {
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_LUSERCLIENT,
|
||||
&format!(":There are {} users on 1 server", s.users.len()),
|
||||
);
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct Motd;
|
||||
impl Command for Motd {
|
||||
fn name(&self) -> &'static str {
|
||||
"MOTD"
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, _params: &[String]) -> CmdResult {
|
||||
s.send_motd(uid);
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct VersionCmd;
|
||||
impl Command for VersionCmd {
|
||||
fn name(&self) -> &'static str {
|
||||
"VERSION"
|
||||
}
|
||||
fn before_reg(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, _params: &[String]) -> CmdResult {
|
||||
s.send(
|
||||
uid,
|
||||
format!(":{} 351 * echoircd-{VERSION} {} :", s.name, s.name),
|
||||
);
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
546
src/coremods/core_message.rs
Normal file
546
src/coremods/core_message.rs
Normal file
|
|
@ -0,0 +1,546 @@
|
|||
//! core_message — PRIVMSG and NOTICE (channel + user targets).
|
||||
|
||||
use crate::channels::{glob_match, RANK_HALFOP, RANK_VOICE};
|
||||
use crate::command::{CmdResult, Command};
|
||||
use crate::numeric::*;
|
||||
use crate::server::Server;
|
||||
use crate::Uid;
|
||||
|
||||
/// mIRC/IRC formatting control bytes (bold, colour, hex-colour, reset, …).
|
||||
const FMT: [char; 9] = [
|
||||
'\u{02}', '\u{03}', '\u{04}', '\u{0F}', '\u{11}', '\u{16}', '\u{1D}', '\u{1E}', '\u{1F}',
|
||||
];
|
||||
|
||||
fn is_ctcp(t: &str) -> bool {
|
||||
t.starts_with('\u{01}')
|
||||
}
|
||||
fn is_action(t: &str) -> bool {
|
||||
t.starts_with("\u{01}ACTION")
|
||||
}
|
||||
fn has_formatting(t: &str) -> bool {
|
||||
t.chars().any(|c| FMT.contains(&c))
|
||||
}
|
||||
/// Strip formatting/colour codes (drops \x03 colour specs and \x04 hex specs).
|
||||
fn strip_formatting(t: &str) -> String {
|
||||
let cs: Vec<char> = t.chars().collect();
|
||||
let mut out = String::with_capacity(cs.len());
|
||||
let mut i = 0;
|
||||
while i < cs.len() {
|
||||
match cs[i] {
|
||||
'\u{02}' | '\u{0F}' | '\u{11}' | '\u{16}' | '\u{1D}' | '\u{1E}' | '\u{1F}' => i += 1,
|
||||
'\u{03}' => {
|
||||
i += 1;
|
||||
let mut n = 0;
|
||||
while n < 2 && i < cs.len() && cs[i].is_ascii_digit() {
|
||||
i += 1;
|
||||
n += 1;
|
||||
}
|
||||
if n > 0 && i + 1 < cs.len() && cs[i] == ',' && cs[i + 1].is_ascii_digit() {
|
||||
i += 1;
|
||||
let mut m = 0;
|
||||
while m < 2 && i < cs.len() && cs[i].is_ascii_digit() {
|
||||
i += 1;
|
||||
m += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
'\u{04}' => {
|
||||
i += 1;
|
||||
let mut n = 0;
|
||||
while n < 6 && i < cs.len() && cs[i].is_ascii_hexdigit() {
|
||||
i += 1;
|
||||
n += 1;
|
||||
}
|
||||
if n == 6 && i + 1 < cs.len() && cs[i] == ',' && cs[i + 1].is_ascii_hexdigit() {
|
||||
i += 1;
|
||||
let mut m = 0;
|
||||
while m < 6 && i < cs.len() && cs[i].is_ascii_hexdigit() {
|
||||
i += 1;
|
||||
m += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
c => {
|
||||
out.push(c);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Case-insensitive substring test over chars (Unicode-safe, no byte slicing).
|
||||
fn ci_contains(hay: &str, find: &str) -> bool {
|
||||
let h: Vec<char> = hay.chars().collect();
|
||||
let f: Vec<char> = find.chars().collect();
|
||||
if f.is_empty() || f.len() > h.len() {
|
||||
return false;
|
||||
}
|
||||
(0..=h.len() - f.len()).any(|i| (0..f.len()).all(|k| h[i + k].eq_ignore_ascii_case(&f[k])))
|
||||
}
|
||||
|
||||
/// Case-insensitive replace-all over chars (Unicode-safe, no byte slicing).
|
||||
fn ci_replace(hay: &str, find: &str, rep: &str) -> String {
|
||||
let h: Vec<char> = hay.chars().collect();
|
||||
let f: Vec<char> = find.chars().collect();
|
||||
if f.is_empty() {
|
||||
return hay.to_string();
|
||||
}
|
||||
let mut out = String::with_capacity(hay.len());
|
||||
let mut i = 0;
|
||||
while i < h.len() {
|
||||
let hit =
|
||||
i + f.len() <= h.len() && (0..f.len()).all(|k| h[i + k].eq_ignore_ascii_case(&f[k]));
|
||||
if hit {
|
||||
out.push_str(rep);
|
||||
i += f.len();
|
||||
} else {
|
||||
out.push(h[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// +G censor: replace each configured bad word in `body`. Returns `None` when a
|
||||
/// matched word has an empty replacement (⇒ the message must be blocked).
|
||||
fn apply_censor(body: &str, censor: &[(String, String)]) -> Option<String> {
|
||||
let mut out = body.to_string();
|
||||
for (find, replace) in censor {
|
||||
if find.is_empty() || !ci_contains(&out, find) {
|
||||
continue;
|
||||
}
|
||||
if replace.is_empty() {
|
||||
return None; // no replacement ⇒ block
|
||||
}
|
||||
out = ci_replace(&out, find, replace);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
pub fn commands() -> Vec<Box<dyn Command>> {
|
||||
vec![Box::new(PrivMsg), Box::new(Notice), Box::new(TagMsg)]
|
||||
}
|
||||
|
||||
/// Shared PRIVMSG/NOTICE delivery. NOTICE never generates automatic replies.
|
||||
fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool) -> CmdResult {
|
||||
let cmd = if notice { "NOTICE" } else { "PRIVMSG" };
|
||||
if params.is_empty() {
|
||||
if !notice {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NORECIPIENT,
|
||||
&format!(":No recipient given ({cmd})"),
|
||||
);
|
||||
}
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
if params.len() < 2 || params[1].is_empty() {
|
||||
if !notice {
|
||||
s.numeric(uid, ERR_NOTEXTTOSEND, ":No text to send");
|
||||
}
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let (target, text) = (¶ms[0], ¶ms[1]);
|
||||
let Some(prefix) = s.users.get(&uid).map(|u| u.prefix()) else {
|
||||
return CmdResult::Fail;
|
||||
};
|
||||
if target.starts_with('#') {
|
||||
let key = target.to_ascii_lowercase();
|
||||
let member = s
|
||||
.channels
|
||||
.get(&key)
|
||||
.map(|c| c.members.contains_key(&uid))
|
||||
.unwrap_or(false);
|
||||
if !member {
|
||||
if !notice {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_CANNOTSENDTOCHAN,
|
||||
&format!("{target} :Cannot send to channel"),
|
||||
);
|
||||
}
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
// +m: only voiced-or-above may speak
|
||||
let moderated = s
|
||||
.channels
|
||||
.get(&key)
|
||||
.map(|c| c.modes.moderated)
|
||||
.unwrap_or(false);
|
||||
if moderated && s.rank(uid, &key) < RANK_VOICE {
|
||||
if !notice {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_CANNOTSENDTOCHAN,
|
||||
&format!("{target} :Cannot send to channel (+m)"),
|
||||
);
|
||||
}
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
// +M: only logged-in (account) users may speak (voiced-or-above exempt)
|
||||
let reg_moderated = s
|
||||
.channels
|
||||
.get(&key)
|
||||
.map(|c| c.modes.reg_moderated)
|
||||
.unwrap_or(false);
|
||||
if reg_moderated && s.rank(uid, &key) < RANK_VOICE && !s.is_logged_in(uid) {
|
||||
if !notice {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NEEDREGGEDNICK,
|
||||
&format!("{target} :You must be logged into an account to speak here (+M)"),
|
||||
);
|
||||
}
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
// extban `m:` mute — matched users can't speak unless voiced-or-above
|
||||
if s.extban_active(uid, &key, 'm') && s.rank(uid, &key) < RANK_VOICE {
|
||||
if !notice {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_CANNOTSENDTOCHAN,
|
||||
&format!("{target} :Cannot send to channel (you're muted, +b m:)"),
|
||||
);
|
||||
}
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
// +f message flood — ops/half-ops and opers are exempt; others get kicked
|
||||
let flood_exempt = s.rank(uid, &key) >= RANK_HALFOP
|
||||
|| s.users.get(&uid).map(|u| u.flags.oper).unwrap_or(false);
|
||||
if !flood_exempt {
|
||||
if let Some(ban) = s.messageflood_hit(uid, &key) {
|
||||
s.flood_kick(uid, &key, ban);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
}
|
||||
// content-based modes: +C no CTCP, +T no notices, +c no colour, +S strip
|
||||
let (no_ctcp, no_notice, no_color, strip) = s
|
||||
.channels
|
||||
.get(&key)
|
||||
.map(|c| {
|
||||
(
|
||||
c.modes.no_ctcp,
|
||||
c.modes.no_notice,
|
||||
c.modes.no_color,
|
||||
c.modes.strip_color,
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if notice && no_notice {
|
||||
return CmdResult::Fail; // +T — NOTICEs are silently dropped
|
||||
}
|
||||
if no_ctcp && is_ctcp(text) && !is_action(text) {
|
||||
if !notice {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_CANNOTSENDTOCHAN,
|
||||
&format!("{target} :CTCP is disabled (+C)"),
|
||||
);
|
||||
}
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
if no_color && has_formatting(text) {
|
||||
if !notice {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_CANNOTSENDTOCHAN,
|
||||
&format!("{target} :Formatting/colour is disabled (+c)"),
|
||||
);
|
||||
}
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
// extban `c:` no-colour — matched users can't send formatting
|
||||
if s.extban_active(uid, &key, 'c') && has_formatting(text) {
|
||||
if !notice {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_CANNOTSENDTOCHAN,
|
||||
&format!("{target} :Formatting/colour is disabled for you (+b c:)"),
|
||||
);
|
||||
}
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
// +g channel filter — block messages matching any word/glob (use *word*)
|
||||
let filtered = s
|
||||
.channels
|
||||
.get(&key)
|
||||
.map(|c| c.filters.iter().any(|f| glob_match(&f.mask, text)))
|
||||
.unwrap_or(false);
|
||||
if filtered {
|
||||
if !notice {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_CANNOTSENDTOCHAN,
|
||||
&format!("{target} :Cannot send to channel (blocked by +g filter)"),
|
||||
);
|
||||
}
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let mut body = if strip {
|
||||
strip_formatting(text)
|
||||
} else {
|
||||
text.clone()
|
||||
};
|
||||
// +G censor — replace configured bad words (empty replacement ⇒ block)
|
||||
let censor_on = s
|
||||
.channels
|
||||
.get(&key)
|
||||
.map(|c| c.modes.censor)
|
||||
.unwrap_or(false);
|
||||
if censor_on && !s.censor.is_empty() {
|
||||
match apply_censor(&body, &s.censor) {
|
||||
Some(b) => body = b,
|
||||
None => {
|
||||
if !notice {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_CANNOTSENDTOCHAN,
|
||||
&format!("{target} :Cannot send to channel (+G censor)"),
|
||||
);
|
||||
}
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
}
|
||||
}
|
||||
// deliver to every member except the sender and +D (deaf) users, tagging
|
||||
// per-recipient (server-time + any client-only tags on the line)
|
||||
let line = format!(":{prefix} {cmd} {target} :{body}");
|
||||
let ctags = s.line_ctags.clone();
|
||||
let msgid = s.next_msgid(); // one id shared by every recipient of this message
|
||||
let members: Vec<Uid> = s
|
||||
.channels
|
||||
.get(&key)
|
||||
.map(|c| c.members.keys().copied().collect())
|
||||
.unwrap_or_default();
|
||||
for m in members {
|
||||
if m == uid || s.users.get(&m).map(|u| u.flags.deaf).unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
s.send_tagged(m, &ctags, &msgid, &line);
|
||||
}
|
||||
// echo-message: give the sender their own copy if they asked for one
|
||||
if s.users
|
||||
.get(&uid)
|
||||
.map(|u| u.caps.echo_message)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
s.send_tagged(uid, &ctags, &msgid, &line);
|
||||
}
|
||||
// propagate to linked servers that have members in this channel
|
||||
s.send_channel_to_links(uid, &key, target, cmd, &body);
|
||||
} else if let Some(tuid) = s.find_nick(target) {
|
||||
// user +R (regdeaf): drop messages from users not logged into an account
|
||||
if s.users
|
||||
.get(&tuid)
|
||||
.map(|u| u.flags.reg_only_pm)
|
||||
.unwrap_or(false)
|
||||
&& !s.is_logged_in(uid)
|
||||
{
|
||||
if !notice {
|
||||
let tn = s
|
||||
.users
|
||||
.get(&tuid)
|
||||
.map(|u| u.nick.clone())
|
||||
.unwrap_or_default();
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NEEDREGGEDNICK,
|
||||
&format!("{tn} :You must be logged into an account to message this user (+R)"),
|
||||
);
|
||||
}
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
// user +z (sslqueries): only TLS users may PM them
|
||||
if s.users.get(&tuid).map(|u| u.flags.ssl_pm).unwrap_or(false)
|
||||
&& !s.users.get(&uid).map(|u| u.secure).unwrap_or(false)
|
||||
{
|
||||
if !notice {
|
||||
let (tn, sn) = (
|
||||
s.users
|
||||
.get(&tuid)
|
||||
.map(|u| u.nick.clone())
|
||||
.unwrap_or_default(),
|
||||
s.users
|
||||
.get(&uid)
|
||||
.map(|u| u.nick.clone())
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
s.send(
|
||||
uid,
|
||||
format!(
|
||||
":{} NOTICE {sn} :Cannot message {tn}: a TLS connection is required (+z)",
|
||||
s.name
|
||||
),
|
||||
);
|
||||
}
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
// SILENCE: if the recipient silenced the sender, drop it silently — the
|
||||
// sender is never told (that's the point), but still gets their own echo.
|
||||
let silenced = s.is_silenced(tuid, &prefix);
|
||||
let pm = format!(":{prefix} {cmd} {target} :{text}");
|
||||
let ctags = s.line_ctags.clone();
|
||||
let msgid = s.next_msgid();
|
||||
if !silenced {
|
||||
s.send_tagged(tuid, &ctags, &msgid, &pm);
|
||||
}
|
||||
if s.users
|
||||
.get(&uid)
|
||||
.map(|u| u.caps.echo_message)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
s.send_tagged(uid, &ctags, &msgid, &pm);
|
||||
}
|
||||
// if the recipient is away, tell the sender (PRIVMSG only, not if silenced)
|
||||
if !notice && !silenced {
|
||||
if let Some(msg) = s.users.get(&tuid).and_then(|u| u.flags.away.clone()) {
|
||||
s.numeric(uid, RPL_AWAY, &format!("{target} :{msg}"));
|
||||
}
|
||||
}
|
||||
} else if let Some((uuid, via)) = s.find_remote(target) {
|
||||
// the target is a user on another server — route it across the link
|
||||
s.send_to_remote(uid, &uuid, via, cmd, text);
|
||||
if s.users
|
||||
.get(&uid)
|
||||
.map(|u| u.caps.echo_message)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let ctags = s.line_ctags.clone();
|
||||
let msgid = s.next_msgid();
|
||||
s.send_tagged(
|
||||
uid,
|
||||
&ctags,
|
||||
&msgid,
|
||||
&format!(":{prefix} {cmd} {target} :{text}"),
|
||||
);
|
||||
}
|
||||
} else if !notice {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NOSUCHNICK,
|
||||
&format!("{target} :No such nick/channel"),
|
||||
);
|
||||
}
|
||||
CmdResult::Ok
|
||||
}
|
||||
|
||||
struct PrivMsg;
|
||||
impl Command for PrivMsg {
|
||||
fn name(&self) -> &'static str {
|
||||
"PRIVMSG"
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
deliver(s, uid, params, false)
|
||||
}
|
||||
}
|
||||
|
||||
struct Notice;
|
||||
impl Command for Notice {
|
||||
fn name(&self) -> &'static str {
|
||||
"NOTICE"
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
deliver(s, uid, params, true)
|
||||
}
|
||||
}
|
||||
|
||||
/// TAGMSG — an IRCv3 message that carries only client tags (typing, reactions, …)
|
||||
/// and no text. Relayed to targets whose clients enabled `message-tags`; clients
|
||||
/// without it never see it. Mirrors PRIVMSG's target / membership / +m rules.
|
||||
struct TagMsg;
|
||||
impl Command for TagMsg {
|
||||
fn name(&self) -> &'static str {
|
||||
"TAGMSG"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let ctags = s.line_ctags.clone();
|
||||
if ctags.is_empty() {
|
||||
return CmdResult::Ok; // no client tags -> nothing to relay
|
||||
}
|
||||
let target = ¶ms[0];
|
||||
let Some(prefix) = s.users.get(&uid).map(|u| u.prefix()) else {
|
||||
return CmdResult::Fail;
|
||||
};
|
||||
let body = format!(":{prefix} TAGMSG {target}");
|
||||
let msgid = s.next_msgid(); // shared across this TAGMSG's recipients
|
||||
if target.starts_with('#') {
|
||||
let key = target.to_ascii_lowercase();
|
||||
if !s
|
||||
.channels
|
||||
.get(&key)
|
||||
.map(|c| c.members.contains_key(&uid))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
// +m: only voiced-or-above may emit tags
|
||||
let moderated = s
|
||||
.channels
|
||||
.get(&key)
|
||||
.map(|c| c.modes.moderated)
|
||||
.unwrap_or(false);
|
||||
if moderated && s.rank(uid, &key) < RANK_VOICE {
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let echo = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.caps.echo_message)
|
||||
.unwrap_or(false);
|
||||
let members: Vec<Uid> = s
|
||||
.channels
|
||||
.get(&key)
|
||||
.map(|c| c.members.keys().copied().collect())
|
||||
.unwrap_or_default();
|
||||
for m in members {
|
||||
if (m == uid && !echo) || s.users.get(&m).map(|u| u.flags.deaf).unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
// only message-tags clients receive a TAGMSG
|
||||
if s.users
|
||||
.get(&m)
|
||||
.map(|u| u.caps.message_tags)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
s.send_tagged(m, &ctags, &msgid, &body);
|
||||
}
|
||||
}
|
||||
} else if let Some(tuid) = s.find_nick(target) {
|
||||
if s.users
|
||||
.get(&tuid)
|
||||
.map(|u| u.caps.message_tags)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
s.send_tagged(tuid, &ctags, &msgid, &body);
|
||||
}
|
||||
if s.users
|
||||
.get(&uid)
|
||||
.map(|u| u.caps.echo_message)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
s.send_tagged(uid, &ctags, &msgid, &body);
|
||||
}
|
||||
}
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn strip_drops_codes_but_keeps_text_and_bare_commas() {
|
||||
assert_eq!(strip_formatting("\u{03}04red\u{03} text"), "red text");
|
||||
assert_eq!(strip_formatting("\u{02}bold\u{02}"), "bold");
|
||||
assert_eq!(strip_formatting("\u{03}04,08fg"), "fg"); // colour,bg spec
|
||||
assert_eq!(strip_formatting("\u{03}4, hi"), ", hi"); // bare comma survives
|
||||
assert!(has_formatting("\u{03}4x") && !has_formatting("plain"));
|
||||
assert!(is_ctcp("\u{01}PING\u{01}") && !is_ctcp("hi"));
|
||||
assert!(is_action("\u{01}ACTION waves"));
|
||||
}
|
||||
}
|
||||
179
src/coremods/core_mode.rs
Normal file
179
src/coremods/core_mode.rs
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
//! core_mode — the MODE command. Both channel and user modes are dispatched to
|
||||
//! the handler objects in [`crate::mode`] (InspIRCd-style `ModeHandler`s); this
|
||||
//! file just parses the modestring and orchestrates.
|
||||
|
||||
use crate::channels::RANK_HALFOP;
|
||||
use crate::command::{CmdResult, Command};
|
||||
use crate::mode::{chan_mode, user_mode, Applied};
|
||||
use crate::numeric::*;
|
||||
use crate::server::Server;
|
||||
use crate::Uid;
|
||||
|
||||
pub fn commands() -> Vec<Box<dyn Command>> {
|
||||
vec![Box::new(Mode)]
|
||||
}
|
||||
|
||||
/// Append one mode change to the echo string, emitting the +/- only when it flips.
|
||||
fn emit(applied: &mut String, last: &mut char, sign: char, c: char) {
|
||||
if *last != sign {
|
||||
applied.push(sign);
|
||||
*last = sign;
|
||||
}
|
||||
applied.push(c);
|
||||
}
|
||||
|
||||
struct Mode;
|
||||
impl Command for Mode {
|
||||
fn name(&self) -> &'static str {
|
||||
"MODE"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
apply_mode(s, uid, params)
|
||||
}
|
||||
}
|
||||
|
||||
/// The MODE body, shared with SAMODE (which wraps it in `Server::mode_sudo` so
|
||||
/// the rank gates below all pass — see `core_oper::SaMode`).
|
||||
pub fn apply_mode(s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let target = ¶ms[0];
|
||||
if !target.starts_with('#') {
|
||||
return apply_user_modes(s, uid, target, params);
|
||||
}
|
||||
let key = target.to_ascii_lowercase();
|
||||
if !s.channels.contains_key(&key) {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NOSUCHCHANNEL,
|
||||
&format!("{target} :No such channel"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
// query: `MODE #c`
|
||||
if params.len() < 2 {
|
||||
let modestr = s.channels[&key].modes.render(s.is_member(uid, &key));
|
||||
s.numeric(uid, RPL_CHANNELMODEIS, &format!("{target} {modestr}"));
|
||||
let created = s.channels[&key].created;
|
||||
s.numeric(uid, RPL_CREATIONTIME, &format!("{target} {created}"));
|
||||
return CmdResult::Ok;
|
||||
}
|
||||
// setting modes needs at least half-op; each handler then enforces its
|
||||
// own finer rule (prefixes need enough rank, +z needs all-secure, …)
|
||||
if s.rank(uid, &key) < RANK_HALFOP {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_CHANOPRIVSNEEDED,
|
||||
&format!("{target} :You're not a channel operator"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
|
||||
// dispatch each mode letter to its handler
|
||||
let modestring = params[1].clone();
|
||||
let args = ¶ms[2..];
|
||||
let mut argi = 0usize;
|
||||
let mut sign = '+';
|
||||
let mut applied = String::new();
|
||||
let mut last = ' ';
|
||||
let mut echoed: Vec<String> = Vec::new();
|
||||
for c in modestring.chars() {
|
||||
if c == '+' || c == '-' {
|
||||
sign = c;
|
||||
continue;
|
||||
}
|
||||
let adding = sign == '+';
|
||||
let Some(handler) = chan_mode(c) else {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_UNKNOWNMODE,
|
||||
&format!("{c} :is unknown mode char to me"),
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let param = if handler.wants_param(adding) {
|
||||
let p = args.get(argi).cloned();
|
||||
if p.is_some() {
|
||||
argi += 1;
|
||||
}
|
||||
p
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Applied::Yes(echo) = handler.apply(s, target, &key, uid, adding, param.as_deref()) {
|
||||
emit(&mut applied, &mut last, sign, c);
|
||||
if let Some(p) = echo {
|
||||
echoed.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !applied.is_empty() {
|
||||
let prefix = s.users[&uid].prefix();
|
||||
let pstr = if echoed.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" {}", echoed.join(" "))
|
||||
};
|
||||
s.to_channel(
|
||||
&key,
|
||||
&format!(":{prefix} MODE {target} {applied}{pstr}"),
|
||||
None,
|
||||
);
|
||||
s.propagate_from_user(uid, &format!("MODE {target} {applied}{pstr}"));
|
||||
// links
|
||||
}
|
||||
CmdResult::Ok
|
||||
}
|
||||
|
||||
/// User modes: dispatched to the [`crate::mode`] `UserMode` handler objects.
|
||||
fn apply_user_modes(s: &mut Server, uid: Uid, target: &str, params: &[String]) -> CmdResult {
|
||||
let me = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.nick.clone())
|
||||
.unwrap_or_default();
|
||||
if !target.eq_ignore_ascii_case(&me) {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_USERSDONTMATCH,
|
||||
":Can't change mode for other users",
|
||||
);
|
||||
return CmdResult::Ok;
|
||||
}
|
||||
if params.len() < 2 {
|
||||
let um = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.flags.umodes())
|
||||
.unwrap_or_else(|| "+".to_string());
|
||||
s.numeric(uid, RPL_UMODEIS, &um);
|
||||
return CmdResult::Ok;
|
||||
}
|
||||
let modestring = params[1].clone();
|
||||
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 == '+';
|
||||
let Some(handler) = user_mode(c) else {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_UMODEUNKNOWNFLAG,
|
||||
&format!(":Unknown MODE flag {c}"),
|
||||
);
|
||||
continue;
|
||||
};
|
||||
if handler.apply(s, uid, adding) {
|
||||
emit(&mut applied, &mut last, sign, c);
|
||||
}
|
||||
}
|
||||
if !applied.is_empty() {
|
||||
s.send(uid, format!(":{me} MODE {me} :{applied}"));
|
||||
}
|
||||
CmdResult::Ok
|
||||
}
|
||||
778
src/coremods/core_oper.rs
Normal file
778
src/coremods/core_oper.rs
Normal file
|
|
@ -0,0 +1,778 @@
|
|||
//! core_oper — IRC operator commands: OPER, KILL, WALLOPS. Mirrors InspIRCd's
|
||||
//! `coremods/core_oper/`. Oper blocks are configured with `oper = name pass`.
|
||||
|
||||
use crate::channels::Topic;
|
||||
use crate::command::{CmdResult, Command};
|
||||
use crate::config::Config;
|
||||
use crate::coremods::core_mode::apply_mode;
|
||||
use crate::module::Hook;
|
||||
use crate::numeric::*;
|
||||
use crate::server::{now, Server};
|
||||
use crate::users::{valid_host, valid_ident, valid_nick};
|
||||
use crate::xline::{parse_duration, XKind};
|
||||
use crate::Uid;
|
||||
|
||||
pub fn commands() -> Vec<Box<dyn Command>> {
|
||||
vec![
|
||||
Box::new(Oper),
|
||||
Box::new(Kill),
|
||||
Box::new(Wallops),
|
||||
Box::new(SvsLogin),
|
||||
Box::new(SvsLogout),
|
||||
Box::new(Rehash),
|
||||
Box::new(GlobOps),
|
||||
Box::new(SaJoin),
|
||||
Box::new(SaPart),
|
||||
Box::new(SaNick),
|
||||
Box::new(Die),
|
||||
Box::new(Restart),
|
||||
Box::new(Kline),
|
||||
Box::new(Gline),
|
||||
Box::new(Zline),
|
||||
Box::new(ChgHost),
|
||||
Box::new(ChgIdent),
|
||||
Box::new(SetHost),
|
||||
Box::new(SetIdent),
|
||||
Box::new(SaMode),
|
||||
Box::new(SaTopic),
|
||||
Box::new(SaKick),
|
||||
]
|
||||
}
|
||||
|
||||
/// Reject non-opers with 481; returns whether the caller is an oper.
|
||||
fn require_oper(s: &mut Server, uid: Uid) -> bool {
|
||||
if s.is_oper(uid) {
|
||||
return true;
|
||||
}
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NOPRIVILEGES,
|
||||
":Permission Denied- You're not an IRC operator",
|
||||
);
|
||||
false
|
||||
}
|
||||
|
||||
struct Oper;
|
||||
impl Command for Oper {
|
||||
fn name(&self) -> &'static str {
|
||||
"OPER"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
2
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let (name, pass) = (¶ms[0], ¶ms[1]);
|
||||
if s.opers.iter().any(|(n, p)| n == name && p == pass) {
|
||||
s.oper_up(uid);
|
||||
CmdResult::Ok
|
||||
} else {
|
||||
s.numeric(uid, ERR_PASSWDMISMATCH, ":Password incorrect");
|
||||
CmdResult::Fail
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Kill;
|
||||
impl Command for Kill {
|
||||
fn name(&self) -> &'static str {
|
||||
"KILL"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
if !s.is_oper(uid) {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NOPRIVILEGES,
|
||||
":Permission Denied- You're not an IRC operator",
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let target = ¶ms[0];
|
||||
let reason = params
|
||||
.get(1)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "Killed".to_string());
|
||||
let Some(tuid) = s.find_nick(target) else {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NOSUCHNICK,
|
||||
&format!("{target} :No such nick/channel"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
};
|
||||
let killer = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.nick.clone())
|
||||
.unwrap_or_default();
|
||||
s.send(
|
||||
tuid,
|
||||
format!(":{} KILL {target} :{killer} ({reason})", s.name),
|
||||
);
|
||||
s.remove_user(tuid, &format!("Killed by {killer}: {reason}"));
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
/// SVSLOGIN / SVSLOGOUT — the **services interface** to the account layer
|
||||
/// ([`crate::accounts`]). Over S2S these arrive from a services pseudoserver
|
||||
/// (Anope/Atheme); until S2S exists an oper may invoke them to drive `+r` and the
|
||||
/// account-gated channel modes. `SVSLOGIN <nick> <account>` logs a user in
|
||||
/// (`account` of `*`/`0` logs out); `SVSLOGOUT <nick>` logs them out.
|
||||
struct SvsLogin;
|
||||
impl Command for SvsLogin {
|
||||
fn name(&self) -> &'static str {
|
||||
"SVSLOGIN"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
2
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
if !s.is_oper(uid) {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NOPRIVILEGES,
|
||||
":Permission Denied- SVSLOGIN is a services command",
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let (target, account) = (¶ms[0], ¶ms[1]);
|
||||
let Some(tuid) = s.find_nick(target) else {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NOSUCHNICK,
|
||||
&format!("{target} :No such nick/channel"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
};
|
||||
if account == "*" || account == "0" {
|
||||
s.logout(tuid);
|
||||
} else {
|
||||
s.set_login(tuid, account);
|
||||
}
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct SvsLogout;
|
||||
impl Command for SvsLogout {
|
||||
fn name(&self) -> &'static str {
|
||||
"SVSLOGOUT"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
if !s.is_oper(uid) {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NOPRIVILEGES,
|
||||
":Permission Denied- SVSLOGOUT is a services command",
|
||||
);
|
||||
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.logout(tuid);
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct Wallops;
|
||||
impl Command for Wallops {
|
||||
fn name(&self) -> &'static str {
|
||||
"WALLOPS"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
if !s.is_oper(uid) {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NOPRIVILEGES,
|
||||
":Permission Denied- You're not an IRC operator",
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let from = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.nick.clone())
|
||||
.unwrap_or_default();
|
||||
s.wallops(&from, ¶ms[0]);
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
/// REHASH — reload the config file (MOTD, oper blocks, cloak key).
|
||||
struct Rehash;
|
||||
impl Command for Rehash {
|
||||
fn name(&self) -> &'static str {
|
||||
"REHASH"
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, _params: &[String]) -> CmdResult {
|
||||
if !require_oper(s, uid) {
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let fresh = Config::load(&s.conf_path);
|
||||
s.motd = fresh.motd;
|
||||
s.opers = fresh.opers;
|
||||
s.cloak_key = fresh.cloak_key;
|
||||
s.censor = fresh.censor;
|
||||
s.amu = fresh.amu;
|
||||
s.numeric(uid, RPL_REHASHING, &format!("{} :Rehashing", s.conf_path));
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
/// GLOBOPS — a message to every IRC operator.
|
||||
struct GlobOps;
|
||||
impl Command for GlobOps {
|
||||
fn name(&self) -> &'static str {
|
||||
"GLOBOPS"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
if !require_oper(s, uid) {
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let from = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.nick.clone())
|
||||
.unwrap_or_default();
|
||||
let opers: Vec<Uid> = s
|
||||
.users
|
||||
.iter()
|
||||
.filter(|(_, u)| u.flags.oper)
|
||||
.map(|(&u, _)| u)
|
||||
.collect();
|
||||
for o in opers {
|
||||
let nick = s.users.get(&o).map(|u| u.nick.clone()).unwrap_or_default();
|
||||
s.send(
|
||||
o,
|
||||
format!(
|
||||
":{} NOTICE {nick} :*** GLOBOPS from {from}: {}",
|
||||
s.name, params[0]
|
||||
),
|
||||
);
|
||||
}
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
/// SAJOIN — force a user into a channel.
|
||||
struct SaJoin;
|
||||
impl Command for SaJoin {
|
||||
fn name(&self) -> &'static str {
|
||||
"SAJOIN"
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// SAPART — force a user out of a channel.
|
||||
struct SaPart;
|
||||
impl Command for SaPart {
|
||||
fn name(&self) -> &'static str {
|
||||
"SAPART"
|
||||
}
|
||||
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 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());
|
||||
}
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
/// SANICK — force a user's nickname.
|
||||
struct SaNick;
|
||||
impl Command for SaNick {
|
||||
fn name(&self) -> &'static str {
|
||||
"SANICK"
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// DIE — shut the server down (requires the server name as confirmation).
|
||||
struct Die;
|
||||
impl Command for Die {
|
||||
fn name(&self) -> &'static str {
|
||||
"DIE"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
if !require_oper(s, uid) {
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
if params[0] != s.name {
|
||||
s.numeric(uid, ERR_NOPRIVILEGES, ":DIE requires the server name");
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let by = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.nick.clone())
|
||||
.unwrap_or_default();
|
||||
eprintln!("[oper] DIE by {by}");
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// RESTART — like DIE (a supervisor is expected to relaunch us).
|
||||
struct Restart;
|
||||
impl Command for Restart {
|
||||
fn name(&self) -> &'static str {
|
||||
"RESTART"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
if !require_oper(s, uid) {
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
if params[0] != s.name {
|
||||
s.numeric(uid, ERR_NOPRIVILEGES, ":RESTART requires the server name");
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let by = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.nick.clone())
|
||||
.unwrap_or_default();
|
||||
eprintln!("[oper] RESTART by {by}");
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared KLINE/GLINE/ZLINE handling: the mask alone removes, mask+duration adds.
|
||||
fn do_xline(s: &mut Server, uid: Uid, params: &[String], kind: XKind) -> CmdResult {
|
||||
if !require_oper(s, uid) {
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let mask = params[0].clone();
|
||||
let nick = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.nick.clone())
|
||||
.unwrap_or_default();
|
||||
if params.len() < 2 {
|
||||
let word = if s.remove_xline(kind, &mask) {
|
||||
"removed"
|
||||
} else {
|
||||
"not found"
|
||||
};
|
||||
s.send(
|
||||
uid,
|
||||
format!(
|
||||
":{} NOTICE {nick} :{}-line {word}: {mask}",
|
||||
s.name,
|
||||
kind.tag()
|
||||
),
|
||||
);
|
||||
return CmdResult::Ok;
|
||||
}
|
||||
let dur = parse_duration(¶ms[1]).unwrap_or(0);
|
||||
let reason = params
|
||||
.get(2)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "No reason given".to_string());
|
||||
s.add_xline(kind, &mask, dur, &nick, &reason);
|
||||
CmdResult::Ok
|
||||
}
|
||||
|
||||
/// KLINE — ban a `user@host` mask on this server.
|
||||
struct Kline;
|
||||
impl Command for Kline {
|
||||
fn name(&self) -> &'static str {
|
||||
"KLINE"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
do_xline(s, uid, params, XKind::Kline)
|
||||
}
|
||||
}
|
||||
|
||||
/// GLINE — a network-wide `user@host` ban.
|
||||
struct Gline;
|
||||
impl Command for Gline {
|
||||
fn name(&self) -> &'static str {
|
||||
"GLINE"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
do_xline(s, uid, params, XKind::Gline)
|
||||
}
|
||||
}
|
||||
|
||||
/// ZLINE — ban an IP address (glob).
|
||||
struct Zline;
|
||||
impl Command for Zline {
|
||||
fn name(&self) -> &'static str {
|
||||
"ZLINE"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
do_xline(s, uid, params, XKind::Zline)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a nick to a uid, sending ERR_NOSUCHNICK if it's unknown.
|
||||
fn oper_target(s: &mut Server, uid: Uid, nick: &str) -> Option<Uid> {
|
||||
match s.find_nick(nick) {
|
||||
Some(t) => Some(t),
|
||||
None => {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NOSUCHNICK,
|
||||
&format!("{nick} :No such nick/channel"),
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A server NOTICE to the invoking oper (soft errors for the CHG*/SA* set).
|
||||
fn onotice(s: &mut Server, uid: Uid, msg: &str) {
|
||||
let nick = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.nick.clone())
|
||||
.unwrap_or_else(|| "*".to_string());
|
||||
s.send(uid, format!(":{} NOTICE {nick} :{msg}", s.name));
|
||||
}
|
||||
|
||||
/// The oper's nick, for audit snotices.
|
||||
fn oper_nick(s: &Server, uid: Uid) -> String {
|
||||
s.users
|
||||
.get(&uid)
|
||||
.map(|u| u.nick.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// CHGHOST — change another user's displayed host.
|
||||
struct ChgHost;
|
||||
impl Command for ChgHost {
|
||||
fn name(&self) -> &'static str {
|
||||
"CHGHOST"
|
||||
}
|
||||
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 !valid_host(¶ms[1]) {
|
||||
onotice(s, uid, "*** CHGHOST: invalid characters in hostname");
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let Some(t) = oper_target(s, uid, ¶ms[0]) else {
|
||||
return CmdResult::Fail;
|
||||
};
|
||||
s.change_host_ident(t, None, Some(¶ms[1]));
|
||||
let by = oper_nick(s, uid);
|
||||
s.snotice(&format!(
|
||||
"{by} used CHGHOST on {}: {}",
|
||||
params[0], params[1]
|
||||
));
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
/// SETHOST — change your own displayed host.
|
||||
struct SetHost;
|
||||
impl Command for SetHost {
|
||||
fn name(&self) -> &'static str {
|
||||
"SETHOST"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
if !require_oper(s, uid) {
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
if !valid_host(¶ms[0]) {
|
||||
onotice(s, uid, "*** SETHOST: invalid characters in hostname");
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
s.change_host_ident(uid, None, Some(¶ms[0]));
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
/// CHGIDENT — change another user's ident/username.
|
||||
struct ChgIdent;
|
||||
impl Command for ChgIdent {
|
||||
fn name(&self) -> &'static str {
|
||||
"CHGIDENT"
|
||||
}
|
||||
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 !valid_ident(¶ms[1]) {
|
||||
onotice(s, uid, "*** CHGIDENT: invalid characters in ident");
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let Some(t) = oper_target(s, uid, ¶ms[0]) else {
|
||||
return CmdResult::Fail;
|
||||
};
|
||||
s.change_host_ident(t, Some(¶ms[1]), None);
|
||||
let by = oper_nick(s, uid);
|
||||
s.snotice(&format!(
|
||||
"{by} used CHGIDENT on {}: {}",
|
||||
params[0], params[1]
|
||||
));
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
/// SETIDENT — change your own ident/username.
|
||||
struct SetIdent;
|
||||
impl Command for SetIdent {
|
||||
fn name(&self) -> &'static str {
|
||||
"SETIDENT"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
if !require_oper(s, uid) {
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
if !valid_ident(¶ms[0]) {
|
||||
onotice(s, uid, "*** SETIDENT: invalid characters in ident");
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
s.change_host_ident(uid, Some(¶ms[0]), None);
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
/// SAMODE — apply a channel MODE as the server, bypassing the rank ladder.
|
||||
struct SaMode;
|
||||
impl Command for SaMode {
|
||||
fn name(&self) -> &'static str {
|
||||
"SAMODE"
|
||||
}
|
||||
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;
|
||||
}
|
||||
s.mode_sudo = true;
|
||||
let r = apply_mode(s, uid, params);
|
||||
s.mode_sudo = false;
|
||||
let by = oper_nick(s, uid);
|
||||
s.snotice(&format!("{by} used SAMODE: {}", params.join(" ")));
|
||||
r
|
||||
}
|
||||
}
|
||||
|
||||
/// SATOPIC — set a channel topic as the server, bypassing +t / op checks.
|
||||
struct SaTopic;
|
||||
impl Command for SaTopic {
|
||||
fn name(&self) -> &'static str {
|
||||
"SATOPIC"
|
||||
}
|
||||
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 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;
|
||||
}
|
||||
let text = params[1].clone();
|
||||
let (prefix, setter) = {
|
||||
let u = &s.users[&uid];
|
||||
(u.prefix(), u.nick.clone())
|
||||
};
|
||||
if let Some(ch) = s.channels.get_mut(&key) {
|
||||
ch.topic = Some(Topic {
|
||||
text: text.clone(),
|
||||
setter,
|
||||
ts: now(),
|
||||
});
|
||||
}
|
||||
s.to_channel(&key, &format!(":{prefix} TOPIC {chan} :{text}"), None);
|
||||
s.propagate_from_user(uid, &format!("TOPIC {chan} :{text}"));
|
||||
let by = oper_nick(s, uid);
|
||||
s.snotice(&format!("{by} used SATOPIC on {chan}"));
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
/// SAKICK — kick a user as the server, bypassing rank checks.
|
||||
struct SaKick;
|
||||
impl Command for SaKick {
|
||||
fn name(&self) -> &'static str {
|
||||
"SAKICK"
|
||||
}
|
||||
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 (chan, victim) = (¶ms[0], ¶ms[1]);
|
||||
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;
|
||||
}
|
||||
let Some(tuid) = s.find_nick(victim) else {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NOSUCHNICK,
|
||||
&format!("{victim} :No such nick/channel"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
};
|
||||
if !s.channels[&key].members.contains_key(&tuid) {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_USERNOTINCHANNEL,
|
||||
&format!("{victim} {chan} :They aren't on that channel"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let reason = params
|
||||
.get(2)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "Kicked by services".to_string());
|
||||
let prefix = s.users[&uid].prefix();
|
||||
s.to_channel(
|
||||
&key,
|
||||
&format!(":{prefix} KICK {chan} {victim} :{reason}"),
|
||||
None,
|
||||
);
|
||||
s.propagate_from_user(uid, &format!("KICK {chan} {victim} :{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.events
|
||||
.push_back(Hook::Part(tuid, key, "kicked".to_string()));
|
||||
let by = oper_nick(s, uid);
|
||||
s.snotice(&format!("{by} used SAKICK on {victim} in {chan}"));
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
398
src/coremods/core_user.rs
Normal file
398
src/coremods/core_user.rs
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
//! core_user — the client registration & session commands: CAP, NICK, USER,
|
||||
//! PING, PONG, QUIT.
|
||||
|
||||
use crate::command::{CmdResult, Command};
|
||||
use crate::numeric::*;
|
||||
use crate::server::Server;
|
||||
use crate::users::{ident_of, valid_nick, Caps};
|
||||
use crate::Uid;
|
||||
|
||||
pub fn commands() -> Vec<Box<dyn Command>> {
|
||||
vec![
|
||||
Box::new(Cap),
|
||||
Box::new(Authenticate),
|
||||
Box::new(Nick),
|
||||
Box::new(UserCmd),
|
||||
Box::new(Ping),
|
||||
Box::new(Pong),
|
||||
Box::new(Quit),
|
||||
Box::new(Away),
|
||||
Box::new(SetName),
|
||||
]
|
||||
}
|
||||
|
||||
struct Away;
|
||||
impl Command for Away {
|
||||
fn name(&self) -> &'static str {
|
||||
"AWAY"
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let msg = params.first().cloned().filter(|m| !m.is_empty());
|
||||
let now_away = msg.is_some();
|
||||
let prefix = match s.users.get_mut(&uid) {
|
||||
Some(u) => {
|
||||
u.flags.away = msg.clone();
|
||||
u.prefix()
|
||||
}
|
||||
None => return CmdResult::Fail,
|
||||
};
|
||||
// away-notify: tell capable peers we went away / came back
|
||||
let line = match &msg {
|
||||
Some(m) => format!(":{prefix} AWAY :{m}"),
|
||||
None => format!(":{prefix} AWAY"),
|
||||
};
|
||||
s.notify_peers(uid, &line, |c| c.away_notify);
|
||||
if now_away {
|
||||
s.numeric(uid, RPL_NOWAWAY, ":You have been marked as being away");
|
||||
} else {
|
||||
s.numeric(uid, RPL_UNAWAY, ":You are no longer marked as being away");
|
||||
}
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct Cap;
|
||||
impl Command for Cap {
|
||||
fn name(&self) -> &'static str {
|
||||
"CAP"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn before_reg(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let who = cap_target(s, uid);
|
||||
match params[0].to_ascii_uppercase().as_str() {
|
||||
"LS" => {
|
||||
let cap302 = params.get(1).map(|v| v == "302").unwrap_or(false);
|
||||
if let Some(u) = s.users.get_mut(&uid) {
|
||||
u.cap = true; // hold registration until CAP END
|
||||
u.cap_302 |= cap302;
|
||||
}
|
||||
s.send(
|
||||
uid,
|
||||
format!(":{} CAP {who} LS :{}", s.name, Caps::ls_line(cap302)),
|
||||
);
|
||||
}
|
||||
"REQ" => {
|
||||
if let Some(u) = s.users.get_mut(&uid) {
|
||||
u.cap = true;
|
||||
}
|
||||
let req = params.get(1).cloned().unwrap_or_default();
|
||||
let wanted: Vec<(&str, bool)> = req
|
||||
.split_whitespace()
|
||||
.map(|t| match t.strip_prefix('-') {
|
||||
Some(rest) => (rest, false),
|
||||
None => (t, true),
|
||||
})
|
||||
.collect();
|
||||
// CAP REQ is atomic: ACK the whole set or NAK the whole set
|
||||
if !wanted.is_empty() && wanted.iter().all(|(n, _)| Caps::is_known(n)) {
|
||||
for (name, on) in &wanted {
|
||||
if let Some(u) = s.users.get_mut(&uid) {
|
||||
u.caps.set(name, *on);
|
||||
}
|
||||
}
|
||||
s.send(uid, format!(":{} CAP {who} ACK :{req}", s.name));
|
||||
} else {
|
||||
s.send(uid, format!(":{} CAP {who} NAK :{req}", s.name));
|
||||
}
|
||||
}
|
||||
"LIST" => {
|
||||
let list = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.caps.enabled())
|
||||
.unwrap_or_default();
|
||||
s.send(uid, format!(":{} CAP {who} LIST :{list}", s.name));
|
||||
}
|
||||
"END" => {
|
||||
if let Some(u) = s.users.get_mut(&uid) {
|
||||
u.cap = false;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
/// CAP reply target: the nick, or `*` before one is set.
|
||||
fn cap_target(s: &Server, uid: Uid) -> String {
|
||||
s.users
|
||||
.get(&uid)
|
||||
.map(|u| {
|
||||
if u.nick.is_empty() {
|
||||
"*".to_string()
|
||||
} else {
|
||||
u.nick.clone()
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "*".to_string())
|
||||
}
|
||||
|
||||
/// AUTHENTICATE — the SASL handshake. echoIRCd verifies nothing itself (it has no
|
||||
/// accounts); once a services server is linked over S2S the payload is relayed to
|
||||
/// it and `set_login` applied on success. Until then — exactly like InspIRCd with
|
||||
/// no services — SASL fails cleanly.
|
||||
struct Authenticate;
|
||||
impl Command for Authenticate {
|
||||
fn name(&self) -> &'static str {
|
||||
"AUTHENTICATE"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn before_reg(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
if !s.users.get(&uid).map(|u| u.caps.sasl).unwrap_or(false) {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_SASLFAIL,
|
||||
":You must request the sasl capability first",
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let arg = ¶ms[0];
|
||||
let mech = s.users.get(&uid).and_then(|u| u.sasl_mech.clone());
|
||||
match mech {
|
||||
// step 1 — the client picks a mechanism
|
||||
None => {
|
||||
if arg.eq_ignore_ascii_case("PLAIN") {
|
||||
if let Some(u) = s.users.get_mut(&uid) {
|
||||
u.sasl_mech = Some("PLAIN".to_string());
|
||||
}
|
||||
s.send(uid, "AUTHENTICATE +".to_string());
|
||||
CmdResult::Ok
|
||||
} else if arg == "*" {
|
||||
s.numeric(uid, ERR_SASLABORTED, ":SASL authentication aborted");
|
||||
CmdResult::Ok
|
||||
} else {
|
||||
s.numeric(uid, RPL_SASLMECHS, "PLAIN :are available SASL mechanisms");
|
||||
s.numeric(uid, ERR_SASLFAIL, ":Unsupported SASL mechanism");
|
||||
CmdResult::Fail
|
||||
}
|
||||
}
|
||||
// step 2 — the client sends the base64 payload (or aborts with `*`)
|
||||
Some(_) => {
|
||||
if let Some(u) = s.users.get_mut(&uid) {
|
||||
u.sasl_mech = None;
|
||||
}
|
||||
if arg == "*" {
|
||||
s.numeric(uid, ERR_SASLABORTED, ":SASL authentication aborted");
|
||||
return CmdResult::Ok;
|
||||
}
|
||||
if arg.len() > 400 {
|
||||
s.numeric(uid, ERR_SASLTOOLONG, ":SASL message too long");
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
// base64(authzid \0 authcid \0 passwd) — would be relayed to services
|
||||
let _creds = openssl::base64::decode_block(arg).unwrap_or_default();
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_SASLFAIL,
|
||||
":SASL authentication failed (services are not available)",
|
||||
);
|
||||
CmdResult::Fail
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SETNAME — change your realname (IRCv3). Broadcast to `setname`-capable peers.
|
||||
struct SetName;
|
||||
impl Command for SetName {
|
||||
fn name(&self) -> &'static str {
|
||||
"SETNAME"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let realname = params[0].clone();
|
||||
let prefix = match s.users.get_mut(&uid) {
|
||||
Some(u) => {
|
||||
u.realname = realname.clone();
|
||||
u.prefix()
|
||||
}
|
||||
None => return CmdResult::Fail,
|
||||
};
|
||||
let line = format!(":{prefix} SETNAME :{realname}");
|
||||
if s.users.get(&uid).map(|u| u.caps.setname).unwrap_or(false) {
|
||||
s.send(uid, line.clone());
|
||||
}
|
||||
s.notify_peers(uid, &line, |c| c.setname);
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct Nick;
|
||||
impl Command for Nick {
|
||||
fn name(&self) -> &'static str {
|
||||
"NICK"
|
||||
}
|
||||
fn before_reg(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let Some(newnick) = params.first() else {
|
||||
s.numeric(uid, ERR_NONICKNAMEGIVEN, ":No nickname given");
|
||||
return CmdResult::Fail;
|
||||
};
|
||||
if !valid_nick(newnick) {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_ERRONEUSNICKNAME,
|
||||
&format!("{newnick} :Erroneous nickname"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
if let Some(other) = s.find_nick(newnick) {
|
||||
if other != uid {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NICKNAMEINUSE,
|
||||
&format!("{newnick} :Nickname is already in use"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
return CmdResult::Ok; // same nick, no-op
|
||||
}
|
||||
// a nick already held by a user on a linked server is taken too
|
||||
if s.remote_nick.contains_key(&newnick.to_ascii_lowercase()) {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NICKNAMEINUSE,
|
||||
&format!("{newnick} :Nickname is already in use"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
// +N — can't change nick while on a no-nick-change channel (opers bypass)
|
||||
if !s.is_oper(uid) {
|
||||
let blocked = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.channels.clone())
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.find_map(|k| {
|
||||
s.channels
|
||||
.get(k)
|
||||
.filter(|c| c.modes.no_nick)
|
||||
.map(|c| c.name.clone())
|
||||
});
|
||||
if let Some(cn) = blocked {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_CANTCHANGENICK,
|
||||
&format!("{cn} :Cannot change nick while on this channel (+N is set)"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
// extban `n:` — a matched user can't change nick on that channel
|
||||
let chans = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.channels.clone())
|
||||
.unwrap_or_default();
|
||||
if let Some(k) = chans.into_iter().find(|k| s.extban_active(uid, k, 'n')) {
|
||||
let cn = s.channels.get(&k).map(|c| c.name.clone()).unwrap_or(k);
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_CANTCHANGENICK,
|
||||
&format!("{cn} :Cannot change nick here (+b n:)"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
// +F nick-change flood — locks nick changes on the channel for 60s
|
||||
if let Some(cn) = s.nickflood_blocked(uid) {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_CANTCHANGENICK,
|
||||
&format!("{cn} :Too many nick changes, try later (+F is set)"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
}
|
||||
s.set_nick(uid, newnick);
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct UserCmd;
|
||||
impl Command for UserCmd {
|
||||
fn name(&self) -> &'static str {
|
||||
"USER"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
4
|
||||
}
|
||||
fn before_reg(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
if s.users.get(&uid).map(|u| u.registered).unwrap_or(false) {
|
||||
s.numeric(uid, ERR_ALREADYREGISTERED, ":You may not reregister");
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let ident = ident_of(¶ms[0]);
|
||||
let realname = params[3].clone();
|
||||
if let Some(u) = s.users.get_mut(&uid) {
|
||||
u.ident = format!("~{ident}");
|
||||
u.realname = realname;
|
||||
}
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct Ping;
|
||||
impl Command for Ping {
|
||||
fn name(&self) -> &'static str {
|
||||
"PING"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn before_reg(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
s.send(uid, format!(":{} PONG {} :{}", s.name, s.name, params[0]));
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct Pong;
|
||||
impl Command for Pong {
|
||||
fn name(&self) -> &'static str {
|
||||
"PONG"
|
||||
}
|
||||
fn before_reg(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn handle(&self, _s: &mut Server, _uid: Uid, _params: &[String]) -> CmdResult {
|
||||
CmdResult::Ok // keepalive; nothing to do yet
|
||||
}
|
||||
}
|
||||
|
||||
struct Quit;
|
||||
impl Command for Quit {
|
||||
fn name(&self) -> &'static str {
|
||||
"QUIT"
|
||||
}
|
||||
fn before_reg(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let reason = params
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "Client quit".to_string());
|
||||
s.mark_quit(uid, format!("Quit: {reason}"));
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
306
src/coremods/core_watch.rs
Normal file
306
src/coremods/core_watch.rs
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
//! core_watch — WATCH, MONITOR (IRCv3) and SILENCE. The per-user lists live on
|
||||
//! the `User`; the online/offline notifications are driven from the lifecycle
|
||||
//! code via [`crate::server::Server::watch_notify_online`] / `_offline`. Mirrors
|
||||
//! InspIRCd's `m_watch` / `m_monitor` / `m_silence`.
|
||||
|
||||
use crate::channels::normalize_mask;
|
||||
use crate::command::{CmdResult, Command};
|
||||
use crate::numeric::*;
|
||||
use crate::server::Server;
|
||||
use crate::watch::{MONITOR_MAX, SILENCE_MAX, WATCH_MAX};
|
||||
use crate::Uid;
|
||||
|
||||
pub fn commands() -> Vec<Box<dyn Command>> {
|
||||
vec![Box::new(Watch), Box::new(Monitor), Box::new(Silence)]
|
||||
}
|
||||
|
||||
// --- WATCH ------------------------------------------------------------------
|
||||
|
||||
/// Report a nick's current presence as RPL_NOWON (604) or RPL_NOWOFF (605).
|
||||
fn watch_status(s: &Server, uid: Uid, nick: &str) {
|
||||
if let Some(u) = s.find_nick(nick).and_then(|tu| s.users.get(&tu)) {
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_NOWON,
|
||||
&format!(
|
||||
"{} {} {} {} :is online",
|
||||
u.nick,
|
||||
u.ident,
|
||||
u.host_display(),
|
||||
u.signon
|
||||
),
|
||||
);
|
||||
} else {
|
||||
s.numeric(uid, RPL_NOWOFF, &format!("{nick} * * 0 :is offline"));
|
||||
}
|
||||
}
|
||||
|
||||
fn watch_add(s: &mut Server, uid: Uid, nick: &str) {
|
||||
if nick.is_empty() {
|
||||
return;
|
||||
}
|
||||
let low = nick.to_ascii_lowercase();
|
||||
let full = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.watch.len() >= WATCH_MAX && !u.watch.contains(&low))
|
||||
.unwrap_or(true);
|
||||
if full {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_TOOMANYWATCH,
|
||||
&format!("{nick} :Maximum size for WATCH-list exceeded"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if let Some(u) = s.users.get_mut(&uid) {
|
||||
if !u.watch.contains(&low) {
|
||||
u.watch.push(low);
|
||||
}
|
||||
}
|
||||
watch_status(s, uid, nick);
|
||||
}
|
||||
|
||||
fn watch_list(s: &Server, uid: Uid, online_only: bool) {
|
||||
let nicks = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.watch.clone())
|
||||
.unwrap_or_default();
|
||||
for n in nicks {
|
||||
// `l` (online-only) skips offline entries; `L` shows all
|
||||
if !online_only || s.find_nick(&n).is_some() {
|
||||
watch_status(s, uid, &n);
|
||||
}
|
||||
}
|
||||
s.numeric(uid, RPL_ENDOFWATCHLIST, ":End of WATCH list");
|
||||
}
|
||||
|
||||
struct Watch;
|
||||
impl Command for Watch {
|
||||
fn name(&self) -> &'static str {
|
||||
"WATCH"
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
if params.is_empty() {
|
||||
watch_list(s, uid, true); // bare WATCH lists your online entries
|
||||
return CmdResult::Ok;
|
||||
}
|
||||
for tok in params.iter().flat_map(|p| p.split_whitespace()) {
|
||||
match tok {
|
||||
"C" | "c" => {
|
||||
if let Some(u) = s.users.get_mut(&uid) {
|
||||
u.watch.clear();
|
||||
}
|
||||
s.numeric(uid, RPL_ENDOFWATCHLIST, ":End of WATCH list");
|
||||
}
|
||||
"S" | "s" => {
|
||||
let (mine, watched) = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| (u.watch.len(), u.watch.clone()))
|
||||
.unwrap_or((0, Vec::new()));
|
||||
let me = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.nick.clone())
|
||||
.unwrap_or_default();
|
||||
let on_me = s.watchers_of(&me);
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_WATCHSTAT,
|
||||
&format!(":You have {mine} and are on {on_me} WATCH entries"),
|
||||
);
|
||||
if !watched.is_empty() {
|
||||
s.numeric(uid, RPL_WATCHLIST, &format!(":{}", watched.join(" ")));
|
||||
}
|
||||
s.numeric(uid, RPL_ENDOFWATCHLIST, ":End of WATCH S");
|
||||
}
|
||||
"L" => watch_list(s, uid, false),
|
||||
"l" => watch_list(s, uid, true),
|
||||
_ if tok.starts_with('+') => watch_add(s, uid, &tok[1..]),
|
||||
_ if tok.starts_with('-') => {
|
||||
let low = tok[1..].to_ascii_lowercase();
|
||||
if let Some(u) = s.users.get_mut(&uid) {
|
||||
u.watch.retain(|n| n != &low);
|
||||
}
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_WATCHOFF,
|
||||
&format!("{} * * 0 :stopped watching", &tok[1..]),
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
// --- MONITOR (IRCv3) --------------------------------------------------------
|
||||
|
||||
/// Report the online/offline split of `nicks` to `uid` (730 / 731).
|
||||
fn monitor_report(s: &Server, uid: Uid, nicks: &[String]) {
|
||||
let mut online = Vec::new();
|
||||
let mut offline = Vec::new();
|
||||
for n in nicks {
|
||||
match s.find_nick(n).and_then(|tu| s.users.get(&tu)) {
|
||||
Some(u) => online.push(format!("{}!{}@{}", u.nick, u.ident, u.host_display())),
|
||||
None => offline.push(n.clone()),
|
||||
}
|
||||
}
|
||||
if !online.is_empty() {
|
||||
s.numeric(uid, RPL_MONONLINE, &format!(":{}", online.join(",")));
|
||||
}
|
||||
if !offline.is_empty() {
|
||||
s.numeric(uid, RPL_MONOFFLINE, &format!(":{}", offline.join(",")));
|
||||
}
|
||||
}
|
||||
|
||||
struct Monitor;
|
||||
impl Command for Monitor {
|
||||
fn name(&self) -> &'static str {
|
||||
"MONITOR"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
match params[0].to_ascii_uppercase().as_str() {
|
||||
"+" => {
|
||||
let targets: Vec<String> = params
|
||||
.get(1)
|
||||
.map(|t| {
|
||||
t.split(',')
|
||||
.filter(|x| !x.is_empty())
|
||||
.map(String::from)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let mut added = Vec::new();
|
||||
for t in targets {
|
||||
let low = t.to_ascii_lowercase();
|
||||
let full = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.monitor.len() >= MONITOR_MAX && !u.monitor.contains(&low))
|
||||
.unwrap_or(true);
|
||||
if full {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_MONLISTFULL,
|
||||
&format!("{MONITOR_MAX} {t} :Monitor list is full"),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if let Some(u) = s.users.get_mut(&uid) {
|
||||
if !u.monitor.contains(&low) {
|
||||
u.monitor.push(low);
|
||||
}
|
||||
}
|
||||
added.push(t);
|
||||
}
|
||||
monitor_report(s, uid, &added);
|
||||
}
|
||||
"-" => {
|
||||
let targets: Vec<String> = params
|
||||
.get(1)
|
||||
.map(|t| {
|
||||
t.split(',')
|
||||
.filter(|x| !x.is_empty())
|
||||
.map(|x| x.to_ascii_lowercase())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if let Some(u) = s.users.get_mut(&uid) {
|
||||
u.monitor.retain(|n| !targets.contains(n));
|
||||
}
|
||||
}
|
||||
"C" => {
|
||||
if let Some(u) = s.users.get_mut(&uid) {
|
||||
u.monitor.clear();
|
||||
}
|
||||
}
|
||||
"L" => {
|
||||
let list = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.monitor.clone())
|
||||
.unwrap_or_default();
|
||||
if !list.is_empty() {
|
||||
s.numeric(uid, RPL_MONLIST, &format!(":{}", list.join(",")));
|
||||
}
|
||||
s.numeric(uid, RPL_ENDOFMONLIST, ":End of MONITOR list");
|
||||
}
|
||||
"S" => {
|
||||
let list = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.monitor.clone())
|
||||
.unwrap_or_default();
|
||||
monitor_report(s, uid, &list);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
// --- SILENCE ----------------------------------------------------------------
|
||||
|
||||
fn silence_list(s: &Server, uid: Uid) {
|
||||
let list = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.silence.clone())
|
||||
.unwrap_or_default();
|
||||
for m in list {
|
||||
s.numeric(uid, RPL_SILELIST, &m);
|
||||
}
|
||||
s.numeric(uid, RPL_ENDOFSILENCE, ":End of SILENCE list");
|
||||
}
|
||||
|
||||
struct Silence;
|
||||
impl Command for Silence {
|
||||
fn name(&self) -> &'static str {
|
||||
"SILENCE"
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let Some(arg) = params.first() else {
|
||||
silence_list(s, uid);
|
||||
return CmdResult::Ok;
|
||||
};
|
||||
let prefix = s.users.get(&uid).map(|u| u.prefix()).unwrap_or_default();
|
||||
if let Some(m) = arg.strip_prefix('+') {
|
||||
let mask = normalize_mask(m);
|
||||
let full = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.silence.len() >= SILENCE_MAX && !u.silence.contains(&mask))
|
||||
.unwrap_or(true);
|
||||
if full {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_SILELISTFULL,
|
||||
&format!("{mask} :Your SILENCE list is full"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
if let Some(u) = s.users.get_mut(&uid) {
|
||||
if !u.silence.contains(&mask) {
|
||||
u.silence.push(mask.clone());
|
||||
}
|
||||
}
|
||||
s.send(uid, format!(":{prefix} SILENCE +{mask}"));
|
||||
} else if let Some(m) = arg.strip_prefix('-') {
|
||||
let mask = normalize_mask(m);
|
||||
if let Some(u) = s.users.get_mut(&uid) {
|
||||
u.silence.retain(|x| x != &mask);
|
||||
}
|
||||
s.send(uid, format!(":{prefix} SILENCE -{mask}"));
|
||||
} else {
|
||||
silence_list(s, uid);
|
||||
}
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
34
src/coremods/mod.rs
Normal file
34
src/coremods/mod.rs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
//! The built-in commands, grouped the way InspIRCd groups its `coremods/`:
|
||||
//! `core_user`, `core_channel`, `core_message`, `core_mode`, `core_oper`,
|
||||
//! `core_info`. Each module exposes `commands()`; [`command_table`] assembles the
|
||||
//! registry the core dispatches through.
|
||||
|
||||
pub mod core_channel;
|
||||
pub mod core_extra;
|
||||
pub mod core_info;
|
||||
pub mod core_message;
|
||||
pub mod core_mode;
|
||||
pub mod core_oper;
|
||||
pub mod core_user;
|
||||
pub mod core_watch;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::command::Command;
|
||||
|
||||
pub fn command_table() -> HashMap<&'static str, Box<dyn Command>> {
|
||||
let mut m: HashMap<&'static str, Box<dyn Command>> = HashMap::new();
|
||||
for c in core_user::commands()
|
||||
.into_iter()
|
||||
.chain(core_channel::commands())
|
||||
.chain(core_message::commands())
|
||||
.chain(core_mode::commands())
|
||||
.chain(core_oper::commands())
|
||||
.chain(core_info::commands())
|
||||
.chain(core_extra::commands())
|
||||
.chain(core_watch::commands())
|
||||
{
|
||||
m.insert(c.name(), c);
|
||||
}
|
||||
m
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue