aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/archive.rs160
-rw-r--r--src/errors.rs100
-rw-r--r--src/listing.rs83
-rw-r--r--src/main.rs4
-rw-r--r--src/renderer.rs35
5 files changed, 362 insertions, 20 deletions
diff --git a/src/archive.rs b/src/archive.rs
new file mode 100644
index 0000000..9df1e5e
--- /dev/null
+++ b/src/archive.rs
@@ -0,0 +1,160 @@
+use actix_web::http::ContentEncoding;
+use bytes::Bytes;
+use failure::ResultExt;
+use libflate::gzip::Encoder;
+use serde::Deserialize;
+use std::fs::{File, OpenOptions};
+use std::io::{self, Read};
+use std::path::PathBuf;
+use tar::Builder;
+use tempfile::tempdir;
+use yansi::Color;
+
+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,
+ }
+ }
+}
+
+pub fn create_archive_file(
+ method: &CompressionMethod,
+ dir: &PathBuf,
+) -> Result<(String, Bytes), errors::CompressionError> {
+ match method {
+ CompressionMethod::TarGz => tgz_compress(&dir),
+ }
+}
+
+/// Compresses a given folder in .tar.gz format
+fn tgz_compress(dir: &PathBuf) -> 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, dst_filename, inner_folder.to_string())
+ .context(errors::CompressionErrorKind::TarContentError)?;
+ let gz_data = gzip(&tar_content).context(errors::CompressionErrorKind::GZipContentError)?;
+
+ let mut data = Bytes::new();
+ data.extend_from_slice(&gz_data);
+
+ Ok((dst_tgz_filename, data))
+}
+
+/// Creates a temporary tar file of a given directory, reads it and returns its content as bytes
+fn tar(
+ src_dir: String,
+ dst_filename: String,
+ inner_folder: String,
+) -> Result<Vec<u8>, errors::CompressionError> {
+ let tmp_dir = tempdir().context(errors::CompressionErrorKind::CreateTemporaryFileError)?;
+ let dst_filepath = tmp_dir.path().join(dst_filename.clone());
+ let tar_file =
+ File::create(&dst_filepath).context(errors::CompressionErrorKind::CreateFileError {
+ path: color_path(&dst_filepath.display().to_string()),
+ })?;
+
+ // Create a TAR file of src_dir
+ let mut tar_builder = Builder::new(&tar_file);
+
+ // Temporary workaround for known issue:
+ // https://github.com/alexcrichton/tar-rs/issues/147
+ // https://github.com/alexcrichton/tar-rs/issues/174
+ tar_builder.follow_symlinks(false);
+ tar_builder.append_dir_all(inner_folder, &src_dir).context(
+ errors::CompressionErrorKind::TarBuildingError {
+ message: format!(
+ "failed to append the content of {} in the TAR archive",
+ color_path(&src_dir)
+ ),
+ },
+ )?;
+ tar_builder
+ .into_inner()
+ .context(errors::CompressionErrorKind::TarBuildingError {
+ message: "failed to finish writing the TAR archive".to_string(),
+ })?;
+
+ // Read the content of the TAR file and store it as bytes
+ let mut tar_file = OpenOptions::new().read(true).open(&dst_filepath).context(
+ errors::CompressionErrorKind::OpenFileError {
+ path: color_path(&dst_filepath.display().to_string()),
+ },
+ )?;
+ let mut tar_content = Vec::new();
+ tar_file
+ .read_to_end(&mut tar_content)
+ .context(errors::CompressionErrorKind::TarContentError)?;
+
+ Ok(tar_content)
+}
+
+/// Compresses a stream of bytes using the GZIP algorithm
+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)
+}
+
+fn color_path(path: &str) -> String {
+ Color::White.paint(path).bold().to_string()
+}
diff --git a/src/errors.rs b/src/errors.rs
new file mode 100644
index 0000000..6781bc6
--- /dev/null
+++ b/src/errors.rs
@@ -0,0 +1,100 @@
+use failure::{Backtrace, Context, Fail};
+use std::fmt::{self, Debug, Display};
+use yansi::{Color, Paint};
+
+/// Kinds of error which might happen during folder archive generation
+#[derive(Debug, Fail)]
+pub enum CompressionErrorKind {
+ #[fail(display = "Could not open file {}", path)]
+ OpenFileError { path: String },
+ #[fail(display = "Could not create temporary file")]
+ CreateTemporaryFileError,
+ #[fail(display = "Could not create file {}", path)]
+ CreateFileError { path: String },
+ #[fail(display = "Invalid path: directory name cannot end with \"..\"")]
+ InvalidDirectoryName,
+ #[fail(display = "Directory name contains invalid UTF-8 characters")]
+ InvalidUTF8DirectoryName,
+ #[fail(display = "Failed to create the TAR archive: {}", message)]
+ TarBuildingError { message: String },
+ #[fail(display = "Failed to create the GZIP archive: {}", message)]
+ GZipBuildingError { message: String },
+ #[fail(display = "Failed to retrieve TAR content")]
+ TarContentError,
+ #[fail(display = "Failed to retrieve GZIP content")]
+ GZipContentError,
+}
+
+pub fn print_error_chain(err: CompressionError) {
+ println!(
+ "{error} {err}",
+ error = Paint::red("error:").bold(),
+ err = Paint::white(&err).bold()
+ );
+ print_backtrace(&err);
+ for cause in Fail::iter_causes(&err) {
+ println!(
+ "{} {}",
+ Color::RGB(255, 192, 0).paint("caused by:").to_string(),
+ cause
+ );
+ print_backtrace(cause);
+ }
+}
+
+fn print_backtrace(err: &dyn Fail) {
+ if let Some(backtrace) = err.backtrace() {
+ let backtrace = backtrace.to_string();
+ if backtrace != "" {
+ println!("{}", backtrace);
+ }
+ }
+}
+
+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..a9f0f19 100644
--- a/src/listing.rs
+++ b/src/listing.rs
@@ -1,12 +1,16 @@
-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;
use std::io;
use std::path::Path;
use std::time::SystemTime;
+use yansi::Color;
+use crate::archive;
+use crate::errors;
use crate::renderer;
/// Query parameters
@@ -14,6 +18,7 @@ use crate::renderer;
struct QueryParameters {
sort: Option<SortingMethod>,
order: Option<SortingOrder>,
+ download: Option<archive::CompressionMethod>,
}
/// Available sorting methods
@@ -134,11 +139,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 +228,52 @@ 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 {
+ println!(
+ "{info} Creating an archive ({extension}) of {path}...",
+ info = Color::Blue.paint("info:").bold(),
+ extension = Color::White.paint(compression_method.extension()).bold(),
+ path = Color::White.paint(&dir.path.display().to_string()).bold()
+ );
+ match archive::create_archive_file(&compression_method, &dir.path) {
+ Ok((filename, content)) => {
+ println!(
+ "{success} {file} successfully created !",
+ success = Color::Green.paint("success:").bold(),
+ file = Color::White.paint(&filename).bold(),
+ );
+ Ok(HttpResponse::Ok()
+ .content_type(compression_method.content_type())
+ .content_length(content.len() as u64)
+ .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..260551c 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;
@@ -119,7 +121,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!("{info} miniserve has been invoked without an explicit path so it will serve the current directory.", info=Color::Blue.paint("info:").bold());
println!(
" Invoke with -h|--help to see options or invoke as `miniserve .` to hide this advice."
);
diff --git a/src/renderer.rs b/src/renderer.rs
index 89a9248..b83a67c 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
@@ -19,6 +20,9 @@ pub fn page(
body {
span #top { }
h1 { (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,
@@ -259,6 +275,25 @@ fn css() -> Markup {
color: #3498db;
text-decoration: none;
}
+ .download {
+ display: flex;
+ justify-content: flex-end;
+ padding: 0.125rem;
+ }
+ .download a, .download a:visited {
+ color: #3498db;
+ }
+ .download a {
+ background: #efefef;
+ padding: 0.5rem;
+ border-radius: 0.2rem;
+ }
+ .download a:hover {
+ background: #deeef7a6;
+ }
+ .download a:not(:last-of-type) {
+ margin-right: 1rem;
+ }
@media (max-width: 600px) {
h1 {
font-size: 1.375em;