salvo_serve_static/
lib.rs

1//! Serve static files and directories for Salvo web framework.
2//!
3//! This crate provides handlers for serving static content:
4//! - `StaticDir` - Serve files from directory with options for directory listing
5//! - `StaticFile` - Serve a single file
6//! - `StaticEmbed` - Serve embedded files using rust-embed (when "embed" feature is enabled)
7//!
8//! Read more: <https://salvo.rs>
9#![doc(html_favicon_url = "https://salvo.rs/favicon-32x32.png")]
10#![doc(html_logo_url = "https://salvo.rs/images/logo.svg")]
11#![cfg_attr(docsrs, feature(doc_cfg))]
12
13pub mod dir;
14mod file;
15
16pub use dir::StaticDir;
17pub use file::StaticFile;
18use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
19use salvo_core::Response;
20use salvo_core::http::uri::{Parts as UriParts, Uri};
21use salvo_core::writing::Redirect;
22
23const HTML_ENCODE_SET: &AsciiSet = &CONTROLS
24    .add(b'\'')
25    .add(b'"')
26    .add(b'`')
27    .add(b'<')
28    .add(b'>')
29    .add(b'&');
30
31#[macro_use]
32mod cfg;
33
34#[doc(hidden)]
35#[macro_export]
36macro_rules! join_path {
37    ($($part:expr),+) => {
38        {
39            let mut p = std::path::PathBuf::new();
40            $(
41                p.push($part);
42            )*
43            path_slash::PathBufExt::to_slash_lossy(&p).to_string()
44        }
45    }
46}
47
48cfg_feature! {
49    #![feature = "embed"]
50    mod embed;
51    pub use embed::{render_embedded_file, static_embed, EmbeddedFileExt, StaticEmbed};
52}
53
54#[inline]
55pub(crate) fn encode_url_path(path: &str) -> String {
56    path.split('/')
57        .map(|s| utf8_percent_encode(s, HTML_ENCODE_SET).to_string())
58        .collect::<Vec<_>>()
59        .join("/")
60}
61
62#[inline]
63pub(crate) fn decode_url_path_safely(path: &str) -> String {
64    percent_encoding::percent_decode_str(path)
65        .decode_utf8_lossy()
66        .to_string()
67}
68
69#[inline]
70pub(crate) fn format_url_path_safely(path: &str) -> String {
71    let final_slash = if path.ends_with('/') { "/" } else { "" };
72    let mut used_parts = Vec::with_capacity(8);
73    for part in path.split(['/', '\\']) {
74        if part.is_empty() || part == "." || (cfg!(windows) && part.contains(':')) {
75            continue;
76        }
77        if part == ".." {
78            used_parts.pop();
79        } else {
80            used_parts.push(part);
81        }
82    }
83    used_parts.join("/") + final_slash
84}
85
86pub(crate) fn redirect_to_dir_url(req_uri: &Uri, res: &mut Response) {
87    let UriParts {
88        scheme,
89        authority,
90        path_and_query,
91        ..
92    } = req_uri.clone().into_parts();
93    let mut builder = Uri::builder();
94    if let Some(scheme) = scheme {
95        builder = builder.scheme(scheme);
96    }
97    if let Some(authority) = authority {
98        builder = builder.authority(authority);
99    }
100    if let Some(path_and_query) = path_and_query {
101        if let Some(query) = path_and_query.query() {
102            builder = builder.path_and_query(format!("{}/?{}", path_and_query.path(), query));
103        } else {
104            builder = builder.path_and_query(format!("{}/", path_and_query.path()));
105        }
106    }
107    let redirect_uri = builder.build().expect("Invalid uri");
108    res.render(Redirect::found(redirect_uri));
109}
110
111#[cfg(test)]
112mod tests {
113    use salvo_core::prelude::*;
114    use salvo_core::test::{ResponseExt, TestClient};
115
116    use crate::*;
117
118    #[tokio::test]
119    async fn test_serve_static_dir() {
120        let router = Router::with_path("{*path}").get(
121            StaticDir::new(vec!["test/static"])
122                .include_dot_files(false)
123                .auto_list(true)
124                .defaults("index.html"),
125        );
126        let service = Service::new(router);
127
128        async fn access(service: &Service, accept: &str, url: &str) -> String {
129            TestClient::get(url)
130                .add_header("accept", accept, true)
131                .send(service)
132                .await
133                .take_string()
134                .await
135                .unwrap()
136        }
137        let content = access(&service, "text/plain", "http://127.0.0.1:5801").await;
138        assert!(content.contains("Index page"));
139        let content = access(&service, "text/plain", "http://127.0.0.1:5801/").await;
140        assert!(content.contains("Index page"));
141
142        let content = access(&service, "text/plain", "http://127.0.0.1:5801/dir1/").await;
143        assert!(content.contains("test3.txt") && content.contains("dir2"));
144
145        let content = access(&service, "text/xml", "http://127.0.0.1:5801/dir1/").await;
146        assert!(
147            content.starts_with("<list>")
148                && content.contains("test3.txt")
149                && content.contains("dir2")
150        );
151
152        let content = access(&service, "text/html", "http://127.0.0.1:5801/dir1/").await;
153        assert!(
154            content.contains("<html>") && content.contains("test3.txt") && content.contains("dir2")
155        );
156
157        let content = access(&service, "application/json", "http://127.0.0.1:5801/dir1/").await;
158        assert!(
159            content.starts_with('{') && content.contains("test3.txt") && content.contains("dir2")
160        );
161
162        let content = access(&service, "text/plain", "http://127.0.0.1:5801/test1.txt").await;
163        assert!(content.contains("copy1"));
164
165        let content = access(&service, "text/plain", "http://127.0.0.1:5801/test3.txt").await;
166        assert!(content.contains("Not Found"));
167
168        let content = access(
169            &service,
170            "text/plain",
171            "http://127.0.0.1:5801/../girl/love/eat.txt",
172        )
173        .await;
174        assert!(content.contains("Not Found"));
175        let content = access(
176            &service,
177            "text/plain",
178            "http://127.0.0.1:5801/..\\girl\\love\\eat.txt",
179        )
180        .await;
181        assert!(content.contains("Not Found"));
182
183        let content = access(
184            &service,
185            "text/plain",
186            "http://127.0.0.1:5801/dir1/test3.txt",
187        )
188        .await;
189        assert!(content.contains("copy3"));
190        let content = access(
191            &service,
192            "text/plain",
193            "http://127.0.0.1:5801/dir1/dir2/test3.txt",
194        )
195        .await;
196        assert!(content == "dir2 test3");
197        let content = access(
198            &service,
199            "text/plain",
200            "http://127.0.0.1:5801/dir1/../dir1/test3.txt",
201        )
202        .await;
203        assert!(content == "copy3");
204        let content = access(
205            &service,
206            "text/plain",
207            "http://127.0.0.1:5801/dir1\\..\\dir1\\test3.txt",
208        )
209        .await;
210        assert!(content == "copy3");
211    }
212
213    #[tokio::test]
214    async fn test_serve_static_file() {
215        let router = Router::new()
216            .push(
217                Router::with_path("test1.txt")
218                    .get(StaticFile::new("test/static/test1.txt").chunk_size(1024)),
219            )
220            .push(
221                Router::with_path("notexist.txt").get(StaticFile::new("test/static/notexist.txt")),
222            );
223        let service = Service::new(router);
224
225        let mut response = TestClient::get("http://127.0.0.1:5801/test1.txt")
226            .send(&service)
227            .await;
228        assert_eq!(response.status_code.unwrap(), StatusCode::OK);
229        assert_eq!(response.take_string().await.unwrap(), "copy1");
230
231        let response = TestClient::get("http://127.0.0.1:5801/notexist.txt")
232            .send(&service)
233            .await;
234        assert_eq!(response.status_code.unwrap(), StatusCode::NOT_FOUND);
235    }
236
237    #[cfg(feature = "embed")]
238    #[tokio::test]
239    async fn test_serve_embed_files() {
240        #[derive(rust_embed::RustEmbed)]
241        #[folder = "test/static"]
242        struct Assets;
243
244        let router = Router::new()
245            .push(
246                Router::with_path("test1.txt")
247                    .get(Assets::get("test1.txt").unwrap().into_handler()),
248            )
249            .push(Router::with_path("files/{**path}").get(serve_file))
250            .push(
251                Router::with_path("dir/{**path}").get(
252                    static_embed::<Assets>()
253                        .defaults("index.html")
254                        .fallback("fallback.html"),
255                ),
256            )
257            .push(Router::with_path("dir2/{**path}").get(static_embed::<Assets>()))
258            .push(
259                Router::with_path("dir3/{**path}")
260                    .get(static_embed::<Assets>().fallback("notexist.html")),
261            );
262        let service = Service::new(router);
263
264        #[handler]
265        async fn serve_file(req: &mut Request, res: &mut Response) {
266            let path = req.param::<String>("path").unwrap();
267            if let Some(file) = Assets::get(&path) {
268                file.render(req, res);
269            }
270        }
271
272        let mut response = TestClient::get("http://127.0.0.1:5801/files/test1.txt")
273            .send(&service)
274            .await;
275        assert_eq!(response.status_code.unwrap(), StatusCode::OK);
276        assert_eq!(response.take_string().await.unwrap(), "copy1");
277
278        let mut response = TestClient::get("http://127.0.0.1:5801/dir/test1.txt")
279            .send(&service)
280            .await;
281        assert_eq!(response.status_code.unwrap(), StatusCode::OK);
282        assert_eq!(response.take_string().await.unwrap(), "copy1");
283
284        let mut response = TestClient::get("http://127.0.0.1:5801/dir/test1111.txt")
285            .send(&service)
286            .await;
287        assert_eq!(response.status_code.unwrap(), StatusCode::OK);
288        assert!(
289            response
290                .take_string()
291                .await
292                .unwrap()
293                .contains("Fallback page")
294        );
295
296        let response = TestClient::get("http://127.0.0.1:5801/dir")
297            .send(&service)
298            .await;
299        assert_eq!(response.status_code.unwrap(), StatusCode::OK);
300
301        let mut response = TestClient::get("http://127.0.0.1:5801/dir/")
302            .send(&service)
303            .await;
304        assert_eq!(response.status_code.unwrap(), StatusCode::OK);
305        assert!(response.take_string().await.unwrap().contains("Index page"));
306
307        let response = TestClient::get("http://127.0.0.1:5801/dir2/")
308            .send(&service)
309            .await;
310        assert_eq!(response.status_code.unwrap(), StatusCode::NOT_FOUND);
311
312        let response = TestClient::get("http://127.0.0.1:5801/dir3/abc.txt")
313            .send(&service)
314            .await;
315        assert_eq!(response.status_code.unwrap(), StatusCode::NOT_FOUND);
316    }
317}