From db971e64349920d85920f88f925030e4787c1848 Mon Sep 17 00:00:00 2001 From: "Helmut K. C. Tessarek" Date: Mon, 12 Feb 2024 07:06:12 -0500 Subject: [PATCH] feat(server): allow to override filename when using random_url (#233) * feat(server): allow to override filename when using random_url * docs(README): remove line from features * refactor(header): make const private --- README.md | 9 +++ .../config.toml | 9 +++ .../test.sh | 19 ++++++ src/header.rs | 12 ++++ src/paste.rs | 62 ++++++++++++++++--- src/server.rs | 58 ++++++++++++++++- 6 files changed, 160 insertions(+), 9 deletions(-) create mode 100644 fixtures/test-file-upload-override-filename/config.toml create mode 100755 fixtures/test-file-upload-override-filename/test.sh diff --git a/README.md b/README.md index 80731e3..bd7e779 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ Here you can read the blog post about how it is deployed on Shuttle: [https://bl - [Paste file from remote URL](#paste-file-from-remote-url) - [Cleaning up expired files](#cleaning-up-expired-files) - [Delete file from server](#delete-file-from-server) + - [Override the filename when using `random_url`](#override-the-filename-when-using-random_url) - [Server](#server) - [List endpoint](#list-endpoint) - [HTML Form](#html-form) @@ -254,6 +255,14 @@ $ curl -H "Authorization: " -X DELETE "/file.txt" > The `DELETE` endpoint will not be exposed and will return `404` error if `delete_tokens` are not set. +#### Override the filename when using `random_url` + +The generation of a random filename can be overridden by sending a header called `filename`: + +```sh +curl -F "file=@x.txt" -H "filename: " "" +``` + ### Server To start the server: diff --git a/fixtures/test-file-upload-override-filename/config.toml b/fixtures/test-file-upload-override-filename/config.toml new file mode 100644 index 0000000..279c7c9 --- /dev/null +++ b/fixtures/test-file-upload-override-filename/config.toml @@ -0,0 +1,9 @@ +[server] +address = "127.0.0.1:8000" +max_content_length = "10MB" +upload_path = "./upload" + +[paste] +random_url = { type = "alphanumeric", length = "4", suffix_mode = true } +default_extension = "txt" +duplicate_files = true diff --git a/fixtures/test-file-upload-override-filename/test.sh b/fixtures/test-file-upload-override-filename/test.sh new file mode 100755 index 0000000..cfd5d2a --- /dev/null +++ b/fixtures/test-file-upload-override-filename/test.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +content="test data" + +setup() { + echo "$content" > file +} + +run_test() { + file_url=$(curl -s -F "file=@file" -H "filename:fn_from_header.txt" localhost:8000) + test "$file_url" = "http://localhost:8000/fn_from_header.txt" + test "$content" = "$(cat upload/fn_from_header.txt)" + test "$content" = "$(curl -s $file_url)" +} + +teardown() { + rm file + rm -r upload +} diff --git a/src/header.rs b/src/header.rs index 3d78e55..e9726af 100644 --- a/src/header.rs +++ b/src/header.rs @@ -7,6 +7,9 @@ use std::time::Duration; /// Custom HTTP header for expiry dates. pub const EXPIRE: &str = "expire"; +/// Custom HTTP header to override filename. +const FILENAME: &str = "filename"; + /// Parses the expiry date from the [`custom HTTP header`](EXPIRE). pub fn parse_expiry_date(headers: &HeaderMap, time: Duration) -> Result, ActixError> { if let Some(expire_time) = headers.get(EXPIRE).and_then(|v| v.to_str().ok()) { @@ -18,6 +21,15 @@ pub fn parse_expiry_date(headers: &HeaderMap, time: Duration) -> Result Result, ActixError> { + if let Some(file_name) = headers.get(FILENAME).and_then(|v| v.to_str().ok()) { + Ok(Some(file_name.to_string())) + } else { + Ok(None) + } +} + /// Wrapper for Actix content disposition header. /// /// Aims to parse the file data from multipart body. diff --git a/src/paste.rs b/src/paste.rs index ba525ce..03b94f5 100644 --- a/src/paste.rs +++ b/src/paste.rs @@ -88,6 +88,7 @@ impl Paste { /// - 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 @@ -95,6 +96,7 @@ impl Paste { &self, file_name: &str, expiry_date: Option, + header_filename: Option, config: &Config, ) -> IoResult { let file_type = infer::get(&self.data); @@ -166,6 +168,10 @@ impl Paste { } 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()) @@ -235,7 +241,7 @@ impl Paste { .to_string()); } } - Ok(self.store_file(file_name, expiry_date, &config)?) + Ok(self.store_file(file_name, expiry_date, None, &config)?) } /// Writes an URL to a file in upload directory. @@ -295,7 +301,7 @@ mod tests { data: vec![65, 66, 67], type_: PasteType::File, }; - let file_name = paste.store_file("test.txt", None, &config)?; + let file_name = paste.store_file("test.txt", None, None, &config)?; assert_eq!("ABC", fs::read_to_string(&file_name)?); assert_eq!( Some("txt"), @@ -315,7 +321,7 @@ mod tests { data: vec![116, 101, 115, 115, 117, 115], type_: PasteType::File, }; - let file_name = paste.store_file("foo.tar.gz", None, &config)?; + 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.")); @@ -331,7 +337,7 @@ mod tests { data: vec![116, 101, 115, 115, 117, 115], type_: PasteType::File, }; - let file_name = paste.store_file(".foo.tar.gz", None, &config)?; + 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.")); @@ -347,7 +353,7 @@ mod tests { data: vec![116, 101, 115, 115, 117, 115], type_: PasteType::File, }; - let file_name = paste.store_file("foo.tar.gz", None, &config)?; + 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)?; @@ -358,7 +364,7 @@ mod tests { data: vec![120, 121, 122], type_: PasteType::File, }; - let file_name = paste.store_file(".foo", None, &config)?; + 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)?; @@ -373,7 +379,7 @@ mod tests { data: vec![120, 121, 122], type_: PasteType::File, }; - let file_name = paste.store_file("random", None, &config)?; + let file_name = paste.store_file("random", None, None, &config)?; assert_eq!("xyz", fs::read_to_string(&file_name)?); assert_eq!( Some("bin"), @@ -383,6 +389,46 @@ mod tests { ); 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))?; } @@ -393,7 +439,7 @@ mod tests { type_: PasteType::Oneshot, }; let expiry_date = util::get_system_time()?.as_millis() + 100; - let file_name = paste.store_file("test.file", Some(expiry_date), &config)?; + let file_name = paste.store_file("test.file", Some(expiry_date), None, &config)?; let file_path = PasteType::Oneshot .get_path(&config.server.upload_path) .join(format!("{file_name}.{expiry_date}")); diff --git a/src/server.rs b/src/server.rs index 605daa8..1e585a1 100644 --- a/src/server.rs +++ b/src/server.rs @@ -225,6 +225,7 @@ async fn upload( } let mut urls: Vec = Vec::new(); while let Some(item) = payload.next().await { + let header_filename = header::parse_header_filename(request.headers())?; let mut field = item?; let content = ContentDisposition::from(field.content_disposition().clone()); if let Ok(paste_type) = PasteType::try_from(&content) { @@ -274,7 +275,12 @@ async fn upload( let config = config .read() .map_err(|_| error::ErrorInternalServerError("cannot acquire config"))?; - paste.store_file(content.get_file_name()?, expiry_date, &config)? + paste.store_file( + content.get_file_name()?, + expiry_date, + header_filename, + &config, + )? } PasteType::RemoteFile => { paste @@ -853,6 +859,56 @@ mod tests { Ok(()) } + #[actix_web::test] + async fn test_upload_file_override_filename() -> Result<(), Error> { + let mut config = Config::default(); + config.server.upload_path = env::current_dir()?; + + let app = test::init_service( + App::new() + .app_data(Data::new(RwLock::new(config))) + .app_data(Data::new(Client::default())) + .configure(configure_routes), + ) + .await; + + let file_name = "test_file.txt"; + let header_filename = "fn_from_header.txt"; + let timestamp = util::get_system_time()?.as_secs().to_string(); + let response = test::call_service( + &app, + get_multipart_request(×tamp, "file", file_name) + .insert_header(( + header::HeaderName::from_static("filename"), + header::HeaderValue::from_static("fn_from_header.txt"), + )) + .to_request(), + ) + .await; + assert_eq!(StatusCode::OK, response.status()); + assert_body( + response.into_body(), + &format!("http://localhost:8080/{header_filename}\n"), + ) + .await?; + + let serve_request = TestRequest::get() + .uri(&format!("/{header_filename}")) + .to_request(); + let response = test::call_service(&app, serve_request).await; + assert_eq!(StatusCode::OK, response.status()); + assert_body(response.into_body(), ×tamp).await?; + + fs::remove_file(header_filename)?; + let serve_request = TestRequest::get() + .uri(&format!("/{header_filename}")) + .to_request(); + let response = test::call_service(&app, serve_request).await; + assert_eq!(StatusCode::NOT_FOUND, response.status()); + + Ok(()) + } + #[actix_web::test] #[allow(deprecated)] async fn test_upload_duplicate_file() -> Result<(), Error> {