Repository: svenstaro/miniserve
Pull request #508 (2021-04-25 15:50:18)

Implement a raw rendering mode for recursive folder download
- Raw mode only displays file/folders and focused on computer processing. So no js, no hints, no colors, no unneeded css classes...
- Show a banner in the footer displaying the command to recursively download the current folder with wget

# Screenshots
![Screenshot_20210425_175359](https://user-images.githubusercontent.com/34889164/115999978-9aa16500-a5ee-11eb-9a87-c5f23c336c7d.png)

![Screenshot_20210425_175437](https://user-images.githubusercontent.com/34889164/115999992-abea7180-a5ee-11eb-9917-3e4c59129ceb.png)



This PR is a rewrite of https://github.com/svenstaro/miniserve/pull/228

Jikstra: @svenstaro it was super nice to implement this on top of current master. Code got a lot cleaner since the last time I tried. Really awesome that you're still taking care of this wonderful project :)
Jikstra: @svenstaro anything blocking you to merge this? :)
Jikstra: @svenstaro bumping this again as I saw you being active in another PR. Maybe you just oversaw the last comment, don't want to annoy you :)
svenstaro: Hey, sorry yeah as I said in the other review, I'm currently trying to wrap up the PRs here. I'd really like @aliemjay's testing improvements to go in first but I'll take a look at this one.
svenstaro: This needs another rebase now, sorry!

Patch:
diff --git a/data/style.scss b/data/style.scss
index 03ad0af..e398c9e 100644
--- a/data/style.scss
+++ b/data/style.scss
@@ -52,6 +52,20 @@ body {
     padding-top: 1.5rem;
     font-size: 0.7em;
     color: var(--footer_color);
+
+    .downloadDirectory {
+        display: flex;
+        flex-direction: row;
+        justify-content: center;
+        flex-wrap: wrap;
+        .cmd {
+            margin: 0;
+            padding-left: 5px;
+
+            line-height: 13px;
+            font-family: monospace;
+        }
+    }
 }
 
 a {
diff --git a/src/args.rs b/src/args.rs
index ff8d92b..c610bbe 100644
--- a/src/args.rs
+++ b/src/args.rs
@@ -142,6 +142,10 @@ pub struct CliArgs {
     #[clap(short = 'F', long = "hide-version-footer")]
     pub hide_version_footer: bool,
 
+    /// If enabled, display a wget command to recursively download the current directory
+    #[clap(short = 'W', long = "show-wget-footer")]
+    pub show_wget_footer: bool,
+
     /// Generate completion file for a shell
     #[clap(long = "print-completions", value_name = "shell", possible_values = &Shell::variants())]
     pub print_completions: Option<Shell>,
diff --git a/src/auth.rs b/src/auth.rs
index 0d97f11..82b407c 100644
--- a/src/auth.rs
+++ b/src/auth.rs
@@ -42,7 +42,7 @@ pub struct RequiredAuth {
 }
 
 /// Return `true` if `basic_auth` is matches any of `required_auth`
-pub fn match_auth(basic_auth: BasicAuthParams, required_auth: &[RequiredAuth]) -> bool {
+pub fn match_auth(basic_auth: &BasicAuthParams, required_auth: &[RequiredAuth]) -> bool {
     required_auth
         .iter()
         .any(|RequiredAuth { username, password }| {
@@ -74,6 +74,9 @@ pub fn get_hash<T: Digest>(text: &str) -> Vec<u8> {
     hasher.update(text);
     hasher.finalize().to_vec()
 }
+pub struct CurrentUser {
+    pub name: String,
+}
 
 fn handle_auth(req: &HttpRequest) -> Result<(), ContextualError> {
     let required_auth = &req.app_data::<crate::MiniserveConfig>().unwrap().auth;
@@ -84,8 +87,13 @@ fn handle_auth(req: &HttpRequest) -> Result<(), ContextualError> {
     }
 
     match BasicAuthParams::try_from_request(req) {
-        Ok(cred) => match match_auth(cred, required_auth) {
-            true => Ok(()),
+        Ok(cred) => match match_auth(&cred, required_auth) {
+            true => {
+                req.extensions_mut().insert(CurrentUser {
+                    name: cred.username,
+                });
+                Ok(())
+            }
             false => Err(ContextualError::InvalidHttpCredentials),
         },
         Err(_) => Err(ContextualError::RequireHttpCredentials),
@@ -173,7 +181,7 @@ mod tests {
     ) {
         assert_eq!(
             match_auth(
-                BasicAuthParams {
+                &BasicAuthParams {
                     username: param_username.to_owned(),
                     password: param_password.to_owned(),
                 },
@@ -214,7 +222,7 @@ mod tests {
         password: &str,
     ) {
         assert!(match_auth(
-            BasicAuthParams {
+            &BasicAuthParams {
                 username: username.to_owned(),
                 password: password.to_owned(),
             },
@@ -225,7 +233,7 @@ mod tests {
     #[rstest]
     fn test_multiple_auth_wrong_username(account_sample: Vec<RequiredAuth>) {
         assert_eq!(match_auth(
-            BasicAuthParams {
+            &BasicAuthParams {
                 username: "unregistered user".to_owned(),
                 password: "pwd0".to_owned(),
             },
@@ -248,7 +256,7 @@ mod tests {
         password: &str,
     ) {
         assert_eq!(match_auth(
-            BasicAuthParams {
+            &BasicAuthParams {
                 username: username.to_owned(),
                 password: password.to_owned(),
             },
diff --git a/src/config.rs b/src/config.rs
index 86fe314..ce4e5d7 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -101,6 +101,9 @@ pub struct MiniserveConfig {
     /// If enabled, version footer is hidden
     pub hide_version_footer: bool,
 
+    /// If enabled, display a wget command to recursively download the current directory
+    pub show_wget_footer: bool,
+
     /// If set, use provided rustls config for TLS
     #[cfg(feature = "tls")]
     pub tls_rustls_config: Option<rustls::ServerConfig>,
@@ -192,6 +195,7 @@ impl MiniserveConfig {
             header: args.header,
             show_symlink_info: args.show_symlink_info,
             hide_version_footer: args.hide_version_footer,
+            show_wget_footer: args.show_wget_footer,
             tls_rustls_config: tls_rustls_server_config,
         })
     }
diff --git a/src/listing.rs b/src/listing.rs
index ef4c8c8..9273025 100644
--- a/src/listing.rs
+++ b/src/listing.rs
@@ -12,6 +12,7 @@ use std::time::SystemTime;
 use strum_macros::{Display, EnumString};
 
 use crate::archive::ArchiveMethod;
+use crate::auth::CurrentUser;
 use crate::errors::{self, ContextualError};
 use crate::renderer;
 use percent_encode_sets::PATH_SEGMENT;
@@ -32,6 +33,7 @@ pub struct QueryParameters {
     pub path: Option<PathBuf>,
     pub sort: Option<SortingMethod>,
     pub order: Option<SortingOrder>,
+    pub raw: Option<bool>,
     qrcode: Option<String>,
     download: Option<ArchiveMethod>,
 }
@@ -152,6 +154,9 @@ pub fn directory_listing(
     dir: &actix_files::Directory,
     req: &HttpRequest,
 ) -> io::Result<ServiceResponse> {
+    let extensions = req.extensions();
+    let current_user: Option<&CurrentUser> = extensions.get::<CurrentUser>();
+
     use actix_web::dev::BodyEncoding;
     let conf = req.app_data::<crate::MiniserveConfig>().unwrap();
     let serve_path = req.path();
@@ -374,6 +379,7 @@ pub fn directory_listing(
                         breadcrumbs,
                         &encoded_dir,
                         conf,
+                        current_user,
                     )
                     .into_string(),
                 ),
diff --git a/src/renderer.rs b/src/renderer.rs
index d1821dd..3c2d008 100644
--- a/src/renderer.rs
+++ b/src/renderer.rs
@@ -6,6 +6,7 @@ use maud::{html, Markup, PreEscaped, DOCTYPE};
 use std::time::SystemTime;
 use strum::IntoEnumIterator;
 
+use crate::auth::CurrentUser;
 use crate::listing::{Breadcrumb, Entry, QueryParameters, SortingMethod, SortingOrder};
 use crate::{archive::ArchiveMethod, MiniserveConfig};
 
@@ -17,12 +18,19 @@ pub fn page(
     breadcrumbs: Vec<Breadcrumb>,
     encoded_dir: &str,
     conf: &MiniserveConfig,
+    current_user: Option<&CurrentUser>,
 ) -> Markup {
+    // If query_params.raw is true, we want render a minimal directory listing
+    if query_params.raw.is_some() && query_params.raw.unwrap() {
+        return raw(entries, is_root);
+    }
+
     let upload_route = match conf.random_route {
         Some(ref random_route) => format!("/{}/upload", random_route),
         None => "/upload".to_string(),
     };
     let (sort_method, sort_order) = (query_params.sort, query_params.order);
+
     let upload_action = build_upload_action(&upload_route, encoded_dir, sort_method, sort_order);
 
     let title_path = breadcrumbs
@@ -80,7 +88,7 @@ pub fn page(
                                 // wrapped in span so the text doesn't shift slightly when it turns into a link
                                 span { bdi { (el.name) } }
                             } @else {
-                                a href=(parametrized_link(&el.link, sort_method, sort_order)) {
+                                a href=(parametrized_link(&el.link, sort_method, sort_order, false)) {
                                     bdi { (el.name) }
                                 }
                             }
@@ -120,22 +128,59 @@ pub fn page(
                                 tr {
                                     td colspan="3" {
                                         span.root-chevron { (chevron_left()) }
-                                        a.root href=(parametrized_link("../", sort_method, sort_order)) {
+                                        a.root href=(parametrized_link("../", sort_method, sort_order, false)) {
                                             "Parent directory"
                                         }
                                     }
                                 }
                             }
                             @for entry in entries {
-                                (entry_row(entry, sort_method, sort_order))
+                                (entry_row(entry, sort_method, sort_order, false))
                             }
                         }
                     }
                     a.back href="#top" {
                         (arrow_up())
                     }
-                    @if !conf.hide_version_footer {
-                        (version_footer())
+                    div.footer {
+                        @if conf.show_wget_footer {
+                            (wget_footer(&title_path, current_user))
+                        }
+                        @if !conf.hide_version_footer {
+                            (version_footer())
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
+
+/// Renders the file listing
+pub fn raw(entries: Vec<Entry>, is_root: bool) -> Markup {
+    html! {
+        (DOCTYPE)
+        html {
+            body {
+                table {
+                    thead {
+                        th.name { "Name" }
+                        th.size { "Size" }
+                        th.date { "Last modification" }
+                    }
+                    tbody {
+                        @if !is_root {
+                            tr {
+                                td colspan="3" {
+                                    a.root href=(parametrized_link("../", None, None, true)) {
+                                        ".."
+                                    }
+                                }
+                            }
+                        }
+                        @for entry in entries {
+                            (entry_row(entry, None, None, true))
+                        }
                     }
                 }
             }
@@ -146,12 +191,36 @@ pub fn page(
 // Partial: version footer
 fn version_footer() -> Markup {
     html! {
-        p.footer {
-            (format!("{}/{}", crate_name!(), crate_version!()))
-        }
+       div.version {
+           (format!("{}/{}", crate_name!(), crate_version!()))
+       }
     }
 }
 
+fn wget_footer(title_path: &str, current_user: Option<&CurrentUser>) -> Markup {
+    let count = {
+        let count_slashes = title_path.matches('/').count();
+        if count_slashes > 0 {
+            count_slashes - 1
+        } else {
+            0
+        }
+    };
+
+    let user_params = if let Some(user) = current_user {
+        format!(" --ask-password --user {}", user.name)
+    } else {
+        "".to_string()
+    };
+
+    return html! {
+        div.downloadDirectory {
+            p { "Download folder:" }
+            div.cmd { (format!("wget -r -c -nH -np --cut-dirs={} -R \"index.html*\"{} \"http://{}/?raw=true\"", count, user_params, title_path)) }
+        }
+    };
+}
+
 /// Build the action of the upload form
 fn build_upload_action(
     upload_route: &str,
@@ -232,7 +301,7 @@ fn archive_button(
     } else {
         format!(
             "{}&download={}",
-            parametrized_link("", sort_method, sort_order,),
+            parametrized_link("", sort_method, sort_order, false),
             archive_method
         )
     };
@@ -260,14 +329,19 @@ fn parametrized_link(
     link: &str,
     sort_method: Option<SortingMethod>,
     sort_order: Option<SortingOrder>,
+    raw: bool,
 ) -> String {
+    if raw {
+        return format!("{}?raw=true", make_link_with_trailing_slash(link));
+    }
+
     if let Some(method) = sort_method {
         if let Some(order) = sort_order {
             let parametrized_link = format!(
                 "{}?sort={}&order={}",
                 make_link_with_trailing_slash(link),
                 method,
-                order
+                order,
             );
 
             return parametrized_link;
@@ -315,6 +389,7 @@ fn entry_row(
     entry: Entry,
     sort_method: Option<SortingMethod>,
     sort_order: Option<SortingOrder>,
+    raw: bool,
 ) -> Markup {
     html! {
         tr {
@@ -322,13 +397,13 @@ fn entry_row(
                 p {
                     @if entry.is_dir() {
                         @if let Some(symlink_dest) = entry.symlink_info {
-                            a.symlink href=(parametrized_link(&entry.link, sort_method, sort_order)) {
+                            a.symlink href=(parametrized_link(&entry.link, sort_method, sort_order, raw)) {
                                 (entry.name) "/"
                                 span.symlink-symbol { }
                                 a.directory {(symlink_dest) "/"}
                             }
                         }@else {
-                            a.directory href=(parametrized_link(&entry.link, sort_method, sort_order)) {
+                            a.directory href=(parametrized_link(&entry.link, sort_method, sort_order, raw)) {
                                 (entry.name) "/"
                             }
                         }
@@ -345,9 +420,11 @@ fn entry_row(
                             }
                         }
 
-                        @if let Some(size) = entry.size {
-                            span.mobile-info.size {
-                                (size)
+                        @if !raw {
+                            @if let Some(size) = entry.size {
+                                span.mobile-info.size {
+                                    (size)
+                                }
                             }
                         }
                     }
@@ -509,7 +586,10 @@ pub fn render_error(
                         }
                     }
                     @if !conf.hide_version_footer {
-                        (version_footer())
+                        p.footer {
+                            (version_footer())
+                        }
+
                     }
                 }
             }


Test patch:
diff --git a/tests/raw.rs b/tests/raw.rs
new file mode 100644
index 0000000..82a1eee
--- /dev/null
+++ b/tests/raw.rs
@@ -0,0 +1,100 @@
+mod fixtures;
+mod utils;
+
+use crate::fixtures::TestServer;
+use fixtures::{server, Error};
+use pretty_assertions::assert_eq;
+use reqwest::blocking::Client;
+use rstest::rstest;
+use select::document::Document;
+use select::predicate::Class;
+use select::predicate::Name;
+
+#[rstest]
+/// The ui displays the correct wget command to download the folder recursively
+fn ui_displays_wget_element(#[with(&["-W"])] server: TestServer) -> Result<(), Error> {
+    let client = Client::new();
+
+    let body = client.get(server.url()).send()?.error_for_status()?;
+    let parsed = Document::from_read(body)?;
+    let wget_url = parsed
+        .find(Class("downloadDirectory"))
+        .next()
+        .unwrap()
+        .find(Class("cmd"))
+        .next()
+        .unwrap()
+        .text();
+    assert_eq!(
+        wget_url,
+        format!(
+            "wget -r -c -nH -np --cut-dirs=0 -R \"index.html*\" \"{}?raw=true\"",
+            server.url()
+        )
+    );
+
+    let body = client
+        .get(format!("{}/very/deeply/nested/", server.url()))
+        .send()?
+        .error_for_status()?;
+    let parsed = Document::from_read(body)?;
+    let wget_url = parsed
+        .find(Class("downloadDirectory"))
+        .next()
+        .unwrap()
+        .find(Class("cmd"))
+        .next()
+        .unwrap()
+        .text();
+    assert_eq!(
+        wget_url,
+        format!(
+            "wget -r -c -nH -np --cut-dirs=2 -R \"index.html*\" \"{}very/deeply/nested/?raw=true\"",
+            server.url()
+        )
+    );
+
+    Ok(())
+}
+
+#[rstest]
+/// All hrefs in raw mode are links to directories or files & directories end with ?raw=true
+fn raw_mode_links_to_directories_end_with_raw_true(
+    #[with(&["-W"])] server: TestServer,
+) -> Result<(), Error> {
+    fn verify_a_tags(parsed: Document) {
+        // Ensure all links end with ?raw=true or are files
+        for node in parsed.find(Name("a")) {
+            if let Some(class) = node.attr("class") {
+                if class == "root" || class == "directory" {
+                    assert!(node.attr("href").unwrap().ends_with("?raw=true"));
+                } else if class == "file" {
+                    assert!(true);
+                } else {
+                    println!(
+                        "This node is a link and neither of class directory, root or file: {:?}",
+                        node
+                    );
+                    assert!(false);
+                }
+            }
+        }
+    }
+
+    let urls = [
+        format!("{}?raw=true", server.url()),
+        format!("{}very/?raw=true", server.url()),
+        format!("{}very/deeply/?raw=true", server.url()),
+        format!("{}very/deeply/nested/?raw=true", server.url()),
+    ];
+
+    let client = Client::new();
+    // Ensure the links to the archives are not present
+    for url in urls.iter() {
+        let body = client.get(url).send()?.error_for_status()?;
+        let parsed = Document::from_read(body)?;
+        verify_a_tags(parsed);
+    }
+
+    Ok(())
+}