volo_http/server/utils/
serve_dir.rs

1//! Service for serving a directory.
2//!
3//! This module includes [`ServeDir`], which can be used for serving a directory through a
4//! catch-all uri like `/static/{*path}` or `Router::nest_service`.
5//!
6//! # Examples
7//!
8//! ```
9//! use volo_http::server::{
10//!     route::{Router, get},
11//!     utils::ServeDir,
12//! };
13//!
14//! let router: Router = Router::new()
15//!     .route("/", get(|| async { "Hello, World" }))
16//!     .nest_service("/static/", ServeDir::new("."));
17//! ```
18//!
19//! The `"."` means `ServeDir` will serve the CWD (current working directory) and then you can
20//! access any file in the directory.
21
22use std::{
23    fs,
24    marker::PhantomData,
25    path::{Path, PathBuf},
26};
27
28use http::{header::HeaderValue, status::StatusCode};
29use motore::service::Service;
30
31use super::FileResponse;
32use crate::{context::ServerContext, request::Request, response::Response, server::IntoResponse};
33
34/// [`ServeDir`] is a service for sending files from a given directory.
35pub struct ServeDir<E, F> {
36    path: PathBuf,
37    mime_getter: F,
38    _marker: PhantomData<fn(E)>,
39}
40
41impl<E> ServeDir<E, fn(&Path) -> HeaderValue> {
42    /// Create a new [`ServeDir`] service with the given path.
43    ///
44    /// # Panics
45    ///
46    /// - Panics if the path is invalid
47    /// - Panics if the path is not a directory
48    pub fn new<P>(path: P) -> Self
49    where
50        P: AsRef<Path>,
51    {
52        let path = fs::canonicalize(path).expect("ServeDir: failed to canonicalize path");
53        assert!(path.is_dir());
54        Self {
55            path,
56            mime_getter: guess_mime,
57            _marker: PhantomData,
58        }
59    }
60
61    /// Set a function for getting mime from file path.
62    ///
63    /// By default, [`ServeDir`] will use `mime_guess` crate for guessing a mime through the file
64    /// extension name.
65    pub fn mime_getter<F>(self, mime_getter: F) -> ServeDir<E, F>
66    where
67        F: Fn(&Path) -> HeaderValue,
68    {
69        ServeDir {
70            path: self.path,
71            mime_getter,
72            _marker: self._marker,
73        }
74    }
75}
76
77impl<B, E, F> Service<ServerContext, Request<B>> for ServeDir<E, F>
78where
79    B: Send,
80    F: Fn(&Path) -> HeaderValue + Sync,
81{
82    type Response = Response;
83    type Error = E;
84
85    async fn call(
86        &self,
87        _: &mut ServerContext,
88        req: Request<B>,
89    ) -> Result<Self::Response, Self::Error> {
90        // Get relative path from uri
91        let path = req.uri().path();
92        let path = path.strip_prefix('/').unwrap_or(path);
93
94        tracing::trace!("[Volo-HTTP] ServeDir: path: {path}");
95
96        // Join to the serving directory and canonicalize it
97        let path = self.path.join(path);
98        let Ok(path) = fs::canonicalize(path) else {
99            return Ok(StatusCode::NOT_FOUND.into_response());
100        };
101
102        // Reject file which is out of the serving directory
103        if path.strip_prefix(self.path.as_path()).is_err() {
104            tracing::debug!("[Volo-HTTP] ServeDir: illegal path: {}", path.display());
105            return Ok(StatusCode::FORBIDDEN.into_response());
106        }
107
108        // Check metadata and permission
109        if !path.is_file() {
110            return Ok(StatusCode::NOT_FOUND.into_response());
111        }
112
113        // Get mime and return it!
114        let content_type = (self.mime_getter)(&path);
115        let Ok(resp) = FileResponse::new(path, content_type) else {
116            return Ok(StatusCode::INTERNAL_SERVER_ERROR.into_response());
117        };
118        Ok(resp.into_response())
119    }
120}
121
122pub fn guess_mime(path: &Path) -> HeaderValue {
123    mime_guess::from_path(path)
124        .first_raw()
125        .map(HeaderValue::from_static)
126        .unwrap_or_else(|| HeaderValue::from_str(mime::APPLICATION_OCTET_STREAM.as_ref()).unwrap())
127}
128
129#[cfg(test)]
130mod serve_dir_tests {
131    use http::{StatusCode, method::Method};
132
133    use super::ServeDir;
134    use crate::{
135        body::Body,
136        server::{Router, Server},
137    };
138
139    #[tokio::test]
140    async fn read_file() {
141        // volo/volo-http
142        let router: Router<Option<Body>> =
143            Router::new().nest_service("/static/", ServeDir::new("."));
144        let server = Server::new(router).into_test_server();
145        // volo/volo-http/Cargo.toml
146        assert!(
147            server
148                .call_route(Method::GET, "/static/Cargo.toml", None)
149                .await
150                .status()
151                .is_success()
152        );
153        // volo/volo-http/src/lib.rs
154        assert!(
155            server
156                .call_route(Method::GET, "/static/src/lib.rs", None)
157                .await
158                .status()
159                .is_success()
160        );
161        // volo/volo-http/Cargo.lock, this file does not exist
162        assert_eq!(
163            server
164                .call_route(Method::GET, "/static/Cargo.lock", None)
165                .await
166                .status(),
167            StatusCode::NOT_FOUND
168        );
169        // volo/Cargo.toml, this file should be rejected
170        assert_eq!(
171            server
172                .call_route(Method::GET, "/static/../Cargo.toml", None)
173                .await
174                .status(),
175            StatusCode::FORBIDDEN
176        );
177    }
178}