diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 8961ede..0975a34 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -17,6 +17,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -53,6 +64,15 @@ version = "1.0.98" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "argon2" version = "0.5.3" @@ -248,12 +268,23 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +[[package]] +name = "bzip2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bea8dcd42434048e4f7a304411d9273a411f647446c1234a65ce0554923f4cff" +dependencies = [ + "libbz2-rs-sys", +] + [[package]] name = "cc" version = "1.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c1599538de2394445747c8cf7935946e3cc27e9625f889d979bfb2aaf569362" dependencies = [ + "jobserver", + "libc", "shlex", ] @@ -278,6 +309,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -293,6 +334,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + [[package]] name = "cookie" version = "0.18.1" @@ -344,6 +391,15 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -410,6 +466,12 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" +[[package]] +name = "deflate64" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b" + [[package]] name = "der" version = "0.7.10" @@ -430,6 +492,17 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "derive_more" version = "2.0.1" @@ -548,6 +621,17 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "flate2" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" +dependencies = [ + "crc32fast", + "libz-rs-sys", + "miniz_oxide", +] + [[package]] name = "flume" version = "0.11.1" @@ -1036,6 +1120,15 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "io-uring" version = "0.7.8" @@ -1069,6 +1162,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.77" @@ -1088,12 +1191,38 @@ dependencies = [ "spin", ] +[[package]] +name = "libbz2-rs-sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" + [[package]] name = "libc" version = "0.2.174" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" +[[package]] +name = "liblzma" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10bf66f4598dc77ff96677c8e763655494f00ff9c1cf79e2eb5bb07bc31f807d" +dependencies = [ + "liblzma-sys", +] + +[[package]] +name = "liblzma-sys" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01b9596486f6d60c3bbe644c0e1be1aa6ccc472ad630fe8927b456973d7cb736" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "libm" version = "0.2.15" @@ -1110,6 +1239,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libz-rs-sys" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "172a788537a2221661b480fee8dc5f96c580eb34fa88764d3205dc356c7e4221" +dependencies = [ + "zlib-rs", +] + [[package]] name = "linux-raw-sys" version = "0.9.4" @@ -1427,6 +1565,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -1496,6 +1644,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppmd-rust" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c834641d8ad1b348c9ee86dec3b9840d805acd5f24daa5f90c788951a52ff59b" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1878,6 +2032,12 @@ dependencies = [ "rand_core", ] +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + [[package]] name = "slab" version = "0.4.10" @@ -2653,6 +2813,7 @@ dependencies = [ "tracing-subscriber", "url", "uuid", + "zip", ] [[package]] @@ -3081,6 +3242,20 @@ name = "zeroize" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zerotrie" @@ -3114,3 +3289,76 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zip" +version = "4.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8835eb39822904d39cb19465de1159e05d371973f0c6df3a365ad50565ddc8b9" +dependencies = [ + "aes", + "arbitrary", + "bzip2", + "constant_time_eq", + "crc32fast", + "deflate64", + "flate2", + "getrandom 0.3.3", + "hmac", + "indexmap", + "liblzma", + "memchr", + "pbkdf2", + "ppmd-rust", + "sha1", + "time", + "zeroize", + "zopfli", + "zstd", +] + +[[package]] +name = "zlib-rs" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626bd9fa9734751fc50d6060752170984d7053f5a39061f524cda68023d4db8a" + +[[package]] +name = "zopfli" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edfc5ee405f504cd4984ecc6f14d02d55cfda60fa4b689434ef4102aae150cd7" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.15+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb81183ddd97d0c74cedf1d50d85c8d08c1b8b68ee863bdee9e706eedba1a237" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 8522e6d..a0399f6 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -45,3 +45,4 @@ tracing = "0.1.41" tracing-subscriber = "0.3.19" url = "2.5.4" uuid = { version = "1.17.0", features = ["serde"] } +zip = "4.5.0" diff --git a/backend/src/lib/domain/warren/models/file/mod.rs b/backend/src/lib/domain/warren/models/file/mod.rs index 1321971..5624f2e 100644 --- a/backend/src/lib/domain/warren/models/file/mod.rs +++ b/backend/src/lib/domain/warren/models/file/mod.rs @@ -1,6 +1,7 @@ mod requests; +use bytes::Bytes; +use futures_util::Stream; pub use requests::*; -use tokio_util::io::ReaderStream; use std::path::Path; @@ -263,20 +264,25 @@ impl From for FilePath { } } -#[derive(Debug)] -pub struct FileStream(ReaderStream); +pub type FileStreamInner = + Box> + Send + Sync + Unpin + 'static>; + +pub struct FileStream(FileStreamInner); impl FileStream { - pub fn new(stream: ReaderStream) -> Self { - Self(stream) + pub fn new(stream: S) -> Self + where + S: Stream> + Send + Sync + Unpin + 'static, + { + Self(Box::new(stream)) } - pub fn stream(&self) -> &ReaderStream { + pub fn stream(&self) -> &FileStreamInner { &self.0 } } -impl From for ReaderStream { +impl From for FileStreamInner { fn from(value: FileStream) -> Self { value.0 } diff --git a/backend/src/lib/domain/warren/models/share/requests/cat.rs b/backend/src/lib/domain/warren/models/share/requests/cat.rs index 5e6a1ed..073c94a 100644 --- a/backend/src/lib/domain/warren/models/share/requests/cat.rs +++ b/backend/src/lib/domain/warren/models/share/requests/cat.rs @@ -50,7 +50,6 @@ impl From<&ShareCatRequest> for VerifySharePasswordRequest { } } -#[derive(Debug)] pub struct ShareCatResponse { share: Share, warren: Warren, diff --git a/backend/src/lib/inbound/http/handlers/extractors.rs b/backend/src/lib/inbound/http/handlers/extractors.rs index 9004c1d..b7c9c6f 100644 --- a/backend/src/lib/inbound/http/handlers/extractors.rs +++ b/backend/src/lib/inbound/http/handlers/extractors.rs @@ -74,9 +74,9 @@ where return Ok(Self(None)); }; - SharePassword::new(cookie.value()) + Ok(SharePassword::new(cookie.value()) .map(|v| Self(Some(v))) - .map_err(|_| ApiError::BadRequest("Invalid password".to_string())) + .unwrap_or(Self(None))) } // Debug build else { diff --git a/backend/src/lib/inbound/http/handlers/warrens/mod.rs b/backend/src/lib/inbound/http/handlers/warrens/mod.rs index 0bbb92d..d9504c4 100644 --- a/backend/src/lib/inbound/http/handlers/warrens/mod.rs +++ b/backend/src/lib/inbound/http/handlers/warrens/mod.rs @@ -16,6 +16,7 @@ mod warren_rm; use axum::{ Router, + body::Body, extract::DefaultBodyLimit, routing::{get, post}, }; @@ -41,7 +42,7 @@ use warren_mv::warren_mv; use crate::{ domain::warren::{ - models::file::{File, FileMimeType, FileType}, + models::file::{File, FileMimeType, FileStream, FileStreamInner, FileType}, ports::{AuthService, WarrenService}, }, inbound::http::AppState, @@ -67,6 +68,13 @@ impl From<&File> for WarrenFileElement { } } +impl From for Body { + fn from(value: FileStream) -> Self { + let inner: FileStreamInner = value.into(); + Body::from_stream(inner) + } +} + pub fn routes() -> Router> { Router::new() .route("/", get(list_warrens)) diff --git a/backend/src/lib/inbound/http/handlers/warrens/warren_cat.rs b/backend/src/lib/inbound/http/handlers/warrens/warren_cat.rs index 46c20c9..6301285 100644 --- a/backend/src/lib/inbound/http/handlers/warrens/warren_cat.rs +++ b/backend/src/lib/inbound/http/handlers/warrens/warren_cat.rs @@ -4,14 +4,13 @@ use axum::{ }; use serde::Deserialize; use thiserror::Error; -use tokio_util::io::ReaderStream; use uuid::Uuid; use crate::{ domain::warren::{ models::{ auth_session::AuthRequest, - file::{AbsoluteFilePathError, CatRequest, FilePath, FilePathError, FileStream}, + file::{AbsoluteFilePathError, CatRequest, FilePath, FilePathError}, warren::WarrenCatRequest, }, ports::{AuthService, WarrenService}, @@ -59,12 +58,6 @@ impl WarrenCatHttpRequestBody { } } -impl From for Body { - fn from(value: FileStream) -> Self { - Body::from_stream::>(value.into()) - } -} - pub async fn fetch_file( State(state): State>, SessionIdHeader(session): SessionIdHeader, diff --git a/backend/src/lib/outbound/file_system.rs b/backend/src/lib/outbound/file_system.rs index fc99ffa..c8fdc08 100644 --- a/backend/src/lib/outbound/file_system.rs +++ b/backend/src/lib/outbound/file_system.rs @@ -1,11 +1,15 @@ -use std::{os::unix::fs::MetadataExt, path::PathBuf}; +use std::{ + io::{Cursor, Write}, + os::unix::fs::MetadataExt, + path::{Path, PathBuf}, +}; use anyhow::{Context, anyhow, bail}; use futures_util::TryStreamExt; use rustix::fs::{Statx, statx}; use tokio::{ - fs, - io::{self, AsyncWriteExt as _}, + fs::{self}, + io::{self, AsyncReadExt as _, AsyncWriteExt as _}, }; use tokio_util::io::ReaderStream; @@ -214,7 +218,48 @@ impl FileSystem { .open(&path) .await?; - let file_size = file.metadata().await?.size(); + let metadata = file.metadata().await?; + + if metadata.is_dir() { + drop(file); + + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Zstd) + .unix_permissions(0o755); + + let mut file_buf = Vec::new(); + let zip_buf = Vec::new(); + let cursor = Cursor::new(zip_buf); + let mut zip = zip::ZipWriter::new(cursor); + + for entry_path_buf in walk_dir(&path).await? { + let entry_path = entry_path_buf.as_path(); + let entry_str = entry_path + .strip_prefix(&path)? + .to_str() + .context("Failed to get directory entry name")?; + + if entry_path.is_dir() { + zip.add_directory(entry_str, options)?; + continue; + } + + zip.start_file(entry_str, options)?; + let mut entry_file = tokio::fs::File::open(entry_path).await?; + entry_file.read_to_end(&mut file_buf).await?; + zip.write_all(&file_buf)?; + file_buf.clear(); + } + + let mut cursor = zip.finish()?; + + cursor.set_position(0); + let stream = FileStream::new(ReaderStream::new(cursor)); + + return Ok(stream); + } + + let file_size = metadata.size(); if file_size > self.max_file_fetch_bytes { bail!("File size exceeds configured limit"); @@ -410,3 +455,27 @@ where ) } } + +async fn walk_dir

(dir: P) -> Result, tokio::io::Error> +where + P: AsRef, +{ + let mut dirs = vec![dir.as_ref().to_owned()]; + let mut files = vec![]; + + while !dirs.is_empty() { + let mut dir_iter = tokio::fs::read_dir(dirs.remove(0)).await?; + + while let Some(entry) = dir_iter.next_entry().await? { + let entry_path_buf = entry.path(); + + if entry_path_buf.is_dir() { + dirs.push(entry_path_buf); + } else { + files.push(entry_path_buf); + } + } + } + + Ok(files) +} diff --git a/frontend/components/DirectoryEntry.vue b/frontend/components/DirectoryEntry.vue index 961023f..50e1c12 100644 --- a/frontend/components/DirectoryEntry.vue +++ b/frontend/components/DirectoryEntry.vue @@ -143,10 +143,7 @@ function onDownload() { Copy - + Download diff --git a/frontend/pages/share.vue b/frontend/pages/share.vue index 88fa07c..1a327f3 100644 --- a/frontend/pages/share.vue +++ b/frontend/pages/share.vue @@ -125,10 +125,15 @@ function onDowloadClicked() { return; } - downloadFile( - share.file.name, - getApiUrl(`warrens/files/cat_share?shareId=${share.data.id}&path=/`) + const downloadName = + share.file.fileType === 'directory' + ? `${share.file.name}.zip` + : share.file.name; + const downloadApiUrl = getApiUrl( + `warrens/files/cat_share?shareId=${share.data.id}&path=/` ); + + downloadFile(downloadName, downloadApiUrl); } function onEntryDownload(entry: DirectoryEntry) { @@ -136,12 +141,13 @@ function onEntryDownload(entry: DirectoryEntry) { return; } - downloadFile( - entry.name, - getApiUrl( - `warrens/files/cat_share?shareId=${share.data.id}&path=${joinPaths(warrenStore.current.path, entry.name)}` - ) + const downloadName = + entry.fileType === 'directory' ? `${entry.name}.zip` : entry.name; + const downloadApiUrl = getApiUrl( + `warrens/files/cat_share?shareId=${share.data.id}&path=${joinPaths(warrenStore.current.path, entry.name)}` ); + + downloadFile(downloadName, downloadApiUrl); } diff --git a/frontend/pages/warrens/files.vue b/frontend/pages/warrens/files.vue index 6e2b084..cfafd74 100644 --- a/frontend/pages/warrens/files.vue +++ b/frontend/pages/warrens/files.vue @@ -111,19 +111,13 @@ function onEntryDownload(entry: DirectoryEntry) { return; } - if (entry.fileType !== 'file') { - toast.warning('Download', { - description: 'Directory downloads are not supported yet', - }); - return; - } - - downloadFile( - entry.name, - getApiUrl( - `warrens/files/cat?warrenId=${warrenStore.current.warrenId}&path=${joinPaths(warrenStore.current.path, entry.name)}` - ) + const downloadName = + entry.fileType === 'directory' ? `${entry.name}.zip` : entry.name; + const downloadApiUrl = getApiUrl( + `warrens/files/cat?warrenId=${warrenStore.current.warrenId}&path=${joinPaths(warrenStore.current.path, entry.name)}` ); + + downloadFile(downloadName, downloadApiUrl); } function onBack() {