From 4e0f02950f138b4e85a55fc1bcfd58b72a2e26a6 Mon Sep 17 00:00:00 2001 From: Trevor Arjeski Date: Sat, 6 Jun 2020 23:14:22 +0300 Subject: [PATCH] Implemented DCC get for downloading files. Does not yet support sending files. Closes #206 Added connection timeout Fixed NOTICE format which was causing an issue. Spawn regular task instead of local for file download. Only read number of bytes read. --- README.md | 6 +- crates/libtiny_client/Cargo.toml | 8 +- crates/libtiny_client/src/dcc.rs | 152 ++++++++ crates/libtiny_client/src/lib.rs | 159 ++++++++ crates/libtiny_client/src/state.rs | 39 +- crates/libtiny_wire/src/lib.rs | 7 + crates/tiny/config.yml | 1 + crates/tiny/src/cmd.rs | 62 ++- crates/tiny/src/config.rs | 2 + crates/tiny/src/conn.rs | 39 ++ crates/tiny/src/main.rs | 1 + crates/tiny/src/tests.rs | 5 + libtiny_client/src/state.rs | 579 +++++++++++++++++++++++++++++ 13 files changed, 1055 insertions(+), 5 deletions(-) create mode 100644 crates/libtiny_client/src/dcc.rs create mode 100644 libtiny_client/src/state.rs diff --git a/README.md b/README.md index 4b53646b..7b03a57f 100644 --- a/README.md +++ b/README.md @@ -202,9 +202,13 @@ Commands start with `/` character. Running this command in a server tab applies it to all channels of that server. You can check your notify state in the status line. +- `/dcc [get] `: When you receive a DCC SEND message, you can + accept it and download the file with this command. Currently sending files + is not supported. + - `/quit`: Quit -## Server commands +## Server Commands For commands not supported by tiny as a slash command, sending the command in the server tab will send the message directly to the server. diff --git a/crates/libtiny_client/Cargo.toml b/crates/libtiny_client/Cargo.toml index 67315500..d864d053 100644 --- a/crates/libtiny_client/Cargo.toml +++ b/crates/libtiny_client/Cargo.toml @@ -19,7 +19,13 @@ libtiny_wire = { path = "../libtiny_wire" } log = "0.4" native-tls = { version = "0.2", optional = true } rustls-native-certs = { version = "0.5", optional = true } -tokio = { version = "1.6.1", default-features = false, features = ["net", "rt", "io-util", "macros"] } +tokio = { version = "1.6.1", default-features = false, features = [ + "net", + "rt", + "io-util", + "macros", + "fs", +] } tokio-native-tls = { version = "0.3", optional = true } tokio-rustls = { version = "0.22", optional = true } tokio-stream = { version = "0.1.6" } diff --git a/crates/libtiny_client/src/dcc.rs b/crates/libtiny_client/src/dcc.rs new file mode 100644 index 00000000..7afc62a1 --- /dev/null +++ b/crates/libtiny_client/src/dcc.rs @@ -0,0 +1,152 @@ +use std::fmt; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::str::FromStr; + +#[derive(Debug, PartialEq)] +pub struct DCCRecord { + /// SEND or CHAT (only supporting SEND right now) + dcc_type: DCCType, + /// IP address and port + address: SocketAddr, + /// Nickname of person who wants to send + origin: String, + /// Client nickname + receiver: String, + /// Argument - filename or string "chat" + argument: String, + /// File size of file that will be sent in bytes + file_size: Option, +} + +#[derive(Debug, Copy, Clone, PartialEq)] +pub enum DCCType { + SEND, + CHAT, +} + +#[derive(Debug)] +pub struct DCCTypeParseError; + +impl std::error::Error for DCCTypeParseError {} + +impl fmt::Display for DCCTypeParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "DCCType could not be parsed from string.") + } +} + +impl FromStr for DCCType { + type Err = DCCTypeParseError; + fn from_str(input: &str) -> Result { + match input { + "SEND" => Ok(DCCType::SEND), + "CHAT" => Ok(DCCType::CHAT), + _ => Err(DCCTypeParseError), + } + } +} + +impl fmt::Display for DCCType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DCCType::CHAT => write!(f, "CHAT"), + DCCType::SEND => write!(f, "SEND"), + } + } +} + +impl DCCRecord { + /// DCC type argument address port [size] + /// + /// type - The connection type. + /// argument - The connectin type dependant argument. + /// address - The host address of the initiator as an integer. + /// port - The port or the socket on which the initiator expects + /// to receive the connection. + /// size - If the connection type is "SEND" (see below), then size + /// will indicate the size of the file being offered. Obsolete + /// IRCII clients do not send this, so be prepared if this is + /// not present. + + /// The following DCC connection types are defined: + /// + /// Type Purpose Argument + /// CHAT To carry on a semi-secure conversation the string "chat" + /// SEND To send a file to the recipient the file name + pub fn from( + origin: &str, + receiver: &str, + msg: &str, + ) -> Result> { + let mut param_iter: Vec<&str> = msg.split_whitespace().collect(); + let dcc_type: DCCType = param_iter.remove(0).parse()?; + let file_size = param_iter.pop().and_then(|fs| fs.parse::().ok()); + let port: u16 = param_iter.pop().unwrap().parse()?; + + let address: u32 = param_iter.pop().unwrap().parse()?; + let address_dot_decimal: Ipv4Addr = Ipv4Addr::new( + (address >> 24) as u8, + (address >> 16) as u8, + (address >> 8) as u8, + (address) as u8, + ); + + let socket_address = SocketAddr::new(IpAddr::V4(address_dot_decimal), port); + + let argument = param_iter.join(""); + let argument = argument.trim_start_matches('"').trim_end_matches('"'); + + Ok(DCCRecord { + dcc_type, + address: socket_address, + origin: origin.to_string(), + receiver: receiver.to_string(), + argument: argument.to_string(), + file_size, + }) + } + + pub fn get_type(&self) -> DCCType { + self.dcc_type + } + + pub fn get_addr(&self) -> &SocketAddr { + &self.address + } + + pub fn get_argument(&self) -> &String { + &self.argument + } + + pub fn get_file_size(&self) -> Option { + self.file_size + } + + pub fn get_receiver(&self) -> &String { + &self.receiver + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn can_parse() { + let expected = DCCRecord { + dcc_type: DCCType::SEND, + address: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(173, 80, 26, 71)), 3078), + origin: "Origin".to_string(), + receiver: "me".to_string(), + argument: "SearchBot_results_for_python.txt.zip".to_string(), + file_size: Some(24999), + }; + let s = r#"SEND "SearchBot_results_for_ python.txt.zip" 2907707975 3078 24999"#; + let r = DCCRecord::from("Origin", "me", s); + assert_eq!(expected, r.unwrap()); + + let s = r#"SEND SearchBot_results_for_python.txt.zip 2907707975 3078 24999"#; + let r = DCCRecord::from("Origin", "me", s); + assert_eq!(expected, r.unwrap()); + } +} diff --git a/crates/libtiny_client/src/lib.rs b/crates/libtiny_client/src/lib.rs index 28df06f3..0417a900 100644 --- a/crates/libtiny_client/src/lib.rs +++ b/crates/libtiny_client/src/lib.rs @@ -1,11 +1,13 @@ #![allow(clippy::unneeded_field_pattern)] #![allow(clippy::cognitive_complexity)] +mod dcc; mod pinger; mod state; mod stream; mod utils; +pub use dcc::DCCType; use libtiny_common::{ChanName, ChanNameRef}; pub use libtiny_wire as wire; @@ -14,11 +16,14 @@ use state::State; use stream::{Stream, StreamError}; use std::net::{SocketAddr, ToSocketAddrs}; +use std::path::PathBuf; use std::time::Duration; use futures_util::future::FutureExt; +use tokio::fs::OpenOptions; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::sync::mpsc; +use tokio::time::timeout; use tokio::{pin, select}; use tokio_stream::wrappers::ReceiverStream; use tokio_stream::StreamExt; @@ -255,6 +260,36 @@ impl Client { pub fn get_chan_nicks(&self, chan: &ChanNameRef) -> Vec { self.state.get_chan_nicks(chan) } + + /// Checks if we have a stored DCCRecord for the provided parameters + /// Starts download of file if a record is found. + /// Deletes record after download attempt + /// Returns boolean if record is found + pub fn get_dcc_rec(&mut self, origin: &str, download_dir: PathBuf, file_name: &str) -> bool { + let rec = self.state.get_dcc_rec(origin, file_name); + if let Some(rec) = rec { + debug!("found rec {:?}", rec); + dcc_file_get( + &mut self.msg_chan, + *rec.get_addr(), + download_dir, + file_name.to_string(), + rec.get_receiver().to_string(), + ); + true + } else { + false + } + } + + /// Creates a dcc record and returns the argument (filename) and file size if provided + pub fn create_dcc_rec( + &self, + origin: &str, + msg: &str, + ) -> Option<(dcc::DCCType, String, Option)> { + self.state.add_dcc_rec(origin, msg) + } } // @@ -676,3 +711,127 @@ async fn try_connect + Unpin>( } } } + +/// Spawns a task to connect to given address to download +/// a file +/// https://www.irchelp.org/protocol/ctcpspec.html +fn dcc_file_get( + msg_chan: &mut mpsc::Sender, + addr: SocketAddr, + mut download_dir: PathBuf, + file_name: String, + target: String, +) { + let mut msg_chan = msg_chan.clone(); + tokio::task::spawn(async move { + let stream = match timeout( + tokio::time::Duration::from_secs(25), + tokio::net::TcpStream::connect(addr), + ) + .await + { + Err(err) => { + error!("{}", err); + let err_msg = "File transfer connection timed out."; + dcc_to_msgchan(&mut msg_chan, target.as_str(), err_msg).await; + return; + } + Ok(stream) => match stream { + Err(err) => { + error!("{}", err); + let err_msg = "File transfer connection failed."; + dcc_to_msgchan(&mut msg_chan, target.as_str(), err_msg).await; + return; + } + Ok(stream) => stream, + }, + }; + + let (mut rx, mut tx) = tokio::io::split(stream); + + // Try to create the download directory if it isn't there + if let Err(err) = tokio::fs::create_dir_all(&download_dir).await { + // Directory already exists + if err.kind() != tokio::io::ErrorKind::AlreadyExists { + error!("{}", err); + let err_msg = "Couldn't create download directory."; + dcc_to_msgchan(&mut msg_chan, target.as_str(), err_msg).await; + return; + } + } + // Add the file name to the file path + download_dir.push(&file_name); + // Create a new file for write inside the download directory + let mut file = match OpenOptions::new() + .write(true) + .create_new(true) + .open(download_dir) + .await + { + Err(err) => { + error!("{}", err); + let err_msg = format!("Couldn't create file: {}.", file_name); + dcc_to_msgchan(&mut msg_chan, target.as_str(), err_msg.as_str()).await; + return; + } + Ok(file) => file, + }; + // Stores total bytes received + let mut bytes_received: u32 = 0; + loop { + let mut buf: [u8; 1024] = [0; 1024]; + match rx.read(&mut buf).await { + Err(err) => { + error!("{}", err); + let err_msg = "Error reading from socket for dcc."; + dcc_to_msgchan(&mut msg_chan, target.as_str(), err_msg).await; + return; + } + Ok(0) => break, + Ok(bytes) => { + bytes_received += bytes as u32; + // Write bytes to file + if let Err(err) = file.write(&buf[..bytes]).await { + error!("{}", err); + let err_msg = "Error writing to file."; + dcc_to_msgchan(&mut msg_chan, target.as_str(), err_msg).await; + return; + } + // Send back number of bytes read + let response: [u8; 4] = [ + (bytes_received >> 24) as u8, + (bytes_received >> 16) as u8, + (bytes_received >> 8) as u8, + (bytes_received) as u8, + ]; + if let Err(err) = tx.write(&response).await { + error!("{}", err); + let err_msg = "Error responding to sender."; + dcc_to_msgchan(&mut msg_chan, target.as_str(), err_msg).await; + return; + } + } + } + } + // sync file to filesystem + if let Err(err) = file.sync_all().await { + error!("{}", err); + let err_msg = "Error syncing file to disk."; + dcc_to_msgchan(&mut msg_chan, target.as_str(), err_msg).await; + return; + } + // Write message to client that file was successfully downloaded + let success_msg = format!( + "Downloaded file: {} ({1:.2}KB)", + file_name, + (bytes_received as f32 / 1024 as f32) + ); + dcc_to_msgchan(&mut msg_chan, target.as_str(), success_msg.as_str()).await; + }); +} + +async fn dcc_to_msgchan(msg_chan: &mut mpsc::Sender, target: &str, msg: &str) { + msg_chan + .try_send(Cmd::Msg(wire::notice(target, msg))) + .unwrap(); +} diff --git a/crates/libtiny_client/src/state.rs b/crates/libtiny_client/src/state.rs index 01f72476..e6abc9ed 100644 --- a/crates/libtiny_client/src/state.rs +++ b/crates/libtiny_client/src/state.rs @@ -1,13 +1,14 @@ #![allow(clippy::zero_prefixed_literal)] -use crate::utils; +use crate::dcc::DCCRecord; +use crate::{utils, DCCType}; use crate::{Cmd, Event, ServerInfo}; use libtiny_common::{ChanName, ChanNameRef}; use libtiny_wire as wire; use libtiny_wire::{Msg, Pfx}; use std::cell::RefCell; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::rc::Rc; use tokio::sync::mpsc::{Receiver, Sender}; @@ -77,6 +78,36 @@ impl State { pub(crate) fn kill_join_tasks(&self) { self.inner.borrow_mut().kill_join_tasks(); } + + /// Gets a DCCRecord and removes it + pub(crate) fn get_dcc_rec(&self, origin: &str, argument: &str) -> Option { + self.inner + .borrow_mut() + .dcc_recs + .remove(&(origin.to_string(), argument.to_string())) + } + + /// Adds a DCC record and returns the type, argument and filesize if successfully created + pub(crate) fn add_dcc_rec( + &self, + origin: &str, + msg: &str, + ) -> Option<(DCCType, String, Option)> { + let record = DCCRecord::from(origin, &self.get_nick(), msg); + debug!("record: {:?}", record); + if let Ok(record) = record { + let argument = record.get_argument().clone(); + let dcc_type = record.get_type().clone(); + let file_size = record.get_file_size(); + self.inner + .borrow_mut() + .dcc_recs + .insert((origin.to_string(), argument.to_string()), record); + Some((dcc_type, argument, file_size)) + } else { + None + } + } } struct StateInner { @@ -104,6 +135,9 @@ struct StateInner { /// order, in TUI? chans: Vec, + /// Map of DCCRecords indexed by the nickname of the sender and the argument (filename or 'chat') + dcc_recs: HashMap<(String, String), DCCRecord>, + /// Away reason if away mode is on. `None` otherwise. away_status: Option, @@ -213,6 +247,7 @@ impl StateInner { current_nick_idx: 0, current_nick, chans, + dcc_recs: HashMap::new(), away_status: None, servername: None, usermask: None, diff --git a/crates/libtiny_wire/src/lib.rs b/crates/libtiny_wire/src/lib.rs index 07a23162..1ee6cab6 100644 --- a/crates/libtiny_wire/src/lib.rs +++ b/crates/libtiny_wire/src/lib.rs @@ -57,6 +57,11 @@ pub fn privmsg(msgtarget: &str, msg: &str) -> String { format!("PRIVMSG {} :{}\r\n", msgtarget, msg) } +pub fn notice(msgtarget: &str, msg: &str) -> String { + assert!(msgtarget.len() + msg.len() + 12 <= 512); + format!("NOTICE {} :{}\r\n", msgtarget, msg) +} + pub fn action(msgtarget: &str, msg: &str) -> String { assert!(msgtarget.len() + msg.len() + 21 <= 512); // See comments in `privmsg` format!("PRIVMSG {} :\x01ACTION {}\x01\r\n", msgtarget, msg) @@ -199,6 +204,7 @@ pub struct Msg { pub enum CTCP { Version, Action, + DCC, Other(String), } @@ -207,6 +213,7 @@ impl CTCP { match s { "VERSION" => CTCP::Version, "ACTION" => CTCP::Action, + "DCC" => CTCP::DCC, _ => CTCP::Other(s.to_owned()), } } diff --git a/crates/tiny/config.yml b/crates/tiny/config.yml index 054cae28..b67de855 100644 --- a/crates/tiny/config.yml +++ b/crates/tiny/config.yml @@ -35,6 +35,7 @@ defaults: realname: yourname join: [] tls: false + download_dir: '/tmp/tiny_downloads' # Where to put log files log_dir: "{}" diff --git a/crates/tiny/src/cmd.rs b/crates/tiny/src/cmd.rs index ffa99f90..bf807dd9 100644 --- a/crates/tiny/src/cmd.rs +++ b/crates/tiny/src/cmd.rs @@ -95,7 +95,7 @@ fn find_client<'a>(clients: &'a mut Vec, serv_name: &str) -> Option<&'a //////////////////////////////////////////////////////////////////////////////////////////////////// -static CMDS: [&Cmd; 9] = [ +static CMDS: [&Cmd; 10] = [ &AWAY_CMD, &CLOSE_CMD, &CONNECT_CMD, @@ -104,6 +104,7 @@ static CMDS: [&Cmd; 9] = [ &MSG_CMD, &NAMES_CMD, &NICK_CMD, + &DCC_CMD, &HELP_CMD, ]; @@ -530,6 +531,65 @@ fn help(args: CmdArgs) { } } +static DCC_CMD: Cmd = Cmd { + name: "dcc", + cmd_fn: dcc, + description: "Accepts DCC request", + usage: "/dcc get ", +}; + +const GET: &str = "get"; +const SEND: &str = "send"; + +fn dcc(args: CmdArgs) { + let CmdArgs { + args, + ui, + clients, + src, + defaults, + .. + } = args; + + if let Some(client) = find_client(clients, &src.serv_name()) { + // parse args, ex. GET + let mut params = args.split_whitespace(); + if let Some(sub_cmd) = params.next() { + match sub_cmd { + GET => { + if let Some(origin) = params.next() { + if let Some(file_name) = params.next() { + if let Some(dir) = &defaults.download_dir { + let found = client.get_dcc_rec(origin, dir.clone(), file_name); + match found { + true => ui.add_client_msg( + "DCC Record found...starting download", + &MsgTarget::CurrentTab, + ), + false => ui.add_client_msg( + "DCC Record not found.", + &MsgTarget::CurrentTab, + ), + } + } else { + ui.add_client_err_msg( + "No default download directory found in config.", + &MsgTarget::CurrentTab, + ) + } + } + } + } + // TODO: Support this if people request it + SEND => ui.add_client_err_msg( + "SEND command not implemented yet.", + &MsgTarget::CurrentTab, + ), + _ => ui.add_client_err_msg(DCC_CMD.usage, &MsgTarget::CurrentTab), + } + } + } +} //////////////////////////////////////////////////////////////////////////////////////////////////// #[test] diff --git a/crates/tiny/src/config.rs b/crates/tiny/src/config.rs index 68958de7..50be56d9 100644 --- a/crates/tiny/src/config.rs +++ b/crates/tiny/src/config.rs @@ -62,6 +62,7 @@ pub(crate) struct Defaults { pub(crate) join: Vec, #[serde(default)] pub(crate) tls: bool, + pub(crate) download_dir: Option, } #[derive(Deserialize)] @@ -251,6 +252,7 @@ mod tests { realname: "".to_owned(), join: vec![], tls: false, + download_dir: None, }, log_dir: None, }; diff --git a/crates/tiny/src/conn.rs b/crates/tiny/src/conn.rs index baf9f220..b6daee86 100644 --- a/crates/tiny/src/conn.rs +++ b/crates/tiny/src/conn.rs @@ -4,6 +4,8 @@ //! IRC event handling use crate::ui::UI; + +use libtiny_client::DCCType; use libtiny_common::{ChanNameRef, MsgTarget, TabStyle}; use libtiny_wire as wire; @@ -17,6 +19,8 @@ pub(crate) trait Client { fn get_nick(&self) -> String; fn is_nick_accepted(&self) -> bool; + + fn create_dcc_rec(&self, origin: &str, msg: &str) -> Option<(DCCType, String, Option)>; } impl Client for libtiny_client::Client { @@ -31,6 +35,10 @@ impl Client for libtiny_client::Client { fn is_nick_accepted(&self) -> bool { self.is_nick_accepted() } + + fn create_dcc_rec(&self, origin: &str, msg: &str) -> Option<(DCCType, String, Option)> { + self.create_dcc_rec(origin, msg) + } } pub(crate) async fn task( @@ -186,6 +194,37 @@ fn handle_irc_msg(ui: &UI, client: &dyn Client, msg: wire::Msg) { return; } + if ctcp == Some(wire::CTCP::DCC) { + let msg_target = if ui.user_tab_exists(serv, sender) { + MsgTarget::User { serv, nick: sender } + } else { + MsgTarget::Server { serv } + }; + if let Some((dcc_type, argument, file_size)) = + client.create_dcc_rec(&sender.to_owned(), &msg) + { + match dcc_type { + DCCType::SEND => { + let file_size = + file_size.map_or("unknown size".to_string(), |s| s.to_string()); + ui.add_client_msg( + &format!( + "{} wants to send file - {} ({} bytes)", + sender, argument, file_size + ), + &msg_target, + ); + ui.add_client_msg( + &format!("Command to accept: /dcc get {} {}", sender, argument), + &msg_target, + ); + } + DCCType::CHAT => {} + } + } + return; + } + let is_action = ctcp == Some(wire::CTCP::Action); match target { diff --git a/crates/tiny/src/main.rs b/crates/tiny/src/main.rs index 4f08f463..6387f057 100644 --- a/crates/tiny/src/main.rs +++ b/crates/tiny/src/main.rs @@ -58,6 +58,7 @@ fn main() { servers, defaults, log_dir, + .. } = config; let servers = if !server_args.is_empty() { diff --git a/crates/tiny/src/tests.rs b/crates/tiny/src/tests.rs index 0aa4edde..5bbe43f3 100644 --- a/crates/tiny/src/tests.rs +++ b/crates/tiny/src/tests.rs @@ -1,5 +1,6 @@ use crate::conn; use crate::ui::UI; +use client::DCCType; use libtiny_common::ChanName; use libtiny_tui::test_utils::expect_screen; use libtiny_tui::TUI; @@ -33,6 +34,10 @@ impl conn::Client for TestClient { fn is_nick_accepted(&self) -> bool { true } + + fn create_dcc_rec(&self, _origin: &str, _msg: &str) -> Option<(DCCType, String, Option)> { + None + } } static SERV_NAME: &str = "x.y.z"; diff --git a/libtiny_client/src/state.rs b/libtiny_client/src/state.rs new file mode 100644 index 00000000..7f6d1f55 --- /dev/null +++ b/libtiny_client/src/state.rs @@ -0,0 +1,579 @@ +#![allow(clippy::zero_prefixed_literal)] + +use crate::dcc::{DCCRecord, DCCType}; +use crate::utils; +use crate::{Event, ServerInfo}; +use libtiny_wire as wire; +use libtiny_wire::{Msg, Pfx}; + +use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; +use std::rc::Rc; +use tokio::sync::mpsc::Sender; + +#[derive(Clone)] +pub struct State { + inner: Rc>, +} + +impl State { + pub(crate) fn new(server_info: ServerInfo) -> State { + State { + inner: Rc::new(RefCell::new(StateInner::new(server_info))), + } + } + + pub(crate) fn reset(&self) { + self.inner.borrow_mut().reset() + } + + pub(crate) fn send_ping(&self, snd_irc_msg: &mut Sender) { + self.inner.borrow_mut().send_ping(snd_irc_msg) + } + + pub(crate) fn update( + &self, + msg: &mut Msg, + snd_ev: &mut Sender, + snd_irc_msg: &mut Sender, + ) { + self.inner.borrow_mut().update(msg, snd_ev, snd_irc_msg); + } + + pub(crate) fn introduce(&self, snd_irc_msg: &mut Sender) { + self.inner.borrow_mut().introduce(snd_irc_msg) + } + + // FIXME: This allocates a new String + pub(crate) fn get_nick(&self) -> String { + self.inner.borrow().current_nick.clone() + } + + // FIXME: Maybe use RwLock instead of Mutex + pub(crate) fn is_nick_accepted(&self) -> bool { + self.inner.borrow().nick_accepted + } + + pub(crate) fn get_usermask(&self) -> Option { + self.inner.borrow().usermask.clone() + } + + pub(crate) fn set_away(&self, msg: Option<&str>) { + self.inner.borrow_mut().away_status = msg.map(str::to_owned); + } + + pub(crate) fn get_chan_nicks(&self, chan: &str) -> Vec { + self.inner.borrow().get_chan_nicks(chan) + } + + /// Gets a DCCRecord and removes it + pub(crate) fn get_dcc_rec(&self, origin: &str, argument: &str) -> Option { + self.inner + .borrow_mut() + .dcc_recs + .remove(&(origin.to_string(), argument.to_string())) + } + + /// Adds a DCC record and returns the type, argument and filesize if successfully created + pub(crate) fn add_dcc_rec( + &self, + origin: &str, + msg: &str, + ) -> Option<(DCCType, String, Option)> { + let record = DCCRecord::from(origin, &self.get_nick(), msg); + debug!("record: {:?}", record); + if let Ok(record) = record { + let argument = record.get_argument().clone(); + let dcc_type = record.get_type().clone(); + let file_size = record.get_file_size(); + self.inner + .borrow_mut() + .dcc_recs + .insert((origin.to_string(), argument.to_string()), record); + Some((dcc_type, argument, file_size)) + } else { + None + } + } +} + +struct StateInner { + /// Nicks to try, in this order. + nicks: Vec, + + /// NickServ passowrd. + nickserv_ident: Option, + + /// An index to `nicks`. When out of range we add `current_nick_idx - nicks.length()` + /// underscores to the last nick in `nicks` + current_nick_idx: usize, + + /// A cache of current nick, to avoid allocating new nicks when inventing new nicks with + /// underscores. + current_nick: String, + + /// Currently joined channels. Every channel we join will be added here to be able to re-join + /// automatically on reconnect and channels we leave will be removed. + /// + /// This would be a `HashMap` but we want to join channels in the order the user + /// specified, so using a `Vec`. + /// + /// TODO: I'm not sure if this is necessary. Why not just create channel tabs in the specified + /// order, in TUI? + chans: Vec<(String, HashSet)>, + + /// Map of DCCRecords indexed by the nickname of the sender and the argument (filename or 'chat') + dcc_recs: HashMap<(String, String), DCCRecord>, + + /// Away reason if away mode is on. `None` otherwise. TODO: I don't think the message is used? + away_status: Option, + + /// servername to be used in PING messages. Read from 002 RPL_YOURHOST. `None` until 002. + servername: Option, + + /// Our usermask given by the server. Currently only parsed after a JOIN, reply 396. + /// + /// Note that RPL_USERHOST (302) does not take cloaks into account, so we don't parse USERHOST + /// responses to set this field. + usermask: Option, + + /// Do we have a nick yet? Try another nick on ERR_NICKNAMEINUSE (433) until we've got a nick. + nick_accepted: bool, + + /// Server information + server_info: ServerInfo, +} + +impl StateInner { + fn new(server_info: ServerInfo) -> StateInner { + let current_nick = server_info.nicks[0].to_owned(); + let chans = server_info + .auto_join + .iter() + .map(|s| (s.clone(), HashSet::new())) + .collect(); + StateInner { + nicks: server_info.nicks.clone(), + nickserv_ident: server_info.nickserv_ident.clone(), + current_nick_idx: 0, + current_nick, + chans, + dcc_recs: HashMap::new(), + away_status: None, + servername: None, + usermask: None, + nick_accepted: false, + server_info, + } + } + + fn reset(&mut self) { + self.nick_accepted = false; + self.nicks = self.server_info.nicks.clone(); + self.current_nick_idx = 0; + self.current_nick = self.nicks[0].clone(); + // Only reset the values here; the key set will be used to join channels + for (_, chan) in &mut self.chans { + chan.clear(); + } + self.servername = None; + self.usermask = None; + } + + fn send_ping(&mut self, snd_irc_msg: &mut Sender) { + if let Some(ref servername) = self.servername { + snd_irc_msg.try_send(wire::ping(servername)).unwrap(); + } + } + + fn introduce(&mut self, snd_irc_msg: &mut Sender) { + if let Some(ref pass) = self.server_info.pass { + snd_irc_msg.try_send(wire::pass(pass)).unwrap(); + } + snd_irc_msg + .try_send(wire::nick(&self.current_nick)) + .unwrap(); + snd_irc_msg + .try_send(wire::user(&self.nicks[0], &self.server_info.realname)) + .unwrap(); + } + + fn get_next_nick(&mut self) -> &str { + self.current_nick_idx += 1; + // debug!("current_nick_idx: {}", self.current_nick_idx); + if self.current_nick_idx >= self.nicks.len() { + let n_underscores = self.current_nick_idx - self.nicks.len() + 1; + let mut new_nick = self.nicks.last().unwrap().to_string(); + for _ in 0..n_underscores { + new_nick.push('_'); + } + self.current_nick = new_nick; + } else { + self.current_nick = self.nicks[self.current_nick_idx].clone(); + } + &self.current_nick + } + + fn update( + &mut self, + msg: &mut Msg, + snd_ev: &mut Sender, + snd_irc_msg: &mut Sender, + ) { + let Msg { + ref pfx, + ref mut cmd, + } = msg; + + use wire::Cmd::*; + match cmd { + PING { server } => { + snd_irc_msg.try_send(wire::pong(server)).unwrap(); + } + + // + // Setting usermask using JOIN, RPL_USERHOST and 396 (?) + // Also initialize the channel state on JOIN + // + JOIN { chan } => { + if let Some(Pfx::User { nick, user }) = pfx { + if nick == &self.current_nick { + // Set usermask + let usermask = format!("{}!{}", nick, user); + self.usermask = Some(usermask); + + // Initialize channel state + match utils::find_idx(&self.chans, |(s, _)| s == chan) { + None => { + self.chans.push((chan.to_owned(), HashSet::new())); + } + Some(chan_idx) => { + // This happens because we initialize channel states for channels + // that we will join on connection when the client is first created + self.chans[chan_idx].1.clear(); + } + } + } else { + match utils::find_idx(&self.chans, |(s, _)| s == chan) { + Some(chan_idx) => { + self.chans[chan_idx] + .1 + .insert(wire::drop_nick_prefix(nick).to_owned()); + } + None => { + debug!("Can't find channel state for JOIN: {:?}", cmd); + } + } + } + } + } + + Reply { num: 396, params } => { + // :hobana.freenode.net 396 osa1 haskell/developer/osa1 + // :is now your hidden host (set by services.) + if params.len() == 3 { + let usermask = + format!("{}!~{}@{}", self.current_nick, self.nicks[0], params[1]); + self.usermask = Some(usermask); + } + } + + Reply { num: 302, params } => { + // 302 RPL_USERHOST + // :ircd.stealth.net 302 yournick :syrk=+syrk@millennium.stealth.net + // + // We know there will be only one nick because /userhost cmd sends + // one parameter (our nick) + // + // Example args: ["osa1", "osa1=+omer@moz-s8a.9ac.93.91.IP "] + + let param = ¶ms[1]; + match param.find('=') { + None => { + // TODO: Log this + } + Some(mut i) => { + if param.as_bytes().get(i + 1) == Some(&b'+') + || param.as_bytes().get(i + 1) == Some(&b'-') + { + i += 1; + } + let usermask = (¶m[i..]).trim(); + self.usermask = Some(usermask.to_owned()); + } + } + } + + // + // Remove channel state on PART + // + PART { chan, .. } => { + if let Some(Pfx::User { nick, .. }) = pfx { + if nick == &self.current_nick { + match utils::find_idx(&self.chans, |(s, _)| s == chan) { + None => { + debug!("Can't find channel state: {}", chan); + } + Some(chan_idx) => { + self.chans.remove(chan_idx); + } + } + } else { + match utils::find_idx(&self.chans, |(s, _)| s == chan) { + Some(chan_idx) => { + self.chans[chan_idx].1.remove(wire::drop_nick_prefix(nick)); + } + None => { + debug!("Can't find channel state for PART: {:?}", cmd); + } + } + } + } + } + + // + // RPL_WELCOME, start introduction sequence and NickServ authentication + // + Reply { num: 001, .. } => { + snd_ev.try_send(Event::Connected).unwrap(); + snd_ev + .try_send(Event::NickChange(self.current_nick.clone())) + .unwrap(); + self.nick_accepted = true; + if let Some(ref pwd) = self.nickserv_ident { + snd_irc_msg + .try_send(wire::privmsg("NickServ", &format!("identify {}", pwd))) + .unwrap(); + } + } + + // + // RPL_YOURHOST, set servername + // + Reply { num: 002, params } => { + // 002 RPL_YOURHOST + // "Your host is , running version " + + // An example : cherryh.freenode.net[149.56.134.238/8001] + + match parse_servername(params) { + None => { + // TODO: Log + } + Some(servername) => { + self.servername = Some(servername); + } + } + } + + // + // ERR_NICKNAMEINUSE, try another nick if we don't have a nick yet + // + Reply { num: 433, .. } => { + // ERR_NICKNAMEINUSE. If we don't have a nick already try next nick. + if !self.nick_accepted { + let new_nick = self.get_next_nick(); + // debug!("new nick: {}", new_nick); + snd_ev + .try_send(Event::NickChange(new_nick.to_owned())) + .unwrap(); + snd_irc_msg.try_send(wire::nick(new_nick)).unwrap(); + } + } + + // + // NICK message sent from the server when our nick change request was successful + // + NICK { + nick: new_nick, + ref mut chans, + } => { + if let Some(Pfx::User { nick: old_nick, .. }) = pfx { + if old_nick == &self.current_nick { + snd_ev + .try_send(Event::NickChange(new_nick.to_owned())) + .unwrap(); + + match utils::find_idx(&self.nicks, |nick| nick == new_nick) { + None => { + self.nicks.push(new_nick.to_owned()); + self.current_nick_idx = self.nicks.len() - 1; + } + Some(nick_idx) => { + self.current_nick_idx = nick_idx; + } + } + + self.current_nick = new_nick.to_owned(); + } + + // Rename the nick in channel states, also populate the chan list + for (chan, nicks) in &mut self.chans { + if nicks.remove(old_nick) { + nicks.insert(new_nick.to_owned()); + chans.push(chan.to_owned()); + } + } + } + } + + // + // RPL_ENDOFMOTD, join channels, set away status (TODO) + // + Reply { num: 376, .. } => { + let chans: Vec<&str> = self.chans.iter().map(|(s, _)| s.as_str()).collect(); + if !chans.is_empty() { + snd_irc_msg.try_send(wire::join(&chans)).unwrap(); + } + } + + // + // RPL_NAMREPLY: users in a channel + // + Reply { num: 353, params } => { + let chan = ¶ms[2]; + match utils::find_idx(&self.chans, |(s, _)| s == chan) { + None => self.chans.push(( + chan.to_owned(), + params[3] + .split_whitespace() + .map(|s| wire::drop_nick_prefix(s).to_owned()) + .collect(), + )), + Some(idx) => { + let nick_set = &mut self.chans[idx].1; + for nick in params[3].split_whitespace() { + nick_set.insert(wire::drop_nick_prefix(nick).to_owned()); + } + } + } + } + + // + // QUIT: Update the `chans` field for the channels that the user was in + // + QUIT { ref mut chans, .. } => { + let nick = match pfx { + Some(Pfx::User { nick, .. }) => nick, + _ => { + // TODO: WAT? + return; + } + }; + for (chan, nicks) in self.chans.iter_mut() { + if nicks.contains(nick) { + chans.push(chan.to_owned()); + nicks.remove(nick); + } + } + } + + // + // SASL authentication + // + CAP { + client: _, + subcommand, + params, + } => { + match subcommand.as_ref() { + "ACK" => { + if params.iter().any(|cap| cap.as_str() == "sasl") { + snd_irc_msg.try_send(wire::authenticate("PLAIN")).unwrap(); + } + } + "NAK" => { + snd_irc_msg.try_send(wire::cap_end()).unwrap(); + } + "LS" => { + self.introduce(snd_irc_msg); + if params.iter().any(|cap| cap == "sasl") { + snd_irc_msg.try_send(wire::cap_req(&["sasl"])).unwrap(); + // Will wait for CAP ... ACK from server before authentication. + } + } + _ => {} + } + } + + AUTHENTICATE { ref param } => { + if param.as_str() == "+" { + // Empty AUTHENTICATE response; server accepted the specified SASL mechanism + // (PLAIN) + if let Some(ref auth) = self.server_info.sasl_auth { + let msg = format!( + "{}\x00{}\x00{}", + auth.username, auth.username, auth.password + ); + snd_irc_msg + .try_send(wire::authenticate(&base64::encode(&msg))) + .unwrap(); + } + } + } + + Reply { num: 903 | 904, .. } => { + // 903: RPL_SASLSUCCESS, 904: ERR_SASLFAIL + snd_irc_msg.try_send(wire::cap_end()).unwrap(); + } + + // + // Ignore the rest + // + _ => {} + } + } + + fn get_chan_nicks(&self, chan: &str) -> Vec { + match utils::find_idx(&self.chans, |(s, _)| s == chan) { + None => vec![], // TODO: Log this, this is probably a bug + Some(chan_idx) => self.chans[chan_idx].1.iter().cloned().collect(), + } + } +} + +/// Try to parse servername in a 002 RPL_YOURHOST reply +fn parse_servername(params: &[String]) -> Option { + use std::str::pattern::Pattern; + let msg = params.get(1).or_else(|| params.get(0))?; + if "Your host is ".is_prefix_of(msg) { + let slice1 = &msg[13..]; + let servername_ends = slice1.find('[').or_else(|| slice1.find(','))?; + Some((&slice1[..servername_ends]).to_owned()) + } else { + None + } +} + +//////////////////////////////////////////////////////////////////////////////// + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_servername_1() { + let args = vec![ + "tiny_test".to_owned(), + "Your host is adams.freenode.net[94.125.182.252/8001], \ + running version ircd-seven-1.1.4" + .to_owned(), + ]; + assert_eq!( + parse_servername(&args), + Some("adams.freenode.net".to_owned()) + ); + } + + #[test] + fn test_parse_servername_2() { + let args = vec![ + "tiny_test".to_owned(), + "Your host is belew.mozilla.org, running version InspIRCd-2.0".to_owned(), + ]; + assert_eq!( + parse_servername(&args), + Some("belew.mozilla.org".to_owned()) + ); + } +}