use super::{
backend::{Backend, File as _, Metadata as _},
headers::{ETag, IfMatch, IfModifiedSince, IfNoneMatch, IfUnmodifiedSince, LastModified},
ServeVariant,
};
use crate::content_encoding::{Encoding, QValue};
use bytes::Bytes;
use http::{header, HeaderValue, Method, Request, Uri};
use http_body_util::Empty;
use http_range_header::RangeUnsatisfiableError;
use std::{
ffi::OsStr,
io::{self, ErrorKind, SeekFrom},
ops::RangeInclusive,
path::{Path, PathBuf},
};
use tokio::io::AsyncSeekExt;
pub(super) enum OpenFileOutput {
FileOpened(Box<FileOpened>),
Redirect {
location: HeaderValue,
},
FileNotFound,
PreconditionFailed,
NotModified {
etag: Option<ETag>,
last_modified: Option<LastModified>,
},
InvalidRedirectUri,
InvalidFilename,
}
pub(super) struct FileOpened {
pub(super) extent: FileRequestExtent,
pub(super) chunk_size: usize,
pub(super) mime_header_value: HeaderValue,
pub(super) maybe_encoding: Option<Encoding>,
pub(super) maybe_range: Option<Result<Vec<RangeInclusive<u64>>, RangeUnsatisfiableError>>,
pub(super) last_modified: Option<LastModified>,
pub(super) precompression_configured: bool,
pub(super) etag: Option<ETag>,
}
pub(super) enum FileRequestExtent {
Full(Box<dyn tokio::io::AsyncRead + Unpin + Send>, u64),
Head(u64),
}
pub(super) struct OpenFileRequest<B> {
pub(super) variant: ServeVariant,
pub(super) redirect_path_prefix: String,
pub(super) path_to_file: PathBuf,
pub(super) req: Request<Empty<Bytes>>,
pub(super) negotiated_encodings: Vec<(Encoding, QValue)>,
pub(super) range_header: Option<String>,
pub(super) buf_chunk_size: usize,
pub(super) precompression_configured: bool,
pub(super) backend: B,
}
pub(super) async fn open_file<B: Backend>(
request: OpenFileRequest<B>,
) -> io::Result<OpenFileOutput> {
let OpenFileRequest {
variant,
redirect_path_prefix,
mut path_to_file,
req,
negotiated_encodings,
range_header,
buf_chunk_size,
precompression_configured,
backend,
} = request;
let preconditions = Preconditions {
if_match: req
.headers()
.get(header::IF_MATCH)
.and_then(IfMatch::from_header_value),
if_unmodified_since: req
.headers()
.get(header::IF_UNMODIFIED_SINCE)
.and_then(IfUnmodifiedSince::from_header_value),
if_none_match: req
.headers()
.get(header::IF_NONE_MATCH)
.and_then(IfNoneMatch::from_header_value),
if_modified_since: req
.headers()
.get(header::IF_MODIFIED_SINCE)
.and_then(IfModifiedSince::from_header_value),
};
let mime = match variant {
ServeVariant::Directory {
append_index_html_on_directories,
html_as_default_extension,
} => {
if let Some(output) = maybe_redirect_or_append_path(
&redirect_path_prefix,
&mut path_to_file,
req.uri(),
append_index_html_on_directories,
html_as_default_extension,
&backend,
)
.await
{
return Ok(output);
}
mime_guess::from_path(&path_to_file)
.first_raw()
.map(HeaderValue::from_static)
.unwrap_or_else(|| {
HeaderValue::from_str(mime::APPLICATION_OCTET_STREAM.as_ref()).unwrap()
})
}
ServeVariant::SingleFile { mime } => mime,
};
if req.method() == Method::HEAD {
#[cfg(feature = "tracing")]
let _path_str = path_to_file.display().to_string();
let (meta, maybe_encoding) =
file_metadata_with_fallback(&backend, path_to_file, negotiated_encodings).await?;
let last_modified = meta.modified().ok().map(LastModified::from);
let etag = meta
.modified()
.ok()
.and_then(|mtime| ETag::from_metadata(meta.len(), mtime));
#[cfg(feature = "tracing")]
if etag.is_none() {
rate_limited!(
std::time::Duration::from_secs(60),
tracing::warn!(path = %_path_str, "ETag generation failed (mtime unavailable or pre-epoch)")
);
}
if let Some(output) = preconditions.check(etag.as_ref(), last_modified.as_ref()) {
return Ok(output);
}
let maybe_range = try_parse_range(range_header.as_deref(), meta.len());
Ok(OpenFileOutput::FileOpened(Box::new(FileOpened {
extent: FileRequestExtent::Head(meta.len()),
chunk_size: buf_chunk_size,
mime_header_value: mime,
maybe_encoding,
maybe_range,
last_modified,
precompression_configured,
etag,
})))
} else {
#[cfg(feature = "tracing")]
let _path_str = path_to_file.display().to_string();
let (mut file, maybe_encoding) =
match open_file_with_fallback(&backend, path_to_file, negotiated_encodings).await {
Ok(result) => result,
Err(err) if is_invalid_filename_error(&err) => {
return Ok(OpenFileOutput::InvalidFilename)
}
Err(err) => return Err(err),
};
let meta = file.metadata().await?;
let last_modified = meta.modified().ok().map(LastModified::from);
let etag = meta
.modified()
.ok()
.and_then(|mtime| ETag::from_metadata(meta.len(), mtime));
#[cfg(feature = "tracing")]
if etag.is_none() {
rate_limited!(
std::time::Duration::from_secs(60),
tracing::warn!(path = %_path_str, "ETag generation failed (mtime unavailable or pre-epoch)")
);
}
if let Some(output) = preconditions.check(etag.as_ref(), last_modified.as_ref()) {
return Ok(output);
}
let size = meta.len();
let maybe_range = try_parse_range(range_header.as_deref(), size);
if let Some(Ok(ranges)) = maybe_range.as_ref() {
if ranges.len() == 1 {
file.seek(SeekFrom::Start(*ranges[0].start())).await?;
}
}
Ok(OpenFileOutput::FileOpened(Box::new(FileOpened {
extent: FileRequestExtent::Full(Box::new(file), size),
chunk_size: buf_chunk_size,
mime_header_value: mime,
maybe_encoding,
maybe_range,
last_modified,
precompression_configured,
etag,
})))
}
}
fn is_invalid_filename_error(err: &io::Error) -> bool {
if err.kind() == ErrorKind::InvalidInput {
return true;
}
#[cfg(windows)]
if let Some(raw_err) = err.raw_os_error() {
if (raw_err == 123) || (raw_err == 161) || (raw_err == 206) {
return true;
}
}
false
}
struct Preconditions {
if_match: Option<IfMatch>,
if_unmodified_since: Option<IfUnmodifiedSince>,
if_none_match: Option<IfNoneMatch>,
if_modified_since: Option<IfModifiedSince>,
}
impl Preconditions {
fn check(
self,
etag: Option<&ETag>,
last_modified: Option<&LastModified>,
) -> Option<OpenFileOutput> {
if let Some(if_match) = self.if_match {
let passes = etag
.map(|etag| if_match.precondition_passes(etag))
.unwrap_or(false);
if !passes {
return Some(OpenFileOutput::PreconditionFailed);
}
} else {
if let Some(since) = self.if_unmodified_since {
let passes = last_modified
.map(|lm| since.precondition_passes(lm))
.unwrap_or(true);
if !passes {
return Some(OpenFileOutput::PreconditionFailed);
}
}
}
if let Some(if_none_match) = self.if_none_match {
let passes = etag
.map(|etag| if_none_match.precondition_passes(etag))
.unwrap_or(true);
if !passes {
return Some(OpenFileOutput::NotModified {
etag: etag.cloned(),
last_modified: last_modified.map(|lm| LastModified(lm.0)),
});
}
} else {
if let Some(since) = self.if_modified_since {
let unmodified = last_modified
.map(|lm| !since.is_modified(lm))
.unwrap_or(false);
if unmodified {
return Some(OpenFileOutput::NotModified {
etag: etag.cloned(),
last_modified: last_modified.map(|lm| LastModified(lm.0)),
});
}
}
}
None
}
}
fn preferred_encoding(
path: &mut PathBuf,
negotiated_encoding: &[(Encoding, QValue)],
) -> Option<Encoding> {
let preferred_encoding = Encoding::preferred_encoding(negotiated_encoding.iter().copied());
if let Some(file_extension) =
preferred_encoding.and_then(|encoding| encoding.to_file_extension())
{
let new_file_name = path
.file_name()
.map(|file_name| {
let mut os_string = file_name.to_os_string();
os_string.push(file_extension);
os_string
})
.unwrap_or_else(|| file_extension.to_os_string());
path.set_file_name(new_file_name);
}
preferred_encoding
}
async fn open_file_with_fallback<B: Backend>(
backend: &B,
mut path: PathBuf,
mut negotiated_encoding: Vec<(Encoding, QValue)>,
) -> io::Result<(B::File, Option<Encoding>)> {
let (file, encoding) = loop {
let encoding = preferred_encoding(&mut path, &negotiated_encoding);
match (backend.open(path.clone()).await, encoding) {
(Ok(file), maybe_encoding) => break (file, maybe_encoding),
(Err(err), Some(encoding))
if err.kind() == io::ErrorKind::NotFound && encoding != Encoding::Identity =>
{
path.set_extension(OsStr::new(""));
negotiated_encoding
.retain(|(negotiated_encoding, _)| *negotiated_encoding != encoding);
}
(Err(err), _) => return Err(err),
}
};
Ok((file, encoding))
}
async fn file_metadata_with_fallback<B: Backend>(
backend: &B,
mut path: PathBuf,
mut negotiated_encoding: Vec<(Encoding, QValue)>,
) -> io::Result<(B::Metadata, Option<Encoding>)> {
let (meta, encoding) = loop {
let encoding = preferred_encoding(&mut path, &negotiated_encoding);
match (backend.metadata(path.clone()).await, encoding) {
(Ok(meta), maybe_encoding) => break (meta, maybe_encoding),
(Err(err), Some(encoding))
if err.kind() == io::ErrorKind::NotFound && encoding != Encoding::Identity =>
{
path.set_extension(OsStr::new(""));
negotiated_encoding
.retain(|(negotiated_encoding, _)| *negotiated_encoding != encoding);
}
(Err(err), _) => return Err(err),
}
};
Ok((meta, encoding))
}
async fn maybe_redirect_or_append_path<B: Backend>(
redirect_path_prefix: &str,
path_to_file: &mut PathBuf,
uri: &Uri,
append_index_html_on_directories: bool,
html_as_default_extension: bool,
backend: &B,
) -> Option<OpenFileOutput> {
let uri_path = uri.path();
let is_directory = is_dir(path_to_file, backend).await;
if uri_path.ends_with('/') && uri_path != "/" && is_directory != Some(true) {
return Some(OpenFileOutput::FileNotFound);
}
if html_as_default_extension && is_directory.is_none() && path_to_file.extension().is_none() {
path_to_file.set_extension("html");
return None;
}
if is_directory != Some(true) {
return None;
}
if !append_index_html_on_directories {
return Some(OpenFileOutput::FileNotFound);
}
if uri_path.ends_with('/') {
path_to_file.push("index.html");
None
} else {
let uri = match append_slash_on_path(uri.clone(), redirect_path_prefix) {
Ok(uri) => uri,
Err(err) => return Some(err),
};
let location = HeaderValue::from_str(&uri.to_string()).unwrap();
Some(OpenFileOutput::Redirect { location })
}
}
fn try_parse_range(
maybe_range_ref: Option<&str>,
file_size: u64,
) -> Option<Result<Vec<RangeInclusive<u64>>, RangeUnsatisfiableError>> {
maybe_range_ref.map(|header_value| {
http_range_header::parse_range_header(header_value)
.and_then(|first_pass| first_pass.validate(file_size))
})
}
async fn is_dir<B: Backend>(path_to_file: &Path, backend: &B) -> Option<bool> {
backend
.metadata(path_to_file.to_owned())
.await
.ok()
.map(|meta_data| meta_data.is_dir())
}
fn append_slash_on_path(uri: Uri, redirect_path_prefix: &str) -> Result<Uri, OpenFileOutput> {
let http::uri::Parts {
scheme,
authority,
path_and_query,
..
} = uri.into_parts();
let mut uri_builder = Uri::builder();
if let Some(scheme) = scheme {
uri_builder = uri_builder.scheme(scheme);
}
if let Some(authority) = authority {
uri_builder = uri_builder.authority(authority);
}
let uri_builder = if let Some(path_and_query) = path_and_query {
if let Some(query) = path_and_query.query() {
uri_builder.path_and_query(format!(
"{redirect_path_prefix}{}/?{}",
path_and_query.path(),
query
))
} else {
uri_builder.path_and_query(format!("{redirect_path_prefix}{}/", path_and_query.path()))
}
} else {
uri_builder.path_and_query(format!("{redirect_path_prefix}/"))
};
uri_builder.build().map_err(|_err| {
#[cfg(feature = "tracing")]
tracing::error!(err = ?_err, "redirect uri failed to build");
OpenFileOutput::InvalidRedirectUri
})
}
#[test]
fn preferred_encoding_with_extension() {
let mut path = PathBuf::from("hello.txt");
preferred_encoding(&mut path, &[(Encoding::Gzip, QValue::one())]);
assert_eq!(path, PathBuf::from("hello.txt.gz"));
}
#[test]
fn preferred_encoding_without_extension() {
let mut path = PathBuf::from("hello");
preferred_encoding(&mut path, &[(Encoding::Gzip, QValue::one())]);
assert_eq!(path, PathBuf::from("hello.gz"));
}