use std::path::Path;
use std::path::PathBuf;
#[cfg(feature = "compio")]
use compio::fs;
use http::StatusCode;
use http::header;
use tako_rs_core::body::TakoBody;
use tako_rs_core::responder::Responder;
use tako_rs_core::types::Request;
use tako_rs_core::types::Response;
#[cfg(not(feature = "compio"))]
use tokio::fs;
#[cfg(not(feature = "compio"))]
use tokio::io::AsyncReadExt;
#[doc(alias = "static")]
#[doc(alias = "serve_dir")]
pub struct ServeDir {
base_dir: PathBuf,
fallback: Option<PathBuf>,
index_files: Vec<String>,
precompressed: PrecompressedPolicy,
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)
}
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
}
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
}
}
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
}
async fn resolve_existing(
&self,
file_path: PathBuf,
headers: &http::HeaderMap,
) -> Option<(Response, &'static str)> {
let target = if file_path.is_dir() {
let mut chosen: Option<PathBuf> = None;
for idx in &self.index_files {
let cand = file_path.join(idx);
if !cand.is_file() {
continue;
}
if let Some(canonical) = self.canonical_within_base(&cand) {
chosen = Some(canonical);
break;
}
}
chosen?
} else {
file_path
};
if let Some((compressed, encoding)) = self.precompressed_variant(&target, headers) {
if let Some(resp) = Self::serve_file_with_encoding(&compressed, &target, encoding).await {
return Some((resp, encoding));
}
tracing::debug!(
target = %target.display(),
encoding,
"precompressed sidecar read failed, falling back to identity"
);
}
Some((Self::serve_file(&target).await?, "identity"))
}
#[cfg(not(feature = "compio"))]
async fn open_and_read_regular(path: &Path) -> Option<Vec<u8>> {
let mut file = fs::File::open(path).await.ok()?;
let meta = file.metadata().await.ok()?;
if !meta.is_file() {
return None;
}
let mut contents = Vec::with_capacity(meta.len() as usize);
file.read_to_end(&mut contents).await.ok()?;
Some(contents)
}
#[cfg(feature = "compio")]
async fn open_and_read_regular(path: &Path) -> Option<Vec<u8>> {
let meta = fs::metadata(path).await.ok()?;
if !meta.is_file() {
return None;
}
fs::read(path).await.ok()
}
async fn serve_file(file_path: &Path) -> Option<Response> {
let contents = Self::open_and_read_regular(file_path).await?;
let mime = mime_guess::from_path(file_path).first_or_octet_stream();
Some(
http::Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime.to_string())
.body(TakoBody::from(contents))
.unwrap(),
)
}
async fn serve_file_with_encoding(
compressed: &Path,
original: &Path,
encoding: &'static str,
) -> Option<Response> {
let contents = Self::open_and_read_regular(compressed).await?;
let mime = mime_guess::from_path(original).first_or_octet_stream();
Some(
http::Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime.to_string())
.header(header::CONTENT_ENCODING, encoding)
.header(header::VARY, "Accept-Encoding")
.body(TakoBody::from(contents))
.unwrap(),
)
}
pub async fn handle(&self, req: Request) -> impl Responder {
let path = req.uri().path();
let headers = req.headers().clone();
if let Some(file_path) = self.sanitize_path(path)
&& let Some((resp, _enc)) = self.resolve_existing(file_path, &headers).await
{
return resp;
}
if let Some(fallback) = &self.fallback
&& let Some((resp, _)) = self.resolve_existing(fallback.clone(), &headers).await
{
return resp;
}
http::Response::builder()
.status(StatusCode::NOT_FOUND)
.body(TakoBody::from("File not found"))
.unwrap()
}
}
#[doc(alias = "serve_file")]
pub struct ServeFile {
path: PathBuf,
}
#[must_use]
pub struct ServeFileBuilder {
path: PathBuf,
}
impl ServeFileBuilder {
#[inline]
pub fn new<P: Into<PathBuf>>(path: P) -> Self {
Self { path: path.into() }
}
#[inline]
#[must_use]
pub fn build(self) -> ServeFile {
ServeFile { path: self.path }
}
}
impl ServeFile {
pub fn builder<P: Into<PathBuf>>(path: P) -> ServeFileBuilder {
ServeFileBuilder::new(path)
}
async fn serve_file(&self) -> Option<Response> {
match fs::read(&self.path).await {
Ok(contents) => {
let mime = mime_guess::from_path(&self.path).first_or_octet_stream();
Some(
http::Response::builder()
.status(StatusCode::OK)
.header(http::header::CONTENT_TYPE, mime.to_string())
.body(TakoBody::from(contents))
.unwrap(),
)
}
Err(_) => None,
}
}
pub async fn handle(&self, _req: Request) -> impl Responder {
if let Some(resp) = self.serve_file().await {
resp
} else {
let mut resp = http::Response::new(TakoBody::from("File not found"));
*resp.status_mut() = StatusCode::NOT_FOUND;
resp
}
}
}