index : static-web-server.git

ascending towards madness

author Jose Quintana <joseluisquintana20@gmail.com> 2022-09-13 20:31:48.0 +00:00:00
committer Jose Quintana <joseluisquintana20@gmail.com> 2022-09-13 20:31:48.0 +00:00:00
commit
e9a4aa3809c5a77362227cf38adab8236ff5f8df [patch]
tree
8f77b1f906ca3a291486b7a6efb0eecd8ada1b2b
parent
91b6ba2cf04fd333de5e087969817eb64f56b844
download
e9a4aa3809c5a77362227cf38adab8236ff5f8df.tar.gz

refactor: directory listing module



Diff

 src/directory_listing.rs | 326 ++++++++++++++++++++++++++++++++++++++++++++++++-
 src/lib.rs               |   1 +-
 src/static_files.rs      | 286 +------------------------------------------
 3 files changed, 329 insertions(+), 284 deletions(-)

diff --git a/src/directory_listing.rs b/src/directory_listing.rs
new file mode 100644
index 0000000..27cc7ac
--- /dev/null
+++ b/src/directory_listing.rs
@@ -0,0 +1,326 @@
use futures_util::future::Either;
use futures_util::{future, FutureExt};
use headers::{ContentLength, ContentType, HeaderMapExt};
use humansize::{file_size_opts, FileSize};
use hyper::{Body, Method, Response, StatusCode};
use mime_guess::mime;
use percent_encoding::percent_decode_str;
use std::cmp::Ordering;
use std::future::Future;
use std::io;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};

use crate::Result;

/// Provides directory listing support for the current request.
/// Note that this function highly depends on `static_files::path_from_tail()` function
/// which must be called first. See `static_files::handle()` for more details.
pub fn auto_index<'a>(
    method: &'a Method,
    current_path: &'a str,
    uri_query: Option<&'a str>,
    filepath: &'a Path,
    dir_listing_order: u8,
) -> impl Future<Output = Result<Response<Body>, StatusCode>> + Send + 'a {
    let is_head = method == Method::HEAD;

    // Note: it's safe to call `parent()` here since `filepath`
    // value always refer to a path with file ending and under
    // a root directory boundary.
    // See `path_from_tail()` function which sanitizes the requested
    // path before to be delegated here.
    let parent = filepath.parent().unwrap_or(filepath);

    tokio::fs::read_dir(parent).then(move |res| match res {
        Ok(entries) => Either::Left(async move {
            match read_dir_entries(entries, current_path, uri_query, is_head, dir_listing_order)
                .await
            {
                Ok(resp) => Ok(resp),
                Err(err) => {
                    tracing::error!(
                        "error during directory entries reading (path={:?}): {} ",
                        parent.display(),
                        err
                    );
                    Err(StatusCode::INTERNAL_SERVER_ERROR)
                }
            }
        }),
        Err(err) => {
            let status = match err.kind() {
                io::ErrorKind::NotFound => {
                    tracing::debug!("entry file not found: {:?}", filepath.display());
                    StatusCode::NOT_FOUND
                }
                io::ErrorKind::PermissionDenied => {
                    tracing::warn!("entry file permission denied: {:?}", filepath.display());
                    StatusCode::FORBIDDEN
                }
                _ => {
                    tracing::error!(
                        "directory entries error (filepath={:?}): {} ",
                        filepath.display(),
                        err
                    );
                    StatusCode::INTERNAL_SERVER_ERROR
                }
            };
            Either::Right(future::err(status))
        }
    })
}

const STYLE: &str = r#"<style>html{background-color:#fff;-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;min-width:20rem;text-rendering:optimizeLegibility;-webkit-text-size-adjust:100%;-moz-text-size-adjust:100%;text-size-adjust:100%}body{padding:1rem;font-family:Consolas,'Liberation Mono',Menlo,monospace;font-size:.875rem;max-width:70rem;margin:0 auto;color:#4a4a4a;font-weight:400;line-height:1.5}h1{margin:0;padding:0;font-size:1.375rem;line-height:1.25;margin-bottom:0.5rem;}table{width:100%;border-spacing: 0;}table th,table td{padding:.2rem .5rem;white-space:nowrap;vertical-align:top}table th a,table td a{display:inline-block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:95%;vertical-align:top}table tr:hover td{background-color:#f5f5f5}footer{padding-top:0.5rem}table tr th{text-align:left;}</style>"#;
const FOOTER: &str = r#"<footer>Powered by <a target="_blank" href="https://github.com/joseluisq/static-web-server">static-web-server</a> | MIT &amp; Apache 2.0</footer>"#;

/// It reads a list of directory entries and create an index page content.
/// Otherwise it returns a status error.
async fn read_dir_entries(
    mut file_entries: tokio::fs::ReadDir,
    base_path: &str,
    uri_query: Option<&str>,
    is_head: bool,
    mut dir_listing_order: u8,
) -> Result<Response<Body>> {
    let mut dirs_count: usize = 0;
    let mut files_count: usize = 0;
    let mut files_found: Vec<(String, String, u64, Option<String>)> = vec![];

    while let Some(entry) = file_entries.next_entry().await? {
        let meta = entry.metadata().await?;

        let mut name = entry
            .file_name()
            .into_string()
            .map_err(|err| anyhow::anyhow!(err.into_string().unwrap_or_default()))?;

        let mut filesize = 0_u64;

        if meta.is_dir() {
            name += "/";
            dirs_count += 1;
        } else if meta.is_file() {
            filesize = meta.len();
            files_count += 1;
        } else if meta.file_type().is_symlink() {
            let m = tokio::fs::symlink_metadata(entry.path().canonicalize()?).await?;
            if m.is_dir() {
                name += "/";
                dirs_count += 1;
            } else {
                filesize = meta.len();
                files_count += 1;
            }
        } else {
            continue;
        }

        let mut uri = None;
        // NOTE: Use relative paths by default independently of
        // the "redirect trailing slash" feature.
        // However, when "redirect trailing slash" is disabled
        // and a request path doesn't contain a trailing slash then
        // entries should contain the "parent/entry-name" as a link format.
        // Otherwise, we just use the "entry-name" as a link (default behavior).
        // Note that in both cases, we add a trailing slash if the entry is a directory.
        if !base_path.ends_with('/') {
            let base_path = Path::new(base_path);
            let parent_dir = base_path.parent().unwrap_or(base_path);
            let mut base_dir = base_path;
            if base_path != parent_dir {
                base_dir = base_path.strip_prefix(parent_dir)?;
            }
            let mut base_str = String::new();
            if !base_dir.starts_with("/") {
                let base_dir = base_dir.to_str().unwrap_or_default();
                if !base_dir.is_empty() {
                    base_str.push_str(base_dir);
                }
                base_str.push('/');
            }
            base_str.push_str(&name);
            uri = Some(base_str);
        }

        let modified = match parse_last_modified(meta.modified()?) {
            Ok(tm) => tm.to_local().strftime("%F %T")?.to_string(),
            Err(err) => {
                tracing::error!("error determining file last modified: {:?}", err);
                String::from("-")
            }
        };
        files_found.push((name, modified, filesize, uri));
    }

    // Check the query request uri for a sorting type. E.g https://blah/?sort=5
    if let Some(q) = uri_query {
        let mut parts = form_urlencoded::parse(q.as_bytes());
        if parts.count() > 0 {
            // NOTE: we just pick up the first value (pair)
            if let Some(sort) = parts.next() {
                if sort.0 == "sort" && !sort.1.trim().is_empty() {
                    match sort.1.parse::<u8>() {
                        Ok(order_code) => dir_listing_order = order_code,
                        Err(err) => {
                            tracing::debug!(
                                "sorting: query value error when converting to u8: {:?}",
                                err
                            );
                        }
                    }
                }
            }
        }
    }

    let html = create_auto_index(
        base_path,
        dirs_count,
        files_count,
        dir_listing_order,
        &mut files_found,
    )?;

    let mut resp = Response::new(Body::empty());
    resp.headers_mut()
        .typed_insert(ContentType::from(mime::TEXT_HTML_UTF_8));
    resp.headers_mut()
        .typed_insert(ContentLength(html.len() as u64));

    // We skip the body for HEAD requests
    if is_head {
        return Ok(resp);
    }

    *resp.body_mut() = Body::from(html);

    Ok(resp)
}

/// Create an auto index html content.
fn create_auto_index(
    base_path: &str,
    dirs_count: usize,
    files_count: usize,
    dir_listing_order: u8,
    files_found: &mut Vec<(String, String, u64, Option<String>)>,
) -> Result<String> {
    // Sorting the files by an specific order code and create the table header
    let table_header = create_table_header(sort_files(files_found, dir_listing_order));

    // Prepare table row
    let mut table_row = String::new();
    if base_path != "/" {
        table_row = String::from(r#"<tr><td colspan="3"><a href="../">../</a></td></tr>"#);
    }

    for file in files_found {
        let (file_name, file_modified, file_size, uri) = file;
        let mut filesize_str = file_size
            .file_size(file_size_opts::DECIMAL)
            .map_err(anyhow::Error::msg)?;

        if *file_size == 0_u64 {
            filesize_str = String::from("-");
        }

        let file_uri = uri.clone().unwrap_or_else(|| file_name.to_owned());

        table_row = format!(
            "{}<tr><td><a href=\"{}\">{}</a></td><td>{}</td><td align=\"right\">{}</td></tr>",
            table_row, file_uri, file_name, file_modified, filesize_str
        );
    }

    let current_path = percent_decode_str(base_path).decode_utf8()?.to_owned();
    let dirs_str = if dirs_count == 1 {
        "directory"
    } else {
        "directories"
    };
    let summary = format!(
        "<div>{} {}, {} {}</div>",
        dirs_count, dirs_str, files_count, "file(s)"
    );

    let html_page = format!(
        "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>Index of {}</title>{}</head><body><h1>Index of {}</h1>{}<hr><table>{}{}</table><hr>{}</body></html>",
        current_path, STYLE, current_path, summary, table_header, table_row, FOOTER
    );

    Ok(html_page)
}

/// Create a table header providing the sorting attributes.
fn create_table_header(sorting_attrs: (String, String, String)) -> String {
    let (name, last_modified, size) = sorting_attrs;
    format!(
        r#"<thead><tr><th><a href="?sort={}">Name</a></th><th style="width:160px;"><a href="?sort={}">Last modified</a></th><th style="width:120px;text-align:right;"><a href="?sort={}">Size</a></th></tr></thead>"#,
        name, last_modified, size,
    )
}

/// Sort a list of files by an specific order code.
fn sort_files(
    files: &mut [(String, String, u64, Option<String>)],
    order_code: u8,
) -> (String, String, String) {
    // Default sorting type values
    let mut name = "0".to_owned();
    let mut last_modified = "2".to_owned();
    let mut size = "4".to_owned();

    files.sort_by(|a, b| match order_code {
        // Name (asc, desc)
        0 => {
            name = "1".to_owned();
            a.0.to_lowercase().cmp(&b.0.to_lowercase())
        }
        1 => {
            name = "0".to_owned();
            b.0.to_lowercase().cmp(&a.0.to_lowercase())
        }

        // Modified (asc, desc)
        2 => {
            last_modified = "3".to_owned();
            a.1.cmp(&b.1)
        }
        3 => {
            last_modified = "2".to_owned();
            b.1.cmp(&a.1)
        }

        // File size (asc, desc)
        4 => {
            size = "5".to_owned();
            a.2.cmp(&b.2)
        }
        5 => {
            size = "4".to_owned();
            b.2.cmp(&a.2)
        }

        // Unordered
        _ => Ordering::Equal,
    });

    (name, last_modified, size)
}

fn parse_last_modified(modified: SystemTime) -> Result<time::Tm, Box<dyn std::error::Error>> {
    let since_epoch = modified.duration_since(UNIX_EPOCH)?;
    // HTTP times don't have nanosecond precision, so we truncate
    // the modification time.
    // Converting to i64 should be safe until we get beyond the
    // planned lifetime of the universe
    //
    // TODO: Investigate how to write a test for this. Changing
    // the modification time of a file with greater than second
    // precision appears to be something that only is possible to
    // do on Linux.
    let ts = time::Timespec::new(since_epoch.as_secs() as i64, 0);
    Ok(time::at_utc(ts))
}
diff --git a/src/lib.rs b/src/lib.rs
index 5f9ecda..4a3a7cb 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -14,6 +14,7 @@ pub mod compression;
pub mod control_headers;
pub mod cors;
pub mod custom_headers;
pub mod directory_listing;
pub mod error_page;
pub mod fallback_page;
pub mod handler;
diff --git a/src/static_files.rs b/src/static_files.rs
index f0cfae7..850dac9 100644
--- a/src/static_files.rs
+++ b/src/static_files.rs
@@ -8,11 +8,8 @@ use headers::{
    AcceptRanges, ContentLength, ContentRange, ContentType, HeaderMap, HeaderMapExt, HeaderValue,
    IfModifiedSince, IfRange, IfUnmodifiedSince, LastModified, Range,
};
use humansize::{file_size_opts, FileSize};
use hyper::{Body, Method, Response, StatusCode};
use mime_guess::mime;
use percent_encoding::percent_decode_str;
use std::cmp::Ordering;
use std::fs::Metadata;
use std::future::Future;
use std::io;
@@ -21,13 +18,12 @@ use std::path::{Component, PathBuf};
use std::pin::Pin;
use std::sync::Arc;
use std::task::Poll;
use std::time::{SystemTime, UNIX_EPOCH};
use std::{cmp, path::Path};
use tokio::fs::File as TkFile;
use tokio::io::AsyncSeekExt;
use tokio_util::io::poll_read_buf;

use crate::Result;
use crate::{directory_listing, Result};

/// Arc `PathBuf` reference wrapper since Arc<PathBuf> doesn't implement AsRef<Path>.
#[derive(Clone, Debug)]
@@ -105,7 +101,7 @@ pub async fn handle<'a>(opts: &HandleOpts<'a>) -> Result<Response<Body>, StatusC
    // if current path is a valid directory and
    // if it does not contain an `index.html` file (if a proper auto index is generated)
    if opts.dir_listing && auto_index && !filepath.as_ref().exists() {
        return directory_listing(
        return directory_listing::auto_index(
            method,
            uri_path,
            opts.uri_query,
@@ -144,284 +140,6 @@ fn path_from_tail(
    })
}

/// Provides directory listing support for the current request.
/// Note that this function highly depends on `path_from_tail()` function
/// which must be called first. See `handle()` for more details.
fn directory_listing<'a>(
    method: &'a Method,
    current_path: &'a str,
    uri_query: Option<&'a str>,
    filepath: &'a Path,
    dir_listing_order: u8,
) -> impl Future<Output = Result<Response<Body>, StatusCode>> + Send + 'a {
    let is_head = method == Method::HEAD;

    // Note: it's safe to call `parent()` here since `filepath`
    // value always refer to a path with file ending and under
    // a root directory boundary.
    // See `path_from_tail()` function which sanitizes the requested
    // path before to be delegated here.
    let parent = filepath.parent().unwrap_or(filepath);

    tokio::fs::read_dir(parent).then(move |res| match res {
        Ok(entries) => Either::Left(async move {
            match read_directory_entries(
                entries,
                current_path,
                uri_query,
                is_head,
                dir_listing_order,
            )
            .await
            {
                Ok(resp) => Ok(resp),
                Err(err) => {
                    tracing::error!(
                        "error during directory entries reading (path={:?}): {} ",
                        parent.display(),
                        err
                    );
                    Err(StatusCode::INTERNAL_SERVER_ERROR)
                }
            }
        }),
        Err(err) => {
            let status = match err.kind() {
                io::ErrorKind::NotFound => {
                    tracing::debug!("entry file not found: {:?}", filepath.display());
                    StatusCode::NOT_FOUND
                }
                io::ErrorKind::PermissionDenied => {
                    tracing::warn!("entry file permission denied: {:?}", filepath.display());
                    StatusCode::FORBIDDEN
                }
                _ => {
                    tracing::error!(
                        "directory entries error (filepath={:?}): {} ",
                        filepath.display(),
                        err
                    );
                    StatusCode::INTERNAL_SERVER_ERROR
                }
            };
            Either::Right(future::err(status))
        }
    })
}

/// It reads the current directory entries and create an index page content.
/// Otherwise it returns a status error.
async fn read_directory_entries(
    mut entries: tokio::fs::ReadDir,
    base_path: &str,
    uri_query: Option<&str>,
    is_head: bool,
    mut dir_listing_order: u8,
) -> Result<Response<Body>> {
    let mut dirs_count: usize = 0;
    let mut files_count: usize = 0;
    let mut files_found: Vec<(String, String, u64, Option<String>)> = vec![];

    while let Some(entry) = entries.next_entry().await? {
        let meta = entry.metadata().await?;

        let mut name = entry
            .file_name()
            .into_string()
            .map_err(|err| anyhow::anyhow!(err.into_string().unwrap_or_default()))?;

        let mut filesize = 0_u64;

        if meta.is_dir() {
            name += "/";
            dirs_count += 1;
        } else if meta.is_file() {
            filesize = meta.len();
            files_count += 1;
        } else if meta.file_type().is_symlink() {
            let m = tokio::fs::symlink_metadata(entry.path().canonicalize()?).await?;
            if m.is_dir() {
                name += "/";
                dirs_count += 1;
            } else {
                filesize = meta.len();
                files_count += 1;
            }
        } else {
            continue;
        }

        let mut uri = None;
        // NOTE: Use relative paths by default independently of
        // the "redirect trailing slash" feature.
        // However, when "redirect trailing slash" is disabled
        // and a request path doesn't contain a trailing slash then
        // entries should contain the "parent/entry-name" as a link format.
        // Otherwise, we just use the "entry-name" as a link (default behavior).
        // Note that in both cases, we add a trailing slash if the entry is a directory.
        if !base_path.ends_with('/') {
            let base_path = Path::new(base_path);
            let parent_dir = base_path.parent().unwrap_or(base_path);
            let mut base_dir = base_path;
            if base_path != parent_dir {
                base_dir = base_path.strip_prefix(parent_dir)?;
            }
            let mut base_str = String::new();
            if !base_dir.starts_with("/") {
                let base_dir = base_dir.to_str().unwrap_or_default();
                if !base_dir.is_empty() {
                    base_str.push_str(base_dir);
                }
                base_str.push('/');
            }
            base_str.push_str(&name);
            uri = Some(base_str);
        }

        let modified = match parse_last_modified(meta.modified()?) {
            Ok(tm) => tm.to_local().strftime("%F %T")?.to_string(),
            Err(err) => {
                tracing::error!("error determining file last modified: {:?}", err);
                String::from("-")
            }
        };
        files_found.push((name, modified, filesize, uri));
    }

    // Check the query uri for a sorting type. E.g https://blah/?sort=5
    if let Some(q) = uri_query {
        let mut parts = form_urlencoded::parse(q.as_bytes());
        if parts.count() > 0 {
            // NOTE: we just pick up the first value (pairs)
            if let Some(sort) = parts.next() {
                if sort.0 == "sort" && !sort.1.trim().is_empty() {
                    match sort.1.parse::<u8>() {
                        Ok(n) => dir_listing_order = n,
                        Err(e) => {
                            tracing::debug!("sorting: query value to u8 error: {:?}", e);
                        }
                    }
                }
            }
        }
    }

    // Default sorting type values
    let mut sort_name = "0".to_owned();
    let mut sort_last_modified = "2".to_owned();
    let mut sort_size = "4".to_owned();

    files_found.sort_by(|a, b| match dir_listing_order {
        // Name (asc, desc)
        0 => {
            sort_name = "1".to_owned();
            a.0.to_lowercase().cmp(&b.0.to_lowercase())
        }
        1 => {
            sort_name = "0".to_owned();
            b.0.to_lowercase().cmp(&a.0.to_lowercase())
        }

        // Modified (asc, desc)
        2 => {
            sort_last_modified = "3".to_owned();
            a.1.cmp(&b.1)
        }
        3 => {
            sort_last_modified = "2".to_owned();
            b.1.cmp(&a.1)
        }

        // File size (asc, desc)
        4 => {
            sort_size = "5".to_owned();
            a.2.cmp(&b.2)
        }
        5 => {
            sort_size = "4".to_owned();
            b.2.cmp(&a.2)
        }

        // Unordered
        _ => Ordering::Equal,
    });

    // Prepare table header with sorting support
    let table_header = format!(
        r#"<thead><tr><th><a href="?sort={}">Name</a></th><th style="width:160px;"><a href="?sort={}">Last modified</a></th><th style="width:120px;text-align:right;"><a href="?sort={}">Size</a></th></tr></thead>"#,
        sort_name, sort_last_modified, sort_size,
    );

    let mut entries_str = String::new();
    if base_path != "/" {
        entries_str = String::from(r#"<tr><td colspan="3"><a href="../">../</a></td></tr>"#);
    }

    for f in files_found {
        let (name, modified, filesize, uri) = f;
        let mut filesize_str = filesize
            .file_size(file_size_opts::DECIMAL)
            .map_err(anyhow::Error::msg)?;

        if filesize == 0 {
            filesize_str = String::from("-");
        }

        let entry_uri = uri.unwrap_or_else(|| name.to_owned());

        entries_str = format!(
            "{}<tr><td><a href=\"{}\">{}</a></td><td>{}</td><td align=\"right\">{}</td></tr>",
            entries_str, entry_uri, name, modified, filesize_str
        );
    }

    let current_path = percent_decode_str(base_path).decode_utf8()?.to_owned();
    let dirs_str = if dirs_count == 1 {
        "directory"
    } else {
        "directories"
    };
    let summary_str = format!(
        "<div>{} {}, {} {}</div>",
        dirs_count, dirs_str, files_count, "file(s)"
    );
    let style_str = r#"<style>html{background-color:#fff;-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;min-width:20rem;text-rendering:optimizeLegibility;-webkit-text-size-adjust:100%;-moz-text-size-adjust:100%;text-size-adjust:100%}body{padding:1rem;font-family:Consolas,'Liberation Mono',Menlo,monospace;font-size:.875rem;max-width:70rem;margin:0 auto;color:#4a4a4a;font-weight:400;line-height:1.5}h1{margin:0;padding:0;font-size:1.375rem;line-height:1.25;margin-bottom:0.5rem;}table{width:100%;border-spacing: 0;}table th,table td{padding:.2rem .5rem;white-space:nowrap;vertical-align:top}table th a,table td a{display:inline-block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:95%;vertical-align:top}table tr:hover td{background-color:#f5f5f5}footer{padding-top:0.5rem}table tr th{text-align:left;}</style>"#;
    let footer_str = r#"<footer>Powered by <a target="_blank" href="https://github.com/joseluisq/static-web-server">static-web-server</a> | MIT &amp; Apache 2.0</footer>"#;
    let page_str = format!(
        "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>Index of {}</title>{}</head><body><h1>Index of {}</h1>{}<hr><table>{}{}</table><hr>{}</body></html>", current_path, style_str, current_path, summary_str, table_header, entries_str, footer_str
    );

    let mut resp = Response::new(Body::empty());
    resp.headers_mut()
        .typed_insert(ContentType::from(mime::TEXT_HTML_UTF_8));
    resp.headers_mut()
        .typed_insert(ContentLength(page_str.len() as u64));

    // We skip the body for HEAD requests
    if is_head {
        return Ok(resp);
    }

    *resp.body_mut() = Body::from(page_str);

    Ok(resp)
}

fn parse_last_modified(modified: SystemTime) -> Result<time::Tm, Box<dyn std::error::Error>> {
    let since_epoch = modified.duration_since(UNIX_EPOCH)?;
    // HTTP times don't have nanosecond precision, so we truncate
    // the modification time.
    // Converting to i64 should be safe until we get beyond the
    // planned lifetime of the universe
    //
    // TODO: Investigate how to write a test for this. Changing
    // the modification time of a file with greater than second
    // precision appears to be something that only is possible to
    // do on Linux.
    let ts = time::Timespec::new(since_epoch.as_secs() as i64, 0);
    Ok(time::at_utc(ts))
}

/// Reply with a file content.
fn file_reply<'a>(
    headers: &'a HeaderMap<HeaderValue>,