feat(paste): support setting an expiry date for uploads

This commit is contained in:
orhun 2021-08-27 15:54:23 +03:00
parent 33977de206
commit 92c35fe6df
No known key found for this signature in database
GPG key ID: F83424824B3E4B90
7 changed files with 160 additions and 21 deletions

14
Cargo.lock generated
View file

@ -821,9 +821,15 @@ checksum = "7fcd999463524c52659517fe2cea98493cfe485d10565e7b0fb07dbba7ad2753"
dependencies = [ dependencies = [
"cfg-if 1.0.0", "cfg-if 1.0.0",
"libc", "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]] [[package]]
name = "h2" name = "h2"
version = "0.2.7" version = "0.2.7"
@ -1518,6 +1524,8 @@ dependencies = [
"dotenv", "dotenv",
"env_logger", "env_logger",
"futures-util", "futures-util",
"glob",
"humantime",
"infer", "infer",
"log", "log",
"mime", "mime",
@ -2125,9 +2133,9 @@ checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519"
[[package]] [[package]]
name = "wasi" 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" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f"
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"

View file

@ -27,6 +27,8 @@ url = "2.2.2"
mime = "0.3.16" mime = "0.3.16"
regex = "1.5.4" regex = "1.5.4"
serde_regex = "1.1.0" serde_regex = "1.1.0"
humantime = "2.1.0"
glob = "0.3.0"
[dependencies.config] [dependencies.config]
version = "0.11.0" version = "0.11.0"

View file

@ -1,9 +1,26 @@
use crate::util;
use actix_web::http::header::{ use actix_web::http::header::{
ContentDisposition as ActixContentDisposition, DispositionParam, DispositionType, ContentDisposition as ActixContentDisposition, DispositionParam, DispositionType,
}; };
use actix_web::http::HeaderMap;
use actix_web::{error, Error as ActixError}; use actix_web::{error, Error as ActixError};
use std::convert::TryFrom; 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<Option<u128>, 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. /// Wrapper for Actix content disposition header.
/// ///
/// Aims to parse the file data from multipart body. /// Aims to parse the file data from multipart body.
@ -50,6 +67,9 @@ impl ContentDisposition {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use actix_web::http::{HeaderName, HeaderValue};
use std::thread;
use std::time::Duration;
#[test] #[test]
fn test_content_disposition() -> Result<(), ActixError> { fn test_content_disposition() -> Result<(), ActixError> {
@ -76,4 +96,18 @@ mod tests {
assert!(content_disposition.get_file_name().is_err()); assert!(content_disposition.get_file_name().is_err());
Ok(()) 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(())
}
} }

View file

@ -21,3 +21,6 @@ pub mod paste;
/// Media type handler. /// Media type handler.
pub mod mime; pub mod mime;
/// Helper functions.
pub mod util;

View file

@ -80,7 +80,12 @@ impl Paste {
/// ///
/// [`default_extension`]: crate::config::PasteConfig::default_extension /// [`default_extension`]: crate::config::PasteConfig::default_extension
/// [`random_url.enabled`]: crate::random::RandomURLConfig::enabled /// [`random_url.enabled`]: crate::random::RandomURLConfig::enabled
pub fn store_file(&self, file_name: &str, config: &Config) -> IoResult<String> { pub fn store_file(
&self,
file_name: &str,
expiry_date: Option<u128>,
config: &Config,
) -> IoResult<String> {
let file_type = infer::get(&self.data); let file_type = infer::get(&self.data);
if let Some(file_type) = file_type { if let Some(file_type) = file_type {
for mime_type in &config.paste.mime_blacklist { for mime_type in &config.paste.mime_blacklist {
@ -123,13 +128,17 @@ impl Paste {
); );
} }
} }
let mut buffer = File::create(&path)?; let file_name = path
buffer.write_all(&self.data)?;
Ok(path
.file_name() .file_name()
.map(|v| v.to_string_lossy()) .map(|v| v.to_string_lossy())
.unwrap_or_default() .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. /// 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. /// - If [`random_url.enabled`] is `true`, file name is set to a pet name or random string.
/// ///
/// [`random_url.enabled`]: crate::random::RandomURLConfig::enabled /// [`random_url.enabled`]: crate::random::RandomURLConfig::enabled
pub fn store_url(&self, config: &Config) -> IoResult<String> { pub fn store_url(&self, expiry_date: Option<u128>, config: &Config) -> IoResult<String> {
let data = str::from_utf8(&self.data) let data = str::from_utf8(&self.data)
.map_err(|e| IoError::new(IoErrorKind::Other, e.to_string()))?; .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 url = Url::parse(data).map_err(|e| IoError::new(IoErrorKind::Other, e.to_string()))?;
@ -147,9 +156,12 @@ impl Paste {
.random_url .random_url
.generate() .generate()
.unwrap_or_else(|| PasteType::Url.get_dir()); .unwrap_or_else(|| PasteType::Url.get_dir());
let path = PasteType::Url let mut path = PasteType::Url
.get_path(&config.server.upload_path) .get_path(&config.server.upload_path)
.join(&file_name); .join(&file_name);
if let Some(timestamp) = expiry_date {
path.set_file_name(format!("{}.{}", file_name, timestamp));
}
fs::write(&path, url.to_string())?; fs::write(&path, url.to_string())?;
Ok(file_name) Ok(file_name)
} }
@ -159,6 +171,7 @@ impl Paste {
mod tests { mod tests {
use super::*; use super::*;
use crate::random::{RandomURLConfig, RandomURLType}; use crate::random::{RandomURLConfig, RandomURLType};
use crate::util;
use std::env; use std::env;
#[test] #[test]
@ -176,7 +189,7 @@ mod tests {
data: vec![65, 66, 67], data: vec![65, 66, 67],
type_: PasteType::File, 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!("ABC", fs::read_to_string(&file_name)?);
assert_eq!( assert_eq!(
Some("txt"), Some("txt"),
@ -199,7 +212,7 @@ mod tests {
data: vec![120, 121, 122], data: vec![120, 121, 122],
type_: PasteType::File, 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!("xyz", fs::read_to_string(&file_name)?);
assert_eq!( assert_eq!(
Some("bin"), Some("bin"),
@ -219,11 +232,11 @@ mod tests {
data: vec![116, 101, 115, 116], data: vec![116, 101, 115, 116],
type_: PasteType::Oneshot, 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 let file_path = PasteType::Oneshot
.get_path(&config.server.upload_path) .get_path(&config.server.upload_path)
.join(&file_name); .join(format!("{}.{}", file_name, expiry_date));
assert_eq!("test.file", &file_name);
assert_eq!("test", fs::read_to_string(&file_path)?); assert_eq!("test", fs::read_to_string(&file_path)?);
fs::remove_file(file_path)?; fs::remove_file(file_path)?;
@ -233,7 +246,7 @@ mod tests {
data: url.as_bytes().to_vec(), data: url.as_bytes().to_vec(),
type_: PasteType::Url, type_: PasteType::Url,
}; };
let file_name = paste.store_url(&config)?; let file_name = paste.store_url(None, &config)?;
let file_path = PasteType::Url let file_path = PasteType::Url
.get_path(&config.server.upload_path) .get_path(&config.server.upload_path)
.join(&file_name); .join(&file_name);
@ -245,7 +258,7 @@ mod tests {
data: url.as_bytes().to_vec(), data: url.as_bytes().to_vec(),
type_: PasteType::Url, 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] { for paste_type in &[PasteType::Url, PasteType::Oneshot] {
fs::remove_dir(paste_type.get_path(&config.server.upload_path))?; fs::remove_dir(paste_type.get_path(&config.server.upload_path))?;

View file

@ -1,8 +1,9 @@
use crate::auth; use crate::auth;
use crate::config::Config; use crate::config::Config;
use crate::header::ContentDisposition; use crate::header::{self, ContentDisposition};
use crate::mime; use crate::mime;
use crate::paste::{Paste, PasteType}; use crate::paste::{Paste, PasteType};
use crate::util;
use actix_files::NamedFile; use actix_files::NamedFile;
use actix_multipart::Multipart; use actix_multipart::Multipart;
use actix_web::{error, get, post, web, Error, HttpRequest, HttpResponse, Responder}; use actix_web::{error, get, post, web, Error, HttpRequest, HttpResponse, Responder};
@ -27,11 +28,13 @@ async fn serve(
file: web::Path<String>, file: web::Path<String>,
config: web::Data<Config>, config: web::Data<Config>,
) -> Result<HttpResponse, Error> { ) -> Result<HttpResponse, Error> {
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; let mut paste_type = PasteType::File;
if !path.exists() || path.is_dir() { if !path.exists() || path.is_dir() {
for type_ in &[PasteType::Url, PasteType::Oneshot] { for type_ in &[PasteType::Url, PasteType::Oneshot] {
let alt_path = type_.get_path(&config.server.upload_path).join(&*file); 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() if alt_path.exists()
|| path.file_name().map(|v| v.to_str()).flatten() == Some(&type_.get_dir()) || 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 connection = request.connection_info();
let host = connection.remote_addr().unwrap_or("unknown host"); let host = connection.remote_addr().unwrap_or("unknown host");
auth::check(host, request.headers(), env::var("AUTH_TOKEN").ok())?; auth::check(host, request.headers(), env::var("AUTH_TOKEN").ok())?;
let expiry_date = header::parse_expiry_date(request.headers())?;
let mut urls: Vec<String> = Vec::new(); let mut urls: Vec<String> = Vec::new();
while let Some(item) = payload.next().await { while let Some(item) = payload.next().await {
let mut field = item?; let mut field = item?;
@ -103,9 +107,9 @@ async fn upload(
}; };
let file_name = match paste_type { let file_name = match paste_type {
PasteType::File | PasteType::Oneshot => { 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!(), PasteType::Trash => unreachable!(),
}; };
log::info!("{} ({}) is uploaded from {}", file_name, bytes_unit, host); log::info!("{} ({}) is uploaded from {}", file_name, bytes_unit, host);

75
src/util.rs Normal file
View file

@ -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<Duration, ActixError> {
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<PathBuf, ActixError> {
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(())
}
}