use crate::config::Config; use crate::file::Directory; use crate::header::ContentDisposition; use crate::util; use actix_web::{error, Error}; use awc::Client; use std::convert::{TryFrom, TryInto}; use std::fs::{self, File}; use std::io::{Error as IoError, ErrorKind as IoErrorKind, Result as IoResult, Write}; use std::path::{Path, PathBuf}; use std::str; use std::sync::RwLock; use url::Url; /// Type of the data to store. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PasteType { /// Any type of file. File, /// A file that is on a remote URL. RemoteFile, /// A file that allowed to be accessed once. Oneshot, /// A file that only contains an URL. Url, /// A oneshot url. OneshotUrl, } impl<'a> TryFrom<&'a ContentDisposition> for PasteType { type Error = (); fn try_from(content_disposition: &'a ContentDisposition) -> Result { if content_disposition.has_form_field("file") { Ok(Self::File) } else if content_disposition.has_form_field("remote") { Ok(Self::RemoteFile) } else if content_disposition.has_form_field("oneshot") { Ok(Self::Oneshot) } else if content_disposition.has_form_field("oneshot_url") { Ok(Self::OneshotUrl) } else if content_disposition.has_form_field("url") { Ok(Self::Url) } else { Err(()) } } } impl PasteType { /// Returns the corresponding directory of the paste type. pub fn get_dir(&self) -> String { match self { Self::File | Self::RemoteFile => String::new(), Self::Oneshot => String::from("oneshot"), Self::Url => String::from("url"), Self::OneshotUrl => String::from("oneshot_url"), } } /// Returns the given path with [`directory`](Self::get_dir) adjoined. pub fn get_path(&self, path: &Path) -> IoResult { let dir = self.get_dir(); if dir.is_empty() { Ok(path.to_path_buf()) } else { util::safe_path_join(path, Path::new(&dir)) } } /// Returns `true` if the variant is [`Oneshot`](Self::Oneshot). pub fn is_oneshot(&self) -> bool { self == &Self::Oneshot } } /// Representation of a single paste. #[derive(Debug)] pub struct Paste { /// Data to store. pub data: Vec, /// Type of the data. pub type_: PasteType, } impl Paste { /// Writes the bytes to a file in upload directory. /// /// - If `file_name` does not have an extension, it is replaced with [`default_extension`]. /// - If `file_name` is "-", it is replaced with "stdin". /// - If [`random_url.enabled`] is `true`, `file_name` is replaced with a pet name or random string. /// - If `header_filename` is set, it will override the filename. /// /// [`default_extension`]: crate::config::PasteConfig::default_extension /// [`random_url.enabled`]: crate::random::RandomURLConfig::enabled pub fn store_file( &self, file_name: &str, expiry_date: Option, header_filename: Option, config: &Config, ) -> Result { let file_type = infer::get(&self.data); if let Some(file_type) = file_type { for mime_type in &config.paste.mime_blacklist { if mime_type == file_type.mime_type() { return Err(error::ErrorUnsupportedMediaType( "this file type is not permitted", )); } } } let mut file_name = match PathBuf::from(file_name) .file_name() .and_then(|v| v.to_str()) { Some("-") => String::from("stdin"), Some(".") => String::from("file"), Some(v) => v.to_string(), None => String::from("file"), }; if let Some(handle_spaces_config) = config.server.handle_spaces { file_name = handle_spaces_config.process_filename(&file_name); } let mut path = util::safe_path_join(self.type_.get_path(&config.server.upload_path)?, &file_name)?; let mut parts: Vec<&str> = file_name.split('.').collect(); let mut dotfile = false; let mut lower_bound = 1; let mut file_name = match parts[0] { "" => { // Index shifts one to the right in the array for the rest of the string (the extension) dotfile = true; lower_bound = 2; // If the first array element is empty, it means the file started with a dot (e.g.: .foo) format!(".{}", parts[1]) } _ => parts[0].to_string(), }; let mut extension = if parts.len() > lower_bound { // To get the rest (the extension), we have to remove the first element of the array, which is the filename parts.remove(0); if dotfile { // If the filename starts with a dot, we have to remove another element, because the first element was empty parts.remove(0); } parts.join(".") } else { file_type .map(|t| t.extension()) .unwrap_or(&config.paste.default_extension) .to_string() }; if let Some(random_url) = &config.paste.random_url { if let Some(random_text) = random_url.generate() { if let Some(suffix_mode) = random_url.suffix_mode { if suffix_mode { extension = format!("{}.{}", random_text, extension); } else { file_name = random_text; } } else { file_name = random_text; } } } path.set_file_name(file_name); path.set_extension(extension); if let Some(header_filename) = header_filename { file_name = header_filename; path.set_file_name(file_name); } let file_name = path .file_name() .map(|v| v.to_string_lossy()) .unwrap_or_default() .to_string(); let file_path = util::glob_match_file(path.clone()) .map_err(|_| IoError::new(IoErrorKind::Other, String::from("path is not valid")))?; if file_path.is_file() && file_path.exists() { return Err(error::ErrorConflict("file already exists\n")); } if let Some(timestamp) = expiry_date { path.set_file_name(format!("{file_name}.{timestamp}")); } let mut buffer = File::create(&path)?; buffer.write_all(&self.data)?; Ok(file_name) } /// Downloads a file from URL and stores it with [`store_file`]. /// /// - File name is inferred from URL if the last URL segment is a file. /// - Same content length configuration is applied for download limit. /// - Checks SHA256 digest of the downloaded file for preventing duplication. /// - Assumes `self.data` contains a valid URL, otherwise returns an error. /// /// [`store_file`]: Self::store_file pub async fn store_remote_file( &mut self, expiry_date: Option, client: &Client, config: &RwLock, ) -> Result { let data = str::from_utf8(&self.data).map_err(error::ErrorBadRequest)?; let url = Url::parse(data).map_err(error::ErrorBadRequest)?; let file_name = url .path_segments() .and_then(|segments| segments.last()) .and_then(|name| if name.is_empty() { None } else { Some(name) }) .unwrap_or("file"); let mut response = client .get(url.as_str()) .send() .await .map_err(error::ErrorInternalServerError)?; let payload_limit = config .read() .map_err(|_| error::ErrorInternalServerError("cannot acquire config"))? .server .max_content_length .try_into() .map_err(error::ErrorInternalServerError)?; let bytes = response .body() .limit(payload_limit) .await .map_err(error::ErrorInternalServerError)? .to_vec(); let config = config .read() .map_err(|_| error::ErrorInternalServerError("cannot acquire config"))?; let bytes_checksum = util::sha256_digest(&*bytes)?; self.data = bytes; if !config.paste.duplicate_files.unwrap_or(true) && expiry_date.is_none() { if let Some(file) = Directory::try_from(config.server.upload_path.as_path())?.get_file(bytes_checksum) { return Ok(file .path .file_name() .map(|v| v.to_string_lossy()) .unwrap_or_default() .to_string()); } } self.store_file(file_name, expiry_date, None, &config) } /// Writes an URL to a file in upload directory. /// /// - Checks if the data is a valid URL. /// - If [`random_url.enabled`] is `true`, file name is set to a pet name or random string. /// /// [`random_url.enabled`]: crate::random::RandomURLConfig::enabled #[allow(deprecated)] pub fn store_url(&self, expiry_date: Option, config: &Config) -> IoResult { let data = str::from_utf8(&self.data) .map_err(|e| IoError::new(IoErrorKind::Other, e.to_string()))?; let url = Url::parse(data).map_err(|e| IoError::new(IoErrorKind::Other, e.to_string()))?; let mut file_name = self.type_.get_dir(); if let Some(random_url) = &config.paste.random_url { if let Some(random_text) = random_url.generate() { file_name = random_text; } } let mut path = util::safe_path_join(self.type_.get_path(&config.server.upload_path)?, &file_name)?; if let Some(timestamp) = expiry_date { path.set_file_name(format!("{file_name}.{timestamp}")); } fs::write(&path, url.to_string())?; Ok(file_name) } } #[cfg(test)] mod tests { use super::*; use crate::random::{RandomURLConfig, RandomURLType}; use crate::util; use actix_web::web::Data; use awc::ClientBuilder; use byte_unit::Byte; use std::env; use std::str::FromStr; use std::time::Duration; #[actix_rt::test] #[allow(deprecated)] async fn test_paste_data() -> Result<(), Error> { let mut config = Config::default(); config.server.upload_path = env::current_dir()?; config.paste.random_url = Some(RandomURLConfig { enabled: Some(true), words: Some(3), separator: Some(String::from("_")), type_: RandomURLType::PetName, ..RandomURLConfig::default() }); let paste = Paste { data: vec![65, 66, 67], type_: PasteType::File, }; let file_name = paste.store_file("test.txt", None, None, &config)?; assert_eq!("ABC", fs::read_to_string(&file_name)?); assert_eq!( Some("txt"), PathBuf::from(&file_name) .extension() .and_then(|v| v.to_str()) ); fs::remove_file(file_name)?; config.paste.random_url = Some(RandomURLConfig { length: Some(4), type_: RandomURLType::Alphanumeric, suffix_mode: Some(true), ..RandomURLConfig::default() }); let paste = Paste { data: vec![116, 101, 115, 115, 117, 115], type_: PasteType::File, }; let file_name = paste.store_file("foo.tar.gz", None, None, &config)?; assert_eq!("tessus", fs::read_to_string(&file_name)?); assert!(file_name.ends_with(".tar.gz")); assert!(file_name.starts_with("foo.")); fs::remove_file(file_name)?; config.paste.random_url = Some(RandomURLConfig { length: Some(4), type_: RandomURLType::Alphanumeric, suffix_mode: Some(true), ..RandomURLConfig::default() }); let paste = Paste { data: vec![116, 101, 115, 115, 117, 115], type_: PasteType::File, }; let file_name = paste.store_file(".foo.tar.gz", None, None, &config)?; assert_eq!("tessus", fs::read_to_string(&file_name)?); assert!(file_name.ends_with(".tar.gz")); assert!(file_name.starts_with(".foo.")); fs::remove_file(file_name)?; config.paste.random_url = Some(RandomURLConfig { length: Some(4), type_: RandomURLType::Alphanumeric, suffix_mode: Some(false), ..RandomURLConfig::default() }); let paste = Paste { data: vec![116, 101, 115, 115, 117, 115], type_: PasteType::File, }; let file_name = paste.store_file("foo.tar.gz", None, None, &config)?; assert_eq!("tessus", fs::read_to_string(&file_name)?); assert!(file_name.ends_with(".tar.gz")); fs::remove_file(file_name)?; config.paste.default_extension = String::from("txt"); config.paste.random_url = None; let paste = Paste { data: vec![120, 121, 122], type_: PasteType::File, }; let file_name = paste.store_file(".foo", None, None, &config)?; assert_eq!("xyz", fs::read_to_string(&file_name)?); assert_eq!(".foo.txt", file_name); fs::remove_file(file_name)?; config.paste.default_extension = String::from("bin"); config.paste.random_url = Some(RandomURLConfig { length: Some(10), type_: RandomURLType::Alphanumeric, ..RandomURLConfig::default() }); let paste = Paste { data: vec![120, 121, 122], type_: PasteType::File, }; let file_name = paste.store_file("random", None, None, &config)?; assert_eq!("xyz", fs::read_to_string(&file_name)?); assert_eq!( Some("bin"), PathBuf::from(&file_name) .extension() .and_then(|v| v.to_str()) ); fs::remove_file(file_name)?; config.paste.random_url = Some(RandomURLConfig { length: Some(4), type_: RandomURLType::Alphanumeric, suffix_mode: Some(true), ..RandomURLConfig::default() }); let paste = Paste { data: vec![116, 101, 115, 115, 117, 115], type_: PasteType::File, }; let file_name = paste.store_file( "filename.txt", None, Some("fn_from_header.txt".to_string()), &config, )?; assert_eq!("tessus", fs::read_to_string(&file_name)?); assert_eq!("fn_from_header.txt", file_name); fs::remove_file(file_name)?; config.paste.random_url = Some(RandomURLConfig { length: Some(4), type_: RandomURLType::Alphanumeric, suffix_mode: Some(true), ..RandomURLConfig::default() }); let paste = Paste { data: vec![116, 101, 115, 115, 117, 115], type_: PasteType::File, }; let file_name = paste.store_file( "filename.txt", None, Some("fn_from_header".to_string()), &config, )?; assert_eq!("tessus", fs::read_to_string(&file_name)?); assert_eq!("fn_from_header", file_name); fs::remove_file(file_name)?; for paste_type in &[PasteType::Url, PasteType::Oneshot] { fs::create_dir_all( paste_type .get_path(&config.server.upload_path) .expect("Bad upload path"), )?; } config.paste.random_url = None; let paste = Paste { data: vec![116, 101, 115, 116], type_: PasteType::Oneshot, }; let expiry_date = util::get_system_time()?.as_millis() + 100; let file_name = paste.store_file("test.file", Some(expiry_date), None, &config)?; let file_path = PasteType::Oneshot .get_path(&config.server.upload_path) .expect("Bad upload path") .join(format!("{file_name}.{expiry_date}")); assert_eq!("test", fs::read_to_string(&file_path)?); fs::remove_file(file_path)?; config.paste.random_url = Some(RandomURLConfig { enabled: Some(true), ..RandomURLConfig::default() }); let url = String::from("https://orhun.dev/"); let paste = Paste { data: url.as_bytes().to_vec(), type_: PasteType::Url, }; let file_name = paste.store_url(None, &config)?; let file_path = PasteType::Url .get_path(&config.server.upload_path) .expect("Bad upload path") .join(&file_name); assert_eq!(url, fs::read_to_string(&file_path)?); fs::remove_file(file_path)?; let url = String::from("testurl.com"); let paste = Paste { data: url.as_bytes().to_vec(), type_: PasteType::Url, }; assert!(paste.store_url(None, &config).is_err()); config.server.max_content_length = Byte::from_str("30k").expect("cannot parse byte"); let url = String::from("https://upload.wikimedia.org/wikipedia/en/a/a9/Example.jpg"); let mut paste = Paste { data: url.as_bytes().to_vec(), type_: PasteType::RemoteFile, }; let client_data = Data::new( ClientBuilder::new() .timeout(Duration::from_secs(30)) .finish(), ); let file_name = paste .store_remote_file(None, &client_data, &RwLock::new(config.clone())) .await?; let file_path = PasteType::RemoteFile .get_path(&config.server.upload_path) .expect("Bad upload path") .join(file_name); assert_eq!( "70ff72a2f7651b5fae3aa9834e03d2a2233c52036610562f7fa04e089e8198ed", util::sha256_digest(&*paste.data)? ); fs::remove_file(file_path)?; for paste_type in &[PasteType::Url, PasteType::Oneshot] { fs::remove_dir( paste_type .get_path(&config.server.upload_path) .expect("Bad upload path"), )?; } Ok(()) } }