diff options
author | Sven-Hendrik Haase <svenstaro@gmail.com> | 2019-03-23 12:49:46 +0000 |
---|---|---|
committer | GitHub <noreply@github.com> | 2019-03-23 12:49:46 +0000 |
commit | c47a7773228e4e1beddfdcc6719cbc10acfbfdb5 (patch) | |
tree | b5ebbb5a4c80f9736484f9cd9112ef30956e4178 /src | |
parent | Merge pull request #49 from svenstaro/dependabot/cargo/structopt-0.2.15 (diff) | |
parent | Updated README with known limitations (diff) | |
download | miniserve-c47a7773228e4e1beddfdcc6719cbc10acfbfdb5.tar.gz miniserve-c47a7773228e4e1beddfdcc6719cbc10acfbfdb5.zip |
Merge pull request #48 from boastful-squirrel/targz
Download folders in .tar.gz format
Diffstat (limited to 'src')
-rw-r--r-- | src/archive.rs | 145 | ||||
-rw-r--r-- | src/errors.rs | 96 | ||||
-rw-r--r-- | src/listing.rs | 76 | ||||
-rw-r--r-- | src/main.rs | 21 | ||||
-rw-r--r-- | src/renderer.rs | 42 |
5 files changed, 351 insertions, 29 deletions
diff --git a/src/archive.rs b/src/archive.rs new file mode 100644 index 0000000..206d252 --- /dev/null +++ b/src/archive.rs @@ -0,0 +1,145 @@ +use actix_web::http::ContentEncoding; +use bytes::Bytes; +use failure::ResultExt; +use libflate::gzip::Encoder; +use serde::Deserialize; +use std::io; +use std::path::PathBuf; +use tar::Builder; + +use crate::errors; + +/// Available compression methods +#[derive(Debug, Deserialize, Clone)] +pub enum CompressionMethod { + /// TAR GZ + #[serde(alias = "targz")] + TarGz, +} + +impl CompressionMethod { + pub fn to_string(&self) -> String { + match &self { + CompressionMethod::TarGz => "targz", + } + .to_string() + } + + pub fn extension(&self) -> String { + match &self { + CompressionMethod::TarGz => "tar.gz", + } + .to_string() + } + + pub fn content_type(&self) -> String { + match &self { + CompressionMethod::TarGz => "application/gzip", + } + .to_string() + } + + pub fn content_encoding(&self) -> ContentEncoding { + match &self { + CompressionMethod::TarGz => ContentEncoding::Gzip, + } + } +} + +/// Creates an archive of a folder, using the algorithm the user chose from the web interface +/// This method returns the archive as a stream of bytes +pub fn create_archive( + method: &CompressionMethod, + dir: &PathBuf, + skip_symlinks: bool, +) -> Result<(String, Bytes), errors::CompressionError> { + match method { + CompressionMethod::TarGz => tgz_compress(&dir, skip_symlinks), + } +} + +/// Compresses a given folder in .tar.gz format, and returns the result as a stream of bytes +fn tgz_compress( + dir: &PathBuf, + skip_symlinks: bool, +) -> Result<(String, Bytes), errors::CompressionError> { + let src_dir = dir.display().to_string(); + let inner_folder = match dir.file_name() { + Some(directory_name) => match directory_name.to_str() { + Some(directory) => directory, + None => { + return Err(errors::CompressionError::new( + errors::CompressionErrorKind::InvalidUTF8DirectoryName, + )) + } + }, + None => { + return Err(errors::CompressionError::new( + errors::CompressionErrorKind::InvalidDirectoryName, + )) + } + }; + let dst_filename = format!("{}.tar", inner_folder); + let dst_tgz_filename = format!("{}.gz", dst_filename); + + let tar_content = tar(src_dir, inner_folder.to_string(), skip_symlinks).context( + errors::CompressionErrorKind::TarBuildingError { + message: "an error occured while writing the TAR archive".to_string(), + }, + )?; + let gz_data = gzip(&tar_content).context(errors::CompressionErrorKind::GZipBuildingError { + message: "an error occured while writing the GZIP archive".to_string(), + })?; + + let mut data = Bytes::new(); + data.extend_from_slice(&gz_data); + + Ok((dst_tgz_filename, data)) +} + +/// Creates a TAR archive of a folder, and returns it as a stream of bytes +fn tar( + src_dir: String, + inner_folder: String, + skip_symlinks: bool, +) -> Result<Vec<u8>, errors::CompressionError> { + let mut tar_builder = Builder::new(Vec::new()); + + tar_builder.follow_symlinks(!skip_symlinks); + // Recursively adds the content of src_dir into the archive stream + tar_builder.append_dir_all(inner_folder, &src_dir).context( + errors::CompressionErrorKind::TarBuildingError { + message: format!( + "failed to append the content of {} to the TAR archive", + &src_dir + ), + }, + )?; + + let tar_content = + tar_builder + .into_inner() + .context(errors::CompressionErrorKind::TarBuildingError { + message: "failed to finish writing the TAR archive".to_string(), + })?; + + Ok(tar_content) +} + +/// Compresses a stream of bytes using the GZIP algorithm, and returns the resulting stream +fn gzip(mut data: &[u8]) -> Result<Vec<u8>, errors::CompressionError> { + let mut encoder = + Encoder::new(Vec::new()).context(errors::CompressionErrorKind::GZipBuildingError { + message: "failed to create GZIP encoder".to_string(), + })?; + io::copy(&mut data, &mut encoder).context(errors::CompressionErrorKind::GZipBuildingError { + message: "failed to write GZIP data".to_string(), + })?; + let data = encoder.finish().into_result().context( + errors::CompressionErrorKind::GZipBuildingError { + message: "failed to write GZIP trailer".to_string(), + }, + )?; + + Ok(data) +} diff --git a/src/errors.rs b/src/errors.rs new file mode 100644 index 0000000..2aa5f58 --- /dev/null +++ b/src/errors.rs @@ -0,0 +1,96 @@ +use failure::{Backtrace, Context, Fail}; +use std::fmt::{self, Debug, Display}; + +/// Kinds of errors which might happen during the generation of an archive +#[derive(Debug, Fail)] +pub enum CompressionErrorKind { + /// This error will occur if the directory name could not be retrieved from the path + /// See https://doc.rust-lang.org/std/path/struct.Path.html#method.file_name + #[fail(display = "Invalid path: directory name terminates in \"..\"")] + InvalidDirectoryName, + /// This error will occur when trying to convert an OSString into a String, if the path + /// contains invalid UTF-8 characters + /// See https://doc.rust-lang.org/std/ffi/struct.OsStr.html#method.to_str + #[fail(display = "Invalid path: directory name contains invalid UTF-8 characters")] + InvalidUTF8DirectoryName, + /// This error might occur while building a TAR archive, or while writing the termination sections + /// See https://docs.rs/tar/0.4.22/tar/struct.Builder.html#method.append_dir_all + /// and https://docs.rs/tar/0.4.22/tar/struct.Builder.html#method.into_inner + #[fail(display = "Failed to create the TAR archive: {}", message)] + TarBuildingError { message: String }, + /// This error might occur while building a GZIP archive, or while writing the GZIP trailer + /// See https://docs.rs/libflate/0.1.21/libflate/gzip/struct.Encoder.html#method.finish + #[fail(display = "Failed to create the GZIP archive: {}", message)] + GZipBuildingError { message: String }, +} + +/// Prints the full chain of error, up to the root cause. +/// If RUST_BACKTRACE is set to 1, also prints the backtrace for each error +pub fn print_error_chain(err: CompressionError) { + log::error!("{}", &err); + print_backtrace(&err); + for cause in Fail::iter_causes(&err) { + log::error!("caused by: {}", cause); + print_backtrace(cause); + } +} + +/// Prints the backtrace of an error +/// RUST_BACKTRACE needs to be set to 1 to display the backtrace +fn print_backtrace(err: &dyn Fail) { + if let Some(backtrace) = err.backtrace() { + let backtrace = backtrace.to_string(); + if backtrace != "" { + log::error!("{}", backtrace); + } + } +} + +/// Based on https://boats.gitlab.io/failure/error-errorkind.html +pub struct CompressionError { + inner: Context<CompressionErrorKind>, +} + +impl CompressionError { + pub fn new(kind: CompressionErrorKind) -> CompressionError { + CompressionError { + inner: Context::new(kind), + } + } +} + +impl Fail for CompressionError { + fn cause(&self) -> Option<&Fail> { + self.inner.cause() + } + + fn backtrace(&self) -> Option<&Backtrace> { + self.inner.backtrace() + } +} + +impl Display for CompressionError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + Display::fmt(&self.inner, f) + } +} + +impl Debug for CompressionError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + Debug::fmt(&self.inner, f) + } +} + +impl From<Context<CompressionErrorKind>> for CompressionError { + fn from(inner: Context<CompressionErrorKind>) -> CompressionError { + CompressionError { inner } + } +} + +impl From<CompressionErrorKind> for CompressionError { + fn from(kind: CompressionErrorKind) -> CompressionError { + CompressionError { + inner: Context::new(kind), + } + } +} diff --git a/src/listing.rs b/src/listing.rs index 57bef17..c4daf88 100644 --- a/src/listing.rs +++ b/src/listing.rs @@ -1,5 +1,6 @@ -use actix_web::{fs, FromRequest, HttpRequest, HttpResponse, Query, Result}; +use actix_web::{fs, http, Body, FromRequest, HttpRequest, HttpResponse, Query, Result}; use bytesize::ByteSize; +use futures::stream::once; use htmlescape::encode_minimal as escape_html_entity; use percent_encoding::{utf8_percent_encode, DEFAULT_ENCODE_SET}; use serde::Deserialize; @@ -7,6 +8,8 @@ use std::io; use std::path::Path; use std::time::SystemTime; +use crate::archive; +use crate::errors; use crate::renderer; /// Query parameters @@ -14,6 +17,7 @@ use crate::renderer; struct QueryParameters { sort: Option<SortingMethod>, order: Option<SortingOrder>, + download: Option<archive::CompressionMethod>, } /// Available sorting methods @@ -134,11 +138,16 @@ pub fn directory_listing<S>( let is_root = base.parent().is_none() || req.path() == random_route; let page_parent = base.parent().map(|p| p.display().to_string()); - let (sort_method, sort_order) = if let Ok(query) = Query::<QueryParameters>::extract(req) { - (query.sort.clone(), query.order.clone()) - } else { - (None, None) - }; + let (sort_method, sort_order, download) = + if let Ok(query) = Query::<QueryParameters>::extract(req) { + ( + query.sort.clone(), + query.order.clone(), + query.download.clone(), + ) + } else { + (None, None, None) + }; let mut entries: Vec<Entry> = Vec::new(); @@ -218,17 +227,46 @@ pub fn directory_listing<S>( } } - Ok(HttpResponse::Ok() - .content_type("text/html; charset=utf-8") - .body( - renderer::page( - &title, - entries, - is_root, - page_parent, - sort_method, - sort_order, - ) - .into_string(), - )) + if let Some(compression_method) = &download { + log::info!( + "Creating an archive ({extension}) of {path}...", + extension = compression_method.extension(), + path = &dir.path.display().to_string() + ); + match archive::create_archive(&compression_method, &dir.path, skip_symlinks) { + Ok((filename, content)) => { + log::info!("{file} successfully created !", file = &filename); + Ok(HttpResponse::Ok() + .content_type(compression_method.content_type()) + .content_encoding(compression_method.content_encoding()) + .header("Content-Transfer-Encoding", "binary") + .header( + "Content-Disposition", + format!("attachment; filename={:?}", filename), + ) + .chunked() + .body(Body::Streaming(Box::new(once(Ok(content)))))) + } + Err(err) => { + errors::print_error_chain(err); + Ok(HttpResponse::Ok() + .status(http::StatusCode::INTERNAL_SERVER_ERROR) + .body("")) + } + } + } else { + Ok(HttpResponse::Ok() + .content_type("text/html; charset=utf-8") + .body( + renderer::page( + &title, + entries, + is_root, + page_parent, + sort_method, + sort_order, + ) + .into_string(), + )) + } } diff --git a/src/main.rs b/src/main.rs index b15088c..f662a73 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,8 +9,10 @@ use std::thread; use std::time::Duration; use yansi::{Color, Paint}; +mod archive; mod args; mod auth; +mod errors; mod listing; mod renderer; @@ -48,6 +50,13 @@ fn main() { } let miniserve_config = args::parse_args(); + + let _ = if miniserve_config.verbose { + TermLogger::init(LevelFilter::Info, Config::default()) + } else { + TermLogger::init(LevelFilter::Error, Config::default()) + }; + if miniserve_config.no_symlinks && miniserve_config .path @@ -56,16 +65,10 @@ fn main() { .file_type() .is_symlink() { - println!( - "{error} The no-symlinks option cannot be used with a symlink path", - error = Paint::red("error:").bold(), - ); + log::error!("The no-symlinks option cannot be used with a symlink path"); return; } - if miniserve_config.verbose { - let _ = TermLogger::init(LevelFilter::Info, Config::default()); - } let sys = actix::System::new("miniserve"); let inside_config = miniserve_config.clone(); @@ -119,7 +122,7 @@ fn main() { version = crate_version!() ); if !miniserve_config.path_explicitly_chosen { - println!("{info} miniserve has been invoked without an explicit path so it will serve the current directory.", info=Color::Blue.paint("Info:").bold()); + println!("{warning} miniserve has been invoked without an explicit path so it will serve the current directory.", warning=Color::RGB(255, 192, 0).paint("Notice:").bold()); println!( " Invoke with -h|--help to see options or invoke as `miniserve .` to hide this advice." ); @@ -164,7 +167,7 @@ fn main() { path = Color::Yellow.paint(path_string).bold(), addresses = addresses, ); - println!("Quit by pressing CTRL-C"); + println!("\nQuit by pressing CTRL-C"); let _ = sys.run(); } diff --git a/src/renderer.rs b/src/renderer.rs index 89a9248..66fc714 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -3,6 +3,7 @@ use chrono_humanize::{Accuracy, HumanTime, Tense}; use maud::{html, Markup, PreEscaped, DOCTYPE}; use std::time::SystemTime; +use crate::archive; use crate::listing; /// Renders the file listing @@ -18,7 +19,10 @@ pub fn page( (page_header(page_title)) body { span #top { } - h1 { (page_title) } + h1.title { (page_title) } + div.download { + (archive_button(archive::CompressionMethod::TarGz)) + } table { thead { th { (build_link("name", "Name", &sort_method, &sort_order)) } @@ -50,6 +54,18 @@ pub fn page( } } +/// Partial: archive button +fn archive_button(compress_method: archive::CompressionMethod) -> Markup { + let link = format!("?download={}", compress_method.to_string()); + let text = format!("Download .{}", compress_method.extension()); + + html! { + a href=(link) { + (text) + } + } +} + /// Partial: table header link fn build_link( name: &str, @@ -166,6 +182,9 @@ fn css() -> Markup { margin: 0; padding: 0; } + h1 { + font-size: 1.5rem; + } table { margin-top: 2rem; width: 100%; @@ -259,6 +278,27 @@ fn css() -> Markup { color: #3498db; text-decoration: none; } + .download { + display: flex; + flex-wrap: wrap; + margin-top: .5rem; + padding: 0.125rem; + } + .download a, .download a:visited { + color: #3498db; + } + .download a { + background: #efefef; + padding: 0.5rem; + border-radius: 0.2rem; + margin-top: 1rem; + } + .download a:hover { + background: #deeef7a6; + } + .download a:not(:last-of-type) { + margin-right: 1rem; + } @media (max-width: 600px) { h1 { font-size: 1.375em; |