/* This file is part of DarkFi (https://dark.fi)
*
* Copyright (C) 2020-2026 Dyne.org foundation
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*/
//! IRC command implemenatations
//!
//! These try to follow the RFCs, modified in order for our P2P stack.
//! Copied from
//!
//! Unimplemented commands:
//! * `AWAY`
//! * `CONNECT`
//! * `DIE`
//! * `ERROR`
//! * `INVITE`
//! * `ISON`
//! * `KICK`
//! * `KILL`
//! * `NOTICE`
//! * `OPER`
//! * `RESTART`
//! * `SERVICE`
//! * `SERVLIST`
//! * `SERVER`
//! * `SQUERY`
//! * `SQUIT`
//! * `SUMMON`
//! * `TRACE`
//! * `USERHOST`
//! * `WALLOPS`
//! * `WHO`
//! * `WHOIS`
//! * `WHOWAS`
//!
//! Some of the above commands could actually be implemented and could
//! work in respect to the P2P network.
use std::{collections::HashSet, sync::atomic::Ordering::SeqCst};
use darkfi::Result;
use darkfi_serial::deserialize_async_partial;
use tracing::{error, info, warn};
use super::{
client::{Client, ReplyType},
rpl::*,
server::{MAX_MSG_LEN, MAX_NICK_LEN, SEEN_CHANNELS_TREE},
IrcChannel, SERVER_NAME,
};
use crate::{crypto::bcrypt::bcrypt_hash_password, Privmsg};
const MAX_TOPIC_LEN: usize = MAX_MSG_LEN;
fn is_identifier_char(byte: u8) -> bool {
byte.is_ascii_alphanumeric() ||
matches!(byte, b'-' | b'_' | b'[' | b']' | b'\\' | b'`' | b'^' | b'{' | b'}' | b'|')
}
/// Return true when a client-supplied nickname is safe to store and echo.
fn is_valid_nickname(nickname: &str) -> bool {
!nickname.is_empty() &&
nickname.len() <= MAX_NICK_LEN &&
nickname.bytes().all(is_identifier_char)
}
/// Return true when a client-supplied username is safe for local state keys.
fn is_valid_username(username: &str) -> bool {
is_valid_nickname(username)
}
/// Return true when a channel name is bounded and cannot split IRC replies.
fn is_valid_channel_name(channel: &str) -> bool {
channel.len() >= 2 &&
channel.len() <= MAX_NICK_LEN &&
channel.starts_with('#') &&
!channel[1..].starts_with('#') &&
channel.bytes().all(|b| b.is_ascii_graphic() && b != b',')
}
/// Return true when a topic is bounded and line-safe.
fn is_valid_topic(topic: &str) -> bool {
topic.len() <= MAX_TOPIC_LEN && !topic.bytes().any(|b| matches!(b, b'\0' | b'\r' | b'\n'))
}
fn invalid_syntax(nick: &str, command: &str) -> Vec {
vec![ReplyType::Server((ERR_NEEDMOREPARAMS, format!("{nick} {command} :{INVALID_SYNTAX}")))]
}
#[derive(Debug, PartialEq, Eq)]
enum TopicRequest<'a> {
Get { channel: &'a str },
Set { channel: &'a str, topic: &'a str },
}
/// Parse a TOPIC command without accepting ambiguous topic bodies.
fn parse_topic_request(args: &str) -> Option> {
let args = args.trim_start_matches(|c: char| c.is_ascii_whitespace());
if args.is_empty() {
return None
}
let (channel, rest) = match args.find(|c: char| c.is_ascii_whitespace()) {
Some(idx) => {
(&args[..idx], args[idx..].trim_start_matches(|c: char| c.is_ascii_whitespace()))
}
None => (args, ""),
};
if channel.is_empty() {
return None
}
if rest.is_empty() {
return Some(TopicRequest::Get { channel })
}
let topic = rest.strip_prefix(':')?;
Some(TopicRequest::Set { channel, topic })
}
impl Client {
/// `ADMIN []`
///
/// Asks the server for information about the administrator of the server.
pub async fn handle_cmd_admin(&self, _args: &str) -> Result> {
if !self.registered.load(SeqCst) {
self.penalty.fetch_add(1, SeqCst);
return Ok(vec![ReplyType::Server((ERR_NOTREGISTERED, format!("* :{NOT_REGISTERED}")))])
}
let nick = self.nickname.read().await.to_string();
let replies = vec![
ReplyType::Server((RPL_ADMINME, format!("{nick} {SERVER_NAME} :Administrative info"))),
ReplyType::Server((RPL_ADMINLOC1, format!("{nick} :"))),
ReplyType::Server((RPL_ADMINLOC2, format!("{nick} :"))),
ReplyType::Server((RPL_ADMINEMAIL, format!("{nick} :anon@darkirc"))),
];
Ok(replies)
}
/// `CAP `
pub async fn handle_cmd_cap(&self, args: &str) -> Result> {
let mut tokens = args.split_ascii_whitespace();
let Some(subcommand) = tokens.next() else {
self.penalty.fetch_add(1, SeqCst);
return Ok(vec![ReplyType::Server((
ERR_NEEDMOREPARAMS,
format!("{} CAP :{INVALID_SYNTAX}", self.nickname.read().await),
))])
};
let caps_keys: Vec = self.caps.read().await.keys().cloned().collect();
let nick = self.nickname.read().await.to_string();
match subcommand.to_uppercase().as_str() {
"LS" => {
/*
let Some(_version) = tokens.next() else {
return Ok(vec![ReplyType::Server((
ERR_NEEDMOREPARAMS,
format!("{} CAP :{INVALID_SYNTAX}", self.nickname.read().await),
))])
};
*/
self.reg_paused.store(true, SeqCst);
return Ok(vec![ReplyType::Cap(format!("CAP * LS :{}", caps_keys.join(" ")))])
}
"REQ" => {
let Some(substr_idx) = args.find(':') else {
return Ok(vec![ReplyType::Server((
ERR_NEEDMOREPARAMS,
format!("{nick} CAP :{INVALID_SYNTAX}"),
))])
};
if substr_idx >= args.len() {
return Ok(vec![ReplyType::Server((
ERR_NEEDMOREPARAMS,
format!("{nick} CAP :{INVALID_SYNTAX}"),
))])
}
let cap_reqs: Vec<&str> = args[substr_idx + 1..].split(' ').collect();
let mut ack_list = vec![];
let mut nak_list = vec![];
let mut available_caps = self.caps.write().await;
for cap in cap_reqs {
if available_caps.contains_key(cap) {
available_caps.insert(cap.to_string(), true);
ack_list.push(cap);
} else {
nak_list.push(cap);
}
}
let mut replies = vec![];
if !ack_list.is_empty() {
replies.push(ReplyType::Cap(format!("CAP {nick} ACK :{}", ack_list.join(" "))));
}
if !nak_list.is_empty() {
replies.push(ReplyType::Cap(format!("CAP {nick} NAK :{}", nak_list.join(" "))));
}
return Ok(replies)
}
"LIST" => {
let enabled_caps: Vec = self
.caps
.read()
.await
.clone()
.into_iter()
.filter(|(_, v)| *v)
.map(|(k, _)| k)
.collect();
return Ok(vec![ReplyType::Cap(format!(
"CAP {nick} LIST :{}",
enabled_caps.join(" ")
))])
}
"END" => {
// At CAP END, if we have USER and NICK, we can welcome them.
self.reg_paused.store(false, SeqCst);
if self.registered.load(SeqCst) && !self.is_cap_end.load(SeqCst) {
self.is_cap_end.store(true, SeqCst);
return self.welcome().await
}
return Ok(vec![])
}
_ => {}
}
self.penalty.fetch_add(1, SeqCst);
Ok(vec![ReplyType::Server((ERR_NEEDMOREPARAMS, format!("{nick} CAP :{INVALID_SYNTAX}")))])
}
/// `INFO []`
///
/// Gives information about the `` server, or the current server if
/// `` is not used. The information includes the server's version,
/// when it was compiled, the patch level, when it was started, and any
/// other information which might be relevant.
pub async fn handle_cmd_info(&self, _args: &str) -> Result> {
if !self.registered.load(SeqCst) {
self.penalty.fetch_add(1, SeqCst);
return Ok(vec![ReplyType::Server((ERR_NOTREGISTERED, format!("* :{NOT_REGISTERED}")))])
}
let nick = self.nickname.read().await.clone();
let replies = vec![
ReplyType::Server((RPL_INFO, format!("{nick} :DarkIRC {}", env!("CARGO_PKG_VERSION")))),
ReplyType::Server((RPL_ENDOFINFO, format!("{nick} :End of INFO list"))),
];
Ok(replies)
}
/// `JOIN []`
///
/// Makes the client join the channels in the list ``.
/// Passwords can be used in the list ``. If the channels do not
/// exist, they will be created.
pub async fn handle_cmd_join(&self, args: &str, hist: bool) -> Result> {
if !self.registered.load(SeqCst) {
self.penalty.fetch_add(1, SeqCst);
return Ok(vec![ReplyType::Server((ERR_NOTREGISTERED, format!("* :{NOT_REGISTERED}")))])
}
// Client's (already) active channels
let mut active_channels = self.channels.write().await;
// Here we'll hold valid channel names.
let mut channels = HashSet::new();
// Weechat sends channels as `#chan1,#chan2,#chan3`. Handle both
// comma-separated and whitespace-separated channel lists uniformly.
let nick = self.nickname.read().await.to_string();
let mut saw_channel = false;
for list in args.split_ascii_whitespace() {
for channel in list.split(',') {
saw_channel = true;
if !is_valid_channel_name(channel) {
self.penalty.fetch_add(1, SeqCst);
return Ok(invalid_syntax(&nick, "JOIN"))
}
if !active_channels.contains(channel) {
channels.insert(channel.to_string());
}
}
}
if !saw_channel {
self.penalty.fetch_add(1, SeqCst);
return Ok(invalid_syntax(&nick, "JOIN"))
}
// Create new channels for this client and construct replies.
let mut server_channels = self.server.channels.write().await;
let mut replies = vec![];
for channel in channels.iter() {
// Insert the channel name into the set of client's active channels
active_channels.insert(channel.clone());
// Create or update the channel on the server side.
if let Some(server_chan) = server_channels.get_mut(channel) {
server_chan.nicks.insert(nick.clone());
} else {
let chan = IrcChannel {
topic: String::new(),
nicks: HashSet::from([nick.clone()]),
saltbox: None,
};
server_channels.insert(channel.clone(), chan);
}
// Create the replies
replies.push(ReplyType::Client((nick.clone(), format!("JOIN :{channel}"))));
if let Some(chan) = server_channels.get(channel) {
if !chan.topic.is_empty() {
replies.push(ReplyType::Client((
nick.clone(),
format!("TOPIC {channel} :{}", chan.topic),
)));
}
}
}
// Drop the locks as they're used in get_history()
drop(active_channels);
drop(server_channels);
if hist {
// Potentially extend the replies with channel history
replies.extend(self.get_history(&channels).await?);
}
Ok(replies)
}
/// `LIST [ []]`
///
/// List all public (`#`-prefixed) channels observed on the p2p network.
/// Channels are recorded into the `SEEN_CHANNELS_TREE` as their traffic
/// is seen, and persist across restarts.
pub async fn handle_cmd_list(&self, _args: &str) -> Result> {
if !self.registered.load(SeqCst) {
self.penalty.fetch_add(1, SeqCst);
return Ok(vec![ReplyType::Server((ERR_NOTREGISTERED, format!("* :{NOT_REGISTERED}")))])
}
let nick = self.nickname.read().await.to_string();
let tree = self.server.darkirc.kvdb.open_tree_default(SEEN_CHANNELS_TREE)?;
let mut replies =
vec![ReplyType::Server((RPL_LISTSTART, format!("{nick} Channel :Users Name")))];
for item in tree.iter() {
let (key, _) = item?;
let name = String::from_utf8_lossy(&key);
replies.push(ReplyType::Server((RPL_LIST, format!("{nick} {name} 0 :"))));
}
replies.push(ReplyType::Server((RPL_LISTEND, format!("{nick} :End of /LIST"))));
Ok(replies)
}
/// `MODE `
/// `MODE `
///
/// The MODE command has two uses. It can be used to set both user and
/// channel modes.
pub async fn handle_cmd_mode(&self, args: &str) -> Result> {
if !self.registered.load(SeqCst) {
self.penalty.fetch_add(1, SeqCst);
return Ok(vec![ReplyType::Server((ERR_NOTREGISTERED, format!("* :{NOT_REGISTERED}")))])
}
let nick = self.nickname.read().await.to_string();
let mut tokens = args.split_ascii_whitespace();
let Some(target) = tokens.next() else {
self.penalty.fetch_add(1, SeqCst);
return Ok(vec![ReplyType::Server((
ERR_NEEDMOREPARAMS,
format!("{nick} MODE :{INVALID_SYNTAX}"),
))])
};
if target == nick {
return Ok(vec![ReplyType::Server((RPL_UMODEIS, format!("{nick} +")))])
}
if !target.starts_with('#') {
return Ok(vec![ReplyType::Server((
ERR_USERSDONTMATCH,
format!("{nick} :Can't set/get mode for other users"),
))])
}
if !is_valid_channel_name(target) {
self.penalty.fetch_add(1, SeqCst);
return Ok(invalid_syntax(&nick, "MODE"))
}
if !self.server.channels.read().await.contains_key(target) {
return Ok(vec![ReplyType::Server((
ERR_NOSUCHNICK,
format!("{nick} {target} :No such nick or channel name"),
))])
}
Ok(vec![ReplyType::Server((RPL_CHANNELMODEIS, format!("{nick} {target} +")))])
}
/// `MOTD []`
///
/// Returns the message of the day on `` or the current server if
/// it is not stated.
pub async fn handle_cmd_motd(&self, _args: &str) -> Result> {
let nick = self.nickname.read().await.to_string();
Ok(vec![
ReplyType::Server((
RPL_MOTDSTART,
format!("{nick} :- {SERVER_NAME} message of the day"),
)),
ReplyType::Server((RPL_MOTD, format!("{nick} :Let there be dark!"))),
ReplyType::Server((RPL_ENDOFMOTD, format!("{nick} :End of /MOTD command."))),
])
}
/// `NAMES []`
///
/// Returns a list of who is on the list of ``, by channel name.
/// If `` is not used, all users are shown. They are grouped by
/// channel name with all users who are not on a channel being shown as
/// part of channel "*".
pub async fn handle_cmd_names(&self, args: &str) -> Result> {
if !self.registered.load(SeqCst) {
self.penalty.fetch_add(1, SeqCst);
return Ok(vec![ReplyType::Server((ERR_NOTREGISTERED, format!("* :{NOT_REGISTERED}")))])
}
let nick = self.nickname.read().await.to_string();
let mut tokens = args.split_ascii_whitespace();
let mut replies = vec![];
// If a channel was requested, reply only with that one.
// Otherwise, return info for all known channels.
if let Some(req_chan) = tokens.next() {
if !is_valid_channel_name(req_chan) {
self.penalty.fetch_add(1, SeqCst);
return Ok(invalid_syntax(&nick, "NAMES"))
}
if let Some(chan) = self.server.channels.read().await.get(req_chan) {
let nicks: Vec = chan.nicks.iter().cloned().collect();
replies.push(ReplyType::Server((
RPL_NAMREPLY,
format!("{nick} = {req_chan} :{}", nicks.join(" ")),
)));
}
replies.push(ReplyType::Server((
RPL_ENDOFNAMES,
format!("{nick} {req_chan} :End of NAMES list"),
)));
Ok(replies)
} else {
for (name, chan) in self.server.channels.read().await.iter() {
let nicks: Vec = chan.nicks.iter().cloned().collect();
replies.push(ReplyType::Server((
RPL_NAMREPLY,
format!("{nick} = {name} :{}", nicks.join(" ")),
)));
}
replies
.push(ReplyType::Server((RPL_ENDOFNAMES, format!("{nick} * :End of NAMES list"))));
Ok(replies)
}
}
/// `NICK `
///
/// Allows a client to change their IRC nickname.
pub async fn handle_cmd_nick(&self, args: &str) -> Result> {
// Parse the line
let mut tokens = args.split_ascii_whitespace();
// Reference the current nickname
let old_nick = self.nickname.read().await.to_string();
let Some(nickname) = tokens.next() else {
self.penalty.fetch_add(1, SeqCst);
return Ok(vec![ReplyType::Server((
ERR_NEEDMOREPARAMS,
format!("{old_nick} NICK :{INVALID_SYNTAX}"),
))])
};
// Forbid disallowed characters.
// The next() call is done to check for ASCII whitespace in the nick.
if tokens.next().is_some() || !is_valid_nickname(nickname) {
self.penalty.fetch_add(1, SeqCst);
return Ok(vec![ReplyType::Server((
ERR_ERRONEOUSNICKNAME,
format!("{old_nick} {nickname} :Erroneous nickname"),
))])
}
// Set the new nickname
*self.nickname.write().await = nickname.to_string();
// If the username is set, we can complete the registration
if *self.username.read().await != "*" &&
!self.registered.load(SeqCst) &&
self.is_pass_set.load(SeqCst)
{
self.registered.store(true, SeqCst);
if self.reg_paused.load(SeqCst) {
return Ok(vec![])
} else {
return self.welcome().await
}
}
// If we were registered, we send a client reply about it.
if self.registered.load(SeqCst) {
Ok(vec![ReplyType::Client((old_nick, format!("NICK :{nickname}")))])
} else {
// Otherwise, we don't reply.
Ok(vec![])
}
}
/// `PART `
///
/// Causes a user to leave the channel ``.
pub async fn handle_cmd_part(&self, args: &str) -> Result> {
if !self.registered.load(SeqCst) {
self.penalty.fetch_add(1, SeqCst);
return Ok(vec![ReplyType::Server((ERR_NOTREGISTERED, format!("* :{NOT_REGISTERED}")))])
}
let nick = self.nickname.read().await.to_string();
let mut tokens = args.split_ascii_whitespace();
let Some(channel) = tokens.next() else {
self.penalty.fetch_add(1, SeqCst);
return Ok(vec![ReplyType::Server((
ERR_NEEDMOREPARAMS,
format!("{nick} PART :{INVALID_SYNTAX}"),
))])
};
if !is_valid_channel_name(channel) {
self.penalty.fetch_add(1, SeqCst);
return Ok(invalid_syntax(&nick, "PART"))
}
let mut active_channels = self.channels.write().await;
if !active_channels.contains(channel) {
return Ok(vec![ReplyType::Server((
ERR_NOSUCHCHANNEL,
format!("{nick} {channel} :No such channel"),
))])
}
// Remove the channel from the client's channel list
active_channels.remove(channel);
let replies = vec![ReplyType::Client((nick, format!("PART {channel} :Bye")))];
Ok(replies)
}
/// `PASS `
///
/// Used to set a connection ``. If set, the password must
/// be set before USER/NICK commands.
pub async fn handle_cmd_pass(&self, args: &str) -> Result> {
let nick = self.nickname.read().await.to_string();
// "the final parameter can be prepended with a (':', 0x3A) character"
//
let Some(i) = args.find(' ') else {
// self.penalty.fetch_add(1, SeqCst);
return Ok(vec![ReplyType::Server((
ERR_NEEDMOREPARAMS,
format!("{nick} PASS :{INVALID_SYNTAX}"),
))])
};
let mut password = &args[i + 1..];
if password.starts_with(':') {
password = &password[1..];
}
if self.server.password == bcrypt_hash_password(password) {
self.is_pass_set.store(true, SeqCst);
} else {
error!("[IRC CLIENT] Password is not correct!");
return Ok(vec![ReplyType::Server((
ERR_PASSWDMISMATCH,
format!("{nick} PASS :{PASSWORD_MISMATCH}"),
))])
}
Ok(vec![])
}
/// `PING `
///
/// Tests a connection. A PING message results in a PONG reply.
pub async fn handle_cmd_ping(&self, args: &str) -> Result> {
if !self.registered.load(SeqCst) {
self.penalty.fetch_add(1, SeqCst);
return Ok(vec![ReplyType::Server((ERR_NOTREGISTERED, format!("* :{NOT_REGISTERED}")))])
}
let mut tokens = args.split_ascii_whitespace();
let Some(origin) = tokens.next() else {
self.penalty.fetch_add(1, SeqCst);
return Ok(vec![ReplyType::Server((
ERR_NOORIGIN,
format!("{} :No origin specified", self.nickname.read().await),
))])
};
Ok(vec![ReplyType::Pong(origin.to_string())])
}
/// `PRIVMSG `
///
/// Sends `` to ``. The target is usually a user or
/// a channel.
pub async fn handle_cmd_privmsg(&self, args: &str) -> Result> {
if !self.registered.load(SeqCst) {
self.penalty.fetch_add(1, SeqCst);
return Ok(vec![ReplyType::Server((ERR_NOTREGISTERED, format!("* :{NOT_REGISTERED}")))])
}
let nick = self.nickname.read().await.to_string();
let mut tokens = args.split_ascii_whitespace();
let Some(target) = tokens.next() else {
return Ok(vec![ReplyType::Server((
ERR_NORECIPIENT,
format!("{nick} :No recipient given (PRIVMSG)"),
))])
};
let Some(message) = tokens.next() else {
return Ok(vec![ReplyType::Server((
ERR_NOTEXTTOSEND,
format!("{nick} :No text to send"),
))])
};
if !message.starts_with(':') || (message.trim() == ":" && tokens.next().is_none()) {
return Ok(vec![ReplyType::Server((
ERR_NOTEXTTOSEND,
format!("{nick} :No text to send"),
))])
}
if target.starts_with('#') {
if !is_valid_channel_name(target) {
self.penalty.fetch_add(1, SeqCst);
return Ok(vec![ReplyType::Server((
ERR_NORECIPIENT,
format!("{nick} :Invalid recipient given (PRIVMSG)"),
))])
}
} else if !target.eq_ignore_ascii_case("nickserv") && !is_valid_nickname(target) {
self.penalty.fetch_add(1, SeqCst);
return Ok(vec![ReplyType::Server((ERR_NOSUCHNICK, format!("{nick} :{target}")))])
}
// We only send a client reply if the message is for ourself or if
// we're trying to communicate with IRC services.
// Anything else is rendered by the IRC client and not supposed
// to be echoed by the IRC serer.
if target == nick {
return Ok(vec![ReplyType::Client((
target.to_string(),
format!("PRIVMSG {target} {message}"),
))])
}
// Handle queries to NickServ
if target.to_lowercase().as_str() == "nickserv" {
return self.nickserv.handle_query(args).await
}
// If it's a DM and we don't have an encryption key, we will
// refuse to send it. Send ERR_NORECIPIENT to the client.
if !target.starts_with('#') && !self.server.contacts.read().await.contains_key(target) {
return Ok(vec![ReplyType::Server((ERR_NOSUCHNICK, format!("{nick} :{target}")))])
}
Ok(vec![])
}
/// `REHASH`
///
/// Causes the server to re-read and re-process its configuration file(s).
pub async fn handle_cmd_rehash(&self, _args: &str) -> Result> {
info!("Attempting to rehash server...");
if let Err(e) = self.server.rehash().await {
error!("Failed to rehash server: {e}");
}
Ok(vec![ReplyType::Server((RPL_REHASHING, "Config reloaded!".to_string()))])
}
/// `TOPIC []`
///
/// Used to get the channel topic on ``. If `` is given, it
/// sets the channel topic to ``.
pub async fn handle_cmd_topic(&self, args: &str) -> Result> {
if !self.registered.load(SeqCst) {
self.penalty.fetch_add(1, SeqCst);
return Ok(vec![ReplyType::Server((ERR_NOTREGISTERED, format!("* :{NOT_REGISTERED}")))])
}
let nick = self.nickname.read().await.to_string();
let Some(request) = parse_topic_request(args) else {
self.penalty.fetch_add(1, SeqCst);
return Ok(vec![ReplyType::Server((
ERR_NEEDMOREPARAMS,
format!("{nick} TOPIC :{INVALID_SYNTAX}"),
))])
};
match request {
TopicRequest::Get { channel } => {
if !is_valid_channel_name(channel) {
self.penalty.fetch_add(1, SeqCst);
return Ok(invalid_syntax(&nick, "TOPIC"))
}
let channels = self.server.channels.read().await;
let Some(channel_state) = channels.get(channel) else {
return Ok(vec![ReplyType::Server((
ERR_NOSUCHCHANNEL,
format!("{nick} {channel} :No such channel"),
))])
};
if channel_state.topic.is_empty() {
Ok(vec![ReplyType::Server((
RPL_NOTOPIC,
format!("{nick} {channel} :No topic is set"),
))])
} else {
Ok(vec![ReplyType::Server((
RPL_TOPIC,
format!("{nick} {channel} :{}", channel_state.topic),
))])
}
}
TopicRequest::Set { channel, topic } => {
if !is_valid_channel_name(channel) || !is_valid_topic(topic) {
self.penalty.fetch_add(1, SeqCst);
return Ok(invalid_syntax(&nick, "TOPIC"))
}
let mut channels = self.server.channels.write().await;
let Some(channel_state) = channels.get_mut(channel) else {
return Ok(vec![ReplyType::Server((
ERR_NOSUCHCHANNEL,
format!("{nick} {channel} :No such channel"),
))])
};
channel_state.topic = topic.to_string();
Ok(vec![ReplyType::Client((nick, format!("TOPIC {channel} :{topic}")))])
}
}
}
/// `USER `
///
/// This command is used at the beginning of a connection to specify the
/// username, hostname, real name, and the initial user modes of the
/// connecting client. `` may contain spaces, and thus must be
/// prefixed with a colon.
pub async fn handle_cmd_user(&self, args: &str) -> Result> {
if self.registered.load(SeqCst) {
self.penalty.fetch_add(1, SeqCst);
return Ok(vec![ReplyType::Server((
ERR_ALREADYREGISTERED,
format!("{} :{ALREADY_REGISTERED}", self.nickname.read().await),
))])
}
// If password is not set register user normally
if self.server.password.is_empty() {
self.is_pass_set.store(true, SeqCst);
}
// Parse the line
let nick = self.nickname.read().await.to_string();
let mut tokens = args.split_ascii_whitespace();
let Some(username) = tokens.next() else {
self.penalty.fetch_add(1, SeqCst);
return Ok(vec![ReplyType::Server((
ERR_NEEDMOREPARAMS,
format!("{nick} USER :{INVALID_SYNTAX}"),
))])
};
// Mode syntax is currently ignored, but should be part of the command
let Some(_mode) = tokens.next() else {
self.penalty.fetch_add(1, SeqCst);
return Ok(vec![ReplyType::Server((
ERR_NEEDMOREPARAMS,
format!("{nick} USER :{INVALID_SYNTAX}"),
))])
};
// Next token is unused per RFC, but should be part of the command
let Some(_unused) = tokens.next() else {
self.penalty.fetch_add(1, SeqCst);
return Ok(vec![ReplyType::Server((
ERR_NEEDMOREPARAMS,
format!("{nick} USER :{INVALID_SYNTAX}"),
))])
};
// The final token should be realname and should start with a colon
let Some(realname) = tokens.next() else {
self.penalty.fetch_add(1, SeqCst);
return Ok(vec![ReplyType::Server((
ERR_NEEDMOREPARAMS,
format!("{nick} USER :{INVALID_SYNTAX}"),
))])
};
if !realname.starts_with(':') {
self.penalty.fetch_add(1, SeqCst);
return Ok(invalid_syntax(&nick, "USER"))
}
let realname_body = &realname[1..];
if !is_valid_username(username) || !is_valid_topic(realname_body) {
self.penalty.fetch_add(1, SeqCst);
return Ok(invalid_syntax(&nick, "USER"))
}
*self.username.write().await = username.to_string();
*self.realname.write().await = realname.to_string();
// If the nickname is set, we can complete the registration
if nick != "*" {
if !self.is_pass_set.load(SeqCst) {
return Ok(vec![ReplyType::Server((
ERR_PASSWDMISMATCH,
format!("{nick} PASS :{PASSWORD_MISMATCH}"),
))])
}
self.registered.store(true, SeqCst);
if self.reg_paused.load(SeqCst) {
return Ok(vec![])
} else {
return self.welcome().await
}
}
// Otherwise, we don't have to reply.
Ok(vec![])
}
/// `VERSION`
///
/// Returns the version of the server.
pub async fn handle_cmd_version(&self, _args: &str) -> Result> {
if !self.registered.load(SeqCst) {
self.penalty.fetch_add(1, SeqCst);
return Ok(vec![ReplyType::Server((ERR_NOTREGISTERED, format!("* :{NOT_REGISTERED}")))])
}
let replies = vec![ReplyType::Server((
RPL_VERSION,
format!(
"{} {} {SERVER_NAME} :Let there be dark!",
self.nickname.read().await,
env!("CARGO_PKG_VERSION")
),
))];
Ok(replies)
}
/// Internal function that constructs the welcome message.
async fn welcome(&self) -> Result> {
let nick = self.nickname.read().await.to_string();
let mut replies = vec![
ReplyType::Server((RPL_WELCOME, format!("{nick} :{WELCOME}"))),
ReplyType::Server((
RPL_YOURHOST,
format!(
"{nick} :Your host is irc.dark.fi, running version {}",
env!("CARGO_PKG_VERSION")
),
)),
];
// Append the MOTD
replies.append(&mut self.handle_cmd_motd("").await?);
let mut channels = HashSet::new();
// If we have any configured autojoin channels, let's join the user
// and set their topics, if any.
if !*self.caps.read().await.get("no-autojoin").unwrap() {
for channel in self.server.autojoin.read().await.iter() {
replies.extend(self.handle_cmd_join(channel, false).await?);
channels.insert(channel.to_string());
}
}
// Potentially extend the replies with channel history
replies.extend(self.get_history(&channels).await?);
// And request NAMES list.
if !*self.caps.read().await.get("no-autojoin").unwrap() {
for channel in self.server.autojoin.read().await.iter() {
if let Some(chan) = self.server.channels.read().await.get(channel) {
let nicks: Vec = chan.nicks.iter().cloned().collect();
replies.push(ReplyType::Server((
RPL_NAMREPLY,
format!("{nick} = {channel} :{}", nicks.join(" ")),
)));
}
replies.push(ReplyType::Server((
RPL_ENDOFNAMES,
format!("{nick} {channel} :End of NAMES list"),
)));
}
}
Ok(replies)
}
/// Internal function that scans the DAG and returns events for
/// given channels. Will return empty if no_history CAP is requested.
// N.b. the handling of "live messages" is implemented
// {>
// for which the logic for delivery should be kept in sync
async fn get_history(&self, channels: &HashSet) -> Result> {
let has_contacts = !self.server.contacts.read().await.is_empty();
if (channels.is_empty() && !has_contacts) ||
*self.caps.read().await.get("no-history").unwrap()
{
return Ok(vec![])
}
// Fetch and order all the events from the DAG
let dag_events = self.server.darkirc.event_graph.order_events().await?;
// Here we'll hold the events in order we'll push to the client
let mut replies = vec![];
for event in dag_events.iter() {
let event_id = event.header.id();
// If it was seen, skip
match self.is_seen(&event_id).await {
Ok(true) => continue,
Ok(false) => {}
Err(e) => {
error!("[IRC CLIENT] (get_history) self.is_seen({event_id}) failed: {e}");
return Err(e)
}
}
// Try to deserialize it. (Here we skip errors)
let mut privmsg: Privmsg = match deserialize_async_partial(event.content()).await {
Ok((v, _)) => v,
Err(_) => continue,
};
// Record any public (`#`-prefixed) channel observed on the wire,
// before decryption (see note in client::multiplex_connection).
self.server.record_seen_channel(&privmsg.channel).await?;
// Potentially decrypt the privmsg
self.server.try_decrypt(&mut privmsg, self.nickname.read().await.as_ref()).await;
// We should skip any attempts to contact services from the network.
if ["nickserv", "chanserv"].contains(&privmsg.nick.to_lowercase().as_str()) {
continue
}
// If the PRIVMSG is intended for any of the given
// channels or contacts, add it as a reply and
// mark it as seen in the seen_events tree.
let intended_for_client = channels.contains(&privmsg.channel) ||
self.server.contacts.read().await.contains_key(&privmsg.channel);
if !intended_for_client {
continue
}
let lines = super::client::message_lines(&privmsg.msg);
if lines.is_empty() {
warn!(
"[IRC CLIENT] Refusing to mark historical event {event_id} as seen: \
PRIVMSG has no deliverable lines"
);
continue
}
// Insert nicks into channels
if let Some(chan) = self.server.channels.write().await.get_mut(&privmsg.channel) {
chan.nicks.insert(privmsg.nick.clone());
}
// Handle message lines individually
for (index, line) in lines.iter().enumerate() {
// Format the message
let msg = format!("PRIVMSG {} :{line}", privmsg.channel);
// Mark the event seen only after its final line is written.
let delivered_event = (index + 1 == lines.len()).then_some(event_id);
replies.push(ReplyType::HistoricalClient((
privmsg.nick.clone(),
msg,
delivered_event,
)));
}
}
Ok(replies)
}
}
#[cfg(test)]
mod tests {
use super::{parse_topic_request, TopicRequest};
#[test]
fn parse_topic_request_gets_current_topic() {
assert_eq!(parse_topic_request("#chan"), Some(TopicRequest::Get { channel: "#chan" }));
}
#[test]
fn parse_topic_request_sets_colon_prefixed_topic() {
assert_eq!(
parse_topic_request("#chan :hello world"),
Some(TopicRequest::Set { channel: "#chan", topic: "hello world" })
);
}
#[test]
fn parse_topic_request_allows_empty_topic() {
assert_eq!(
parse_topic_request("#chan :"),
Some(TopicRequest::Set { channel: "#chan", topic: "" })
);
}
#[test]
fn parse_topic_request_rejects_bare_topic_body() {
assert_eq!(parse_topic_request("#chan value"), None);
}
#[test]
fn channel_name_validation_rejects_empty_nested_and_overlong_names() {
assert!(super::is_valid_channel_name("#chan"));
assert!(!super::is_valid_channel_name("#"));
assert!(!super::is_valid_channel_name("##chan"));
assert!(!super::is_valid_channel_name(&format!("#{}", "a".repeat(24))));
}
#[test]
fn nickname_validation_rejects_unsafe_identifier_chars() {
assert!(super::is_valid_nickname("alice_1"));
assert!(!super::is_valid_nickname("bad!nick"));
assert!(!super::is_valid_nickname("bad@nick"));
assert!(!super::is_valid_nickname("bad.nick"));
}
#[test]
fn topic_validation_has_fixed_bound() {
assert!(super::is_valid_topic(&"a".repeat(super::MAX_TOPIC_LEN)));
assert!(!super::is_valid_topic(&"a".repeat(super::MAX_TOPIC_LEN + 1)));
assert!(!super::is_valid_topic("bad\nline"));
}
}