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 = [
"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"

View file

@ -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"

View file

@ -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<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.
///
/// 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(())
}
}

View file

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

View file

@ -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<String> {
pub fn store_file(
&self,
file_name: &str,
expiry_date: Option<u128>,
config: &Config,
) -> IoResult<String> {
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<String> {
pub fn store_url(&self, expiry_date: Option<u128>, config: &Config) -> IoResult<String> {
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))?;

View file

@ -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<String>,
config: web::Data<Config>,
) -> 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;
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<String> = 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);

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(())
}
}