pingora_web 0.1.8

Minimal routing, middleware, and logging (with request ID) for Pingora-based servers.
Documentation
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>"#;

/// Serve static files from a directory, similar to axum's ServeDir.
///
/// Usage:
///   router.get("/assets/{*path}", Arc::new(ServeDir::new("assets")));
///
/// Security: performs simple path normalization to prevent path traversal.
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
    }
}