use std::path::Path;
use std::path::PathBuf;
use http::header;
#[doc(alias = "static")]
#[doc(alias = "serve_dir")]
pub struct ServeDir {
pub(crate) base_dir: PathBuf,
pub(crate) fallback: Option<PathBuf>,
pub(crate) index_files: Vec<String>,
pub(crate) precompressed: PrecompressedPolicy,
pub(crate) sanitized_base: Option<PathBuf>,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct PrecompressedPolicy {
pub brotli: bool,
pub gzip: bool,
}
impl PrecompressedPolicy {
pub const fn both() -> Self {
Self {
brotli: true,
gzip: true,
}
}
pub const fn brotli_only() -> Self {
Self {
brotli: true,
gzip: false,
}
}
pub const fn gzip_only() -> Self {
Self {
brotli: false,
gzip: true,
}
}
}
#[must_use]
pub struct ServeDirBuilder {
base_dir: PathBuf,
fallback: Option<PathBuf>,
index_files: Vec<String>,
precompressed: PrecompressedPolicy,
}
impl ServeDirBuilder {
#[inline]
pub fn new<P: Into<PathBuf>>(base_dir: P) -> Self {
Self {
base_dir: base_dir.into(),
fallback: None,
index_files: vec!["index.html".into(), "index.htm".into()],
precompressed: PrecompressedPolicy::default(),
}
}
#[inline]
pub fn fallback<P: Into<PathBuf>>(mut self, fallback: P) -> Self {
self.fallback = Some(fallback.into());
self
}
#[inline]
pub fn index_files<I, S>(mut self, names: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.index_files = names.into_iter().map(Into::into).collect();
self
}
#[inline]
pub fn precompressed(mut self, policy: PrecompressedPolicy) -> Self {
self.precompressed = policy;
self
}
#[inline]
pub fn build(self) -> ServeDir {
let sanitized_base = self.base_dir.canonicalize().ok();
ServeDir {
base_dir: self.base_dir,
fallback: self.fallback,
index_files: self.index_files,
precompressed: self.precompressed,
sanitized_base,
}
}
}
impl ServeDir {
pub fn builder<P: Into<PathBuf>>(base_dir: P) -> ServeDirBuilder {
ServeDirBuilder::new(base_dir)
}
pub(crate) fn sanitize_path(&self, req_path: &str) -> Option<PathBuf> {
let rel_path = req_path.trim_start_matches('/');
if rel_path
.split(['/', '\\'])
.any(|seg| seg == ".." || seg == ".")
{
return None;
}
let joined = self.base_dir.join(rel_path);
let canonical = joined.canonicalize().ok()?;
let base = self
.sanitized_base
.clone()
.or_else(|| self.base_dir.canonicalize().ok())?;
if canonical.starts_with(&base) {
Some(canonical)
} else {
None
}
}
fn accepts(headers: &http::HeaderMap, encoding: &str) -> bool {
let Some(v) = headers
.get(header::ACCEPT_ENCODING)
.and_then(|v| v.to_str().ok())
else {
return false;
};
for part in v.split(',') {
let part = part.trim();
let mut name_q = part.split(';');
let name = name_q.next().unwrap_or("").trim();
let q_zero = name_q.any(|p| p.trim().strip_prefix("q=").is_some_and(|q| q.trim() == "0"));
if q_zero {
continue;
}
if name.eq_ignore_ascii_case(encoding) || name == "*" {
return true;
}
}
false
}
pub(crate) fn canonical_within_base(&self, p: &Path) -> Option<PathBuf> {
let canonical = p.canonicalize().ok()?;
let base = self
.sanitized_base
.clone()
.or_else(|| self.base_dir.canonicalize().ok())?;
if canonical.starts_with(&base) {
Some(canonical)
} else {
None
}
}
pub(crate) fn precompressed_variant(
&self,
file_path: &Path,
headers: &http::HeaderMap,
) -> Option<(PathBuf, &'static str)> {
if self.precompressed.brotli && Self::accepts(headers, "br") {
let mut p = file_path.as_os_str().to_owned();
p.push(".br");
let p = PathBuf::from(p);
if let Some(canonical) = self.canonical_within_base(&p) {
return Some((canonical, "br"));
}
}
if self.precompressed.gzip && Self::accepts(headers, "gzip") {
let mut p = file_path.as_os_str().to_owned();
p.push(".gz");
let p = PathBuf::from(p);
if let Some(canonical) = self.canonical_within_base(&p) {
return Some((canonical, "gzip"));
}
}
None
}
}