rocket_include_dir/
lib.rs1use 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#[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 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 pub fn options(mut self, options: Options) -> Self {
82 self.options = options;
83 self
84 }
85
86 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 let options = self.options;
113 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 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 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 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}