From be81eaefa526fa80e04166e86978e3a95263b4e3 Mon Sep 17 00:00:00 2001 From: Alec Di Vito Date: Thu, 6 Jun 2024 18:42:20 -0400 Subject: feat: Added HTML and Javascript progress bar when uploading files --- src/file_op.rs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) (limited to 'src/file_op.rs') diff --git a/src/file_op.rs b/src/file_op.rs index e22e3e9..9f5902c 100644 --- a/src/file_op.rs +++ b/src/file_op.rs @@ -4,7 +4,7 @@ use std::io::ErrorKind; use std::path::{Component, Path, PathBuf}; use actix_web::{http::header, web, HttpRequest, HttpResponse}; -use futures::TryFutureExt; +use futures::{StreamExt, TryFutureExt}; use futures::TryStreamExt; use serde::Deserialize; use tokio::fs::File; @@ -20,7 +20,7 @@ use crate::{ /// /// Returns total bytes written to file. async fn save_file( - field: actix_multipart::Field, + field: &mut actix_multipart::Field, file_path: PathBuf, overwrite_files: bool, ) -> Result { @@ -33,8 +33,8 @@ async fn save_file( RuntimeError::InsufficientPermissionsError(file_path.display().to_string()), ), Err(err) => Err(RuntimeError::IoError( - format!("Failed to create {}", file_path.display()), - err, + format!("Failed to create {}", file_path.display()), + err, )), Ok(v) => Ok(v), }?; @@ -164,7 +164,15 @@ async fn handle_multipart( } } - save_file(field, path.join(filename_path), overwrite_files).await + match save_file(&mut field, path.join(filename_path), overwrite_files).await { + Ok(bytes) => Ok(bytes), + Err(err) => { + // Required for file upload. If entire stream is not consumed, javascript + // XML HTTP Request will never complete. + while field.next().await.is_some() {} + Err(err) + }, + } } /// Query parameters used by upload and rm APIs -- cgit v1.2.3 From 413a63a60307bdf60229670b0f858963604d62a3 Mon Sep 17 00:00:00 2001 From: Alec Di Vito Date: Sun, 16 Feb 2025 23:35:26 -0500 Subject: feat: implement temporary file uploads and tweak mobile design --- src/file_op.rs | 140 ++++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 113 insertions(+), 27 deletions(-) (limited to 'src/file_op.rs') diff --git a/src/file_op.rs b/src/file_op.rs index 76a7234..367517a 100644 --- a/src/file_op.rs +++ b/src/file_op.rs @@ -4,10 +4,10 @@ use std::io::ErrorKind; use std::path::{Component, Path, PathBuf}; use actix_web::{http::header, web, HttpRequest, HttpResponse}; -use futures::{StreamExt, TryFutureExt}; -use futures::TryStreamExt; +use futures::{StreamExt, TryStreamExt}; use serde::Deserialize; -use tokio::fs::File; +use sha2::{Digest, Sha256}; +use tempfile::NamedTempFile; use tokio::io::AsyncWriteExt; use crate::{ @@ -15,6 +15,18 @@ use crate::{ file_utils::sanitize_path, }; +enum FileHash { + SHA256(String), +} + +impl FileHash { + pub fn get_hasher(&self) -> impl Digest { + match self { + Self::SHA256(_) => Sha256::new(), + } + } +} + /// Saves file data from a multipart form field (`field`) to `file_path`, optionally overwriting /// existing file. /// @@ -23,31 +35,84 @@ async fn save_file( field: &mut actix_multipart::Field, file_path: PathBuf, overwrite_files: bool, + file_hash: Option<&FileHash>, ) -> Result { if !overwrite_files && file_path.exists() { return Err(RuntimeError::DuplicateFileError); } - let file = match File::create(&file_path).await { - Err(err) if err.kind() == ErrorKind::PermissionDenied => Err( + let named_temp_file = match tokio::task::spawn_blocking(|| NamedTempFile::new()).await { + Err(err) => Err(RuntimeError::MultipartError(format!( + "Failed to complete spawned task to create named temp file. {}", + err + ))), + Ok(Err(err)) if err.kind() == ErrorKind::PermissionDenied => Err( RuntimeError::InsufficientPermissionsError(file_path.display().to_string()), ), - Err(err) => Err(RuntimeError::IoError( - format!("Failed to create {}", file_path.display()), - err, + Ok(Err(err)) => Err(RuntimeError::IoError( + format!("Failed to create temporary file {}", file_path.display()), + err, )), - Ok(v) => Ok(v), + Ok(Ok(file)) => Ok(file), }?; - let (_, written_len) = field - .map_err(|x| RuntimeError::MultipartError(x.to_string())) - .try_fold((file, 0u64), |(mut file, written_len), bytes| async move { - file.write_all(bytes.as_ref()) - .map_err(|e| RuntimeError::IoError("Failed to write to file".to_string(), e)) - .await?; - Ok((file, written_len + bytes.len() as u64)) - }) - .await?; + let (file, temp_path) = named_temp_file.keep().map_err(|err| { + RuntimeError::IoError("Failed to keep temporary file".into(), err.error.into()) + })?; + let mut temp_file = tokio::fs::File::from_std(file); + + let mut written_len = 0; + let mut hasher = file_hash.as_ref().map(|h| h.get_hasher()); + let mut error: Option = None; + + while let Some(Ok(bytes)) = field.next().await { + if let Some(hasher) = hasher.as_mut() { + hasher.update(&bytes) + } + if let Err(e) = temp_file.write_all(&bytes).await { + error = Some(RuntimeError::IoError( + "Failed to write to file".to_string(), + e, + )); + break; + } + written_len += bytes.len() as u64; + } + + drop(temp_file); + + if let Some(e) = error { + let _ = tokio::fs::remove_file(temp_path).await; + return Err(e); + } + + // There isn't a way to get notified when a request is cancelled + // by the user in actix it seems. References: + // - https://github.com/actix/actix-web/issues/1313 + // - https://github.com/actix/actix-web/discussions/3011 + // Therefore, we are relying on the fact that the web UI + // uploads a hash of the file. + if let Some(hasher) = hasher { + if let Some(FileHash::SHA256(expected_hash)) = file_hash { + let actual_hash = hex::encode(hasher.finalize()); + if &actual_hash != expected_hash { + let _ = tokio::fs::remove_file(&temp_path).await; + return Err(RuntimeError::UploadHashMismatchError); + } + } + } + + if let Err(e) = tokio::fs::rename(&temp_path, &file_path).await { + let _ = tokio::fs::remove_file(&temp_path).await; + return Err(RuntimeError::IoError( + format!( + "Failed to move temporary file {} to {}", + temp_path.display(), + file_path.display() + ), + e, + )); + } Ok(written_len) } @@ -60,6 +125,7 @@ async fn handle_multipart( allow_mkdir: bool, allow_hidden_paths: bool, allow_symlinks: bool, + file_hash: Option<&FileHash>, ) -> Result { let field_name = field.name().expect("No name field found").to_string(); @@ -168,15 +234,13 @@ async fn handle_multipart( } } - match save_file(&mut field, path.join(filename_path), overwrite_files).await { - Ok(bytes) => Ok(bytes), - Err(err) => { - // Required for file upload. If entire stream is not consumed, javascript - // XML HTTP Request will never complete. - while field.next().await.is_some() {} - Err(err) - }, - } + save_file( + &mut field, + path.join(filename_path), + overwrite_files, + file_hash, + ) + .await } /// Query parameters used by upload and rm APIs @@ -226,6 +290,27 @@ pub async fn upload_file( )), }?; + let mut file_hash: Option = None; + if let Some(hash) = req + .headers() + .get("X-File-Hash") + .and_then(|h| h.to_str().ok()) + { + if let Some(hash_funciton) = req + .headers() + .get("X-File-Hash-Function") + .and_then(|h| h.to_str().ok()) + { + match hash_funciton.to_ascii_uppercase().as_str() { + "SHA256" => { + file_hash = Some(FileHash::SHA256(hash.to_string())); + } + _ => {} + } + } + } + + let hash_ref = file_hash.as_ref(); actix_multipart::Multipart::new(req.headers(), payload) .map_err(|x| RuntimeError::MultipartError(x.to_string())) .and_then(|field| { @@ -236,6 +321,7 @@ pub async fn upload_file( conf.mkdir_enabled, conf.show_hidden, !conf.no_symlinks, + hash_ref, ) }) .try_collect::>() -- cgit v1.2.3 From 577044ddbd70f5f128512c1a021329fb4c7e7eb3 Mon Sep 17 00:00:00 2001 From: Alec Di Vito Date: Sat, 22 Feb 2025 13:44:16 -0500 Subject: feat: address comments; add in new argument (`temp-directory`); add comments to upload code; add tests --- src/file_op.rs | 146 ++++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 103 insertions(+), 43 deletions(-) (limited to 'src/file_op.rs') diff --git a/src/file_op.rs b/src/file_op.rs index 367517a..4e05e6c 100644 --- a/src/file_op.rs +++ b/src/file_op.rs @@ -5,8 +5,10 @@ use std::path::{Component, Path, PathBuf}; use actix_web::{http::header, web, HttpRequest, HttpResponse}; use futures::{StreamExt, TryStreamExt}; +use log::{info, warn}; use serde::Deserialize; -use sha2::{Digest, Sha256}; +use sha2::digest::DynDigest; +use sha2::{Digest, Sha256, Sha512}; use tempfile::NamedTempFile; use tokio::io::AsyncWriteExt; @@ -17,71 +19,125 @@ use crate::{ enum FileHash { SHA256(String), + SHA512(String), } impl FileHash { - pub fn get_hasher(&self) -> impl Digest { + pub fn get_hasher(&self) -> Box { match self { - Self::SHA256(_) => Sha256::new(), + Self::SHA256(_) => Box::new(Sha256::new()), + Self::SHA512(_) => Box::new(Sha512::new()), + } + } + + pub fn get_hash(&self) -> &str { + match self { + Self::SHA256(string) => &string, + Self::SHA512(string) => &string, } } } -/// Saves file data from a multipart form field (`field`) to `file_path`, optionally overwriting -/// existing file. +/// Saves file data from a multipart form field (`field`) to `file_path`. Optionally overwriting +/// existing file and comparing the uploaded file checksum to the user provided `file_hash`. /// /// Returns total bytes written to file. async fn save_file( field: &mut actix_multipart::Field, file_path: PathBuf, overwrite_files: bool, - file_hash: Option<&FileHash>, + file_checksum: Option<&FileHash>, + temporary_upload_directory: Option<&PathBuf>, ) -> Result { if !overwrite_files && file_path.exists() { return Err(RuntimeError::DuplicateFileError); } - let named_temp_file = match tokio::task::spawn_blocking(|| NamedTempFile::new()).await { + let temp_upload_directory = temporary_upload_directory.cloned(); + // Tempfile doesn't support async operations, so we'll do it on a background thread. + let temp_upload_directory_task = tokio::task::spawn_blocking(move || { + // If the user provided a temporary directory path, then use it. + if let Some(temp_directory) = temp_upload_directory { + NamedTempFile::new_in(temp_directory) + } else { + NamedTempFile::new() + } + }); + + // Validate that the temporary task completed successfully. + let named_temp_file_task = match temp_upload_directory_task.await { + Ok(named_temp_file) => Ok(named_temp_file), Err(err) => Err(RuntimeError::MultipartError(format!( - "Failed to complete spawned task to create named temp file. {}", - err + "Failed to complete spawned task to create named temp file. {err}", ))), - Ok(Err(err)) if err.kind() == ErrorKind::PermissionDenied => Err( + }?; + + // Validate the the temporary file was created successfully. + let named_temp_file = match named_temp_file_task { + Err(err) if err.kind() == ErrorKind::PermissionDenied => Err( RuntimeError::InsufficientPermissionsError(file_path.display().to_string()), ), - Ok(Err(err)) => Err(RuntimeError::IoError( + Err(err) => Err(RuntimeError::IoError( format!("Failed to create temporary file {}", file_path.display()), err, )), - Ok(Ok(file)) => Ok(file), + Ok(file) => Ok(file), }?; + // Convert the temporary file into a non-temporary file. This allows us + // to control the lifecycle of the file. This is useful for us because + // we need to convert the temporary file into an async enabled file and + // on successful upload, we want to move it to the target directory. let (file, temp_path) = named_temp_file.keep().map_err(|err| { RuntimeError::IoError("Failed to keep temporary file".into(), err.error.into()) })?; let mut temp_file = tokio::fs::File::from_std(file); let mut written_len = 0; - let mut hasher = file_hash.as_ref().map(|h| h.get_hasher()); - let mut error: Option = None; + let mut hasher = file_checksum.as_ref().map(|h| h.get_hasher()); + let mut save_upload_file_error: Option = None; + // This while loop take a stream (in this case `field`) and awaits + // new chunks from the websocket connection. The while loop reads + // the file from the HTTP connection and writes it to disk or until + // the stream from the multipart request is aborted. while let Some(Ok(bytes)) = field.next().await { + // If the hasher exists (if the user has also sent a chunksum with the request) + // then we want to update the hasher with the new bytes uploaded. if let Some(hasher) = hasher.as_mut() { hasher.update(&bytes) } + // Write the bytes from the stream into our temporary file. if let Err(e) = temp_file.write_all(&bytes).await { - error = Some(RuntimeError::IoError( - "Failed to write to file".to_string(), - e, - )); + // Failed to write to file. Drop it and return the error + save_upload_file_error = + Some(RuntimeError::IoError("Failed to write to file".into(), e)); break; } + // record the bytes written to the file. written_len += bytes.len() as u64; } + if save_upload_file_error.is_none() { + // Flush the changes to disk so that we are sure they are there. + if let Err(e) = temp_file.flush().await { + save_upload_file_error = Some(RuntimeError::IoError( + "Failed to flush all the file writes to disk".into(), + e, + )); + } + } + + // Drop the file expcitly here because IF there is an error when writing to the + // temp file, we won't be able to remove as per the comment in `tokio::fs::remove_file` + // > Note that there is no guarantee that the file is immediately deleted + // > (e.g. depending on platform, other open file descriptors may prevent immediate removal). drop(temp_file); - if let Some(e) = error { + // If there was an error during uploading. + if let Some(e) = save_upload_file_error { + // If there was an error when writing the file to disk, remove it and return + // the error that was encountered. let _ = tokio::fs::remove_file(temp_path).await; return Err(e); } @@ -90,26 +146,24 @@ async fn save_file( // by the user in actix it seems. References: // - https://github.com/actix/actix-web/issues/1313 // - https://github.com/actix/actix-web/discussions/3011 - // Therefore, we are relying on the fact that the web UI - // uploads a hash of the file. + // Therefore, we are relying on the fact that the web UI uploads a + // hash of the file to determine if it was completed uploaded or not. if let Some(hasher) = hasher { - if let Some(FileHash::SHA256(expected_hash)) = file_hash { + if let Some(expected_hash) = file_checksum.as_ref().map(|f| f.get_hash()) { let actual_hash = hex::encode(hasher.finalize()); if &actual_hash != expected_hash { + warn!("The expected file hash {expected_hash} did not match the calculated hash of {actual_hash}. This can be caused if a file upload was aborted."); let _ = tokio::fs::remove_file(&temp_path).await; return Err(RuntimeError::UploadHashMismatchError); } } } + info!("File upload successful to {temp_path:?}. Moving to {file_path:?}",); if let Err(e) = tokio::fs::rename(&temp_path, &file_path).await { let _ = tokio::fs::remove_file(&temp_path).await; return Err(RuntimeError::IoError( - format!( - "Failed to move temporary file {} to {}", - temp_path.display(), - file_path.display() - ), + format!("Failed to move temporary file {temp_path:?} to {file_path:?}",), e, )); } @@ -126,6 +180,7 @@ async fn handle_multipart( allow_hidden_paths: bool, allow_symlinks: bool, file_hash: Option<&FileHash>, + upload_directory: Option<&PathBuf>, ) -> Result { let field_name = field.name().expect("No name field found").to_string(); @@ -239,6 +294,7 @@ async fn handle_multipart( path.join(filename_path), overwrite_files, file_hash, + upload_directory, ) .await } @@ -290,25 +346,28 @@ pub async fn upload_file( )), }?; - let mut file_hash: Option = None; - if let Some(hash) = req - .headers() - .get("X-File-Hash") - .and_then(|h| h.to_str().ok()) - { - if let Some(hash_funciton) = req - .headers() + let upload_directory = conf.temp_upload_directory.as_ref(); + + let file_hash = if let (Some(hash), Some(hash_function)) = ( + req.headers() + .get("X-File-Hash") + .and_then(|h| h.to_str().ok()), + req.headers() .get("X-File-Hash-Function") - .and_then(|h| h.to_str().ok()) - { - match hash_funciton.to_ascii_uppercase().as_str() { - "SHA256" => { - file_hash = Some(FileHash::SHA256(hash.to_string())); - } - _ => {} + .and_then(|h| h.to_str().ok()), + ) { + match hash_function.to_ascii_uppercase().as_str() { + "SHA256" => Some(FileHash::SHA256(hash.to_string())), + "SHA512" => Some(FileHash::SHA512(hash.to_string())), + sha => { + return Err(RuntimeError::InvalidHttpRequestError(format!( + "Invalid header value found for 'X-File-Hash-Function'. Supported values are SHA256 or SHA512. Found {sha}.", + ))) } } - } + } else { + None + }; let hash_ref = file_hash.as_ref(); actix_multipart::Multipart::new(req.headers(), payload) @@ -322,6 +381,7 @@ pub async fn upload_file( conf.show_hidden, !conf.no_symlinks, hash_ref, + upload_directory, ) }) .try_collect::>() -- cgit v1.2.3 From 33c79837f1e113a1ce83a461413acf474e973c63 Mon Sep 17 00:00:00 2001 From: Alec Di Vito Date: Sun, 2 Mar 2025 14:46:10 -0500 Subject: feat: validate temp dir exists through `value_parser` and fixed clippy issues --- src/file_op.rs | 50 ++++++++++++++++++++++++++++++++------------------ 1 file changed, 32 insertions(+), 18 deletions(-) (limited to 'src/file_op.rs') diff --git a/src/file_op.rs b/src/file_op.rs index 4e05e6c..afd6449 100644 --- a/src/file_op.rs +++ b/src/file_op.rs @@ -32,8 +32,8 @@ impl FileHash { pub fn get_hash(&self) -> &str { match self { - Self::SHA256(string) => &string, - Self::SHA512(string) => &string, + Self::SHA256(string) => string, + Self::SHA512(string) => string, } } } @@ -88,9 +88,9 @@ async fn save_file( // to control the lifecycle of the file. This is useful for us because // we need to convert the temporary file into an async enabled file and // on successful upload, we want to move it to the target directory. - let (file, temp_path) = named_temp_file.keep().map_err(|err| { - RuntimeError::IoError("Failed to keep temporary file".into(), err.error.into()) - })?; + let (file, temp_path) = named_temp_file + .keep() + .map_err(|err| RuntimeError::IoError("Failed to keep temporary file".into(), err.error))?; let mut temp_file = tokio::fs::File::from_std(file); let mut written_len = 0; @@ -151,7 +151,7 @@ async fn save_file( if let Some(hasher) = hasher { if let Some(expected_hash) = file_checksum.as_ref().map(|f| f.get_hash()) { let actual_hash = hex::encode(hasher.finalize()); - if &actual_hash != expected_hash { + if actual_hash != expected_hash { warn!("The expected file hash {expected_hash} did not match the calculated hash of {actual_hash}. This can be caused if a file upload was aborted."); let _ = tokio::fs::remove_file(&temp_path).await; return Err(RuntimeError::UploadHashMismatchError); @@ -171,17 +171,29 @@ async fn save_file( Ok(written_len) } -/// Handles a single field in a multipart form -async fn handle_multipart( - mut field: actix_multipart::Field, - path: PathBuf, +struct HandleMultipartOpts<'a> { overwrite_files: bool, allow_mkdir: bool, allow_hidden_paths: bool, allow_symlinks: bool, - file_hash: Option<&FileHash>, - upload_directory: Option<&PathBuf>, + file_hash: Option<&'a FileHash>, + upload_directory: Option<&'a PathBuf>, +} + +/// Handles a single field in a multipart form +async fn handle_multipart( + mut field: actix_multipart::Field, + path: PathBuf, + opts: HandleMultipartOpts<'_>, ) -> Result { + let HandleMultipartOpts { + overwrite_files, + allow_mkdir, + allow_hidden_paths, + allow_symlinks, + file_hash, + upload_directory, + } = opts; let field_name = field.name().expect("No name field found").to_string(); match tokio::fs::metadata(&path).await { @@ -376,12 +388,14 @@ pub async fn upload_file( handle_multipart( field, non_canonicalized_target_dir.clone(), - conf.overwrite_files, - conf.mkdir_enabled, - conf.show_hidden, - !conf.no_symlinks, - hash_ref, - upload_directory, + HandleMultipartOpts { + overwrite_files: conf.overwrite_files, + allow_mkdir: conf.mkdir_enabled, + allow_hidden_paths: conf.show_hidden, + allow_symlinks: !conf.no_symlinks, + file_hash: hash_ref, + upload_directory, + }, ) }) .try_collect::>() -- cgit v1.2.3