diff options
author | Alec Di Vito <me@alecdivito.com> | 2025-02-17 04:35:26 +0000 |
---|---|---|
committer | Alec Di Vito <me@alecdivito.com> | 2025-02-17 04:35:26 +0000 |
commit | 413a63a60307bdf60229670b0f858963604d62a3 (patch) | |
tree | 45c4e203bf2d39d7cc13b96b30813be6ce44fb74 | |
parent | Merge branch 'svenstaro:master' into upload-progress-bar (diff) | |
download | miniserve-413a63a60307bdf60229670b0f858963604d62a3.tar.gz miniserve-413a63a60307bdf60229670b0f858963604d62a3.zip |
feat: implement temporary file uploads and tweak mobile design
-rw-r--r-- | Cargo.lock | 17 | ||||
-rw-r--r-- | Cargo.toml | 3 | ||||
-rw-r--r-- | data/style.scss | 22 | ||||
-rw-r--r-- | src/args.rs | 10 | ||||
-rw-r--r-- | src/errors.rs | 5 | ||||
-rw-r--r-- | src/file_op.rs | 140 | ||||
-rw-r--r-- | src/renderer.rs | 83 | ||||
-rw-r--r-- | tests/upload_files.rs | 19 |
8 files changed, 247 insertions, 52 deletions
@@ -2097,6 +2097,7 @@ dependencies = [ "socket2", "strum", "tar", + "tempfile", "thiserror 2.0.11", "tokio", "url", @@ -3241,9 +3242,9 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.16.0" +version = "3.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c246215d7d24f48ae091a2902398798e05d978b24315d6efbc00ede9a8bb91" +checksum = "a40f762a77d2afa88c2d919489e390a12bdd261ed568e60cfa7e48d4e20f0d33" dependencies = [ "cfg-if", "fastrand", @@ -3401,10 +3402,22 @@ dependencies = [ "pin-project-lite", "signal-hook-registry", "socket2", + "tokio-macros", "windows-sys 0.52.0", ] [[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + +[[package]] name = "tokio-native-tls" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -56,8 +56,9 @@ simplelog = "0.12" socket2 = "0.5" strum = { version = "0.26", features = ["derive"] } tar = "0.4" +tempfile = "3.17.0" thiserror = "2" -tokio = { version = "1.42.0", features = ["fs"] } +tokio = { version = "1.42.0", features = ["fs", "macros"] } zip = { version = "2", default-features = false } [features] diff --git a/data/style.scss b/data/style.scss index dc1e585..2e02c44 100644 --- a/data/style.scss +++ b/data/style.scss @@ -52,6 +52,10 @@ $upload_container_height: 18rem; &.active { right: 1rem; } + + #upload-toggle { + transition: transform 0.3s ease; + } } .upload_container { @@ -61,11 +65,19 @@ $upload_container_height: 18rem; } .upload_header { + display: flex; + justify-content: space-between; + align-items: center; padding: 1rem; background-color: var(--upload_modal_header_background); color: var(--upload_modal_header_color); } +.upload_header svg { + width: 24px; + height: 24px; +} + .upload_action { background-color: var(--upload_modal_sub_header_background); color: var(--upload_modal_header_color); @@ -89,7 +101,7 @@ $upload_container_height: 18rem; margin: 0px; flex: 1; overflow-y: auto; - max-height:inherit; + max-height: inherit; } .upload_file_list { @@ -224,7 +236,7 @@ a.file, color: var(--file_link_color); &:visited { - color: var(--file_link_color_visited) + color: var(--file_link_color_visited); } } @@ -667,14 +679,16 @@ th span.active span { right: 1.5rem; } - $upload_container_height_mobile: 60vh; + $upload_container_height_mobile: 100vh; .upload_area { - width: 100vw; + width: 100%; + height: 136px; max-height: $upload_container_height_mobile; max-width: unset; min-width: unset; bottom: 0; + transition: height 0.3s ease; &.active { right: 0; diff --git a/src/args.rs b/src/args.rs index f117b1c..17f6c84 100644 --- a/src/args.rs +++ b/src/args.rs @@ -165,13 +165,17 @@ pub struct CliArgs { /// When specified via environment variable, a path always needs to be specified. #[arg(short = 'u', long = "upload-files", value_hint = ValueHint::FilePath, num_args(0..=1), value_delimiter(','), env = "MINISERVE_ALLOWED_UPLOAD_DIR")] pub allowed_upload_dir: Option<Vec<PathBuf>>, - + /// Configure amount of concurrent uploads when visiting the website. Must have /// upload-files option enabled for this setting to matter. - /// + /// /// For example, a value of 4 would mean that the web browser will only upload /// 4 files at a time to the web server when using the web browser interface. - #[arg(long = "web-upload-files-concurrency", env = "MINISERVE_WEB_UPLOAD_CONCURRENCY", default_value = "0")] + #[arg( + long = "web-upload-files-concurrency", + env = "MINISERVE_WEB_UPLOAD_CONCURRENCY", + default_value = "0" + )] pub web_upload_concurrency: usize, /// Enable creating directories diff --git a/src/errors.rs b/src/errors.rs index f0e22ab..24997fc 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -43,6 +43,10 @@ pub enum RuntimeError { #[error("File already exists, and the overwrite_files option has not been set")] DuplicateFileError, + /// Uploaded hash not correct + #[error("File hash that was provided did not match end result of uploaded file")] + UploadHashMismatchError, + /// Upload not allowed #[error("Upload not allowed to this directory")] UploadForbiddenError, @@ -86,6 +90,7 @@ impl ResponseError for RuntimeError { use StatusCode as S; match self { E::IoError(_, _) => S::INTERNAL_SERVER_ERROR, + E::UploadHashMismatchError => S::BAD_REQUEST, E::MultipartError(_) => S::BAD_REQUEST, E::DuplicateFileError => S::CONFLICT, E::UploadForbiddenError => S::FORBIDDEN, 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<u64, RuntimeError> { 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<RuntimeError> = 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<u64, RuntimeError> { 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<FileHash> = 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::<Vec<u64>>() diff --git a/src/renderer.rs b/src/renderer.rs index 035309d..8a87228 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -193,6 +193,9 @@ pub fn page( div.upload_container { div.upload_header { h4 style="margin:0px" id="upload_title" {} + svg id="upload-toggle" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6" { + path stroke-linecap="round" stroke-linejoin="round" d="m4.5 15.75 7.5-7.5 7.5 7.5" {} + } } div.upload_action { p id="upload_action_text" { "Starting upload..." } @@ -663,6 +666,7 @@ fn page_header(title: &str, file_upload: bool, web_file_concurrency: usize, favi const uploadCancelButton = document.querySelector('#upload_cancel'); const uploadList = document.querySelector('#upload_file_list'); const fileUploadItemTemplate = document.querySelector('#upload_file_item'); + const uploadWidgetToggle = document.querySelector('#upload-toggle'); const dropContainer = document.querySelector('#drop-container'); const dragForm = document.querySelector('.drag-form'); @@ -696,6 +700,19 @@ fn page_header(title: &str, file_upload: bool, web_file_concurrency: usize, favi dragForm.style.display = 'none'; }; + uploadWidgetToggle.addEventListener('click', function (e) { + e.preventDefault(); + if (uploadArea.style.height === "100vh") { + uploadArea.style = "" + document.body.style = "" + uploadWidgetToggle.style = "" + } else { + uploadArea.style.height = "100vh" + document.body.style = "overflow: hidden" + uploadWidgetToggle.style = "transform: rotate(180deg)" + } + }) + uploadCancelButton.addEventListener('click', function (e) { e.preventDefault(); CANCEL_UPLOAD = true; @@ -706,7 +723,7 @@ fn page_header(title: &str, file_upload: bool, web_file_concurrency: usize, favi uploadFiles() }) - function updateUploadText() { + function updateUploadTextAndList() { const queryLength = (state) => document.querySelectorAll(`[data-state='${state}']`).length; const total = document.querySelectorAll("[data-state]").length; const uploads = queryLength(UPLOADING); @@ -733,16 +750,19 @@ fn page_header(title: &str, file_upload: bool, web_file_concurrency: usize, favi uploadTitle.textContent = headerText uploadActionText.textContent = statuses.join(', ') - // Update list of uploads - Array.from(uploadList.querySelectorAll('li')) - .sort(({ dataset: { state: a }}, {dataset: { state: b }}) => UPLOAD_ITEM_ORDER[a] >= UPLOAD_ITEM_ORDER[b]) - .forEach((item) => item.parentNode.appendChild(item)) + const items = Array.from(uploadList.querySelectorAll('li')); + items.sort((a, b) => UPLOAD_ITEM_ORDER[a.dataset.state] - UPLOAD_ITEM_ORDER[b.dataset.state]); + items.forEach((item, index) => { + if (uploadList.children[index] !== item) { + uploadList.insertBefore(item, uploadList.children[index]); + } + }); } async function doWork(iterator, i) { for (let [index, item] of iterator) { await item(); - updateUploadText(); + updateUploadTextAndList(); } } @@ -754,17 +774,17 @@ fn page_header(title: &str, file_upload: bool, web_file_concurrency: usize, favi const workers = Array(concurrency).fill(iterator).map(doWork) Promise.allSettled(workers) .finally(() => { - updateUploadText(); + updateUploadTextAndList(); form.reset(); setTimeout(() => { uploadArea.classList.remove('active'); }, 1000) setTimeout(() => { window.location.reload(); }, 1500) }) - updateUploadText(); + updateUploadTextAndList(); uploadArea.classList.add('active') uploadList.scrollTo(0, 0) } - + function formatBytes(bytes, decimals) { if (bytes == 0) return '0 Bytes'; var k = 1024, @@ -774,6 +794,19 @@ fn page_header(title: &str, file_upload: bool, web_file_concurrency: usize, favi return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; } + document.querySelector('input[type="file"]').addEventListener('change', async (e) => { + const file = e.target.files[0]; + const hash = await hashFile(file); + console.log('File hash:', hash); + }); + + async function get256FileHash(file) { + const arrayBuffer = await file.arrayBuffer(); + const hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); + } + function uploadFile(file) { const fileUploadItem = fileUploadItemTemplate.content.cloneNode(true) const itemContainer = fileUploadItem.querySelector(".upload_file_item") @@ -783,16 +816,38 @@ fn page_header(title: &str, file_upload: bool, web_file_concurrency: usize, favi const percentText = fileUploadItem.querySelector(".file_upload_percent") const bar = fileUploadItem.querySelector(".file_progress_bar") const cancel = fileUploadItem.querySelector(".file_cancel_upload") + let preCancel = false; - itemContainer.dataset.state = 'pending' + itemContainer.dataset.state = PENDING name.textContent = file.name size.textContent = formatBytes(file.size) percentText.textContent = "0%" - + uploadList.append(fileUploadItem) + function preCancelUpload() { + console.log('cancelled') + preCancel = true; + itemText.classList.add(CANCELLED); + bar.classList.add(CANCELLED); + itemContainer.dataset.state = CANCELLED; + itemContainer.style.background = 'var(--upload_modal_file_upload_complete_background)'; + cancel.disabled = true; + cancel.removeEventListener("click", preCancelUpload); + uploadCancelButton.removeEventListener("click", preCancelUpload); + updateUploadTextAndList(); + } + + uploadCancelButton.addEventListener("click", preCancelUpload) + cancel.addEventListener("click", preCancelUpload) + return async () => { - return new Promise((resolve, reject) => { + if (preCancel) { + return Promise.resolve() + } + + return new Promise(async (resolve, reject) => { + const fileHash = await get256FileHash(file); const xhr = new XMLHttpRequest(); const formData = new FormData(); formData.append('file', file); @@ -865,12 +920,14 @@ fn page_header(title: &str, file_upload: bool, web_file_concurrency: usize, favi if (CANCEL_UPLOAD) { cancelUpload() } else { - itemContainer.dataset.state = 'uploading' + itemContainer.dataset.state = UPLOADING xhr.addEventListener('readystatechange', onReadyStateChange); xhr.addEventListener("error", onError); xhr.addEventListener("abort", onAbort); xhr.upload.addEventListener('progress', onProgress); xhr.open('post', form.getAttribute("action"), true); + xhr.setRequestHeader('X-File-Hash', fileHash); + xhr.setRequestHeader('X-File-Hash-Function', 'SHA256'); xhr.send(formData); } }) diff --git a/tests/upload_files.rs b/tests/upload_files.rs index 5fdf2cf..a87ca09 100644 --- a/tests/upload_files.rs +++ b/tests/upload_files.rs @@ -3,6 +3,7 @@ use std::path::Path; use assert_fs::fixture::TempDir; use reqwest::blocking::{multipart, Client}; +use reqwest::header::HeaderMap; use rstest::rstest; use select::document::Document; use select::predicate::{Attr, Text}; @@ -12,7 +13,16 @@ mod fixtures; use crate::fixtures::{server, tmpdir, Error, TestServer}; #[rstest] -fn uploading_files_works(#[with(&["-u"])] server: TestServer) -> Result<(), Error> { +#[case("", "")] +#[case( + "SHA256", + "e37b14e22e7b3f50dadaf821c189af80f79b1f39fd5a8b3b4f536103735d4620" +)] +fn uploading_files_works( + #[with(&["-u"])] server: TestServer, + #[case] sha_func: String, + #[case] sha: String, +) -> Result<(), Error> { let test_file_name = "uploaded test file.txt"; // Before uploading, check whether the uploaded file does not yet exist. @@ -33,7 +43,12 @@ fn uploading_files_works(#[with(&["-u"])] server: TestServer) -> Result<(), Erro .mime_str("text/plain")?; let form = form.part("file_to_upload", part); - let client = Client::new(); + let mut headers = HeaderMap::new(); + headers.insert("X-File-Hash", sha.parse()?); + headers.insert("X-File-Hash-Function", sha_func.parse()?); + + let client = Client::builder().default_headers(headers).build()?; + client .post(server.url().join(upload_action)?) .multipart(form) |