-
-
Notifications
You must be signed in to change notification settings - Fork 73
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
3 changed files
with
329 additions
and
284 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 & 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)) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.