/* 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")); } }