From 92c35fe6dfeb5f6c06f9170218d304dbe17f3fcb Mon Sep 17 00:00:00 2001 From: orhun Date: Fri, 27 Aug 2021 15:54:23 +0300 Subject: [PATCH] feat(paste): support setting an expiry date for uploads --- Cargo.lock | 14 +++++++--- Cargo.toml | 2 ++ src/header.rs | 34 +++++++++++++++++++++++ src/lib.rs | 3 +++ src/paste.rs | 41 ++++++++++++++++++---------- src/server.rs | 12 ++++++--- src/util.rs | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 160 insertions(+), 21 deletions(-) create mode 100644 src/util.rs diff --git a/Cargo.lock b/Cargo.lock index ed3426c..430c637 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -821,9 +821,15 @@ checksum = "7fcd999463524c52659517fe2cea98493cfe485d10565e7b0fb07dbba7ad2753" dependencies = [ "cfg-if 1.0.0", "libc", - "wasi 0.10.2+wasi-snapshot-preview1", + "wasi 0.10.0+wasi-snapshot-preview1", ] +[[package]] +name = "glob" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b919933a397b79c37e33b77bb2aa3dc8eb6e165ad809e58ff75bc7db2e34574" + [[package]] name = "h2" version = "0.2.7" @@ -1518,6 +1524,8 @@ dependencies = [ "dotenv", "env_logger", "futures-util", + "glob", + "humantime", "infer", "log", "mime", @@ -2125,9 +2133,9 @@ checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" [[package]] name = "wasi" -version = "0.10.2+wasi-snapshot-preview1" +version = "0.10.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" +checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" [[package]] name = "wasm-bindgen" diff --git a/Cargo.toml b/Cargo.toml index ee33ffc..e203010 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,8 @@ url = "2.2.2" mime = "0.3.16" regex = "1.5.4" serde_regex = "1.1.0" +humantime = "2.1.0" +glob = "0.3.0" [dependencies.config] version = "0.11.0" diff --git a/src/header.rs b/src/header.rs index d0db1e7..aed6e39 100644 --- a/src/header.rs +++ b/src/header.rs @@ -1,9 +1,26 @@ +use crate::util; use actix_web::http::header::{ ContentDisposition as ActixContentDisposition, DispositionParam, DispositionType, }; +use actix_web::http::HeaderMap; use actix_web::{error, Error as ActixError}; use std::convert::TryFrom; +/// Custom HTTP header for expiry dates. +pub const EXPIRE: &str = "expire"; + +/// Parses the expiry date from the [`custom HTTP header`](EXPIRE). +pub fn parse_expiry_date(headers: &HeaderMap) -> Result, ActixError> { + if let Some(expire_time) = headers.get(EXPIRE).map(|v| v.to_str().ok()).flatten() { + let timestamp = util::get_system_time()?; + let expire_time = + humantime::parse_duration(expire_time).map_err(error::ErrorInternalServerError)?; + Ok(timestamp.checked_add(expire_time).map(|t| t.as_millis())) + } else { + Ok(None) + } +} + /// Wrapper for Actix content disposition header. /// /// Aims to parse the file data from multipart body. @@ -50,6 +67,9 @@ impl ContentDisposition { #[cfg(test)] mod tests { use super::*; + use actix_web::http::{HeaderName, HeaderValue}; + use std::thread; + use std::time::Duration; #[test] fn test_content_disposition() -> Result<(), ActixError> { @@ -76,4 +96,18 @@ mod tests { assert!(content_disposition.get_file_name().is_err()); Ok(()) } + + #[test] + fn test_expiry_date() -> Result<(), ActixError> { + let mut headers = HeaderMap::new(); + headers.insert( + HeaderName::from_static(EXPIRE), + HeaderValue::from_static("5ms"), + ); + let expiry_time = parse_expiry_date(&headers)?.unwrap(); + assert!(expiry_time > util::get_system_time()?.as_millis()); + thread::sleep(Duration::from_millis(10)); + assert!(expiry_time < util::get_system_time()?.as_millis()); + Ok(()) + } } diff --git a/src/lib.rs b/src/lib.rs index 089161d..06a7ddc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,3 +21,6 @@ pub mod paste; /// Media type handler. pub mod mime; + +/// Helper functions. +pub mod util; diff --git a/src/paste.rs b/src/paste.rs index 7838686..245dea9 100644 --- a/src/paste.rs +++ b/src/paste.rs @@ -80,7 +80,12 @@ impl Paste { /// /// [`default_extension`]: crate::config::PasteConfig::default_extension /// [`random_url.enabled`]: crate::random::RandomURLConfig::enabled - pub fn store_file(&self, file_name: &str, config: &Config) -> IoResult { + pub fn store_file( + &self, + file_name: &str, + expiry_date: Option, + config: &Config, + ) -> IoResult { let file_type = infer::get(&self.data); if let Some(file_type) = file_type { for mime_type in &config.paste.mime_blacklist { @@ -123,13 +128,17 @@ impl Paste { ); } } - let mut buffer = File::create(&path)?; - buffer.write_all(&self.data)?; - Ok(path + let file_name = path .file_name() .map(|v| v.to_string_lossy()) .unwrap_or_default() - .to_string()) + .to_string(); + 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) } /// Writes an URL to a file in upload directory. @@ -138,7 +147,7 @@ impl Paste { /// - If [`random_url.enabled`] is `true`, file name is set to a pet name or random string. /// /// [`random_url.enabled`]: crate::random::RandomURLConfig::enabled - pub fn store_url(&self, config: &Config) -> IoResult { + 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()))?; @@ -147,9 +156,12 @@ impl Paste { .random_url .generate() .unwrap_or_else(|| PasteType::Url.get_dir()); - let path = PasteType::Url + let mut path = PasteType::Url .get_path(&config.server.upload_path) .join(&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) } @@ -159,6 +171,7 @@ impl Paste { mod tests { use super::*; use crate::random::{RandomURLConfig, RandomURLType}; + use crate::util; use std::env; #[test] @@ -176,7 +189,7 @@ mod tests { data: vec![65, 66, 67], type_: PasteType::File, }; - let file_name = paste.store_file("test.txt", &config)?; + let file_name = paste.store_file("test.txt", None, &config)?; assert_eq!("ABC", fs::read_to_string(&file_name)?); assert_eq!( Some("txt"), @@ -199,7 +212,7 @@ mod tests { data: vec![120, 121, 122], type_: PasteType::File, }; - let file_name = paste.store_file("random", &config)?; + let file_name = paste.store_file("random", None, &config)?; assert_eq!("xyz", fs::read_to_string(&file_name)?); assert_eq!( Some("bin"), @@ -219,11 +232,11 @@ mod tests { data: vec![116, 101, 115, 116], type_: PasteType::Oneshot, }; - let file_name = paste.store_file("test.file", &config)?; + let expiry_date = util::get_system_time().unwrap().as_millis() + 100; + let file_name = paste.store_file("test.file", Some(expiry_date), &config)?; let file_path = PasteType::Oneshot .get_path(&config.server.upload_path) - .join(&file_name); - assert_eq!("test.file", &file_name); + .join(format!("{}.{}", file_name, expiry_date)); assert_eq!("test", fs::read_to_string(&file_path)?); fs::remove_file(file_path)?; @@ -233,7 +246,7 @@ mod tests { data: url.as_bytes().to_vec(), type_: PasteType::Url, }; - let file_name = paste.store_url(&config)?; + let file_name = paste.store_url(None, &config)?; let file_path = PasteType::Url .get_path(&config.server.upload_path) .join(&file_name); @@ -245,7 +258,7 @@ mod tests { data: url.as_bytes().to_vec(), type_: PasteType::Url, }; - assert!(paste.store_url(&config).is_err()); + assert!(paste.store_url(None, &config).is_err()); for paste_type in &[PasteType::Url, PasteType::Oneshot] { fs::remove_dir(paste_type.get_path(&config.server.upload_path))?; diff --git a/src/server.rs b/src/server.rs index 32f068a..cd1fa86 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,8 +1,9 @@ use crate::auth; use crate::config::Config; -use crate::header::ContentDisposition; +use crate::header::{self, ContentDisposition}; use crate::mime; use crate::paste::{Paste, PasteType}; +use crate::util; use actix_files::NamedFile; use actix_multipart::Multipart; use actix_web::{error, get, post, web, Error, HttpRequest, HttpResponse, Responder}; @@ -27,11 +28,13 @@ async fn serve( file: web::Path, config: web::Data, ) -> Result { - let mut path = config.server.upload_path.join(&*file); + let path = config.server.upload_path.join(&*file); + let mut path = util::glob_match_file(path)?; let mut paste_type = PasteType::File; if !path.exists() || path.is_dir() { for type_ in &[PasteType::Url, PasteType::Oneshot] { let alt_path = type_.get_path(&config.server.upload_path).join(&*file); + let alt_path = util::glob_match_file(alt_path)?; if alt_path.exists() || path.file_name().map(|v| v.to_str()).flatten() == Some(&type_.get_dir()) { @@ -79,6 +82,7 @@ async fn upload( let connection = request.connection_info(); let host = connection.remote_addr().unwrap_or("unknown host"); auth::check(host, request.headers(), env::var("AUTH_TOKEN").ok())?; + let expiry_date = header::parse_expiry_date(request.headers())?; let mut urls: Vec = Vec::new(); while let Some(item) = payload.next().await { let mut field = item?; @@ -103,9 +107,9 @@ async fn upload( }; let file_name = match paste_type { PasteType::File | PasteType::Oneshot => { - paste.store_file(content.get_file_name()?, &config)? + paste.store_file(content.get_file_name()?, expiry_date, &config)? } - PasteType::Url => paste.store_url(&config)?, + PasteType::Url => paste.store_url(expiry_date, &config)?, PasteType::Trash => unreachable!(), }; log::info!("{} ({}) is uploaded from {}", file_name, bytes_unit, host); diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..d00da24 --- /dev/null +++ b/src/util.rs @@ -0,0 +1,75 @@ +use actix_web::{error, Error as ActixError}; +use glob::glob; +use std::path::PathBuf; +use std::time::Duration; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Returns the system time as [`Duration`](Duration). +pub fn get_system_time() -> Result { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(error::ErrorInternalServerError) +} + +/// Returns the first _unexpired_ path matched by a custom glob pattern. +/// +/// The file extension is accepted as a timestamp that points to the expiry date. +pub fn glob_match_file(mut path: PathBuf) -> Result { + if let Some(glob_path) = glob(&format!( + "{}.[0-9]*", + path.to_str() + .ok_or_else(|| error::ErrorInternalServerError( + "file name contains invalid characters" + ))?, + )) + .map_err(error::ErrorInternalServerError)? + .next() + { + let glob_path = glob_path.map_err(error::ErrorInternalServerError)?; + if let Some(extension) = glob_path + .extension() + .map(|v| v.to_str()) + .flatten() + .map(|v| v.parse().ok()) + .flatten() + { + if get_system_time()? < Duration::from_millis(extension) { + path = glob_path; + } + } + } + Ok(path) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::thread; + #[test] + fn test_system_time() -> Result<(), ActixError> { + let system_time = get_system_time()?.as_millis(); + thread::sleep(Duration::from_millis(1)); + assert!(system_time < get_system_time()?.as_millis()); + Ok(()) + } + + #[test] + fn test_glob_match() -> Result<(), ActixError> { + let path = PathBuf::from(format!( + "expired.file.{}", + get_system_time()?.as_millis() + 50 + )); + fs::write(&path, String::new())?; + assert_eq!(path, glob_match_file(PathBuf::from("expired.file"))?); + + thread::sleep(Duration::from_millis(75)); + assert_eq!( + PathBuf::from("expired.file"), + glob_match_file(PathBuf::from("expired.file"))? + ); + fs::remove_file(path)?; + + Ok(()) + } +}