rocket_include_dir/
lib.rs

1//! # Static file server, from inside the binary
2//!
3//! Acts as a bridge between `include_dir` and `rocket`, enabling you
4//! to serve files directly out of the binary executable.
5//!
6//! See [`StaticFiles`] for more details.
7
8use std::path::PathBuf;
9
10use include_dir::File;
11use rocket::fs::Options;
12use rocket::http::ext::IntoOwned;
13use rocket::http::uri::fmt::Path;
14use rocket::http::uri::Segments;
15use rocket::http::ContentType;
16use rocket::http::Method;
17use rocket::http::Status;
18use rocket::outcome::IntoOutcome;
19use rocket::response;
20use rocket::response::Redirect;
21use rocket::response::Responder;
22use rocket::route::Handler;
23use rocket::route::Outcome;
24use rocket::Data;
25use rocket::Request;
26use rocket::Route;
27
28pub use include_dir::include_dir;
29pub use include_dir::Dir;
30
31/// Implements a simple bridge between `include_dir` and `rocket`. A simple reponder based on
32/// [`rocket::FileServer`], which uses a directory included at compile time.
33///
34/// ```rust
35/// use rocket_include_dir::{include_dir, Dir, StaticFiles};
36/// #[rocket::launch]
37/// fn launch() -> _ {
38///     static PROJECT_DIR: Dir = include_dir!("static");
39///     build().mount("/", StaticFiles::from(&PROJECT_DIR))
40/// }
41/// # use rocket::{build, local::blocking::Client, http::Status};
42/// # let client = Client::tracked(launch()).expect("valid rocket instance");
43/// # let response = client.get("/test-doesnt-exist").dispatch();
44/// # assert_eq!(response.status(), Status::NotFound);
45/// # let response = client.get("/test.txt").dispatch();
46/// # assert_eq!(response.status(), Status::Ok);
47/// ```
48#[derive(Clone, Copy)]
49pub struct StaticFiles {
50    dir: &'static Dir<'static>,
51    options: Options,
52    rank: isize,
53}
54
55impl From<&'static Dir<'static>> for StaticFiles {
56    fn from(dir: &'static Dir<'static>) -> Self {
57        Self {
58            dir,
59            options: Options::default(),
60            rank: Self::DEFAULT_RANK,
61        }
62    }
63}
64
65impl StaticFiles {
66    const DEFAULT_RANK: isize = 10;
67
68    /// Construct a new `StaticFiles`, with the provided options.
69    ///
70    /// The generated route has a default rank of `10`, to match Rocket's
71    /// `FileServer`
72    pub fn new(dir: &'static Dir<'static>, options: Options) -> Self {
73        Self {
74            dir,
75            options,
76            rank: Self::DEFAULT_RANK,
77        }
78    }
79
80    /// Replace the options for this `StaticFiles`
81    pub fn options(mut self, options: Options) -> Self {
82        self.options = options;
83        self
84    }
85
86    /// Set a non-default rank for this `StaticFiles`
87    pub fn rank(mut self, rank: isize) -> Self {
88        self.rank = rank;
89        self
90    }
91}
92
93fn respond_with<'r>(
94    req: &'r Request<'_>,
95    path: PathBuf,
96    file: &'r File<'r>,
97) -> response::Result<'r> {
98    let mut response = file.contents().respond_to(req)?;
99    if let Some(ext) = path.extension() {
100        if let Some(ct) = ContentType::from_extension(&ext.to_string_lossy()) {
101            response.set_header(ct);
102        }
103    }
104
105    Ok(response)
106}
107
108#[rocket::async_trait]
109impl Handler for StaticFiles {
110    async fn handle<'r>(&self, req: &'r Request<'_>, data: Data<'r>) -> Outcome<'r> {
111        // TODO: Should we reject dotfiles for `self.root` if !DotFiles?
112        let options = self.options;
113        // Get the segments as a `PathBuf`, allowing dotfiles requested.
114        let allow_dotfiles = options.contains(Options::DotFiles);
115        let path = req
116            .segments::<Segments<'_, Path>>(0..)
117            .ok()
118            .and_then(|segments| segments.to_path_buf(allow_dotfiles).ok());
119
120        match path {
121            Some(p) => {
122                // If the path is empty it means the root
123                let dir = if p.as_os_str().is_empty() {
124                    Some(self.dir)
125                } else {
126                    self.dir.get_dir(&p)
127                };
128                if let Some(path) = dir {
129                    if options.contains(Options::NormalizeDirs) && !req.uri().path().ends_with('/')
130                    {
131                        let normal = req
132                            .uri()
133                            .map_path(|p| format!("{}/", p))
134                            .expect("adding a trailing slash to a known good path => valid path")
135                            .into_owned();
136
137                        return Redirect::permanent(normal)
138                            .respond_to(req)
139                            .or_forward((data, Status::InternalServerError));
140                    }
141                    if !options.contains(Options::Index) {
142                        return Outcome::forward(data, Status::NotFound);
143                    }
144                    path.get_entry("index.html")
145                        .and_then(|f| f.as_file())
146                        .ok_or(Status::NotFound)
147                        .and_then(|path| respond_with(req, p.join("index.html"), path))
148                        .or_forward((data, Status::NotFound))
149                } else if let Some(path) = self.dir.get_file(&p) {
150                    respond_with(req, p, path).or_forward((data, Status::NotFound))
151                } else {
152                    Outcome::forward(data, Status::NotFound)
153                }
154            }
155            None => {
156                if options.contains(Options::Index) {
157                    self.dir.get_entry("index.html")
158                        .and_then(|f| f.as_file())
159                        .ok_or(Status::NotFound)
160                        .and_then(|path| respond_with(req, PathBuf::from("index.html"), path))
161                        .or_forward((data, Status::NotFound))
162                } else {
163                    Outcome::forward(data, Status::NotFound)
164                }
165            }
166        }
167    }
168}
169
170impl From<StaticFiles> for Route {
171    fn from(val: StaticFiles) -> Self {
172        Route::ranked(val.rank, Method::Get, "/<path..>", val)
173    }
174}
175
176impl From<StaticFiles> for Vec<Route> {
177    fn from(value: StaticFiles) -> Self {
178        vec![value.into()]
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use include_dir::include_dir;
185    use rocket::{build, local::blocking::Client, Build, Rocket};
186
187    use super::*;
188
189    fn launch() -> Rocket<Build> {
190        static PROJECT_DIR: Dir = include_dir!("static");
191        build()
192            .mount(
193                "/default",
194                StaticFiles::new(&PROJECT_DIR, Options::default()),
195            )
196            .mount("/indexed", StaticFiles::new(&PROJECT_DIR, Options::Index))
197    }
198
199    #[test]
200    fn it_works() {
201        // Move current dir to avoid checking the local filesystem for path existience
202        std::env::set_current_dir("/tmp").expect("Requires /tmp directory");
203        let client = Client::tracked(launch()).expect("valid rocket instance");
204        let response = client.get("/default/test-doesnt-exist").dispatch();
205        assert_eq!(response.status(), Status::NotFound);
206        let response = client.get("/default/test.txt").dispatch();
207        assert_eq!(response.status(), Status::Ok);
208    }
209
210    #[test]
211    fn index_file() {
212        // Move current dir to avoid checking the local filesystem for path existience
213        std::env::set_current_dir("/tmp").expect("Requires /tmp directory");
214        let client = Client::tracked(launch()).expect("valid rocket instance");
215        let response = client.get("/indexed/test-doesnt-exist").dispatch();
216        assert_eq!(response.status(), Status::NotFound);
217        let response = client.get("/indexed/test.txt").dispatch();
218        assert_eq!(response.status(), Status::Ok);
219        let response = client.get("/indexed/").dispatch();
220        assert_eq!(response.status(), Status::Ok);
221    }
222}