use std::path::{Component, Path, PathBuf};
use async_trait::async_trait;
use http::StatusCode;
use crate::core::Handler;
use crate::core::{PingoraHttpRequest, PingoraWebHttpResponse};
use crate::error::WebError;
const DIRECTORY_LISTING_HTML: &str = r#"<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Directory Listing</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
padding: 40px;
background: #f5f5f5;
}
.container {
max-width: 900px;
margin: 0 auto;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
padding: 32px;
}
h1 {
font-size: 22px;
font-weight: 600;
color: #1f2937;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid #e5e7eb;
}
.listing { display: flex; flex-direction: column; gap: 4px; }
a {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
text-decoration: none;
color: #2563eb;
border-radius: 8px;
transition: all 0.15s ease;
}
a:hover {
background: #eff6ff;
}
.up {
background: #f9fafb;
margin-bottom: 8px;
}
.up:hover { background: #f3f4f6; }
.icon {
width: 20px;
height: 20px;
flex-shrink: 0;
}
.dir .icon { color: #f59e0b; }
.file .icon { color: #6b7280; }
.name {
font-size: 15px;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
</head>
<body>
<div class="container">
<h1>{{title}}</h1>
<div class="listing">{{listing}}</div>
</div>
</body>
</html>"#;
pub struct ServeDir {
root: PathBuf,
param: Option<String>,
fallback: Option<PathBuf>,
list_directory: bool,
}
impl ServeDir {
pub fn new<P: Into<PathBuf>>(root: P) -> Self {
Self {
root: root.into(),
param: None,
fallback: None,
list_directory: true,
}
}
pub fn with_param_name<S: Into<String>>(mut self, name: S) -> Self {
self.param = Some(name.into());
self
}
pub fn with_fallback<P: AsRef<str>>(mut self, name: P) -> Self {
let mut out = PathBuf::new();
for comp in Path::new(name.as_ref()).components() {
if let Component::Normal(s) = comp {
out.push(s);
}
}
self.fallback = if out.as_os_str().is_empty() {
None
} else {
Some(out)
};
self
}
pub fn with_list_directory(mut self, enabled: bool) -> Self {
self.list_directory = enabled;
self
}
fn sanitize(rel: &str) -> PathBuf {
let mut out = PathBuf::new();
for comp in Path::new(rel).components() {
if let Component::Normal(s) = comp {
out.push(s)
}
}
out
}
fn get_path_param<'a>(&self, req: &'a PingoraHttpRequest) -> Option<&'a str> {
if let Some(name) = &self.param {
if let Some(v) = req.param(name) {
return Some(v);
}
}
if let Some(v) = req.param("path") {
return Some(v);
}
if let Some(v) = req.param("*path") {
return Some(v);
}
if let Some(v) = req.param("file") {
return Some(v);
}
if req.params.len() == 1 {
let (_, v) = req.params.iter().next().unwrap();
return Some(v.as_str());
}
None
}
async fn generate_directory_listing(
dir_path: &Path,
request_path: &str,
) -> String {
let mut listing = String::new();
const FOLDER_ICON: &str = r#"<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>"#;
const FILE_ICON: &str = r#"<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path><polyline points="13 2 13 9 20 9"></polyline></svg>"#;
const UP_ICON: &str = r#"<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>"#;
if !request_path.is_empty() {
let parent_href = "../".to_string();
listing.push_str(&format!(
"<a class=\"up\" href=\"{href}\">{icon}<span class=\"name\">..</span></a>\n",
href = parent_href,
icon = UP_ICON
));
}
if let Ok(mut entries) = tokio::fs::read_dir(dir_path).await {
let mut dirs = Vec::new();
let mut files = Vec::new();
while let Ok(Some(entry)) = entries.next_entry().await {
let name = entry.file_name();
let name_str = name.to_string_lossy().into_owned();
if name_str.starts_with('.') {
continue;
}
let is_dir = entry
.metadata()
.await
.map(|m| m.is_dir())
.unwrap_or(false);
if is_dir {
dirs.push(name_str);
} else {
files.push(name_str);
}
}
dirs.sort();
files.sort();
for dir in dirs {
let href = format!("{}/", dir);
listing.push_str(&format!(
"<a class=\"dir\" href=\"{href}\">{icon}<span class=\"name\">{dir}/</span></a>\n",
href = href,
icon = FOLDER_ICON,
dir = dir
));
}
for file in files {
listing.push_str(&format!(
"<a class=\"file\" href=\"{href}\">{icon}<span class=\"name\">{file}</span></a>\n",
href = file,
icon = FILE_ICON,
file = file
));
}
}
let title = if request_path.is_empty() {
"Index of /".to_string()
} else {
format!("Index of /{}", request_path)
};
DIRECTORY_LISTING_HTML
.replace("{{title}}", &title)
.replace("{{listing}}", &listing)
}
async fn serve_file(&self, path: PathBuf) -> Result<PingoraWebHttpResponse, WebError> {
let root_canon = match tokio::fs::canonicalize(&self.root).await {
Ok(p) => p,
Err(_) => {
return Ok(PingoraWebHttpResponse::text(
StatusCode::NOT_FOUND,
"Not Found",
));
}
};
let full_canon = match tokio::fs::canonicalize(&path).await {
Ok(p) => p,
Err(_) => {
return Ok(PingoraWebHttpResponse::text(
StatusCode::NOT_FOUND,
"Not Found",
));
}
};
if !full_canon.starts_with(&root_canon) {
return Ok(PingoraWebHttpResponse::text(
StatusCode::NOT_FOUND,
"Not Found",
));
}
match tokio::fs::metadata(&full_canon).await {
Ok(meta) if meta.is_file() => Ok(PingoraWebHttpResponse::stream_file(
StatusCode::OK,
&full_canon,
)),
_ => Ok(PingoraWebHttpResponse::text(
StatusCode::NOT_FOUND,
"Not Found",
)),
}
}
}
#[async_trait]
impl Handler for ServeDir {
async fn handle(&self, req: PingoraHttpRequest) -> Result<PingoraWebHttpResponse, WebError> {
let (full_path, rel_path) = match self.get_path_param(&req) {
Some(rel) => {
let safe = Self::sanitize(rel);
(self.root.join(safe), rel)
}
None => (self.root.clone(), ""),
};
if let Ok(meta) = tokio::fs::metadata(&full_path).await {
if meta.is_dir() {
if let Some(fb) = &self.fallback {
let index_path = full_path.join(fb);
if tokio::fs::metadata(&index_path).await.is_ok() {
return self.serve_file(index_path).await;
}
}
if self.list_directory {
let html = Self::generate_directory_listing(&full_path, &rel_path).await;
return Ok(PingoraWebHttpResponse::html(StatusCode::OK, html));
}
return Ok(PingoraWebHttpResponse::text(
StatusCode::NOT_FOUND,
"Not Found",
));
}
}
self.serve_file(full_path).await
}
}