use std::fs::{File, metadata, read_dir};
use std::time::UNIX_EPOCH;
use file_ext::FileExt;
use crate::controller::Controller;
use crate::ext::decimal::Decimal;
use crate::header::Header;
use crate::mime_type::MimeType;
use crate::range::{ContentRange, Range};
use crate::request::{METHOD, Request};
use crate::response::{Error, Response, STATUS_CODE_REASON_PHRASE};
use crate::server::ConnectionInfo;
use crate::server_config::ServerConfig;
use crate::symbol::SYMBOL;
use crate::url::URL;
#[cfg(test)]
mod tests;
pub(crate) mod open_file_cache;
pub struct StaticResourceController;
impl Controller for StaticResourceController {
fn is_matching(request: &Request, _connection: &ConnectionInfo) -> bool {
let is_get = request.method == METHOD.get;
let is_head = request.method == METHOD.head;
let is_options = request.method == METHOD.options;
let is_matching_method = (is_get || is_head || is_options) && (request.request_uri != SYMBOL.slash);
if !is_matching_method {
return false;
}
let path = match URL::request_target_path(&request.request_uri) {
Some(path) => path,
None => return false,
};
let boxed_static_filepath = static_fs_path(&os_specific_path(path));
if boxed_static_filepath.is_err() {
return false
}
let static_filepath = boxed_static_filepath.unwrap();
let mut is_directory = false;
let mut is_file = open_file_cache::is_cached_file(&static_filepath);
if !is_file {
if let Ok(md) = metadata(&static_filepath) {
if !inside_root(&static_filepath) {
return false;
}
is_directory = md.is_dir();
is_file = md.is_file();
}
}
if is_file || is_directory {
true
} else {
if !static_filepath.ends_with(".html") {
let html_suffix = ".html";
let mut html_file = os_specific_path(path).into_owned();
html_file.push_str(html_suffix);
if let Ok(html_static_filepath) = static_fs_path(&html_file) {
if File::open(&html_static_filepath).is_ok() {
return true;
}
}
}
spa_fallback_url_path(path).is_some()
}
}
fn process(request: &Request, mut response: Response, _connection: &ConnectionInfo) -> Response {
let config = ServerConfig::current();
if let Some(served) = StaticResourceController::serve_regular_file(request, &mut response, &config) {
return served;
}
StaticResourceController::process_general(request, response, &config)
}
}
impl StaticResourceController {
pub(crate) fn is_directory_without_index(request: &Request) -> bool {
let Some(path) = URL::request_target_path(&request.request_uri) else { return false };
let Ok(fs_path) = static_fs_path(&os_specific_path(path)) else { return false };
let dir = std::path::Path::new(&fs_path);
dir.is_dir() && !dir.join("index.html").is_file()
}
pub(crate) fn process_general(request: &Request, mut response: Response, config: &ServerConfig) -> Response {
let without_stale_range;
let request = if range_is_stale(request) {
let mut copy = request.clone();
copy.headers.retain(|h| !h.name.eq_ignore_ascii_case(Header::_RANGE));
without_stale_range = copy;
&without_stale_range
} else {
request
};
let boxed_content_range_list = StaticResourceController::process_static_resources(&request);
if boxed_content_range_list.is_ok() {
let content_range_list = boxed_content_range_list.unwrap();
if content_range_list.len() != 0 {
let mut status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok;
let does_request_include_range_header = request.header(Header::_RANGE).is_some();
if does_request_include_range_header {
status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n206_partial_content;
}
let is_options_request = request.method == METHOD.options;
if is_options_request {
status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n204_no_content;
}
let url = request.request_uri.to_string();
let boxed_url_components = URL::parse_request_target(&url);
if boxed_url_components.is_err() {
let message = boxed_url_components.as_ref().err().unwrap().to_string();
println!("unexpected error, {}", message);
}
let components = boxed_url_components.unwrap();
let static_filepath = static_fs_path(&components.path).unwrap_or_default();
let boxed_modified_date_time = file_modified_utc(&static_filepath);
if boxed_modified_date_time.is_ok() {
let modified_date_time = boxed_modified_date_time.unwrap();
let file_size = metadata(&static_filepath).map(|m| m.len()).unwrap_or(0);
let etag_value = format!("\"{}-{}\"", modified_date_time, file_size);
set_static_cache_control(&mut response, &config.static_cache_control);
if push_validators(request, &mut response, modified_date_time, &etag_value, config.nanos_headers) {
response.status_code = *STATUS_CODE_REASON_PHRASE.n304_not_modified.status_code;
response.reason_phrase = STATUS_CODE_REASON_PHRASE.n304_not_modified.reason_phrase.to_string();
response.headers.push(Header { name: Header::_ETAG.into(), value: etag_value.into() });
return response;
}
response.headers.push(Header { name: Header::_ETAG.into(), value: etag_value.into() });
let is_range_request = request.header(Header::_RANGE).is_some();
if file_size > StaticResourceController::STREAM_THRESHOLD && !is_range_request {
let mime = MimeType::detect_mime_type(&static_filepath);
response.headers.push(Header {
name: Header::_CONTENT_TYPE.into(),
value: mime.into(),
});
response.headers.push(Header {
name: Header::_CONTENT_LENGTH.into(),
value: file_size.to_string().into(),
});
response.status_code = *status_code_reason_phrase.status_code;
response.reason_phrase = status_code_reason_phrase.reason_phrase.to_string();
response.stream_file = Some(static_filepath);
return response;
}
}
response.status_code = *status_code_reason_phrase.status_code;
response.reason_phrase = status_code_reason_phrase.reason_phrase.to_string();
response.content_range_list = content_range_list;
}
} else {
let error : Error = boxed_content_range_list.err().unwrap();
let body = error.message;
let content_range = Range::get_content_range(
Vec::from(body.as_bytes()),
MimeType::TEXT_HTML.to_string()
);
let content_range_list = vec![content_range];
response.status_code = *error.status_code_reason_phrase.status_code;
response.reason_phrase = error.status_code_reason_phrase.reason_phrase.to_string();
response.content_range_list = content_range_list;
if response.status_code == 416 {
let length = URL::parse_request_target(&request.request_uri).ok()
.and_then(|c| static_fs_path(&c.path).ok())
.and_then(|p| metadata(p).ok())
.map(|md| md.len());
if let Some(length) = length {
response.headers.push(Header { name: Header::_CONTENT_RANGE.into(), value: format!("bytes */{}", length).into() });
}
}
}
response
}
}
impl StaticResourceController {
pub fn is_matching_request(request: &Request) -> bool {
let boxed_static_filepath = static_fs_path(&request.request_uri);
if boxed_static_filepath.is_err() {
return false
}
let static_filepath = boxed_static_filepath.unwrap();
let boxed_md = metadata(&static_filepath);
if boxed_md.is_err() || !inside_root(&static_filepath) {
return false
}
let md = boxed_md.unwrap();
if md.is_dir() {
return false
}
let boxed_file = File::open(&static_filepath);
let is_get = request.method == METHOD.get;
let is_head = request.method == METHOD.head;
let is_options = request.method == METHOD.options;
boxed_file.is_ok() && (is_get || is_head || is_options && request.request_uri != SYMBOL.slash)
}
pub fn process_request(request: &Request, mut response: Response) -> Response {
let config = ServerConfig::current();
if let Some(streamed) = StaticResourceController::serve_regular_file(request, &mut response, &config) {
return streamed;
}
let boxed_content_range_list = StaticResourceController::process_static_resources(&request);
if boxed_content_range_list.is_ok() {
let content_range_list = boxed_content_range_list.unwrap();
if content_range_list.len() != 0 {
let mut status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok;
let does_request_include_range_header = request.header(Header::_RANGE).is_some();
if does_request_include_range_header {
status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n206_partial_content;
}
let is_options_request = request.method == METHOD.options;
if is_options_request {
status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n204_no_content;
}
let static_filepath = static_fs_path(&request.request_uri).unwrap_or_default();
let boxed_modified_date_time = file_modified_utc(&static_filepath);
if boxed_modified_date_time.is_ok() {
let modified_date_time = boxed_modified_date_time.unwrap();
let file_size = metadata(&static_filepath).map(|m| m.len()).unwrap_or(0);
let etag_value = format!("\"{}-{}\"", modified_date_time, file_size);
set_static_cache_control(&mut response, &config.static_cache_control);
if push_validators(request, &mut response, modified_date_time, &etag_value, config.nanos_headers) {
response.status_code = *STATUS_CODE_REASON_PHRASE.n304_not_modified.status_code;
response.reason_phrase = STATUS_CODE_REASON_PHRASE.n304_not_modified.reason_phrase.to_string();
response.headers.push(Header { name: Header::_ETAG.into(), value: etag_value.into() });
return response;
}
response.headers.push(Header { name: Header::_ETAG.into(), value: etag_value.into() });
let is_range_request = request.header(Header::_RANGE).is_some();
if file_size > StaticResourceController::STREAM_THRESHOLD && !is_range_request {
let mime = MimeType::detect_mime_type(&static_filepath);
response.headers.push(Header {
name: Header::_CONTENT_TYPE.into(),
value: mime.into(),
});
response.headers.push(Header {
name: Header::_CONTENT_LENGTH.into(),
value: file_size.to_string().into(),
});
response.status_code = *status_code_reason_phrase.status_code;
response.reason_phrase = status_code_reason_phrase.reason_phrase.to_string();
response.stream_file = Some(static_filepath);
return response;
}
}
response.status_code = *status_code_reason_phrase.status_code;
response.reason_phrase = status_code_reason_phrase.reason_phrase.to_string();
response.content_range_list = content_range_list;
}
} else {
let error : Error = boxed_content_range_list.err().unwrap();
let body = error.message;
let content_range = Range::get_content_range(
Vec::from(body.as_bytes()),
MimeType::TEXT_HTML.to_string()
);
let content_range_list = vec![content_range];
response.status_code = *error.status_code_reason_phrase.status_code;
response.reason_phrase = error.status_code_reason_phrase.reason_phrase.to_string();
response.content_range_list = content_range_list;
}
response
}
pub const STREAM_THRESHOLD: u64 = 256 * 1024;
pub(crate) fn serve_regular_file(request: &Request, response: &mut Response, config: &ServerConfig) -> Option<Response> {
if request.method != METHOD.get && request.method != METHOD.head {
return None;
}
if request.header(Header::_RANGE).is_some() {
return None;
}
let path = URL::request_target_path(&request.request_uri)?;
let fs_path = static_fs_path(&os_specific_path(path)).ok()?;
let mime = MimeType::mime_type_for(&fs_path);
let follow_symlinks = mime != MimeType::APPLICATION_OCTET_STREAM;
let file = open_file_cache::open(&fs_path, follow_symlinks)?;
let state = file.state()?;
let encoding = if state.len <= open_file_cache::MAX_GZIPPED_FILE
&& state.len >= crate::compression::MIN_COMPRESS_SIZE as u64
&& crate::compression::is_compressible(mime)
{
crate::compression::negotiate(request, crate::compression::STATIC_PREFERENCE)
} else {
None
};
let modified = Decimal::new(state.modified);
let len = Decimal::new(state.len as u128);
let mut etag_value = String::with_capacity(modified.as_str().len() + len.as_str().len() + 8);
etag_value.push('"');
etag_value.push_str(modified.as_str());
etag_value.push('-');
etag_value.push_str(len.as_str());
if let Some(encoding) = encoding {
etag_value.push('-');
etag_value.push_str(encoding.token());
}
etag_value.push('"');
let not_modified = is_not_modified(request, state.modified, &etag_value);
let mut sent_encoded = None;
let large = state.len > StaticResourceController::STREAM_THRESHOLD;
let body = if not_modified {
None
} else if let Some(encoding) = encoding {
match file.encoded(&state, encoding, &fs_path).ok()? {
open_file_cache::Encoded::Smaller(bytes) => {
sent_encoded = Some(encoding);
Some(bytes.to_vec())
}
open_file_cache::Encoded::NotSmaller if large => None,
open_file_cache::Encoded::NotSmaller => Some(file.contents(&state).ok()?),
}
} else if large {
None
} else {
Some(file.contents(&state).ok()?)
};
let mut response = std::mem::replace(response, Response::empty());
set_static_cache_control(&mut response, &config.static_cache_control);
if config.nanos_headers {
response.headers.push(Header { name: Header::_LAST_MODIFIED_UNIX_EPOCH_NANOS.into(), value: modified.as_str().to_string().into() });
}
response.headers.push(Header { name: Header::_LAST_MODIFIED.into(), value: crate::http::date::format((state.modified / 1_000_000_000) as u64).into() });
response.headers.push(Header { name: Header::_ETAG.into(), value: etag_value.into() });
if not_modified {
response.set_status(STATUS_CODE_REASON_PHRASE.n304_not_modified);
return Some(response);
}
response.set_status(STATUS_CODE_REASON_PHRASE.n200_ok);
let Some(body) = body else {
response.headers.push(Header { name: Header::_CONTENT_TYPE.into(), value: mime.to_string().into() });
response.headers.push(Header { name: Header::_CONTENT_LENGTH.into(), value: len.as_str().to_string().into() });
response.stream_file = Some(fs_path);
return Some(response);
};
response.content_range_list = vec![Range::get_content_range(body, mime.to_string())];
if let Some(encoding) = sent_encoded {
crate::compression::mark_encoded(&mut response, encoding);
}
Some(response)
}
pub fn process_static_resources(request: &Request) -> Result<Vec<ContentRange>, Error> {
let url = request.request_uri.to_string();
let boxed_url_components = URL::parse_request_target(&url);
if boxed_url_components.is_err() {
let message = boxed_url_components.as_ref().err().unwrap().to_string();
println!("unexpected error, {}", message);
}
let components = boxed_url_components.unwrap();
let os_specific_separator : String = FileExt::get_path_separator();
let os_specific_path = &components.path.replace(SYMBOL.slash, os_specific_separator.as_str());
let static_filepath = match static_fs_path(&os_specific_path) {
Ok(path) => path,
Err(message) => {
return Err(Error { status_code_reason_phrase: STATUS_CODE_REASON_PHRASE.n404_not_found, message });
}
};
let mut content_range_list = Vec::new();
let mut boxed_md = metadata(&static_filepath);
if boxed_md.is_err() {
let dot_html = format!("{}{}", &static_filepath, ".html");
boxed_md = metadata(&dot_html);
if boxed_md.is_err() {
let slash_index_html = format!("{}{}{}", &static_filepath, os_specific_separator, "index.html");
boxed_md = metadata(&slash_index_html);
}
}
if boxed_md.is_ok() {
let md = boxed_md.unwrap();
if md.is_dir() {
let mut directory_index : String = "index.html".to_string();
let last_char = components.path.chars().last().unwrap();
if last_char != '/' {
let index : String = "index.html".to_string();
directory_index = format!("{}{}", os_specific_separator, index);
}
let index_html_in_directory = format!("{}{}", os_specific_path, directory_index);
let index_html_fs_path = format!("{}{}", static_filepath, directory_index);
if File::open(&index_html_fs_path).is_ok() {
let mut range_header = &Header {
name: Header::_RANGE.into(),
value: "bytes=0-".into()
};
let boxed_header = request.header(Header::_RANGE);
if boxed_header.is_some() {
range_header = boxed_header.unwrap();
}
let boxed_content_range_list = Range::get_content_range_list(&index_html_in_directory, range_header);
if boxed_content_range_list.is_ok() {
content_range_list = boxed_content_range_list.unwrap();
} else {
let error = boxed_content_range_list.err().unwrap();
return Err(error)
}
} else {
let listing_html = StaticResourceController::render_directory_listing(&static_filepath, &components.path);
let content_range = Range::get_content_range(listing_html.into_bytes(), MimeType::TEXT_HTML.to_string());
content_range_list = vec![content_range];
}
return Ok(content_range_list);
}
let boxed_file = File::open(&static_filepath);
if boxed_file.is_ok() {
let md = metadata(&static_filepath).unwrap();
if md.is_dir() {
let mut range_header = &Header {
name: Header::_RANGE.into(),
value: "bytes=0-".into()
};
let boxed_header = request.header(Header::_RANGE);
if boxed_header.is_some() {
range_header = boxed_header.unwrap();
}
let mut directory_index : String = "index.html".to_string();
let last_char = components.path.chars().last().unwrap();
if last_char != '/' {
let index : String = "index.html".to_string();
directory_index = format!("{}{}", os_specific_separator, index);
}
let index_html_in_directory = format!("{}{}", os_specific_path, directory_index);
let boxed_content_range_list = Range::get_content_range_list(&index_html_in_directory, range_header);
if boxed_content_range_list.is_ok() {
content_range_list = boxed_content_range_list.unwrap();
} else {
let error = boxed_content_range_list.err().unwrap();
return Err(error)
}
}
if md.is_file() {
let mut range_header = &Header {
name: Header::_RANGE.into(),
value: "bytes=0-".into()
};
let boxed_header = request.header(Header::_RANGE);
if boxed_header.is_some() {
range_header = boxed_header.unwrap();
}
let boxed_content_range_list = Range::get_content_range_list(&request.request_uri, range_header);
if boxed_content_range_list.is_ok() {
content_range_list = boxed_content_range_list.unwrap();
} else {
let error = boxed_content_range_list.err().unwrap();
return Err(error)
}
}
}
if boxed_file.is_err() {
let static_filepath = static_fs_path(&format!("{}.html", components.path)).unwrap_or_default();
let boxed_file = File::open(&static_filepath);
if boxed_file.is_ok() {
let md = metadata(&static_filepath).unwrap();
if md.is_file() {
let mut range_header = &Header {
name: Header::_RANGE.into(),
value: "bytes=0-".into()
};
let boxed_header = request.header(Header::_RANGE);
if boxed_header.is_some() {
range_header = boxed_header.unwrap();
}
let url = request.request_uri.to_string();
let boxed_url_components = URL::parse_request_target(&url);
if boxed_url_components.is_err() {
let message = boxed_url_components.as_ref().err().unwrap().to_string();
println!("unexpected error, {}", message);
}
let components = boxed_url_components.unwrap();
let html_file = [components.path.as_str(), ".html"].join(SYMBOL.empty_string);
let boxed_content_range_list = Range::get_content_range_list(html_file.as_str(), range_header);
if boxed_content_range_list.is_ok() {
content_range_list = boxed_content_range_list.unwrap();
} else {
let error = boxed_content_range_list.err().unwrap();
return Err(error)
}
}
}
}
}
if content_range_list.is_empty() {
if let Some(fallback_url_path) = spa_fallback_url_path(&components.path) {
let mut range_header = &Header {
name: Header::_RANGE.into(),
value: "bytes=0-".into()
};
let boxed_header = request.header(Header::_RANGE);
if boxed_header.is_some() {
range_header = boxed_header.unwrap();
}
if let Ok(list) = Range::get_content_range_list(&fallback_url_path, range_header) {
content_range_list = list;
}
}
}
Ok(content_range_list)
}
}
impl StaticResourceController {
pub fn render_directory_listing(fs_dir_path: &str, request_path: &str) -> String {
let normalized_request_path = if request_path.ends_with('/') {
request_path.to_string()
} else {
format!("{}/", request_path)
};
struct Entry {
name: String,
is_dir: bool,
size: u64,
modified_epoch_secs: u64,
}
let mut entries: Vec<Entry> = Vec::new();
if let Ok(dir_entries) = read_dir(fs_dir_path) {
for dir_entry in dir_entries.flatten() {
let name = dir_entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
continue;
}
if let Ok(md) = dir_entry.metadata() {
let modified_epoch_secs = md.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
entries.push(Entry {
name,
is_dir: md.is_dir(),
size: md.len(),
modified_epoch_secs,
});
}
}
}
entries.sort_by(|a, b| {
match (a.is_dir, b.is_dir) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
}
});
let segments: Vec<&str> = normalized_request_path.split('/').filter(|s| !s.is_empty()).collect();
let mut breadcrumb_html = String::from("<a href=\"/\">~</a>");
let mut accumulated_path = String::new();
for segment in &segments {
accumulated_path.push('/');
accumulated_path.push_str(segment);
breadcrumb_html.push_str(&format!(
" <span class=\"sep\">/</span> <a href=\"{}/\">{}</a>",
accumulated_path, html_escape(segment)
));
}
let parent_row = if !segments.is_empty() {
let parent_path = if segments.len() > 1 {
format!("/{}/", segments[..segments.len() - 1].join("/"))
} else {
"/".to_string()
};
format!(
"<tr class=\"entry parent\"><td class=\"name\"><span class=\"icon\">\u{21A9}\u{FE0F}</span><a href=\"{}\">.. (parent directory)</a></td><td class=\"size\">\u{2014}</td><td class=\"modified\">\u{2014}</td></tr>\n",
parent_path
)
} else {
String::new()
};
let entry_count = entries.len();
let mut rows = String::new();
for entry in &entries {
let escaped_name = html_escape(&entry.name);
let encoded_name = URL::percent_encode(&entry.name);
let href = if entry.is_dir {
format!("{}{}/", normalized_request_path, encoded_name)
} else {
format!("{}{}", normalized_request_path, encoded_name)
};
let size_display = if entry.is_dir { "\u{2014}".to_string() } else { human_size(entry.size) };
let modified_display = format_modified(entry.modified_epoch_secs);
let icon = icon_for(&entry.name, entry.is_dir);
let filter_key = html_escape(&entry.name.to_lowercase());
rows.push_str(&format!(
"<tr class=\"entry\" data-name=\"{}\"><td class=\"name\"><span class=\"icon\">{}</span><a href=\"{}\">{}{}</a></td><td class=\"size\">{}</td><td class=\"modified\">{}</td></tr>\n",
filter_key, icon, href, escaped_name, if entry.is_dir { "/" } else { "" }, size_display, modified_display
));
}
let mut html = String::new();
html.push_str(DIRECTORY_LISTING_HEAD);
html.push_str(&format!("<div class=\"breadcrumb\">{}</div>\n", breadcrumb_html));
html.push_str(&format!("<h1>Index of {}</h1>\n", html_escape(&normalized_request_path)));
html.push_str(DIRECTORY_LISTING_TOOLBAR);
html.push_str("<div class=\"card\"><table><thead><tr><th>Name</th><th>Size</th><th>Modified</th></tr></thead><tbody id=\"rows\">\n");
html.push_str(&parent_row);
html.push_str(&rows);
html.push_str("</tbody></table></div>\n");
html.push_str(&format!(
"<footer>{} item{} · served by rws</footer>\n",
entry_count, if entry_count == 1 { "" } else { "s" }
));
html.push_str(DIRECTORY_LISTING_TAIL);
html
}
}
fn path_has_extension(path: &str) -> bool {
path.rsplit('/').next().unwrap_or("").contains('.')
}
fn spa_fallback_url_path(url_path: &str) -> Option<String> {
if path_has_extension(url_path) {
return None;
}
let fallback_name = crate::entry_point::get_spa_fallback()?;
let exclude_prefixes = crate::entry_point::get_spa_fallback_exclude_prefixes();
if exclude_prefixes.iter().any(|prefix| url_path.starts_with(prefix.as_str())) {
return None;
}
let separator = FileExt::get_path_separator();
let normalized_name = fallback_name.trim_start_matches('/').replace('/', &separator);
let fallback_url_path = format!("{}{}", separator, normalized_name);
let boxed_fs_path = static_fs_path(&fallback_url_path);
let fs_path = boxed_fs_path.ok()?;
if File::open(&fs_path).is_ok() {
Some(fallback_url_path)
} else {
None
}
}
fn html_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(c),
}
}
out
}
fn human_size(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
if bytes < 1024 {
return format!("{} {}", bytes, UNITS[0]);
}
let mut size = bytes as f64;
let mut unit_index = 0;
while size >= 1024.0 && unit_index < UNITS.len() - 1 {
size /= 1024.0;
unit_index += 1;
}
format!("{:.1} {}", size, UNITS[unit_index])
}
fn format_modified(epoch_secs: u64) -> String {
let (sec, min, hour, day, month, _dow) = crate::scheduler::cron::epoch_to_datetime(epoch_secs);
let (year, _, _) = crate::scheduler::cron::days_to_ymd(epoch_secs / 86400);
format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02}", year, month, day, hour, min, sec)
}
fn icon_for(name: &str, is_dir: bool) -> &'static str {
if is_dir {
return "\u{1F4C1}";
}
let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
match ext.as_str() {
"png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "ico" | "bmp" => "\u{1F5BC}\u{FE0F}",
"mp4" | "mov" | "avi" | "mkv" | "webm" => "\u{1F3A5}",
"mp3" | "wav" | "flac" | "m4a" | "ogg" => "\u{1F3A7}",
"zip" | "tar" | "gz" | "rar" | "7z" | "bz2" => "\u{1F4E6}",
"pdf" => "\u{1F4D5}",
"html" | "htm" | "css" | "js" | "ts" | "rs" | "py" | "json" | "toml" | "yaml" | "yml" | "sh" => "\u{1F9E9}",
_ => "\u{1F4C4}",
}
}
const DIRECTORY_LISTING_HEAD: &str = concat!(
"<!DOCTYPE html>\n",
"<html lang=\"en\">\n",
"<head>\n",
"<meta charset=\"UTF-8\">\n",
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n",
"<title>Directory listing</title>\n",
"<link rel=\"stylesheet\" href=\"/rws-directory-listing.css\">\n",
"</head>\n",
"<body>\n",
"<div class=\"wrap\">\n",
);
const DIRECTORY_LISTING_TOOLBAR: &str = "<div class=\"toolbar\"><input type=\"search\" id=\"filter\" placeholder=\"Filter entries...\" autocomplete=\"off\"></div>\n";
const DIRECTORY_LISTING_TAIL: &str = concat!(
"</div>\n",
"<script src=\"/rws-directory-listing.js\" defer></script>\n",
"</body>\n",
"</html>\n",
);
fn push_validators(request: &Request, response: &mut Response, modified_nanos: u128, etag: &str, nanos_headers: bool) -> bool {
if nanos_headers {
response.headers.push(Header { name: Header::_LAST_MODIFIED_UNIX_EPOCH_NANOS.into(), value: modified_nanos.to_string().into() });
}
response.headers.push(Header { name: Header::_LAST_MODIFIED.into(), value: crate::http::date::format((modified_nanos / 1_000_000_000) as u64).into() });
is_not_modified(request, modified_nanos, etag)
}
fn is_not_modified(request: &Request, modified_nanos: u128, etag: &str) -> bool {
if let Some(inm) = request.header(Header::_IF_NONE_MATCH) {
return if_none_match_matches(&inm.value, etag);
}
if request.method != METHOD.get && request.method != METHOD.head {
return false;
}
request
.header(Header::_IF_MODIFIED_SINCE)
.and_then(|ims| crate::http::date::parse(&ims.value))
.is_some_and(|since| (modified_nanos / 1_000_000_000) as u64 <= since)
}
fn set_static_cache_control(response: &mut Response, value: &str) {
if value.is_empty() {
return;
}
if let Some(header) = response.headers.iter_mut().find(|h| h.name == Header::_CACHE_CONTROL) {
header.value = if value == crate::server_config::STATIC_CACHE_CONTROL_DEFAULT {
std::borrow::Cow::Borrowed(crate::server_config::STATIC_CACHE_CONTROL_DEFAULT)
} else {
std::borrow::Cow::Owned(value.to_string())
};
}
}
pub(crate) fn working_directory() -> &'static str {
static DIR: std::sync::OnceLock<String> = std::sync::OnceLock::new();
DIR.get_or_init(|| {
std::env::current_dir()
.map(|d| d.to_string_lossy().into_owned())
.unwrap_or_default()
})
}
pub(crate) fn if_none_match_matches(header_value: &str, etag: &str) -> bool {
let opaque = |tag: &str| tag.trim().strip_prefix("W/").unwrap_or(tag.trim()).to_string();
let current = opaque(etag);
header_value.split(',').any(|candidate| {
let candidate = candidate.trim();
candidate == "*" || (!candidate.is_empty() && opaque(candidate) == current)
})
}
pub(crate) fn range_is_stale(request: &Request) -> bool {
let Some(if_range) = request.header(Header::_IF_RANGE) else { return false };
if request.header(Header::_RANGE).is_none() {
return false;
}
let validator = if_range.value.trim();
let Some(path) = URL::request_target_path(&request.request_uri) else { return true };
let Ok(fs_path) = static_fs_path(&os_specific_path(path)) else { return true };
let (Ok(modified), Ok(md)) = (file_modified_utc(&fs_path), metadata(&fs_path)) else { return true };
if validator.starts_with('"') {
return validator != format!("\"{}-{}\"", modified, md.len());
}
let modified_secs = (modified / 1_000_000_000) as u64;
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
match crate::http::date::parse(validator) {
Some(date) => date != modified_secs || now < modified_secs + 1,
None => true,
}
}
pub(crate) fn static_fs_path(path: &str) -> Result<String, String> {
let path = percent_decode_path(path)?;
check_request_path(&path)?;
let working_directory = working_directory();
let mut fs_path = String::with_capacity(working_directory.len() + path.len());
fs_path.push_str(working_directory);
fs_path.push_str(&path);
Ok(fs_path)
}
pub(crate) fn os_specific_path(path: &str) -> std::borrow::Cow<'_, str> {
if std::path::MAIN_SEPARATOR == '/' {
std::borrow::Cow::Borrowed(path)
} else {
std::borrow::Cow::Owned(path.replace('/', std::path::MAIN_SEPARATOR_STR))
}
}
pub(crate) fn percent_decode_path(path: &str) -> Result<std::borrow::Cow<'_, str>, String> {
if !path.contains('%') {
return Ok(std::borrow::Cow::Borrowed(path));
}
let hex = |b: u8| (b as char).to_digit(16).map(|d| d as u8);
let bytes = path.as_bytes();
let mut decoded = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let (Some(high), Some(low)) = (hex(bytes[i + 1]), hex(bytes[i + 2])) {
decoded.push(high << 4 | low);
i += 3;
continue;
}
}
decoded.push(bytes[i]);
i += 1;
}
String::from_utf8(decoded)
.map(std::borrow::Cow::Owned)
.map_err(|_| "path is not UTF-8 after percent-decoding".to_string())
}
pub(crate) fn check_request_path(path: &str) -> Result<(), String> {
for segment in path.split(['/', '\\']) {
if segment == ".." {
return Err("path leaves the web root".to_string());
}
if segment.starts_with('.') && segment != "." && segment != ".well-known" && !serve_dotfiles() {
return Err("hidden files are not served".to_string());
}
if segment.contains('\0') {
return Err("invalid path".to_string());
}
}
let name = path.rsplit(['/', '\\']).next().unwrap_or("").to_ascii_lowercase();
if name.ends_with(".pem") || name.ends_with(".key") || name == "rws.config.toml" {
return Err("key and config files are not served".to_string());
}
Ok(())
}
fn serve_dotfiles() -> bool {
static SERVE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*SERVE.get_or_init(|| {
std::env::var("RWS_CONFIG_SERVE_DOTFILES").is_ok_and(|v| v.trim().eq_ignore_ascii_case("true"))
})
}
pub(crate) fn inside_root(fs_path: &str) -> bool {
static ROOT: std::sync::OnceLock<Option<std::path::PathBuf>> = std::sync::OnceLock::new();
let Some(root) = ROOT.get_or_init(|| std::fs::canonicalize(working_directory()).ok()) else {
return false;
};
std::fs::canonicalize(fs_path).is_ok_and(|resolved| resolved.starts_with(root))
}
pub(crate) fn file_modified_utc(path: &str) -> Result<u128, String> {
let modified = metadata(path)
.and_then(|md| md.modified())
.map_err(|e| format!("<p>Unable to open file: {}</p> <p>error: {}</p>", path, e))?;
Ok(modified
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0))
}