hyper_staticfile/resolve.rs
1use std::{
2 future::Future,
3 io::{Error as IoError, ErrorKind as IoErrorKind, Result as IoResult},
4 ops::BitAnd,
5 path::PathBuf,
6 sync::Arc,
7 time::SystemTime,
8};
9
10use futures_util::future::BoxFuture;
11use http::{header, HeaderValue, Method, Request};
12use mime_guess::{mime, Mime, MimeGuess};
13use tokio::fs::File;
14
15use crate::{
16 util::RequestedPath,
17 vfs::{FileOpener, FileWithMetadata, TokioFileOpener},
18};
19
20/// Struct containing all the required data to serve a file.
21#[derive(Debug)]
22pub struct ResolvedFile<F = File> {
23 /// Open file handle.
24 pub handle: F,
25 /// The resolved and sanitized path to the file.
26 /// For directory indexes, this includes `index.html`.
27 /// For pre-encoded files, this will include the compressed extension. (`.gz` or `.br`)
28 pub path: PathBuf,
29 /// Size in bytes.
30 pub size: u64,
31 /// Last modification time.
32 pub modified: Option<SystemTime>,
33 /// MIME type / 'Content-Type' value.
34 pub content_type: Option<String>,
35 /// 'Content-Encoding' value.
36 pub encoding: Option<Encoding>,
37}
38
39impl<F> ResolvedFile<F> {
40 fn new(
41 file: FileWithMetadata<F>,
42 path: PathBuf,
43 content_type: Option<String>,
44 encoding: Option<Encoding>,
45 ) -> Self {
46 Self {
47 handle: file.handle,
48 path,
49 size: file.size,
50 modified: file.modified,
51 content_type,
52 encoding,
53 }
54 }
55}
56
57/// Resolves request paths to files.
58///
59/// This struct resolves files based on the request path. The path is first sanitized, then mapped
60/// to a file on the filesystem. If the path corresponds to a directory, it will try to look for a
61/// directory index.
62///
63/// Cloning this struct is a cheap operation.
64pub struct Resolver<O = TokioFileOpener> {
65 /// The (virtual) filesystem used to open files.
66 pub opener: Arc<O>,
67
68 /// Encodings the client is allowed to request with `Accept-Encoding`.
69 ///
70 /// This only supports pre-encoded files, that exist adjacent to the original file, but with an
71 /// additional `.br` or `.gz` suffix (after the original extension).
72 ///
73 /// Typically initialized with `AcceptEncoding::all()` or `AcceptEncoding::none()`.
74 pub allowed_encodings: AcceptEncoding,
75
76 /// Optional function that can rewrite requests.
77 ///
78 /// This function is called after parsing the request and before querying the filesystem.
79 ///
80 /// See `set_rewrite` for a convenience setter that simplifies these types.
81 pub rewrite: Option<Arc<dyn (Fn(ResolveParams) -> BoxRewriteFuture) + Send + Sync>>,
82}
83
84/// Future returned by a rewrite function. See `Resolver::set_rewrite`.
85pub type BoxRewriteFuture = BoxFuture<'static, IoResult<ResolveParams>>;
86
87/// All of the parsed request parameters used in resolving a file.
88///
89/// This struct is primarily used for `Resolver::rewrite` / `Resolver::set_rewrite`.
90#[derive(Debug, Clone)]
91pub struct ResolveParams {
92 /// Sanitized path of the request.
93 pub path: PathBuf,
94 /// Whether a directory was requested. (The request path ended with a slash.)
95 pub is_dir_request: bool,
96 /// Intersection of the request `Accept-Encoding` header and `allowed_encodings`.
97 ///
98 /// Only modify this field to disable encodings. Enabling additional encodings here may cause
99 /// a client to receive encodings it does not understand.
100 pub accept_encoding: AcceptEncoding,
101}
102
103/// The result of `Resolver` methods.
104///
105/// Covers all the possible 'normal' scenarios encountered when serving static files.
106#[derive(Debug)]
107pub enum ResolveResult<F = File> {
108 /// The request was not `GET` or `HEAD` request,
109 MethodNotMatched,
110 /// The requested file does not exist.
111 NotFound,
112 /// The requested file could not be accessed.
113 PermissionDenied,
114 /// A directory was requested as a file.
115 IsDirectory {
116 /// Path to redirect to.
117 redirect_to: String,
118 },
119 /// The requested file was found.
120 Found(ResolvedFile<F>),
121}
122
123/// Some IO errors are expected when serving files, and mapped to a regular result here.
124fn map_open_err<F>(err: IoError) -> IoResult<ResolveResult<F>> {
125 match err.kind() {
126 IoErrorKind::NotFound => Ok(ResolveResult::NotFound),
127 IoErrorKind::PermissionDenied => Ok(ResolveResult::PermissionDenied),
128 _ => Err(err),
129 }
130}
131
132impl Resolver<TokioFileOpener> {
133 /// Create a resolver that resolves files inside a root directory on the regular filesystem.
134 pub fn new(root: impl Into<PathBuf>) -> Self {
135 Self::with_opener(TokioFileOpener::new(root))
136 }
137}
138
139impl<O: FileOpener> Resolver<O> {
140 /// Create a resolver with a custom file opener.
141 pub fn with_opener(opener: O) -> Self {
142 Self {
143 opener: Arc::new(opener),
144 allowed_encodings: AcceptEncoding::none(),
145 rewrite: None,
146 }
147 }
148
149 /// Configure a function that can rewrite requests.
150 ///
151 /// This function is called after parsing the request and before querying the filesystem.
152 ///
153 /// ```rust
154 /// let mut resolver = hyper_staticfile::Resolver::new("/");
155 /// resolver.set_rewrite(|mut params| async move {
156 /// if params.path.extension() == Some("htm".as_ref()) {
157 /// params.path.set_extension("html");
158 /// }
159 /// Ok(params)
160 /// });
161 /// ```
162 pub fn set_rewrite<R, F>(&mut self, rewrite: F) -> &mut Self
163 where
164 R: Future<Output = IoResult<ResolveParams>> + Send + 'static,
165 F: (Fn(ResolveParams) -> R) + Send + Sync + 'static,
166 {
167 self.rewrite = Some(Arc::new(move |params| Box::pin(rewrite(params))));
168 self
169 }
170
171 /// Resolve the request by trying to find the file in the root.
172 ///
173 /// The returned future may error for unexpected IO errors, passing on the `std::io::Error`.
174 /// Certain expected IO errors are handled, though, and simply reflected in the result. These are
175 /// `NotFound` and `PermissionDenied`.
176 pub async fn resolve_request<B>(&self, req: &Request<B>) -> IoResult<ResolveResult<O::File>> {
177 // Handle only `GET`/`HEAD` and absolute paths.
178 match *req.method() {
179 Method::HEAD | Method::GET => {}
180 _ => {
181 return Ok(ResolveResult::MethodNotMatched);
182 }
183 }
184
185 // Parse `Accept-Encoding` header.
186 let accept_encoding = self.allowed_encodings
187 & req
188 .headers()
189 .get(header::ACCEPT_ENCODING)
190 .map(AcceptEncoding::from_header_value)
191 .unwrap_or(AcceptEncoding::none());
192
193 self.resolve_path(req.uri().path(), accept_encoding).await
194 }
195
196 /// Resolve the request path by trying to find the file in the given root.
197 ///
198 /// The returned future may error for unexpected IO errors, passing on the `std::io::Error`.
199 /// Certain expected IO errors are handled, though, and simply reflected in the result. These are
200 /// `NotFound` and `PermissionDenied`.
201 ///
202 /// Note that, unlike `resolve_request`, it is up to the caller to check the request method and
203 /// optionally the 'Accept-Encoding' header.
204 pub async fn resolve_path(
205 &self,
206 request_path: &str,
207 accept_encoding: AcceptEncoding,
208 ) -> IoResult<ResolveResult<O::File>> {
209 // Sanitize input path.
210 let requested_path = RequestedPath::resolve(request_path);
211
212 // Apply optional rewrite.
213 let ResolveParams {
214 mut path,
215 is_dir_request,
216 accept_encoding,
217 } = {
218 let mut params = ResolveParams {
219 path: requested_path.sanitized,
220 is_dir_request: requested_path.is_dir_request,
221 accept_encoding,
222 };
223 if let Some(ref rewrite) = self.rewrite {
224 params = rewrite(params).await?;
225 }
226 params
227 };
228
229 // Try to open the file.
230 let file = match self.opener.open(&path).await {
231 Ok(pair) => pair,
232 Err(err) => return map_open_err(err),
233 };
234
235 // The resolved path doesn't contain the trailing slash anymore, so we may
236 // have opened a file for a directory request, which we treat as 'not found'.
237 if is_dir_request && !file.is_dir {
238 return Ok(ResolveResult::NotFound);
239 }
240
241 // We may have opened a directory for a file request, in which case we redirect.
242 if !is_dir_request && file.is_dir {
243 // Build the redirect path. On Windows, we can't just append the entire path, because
244 // it contains Windows path separators. Instead, append each component separately.
245 let mut target = String::with_capacity(path.as_os_str().len() + 2);
246 target.push('/');
247 for component in path.components() {
248 target.push_str(&component.as_os_str().to_string_lossy());
249 target.push('/');
250 }
251
252 return Ok(ResolveResult::IsDirectory {
253 redirect_to: target,
254 });
255 }
256
257 // If not a directory, serve this file.
258 if !is_dir_request {
259 return self.resolve_final(file, path, accept_encoding).await;
260 }
261
262 // Resolve the directory index.
263 path.push("index.html");
264 let file = match self.opener.open(&path).await {
265 Ok(pair) => pair,
266 Err(err) => return map_open_err(err),
267 };
268
269 // The directory index cannot itself be a directory.
270 if file.is_dir {
271 return Ok(ResolveResult::NotFound);
272 }
273
274 // Serve this file.
275 self.resolve_final(file, path, accept_encoding).await
276 }
277
278 // Found a file, perform final resolution steps.
279 async fn resolve_final(
280 &self,
281 file: FileWithMetadata<O::File>,
282 path: PathBuf,
283 accept_encoding: AcceptEncoding,
284 ) -> IoResult<ResolveResult<O::File>> {
285 // Determine MIME-type. This needs to happen before we resolve a pre-encoded file.
286 let mimetype = MimeGuess::from_path(&path)
287 .first()
288 .map(|mimetype| set_charset(mimetype).to_string());
289
290 // Resolve pre-encoded files.
291 if accept_encoding.br {
292 let mut br_path = path.clone().into_os_string();
293 br_path.push(".br");
294 if let Ok(file) = self.opener.open(br_path.as_ref()).await {
295 return Ok(ResolveResult::Found(ResolvedFile::new(
296 file,
297 br_path.into(),
298 mimetype,
299 Some(Encoding::Br),
300 )));
301 }
302 }
303 if accept_encoding.gzip {
304 let mut gzip_path = path.clone().into_os_string();
305 gzip_path.push(".gz");
306 if let Ok(file) = self.opener.open(gzip_path.as_ref()).await {
307 return Ok(ResolveResult::Found(ResolvedFile::new(
308 file,
309 gzip_path.into(),
310 mimetype,
311 Some(Encoding::Gzip),
312 )));
313 }
314 }
315
316 // No pre-encoded file found, serve the original.
317 Ok(ResolveResult::Found(ResolvedFile::new(
318 file, path, mimetype, None,
319 )))
320 }
321}
322
323impl<O> Clone for Resolver<O> {
324 fn clone(&self) -> Self {
325 Self {
326 opener: self.opener.clone(),
327 allowed_encodings: self.allowed_encodings,
328 rewrite: self.rewrite.clone(),
329 }
330 }
331}
332
333/// Type of response encoding.
334#[derive(Debug, Copy, Clone, PartialEq, Eq)]
335pub enum Encoding {
336 /// Response body is encoded with gzip.
337 Gzip,
338 /// Response body is encoded with brotli.
339 Br,
340}
341
342impl Encoding {
343 /// Create a `HeaderValue` for this encoding.
344 pub fn to_header_value(&self) -> HeaderValue {
345 HeaderValue::from_static(match self {
346 Encoding::Gzip => "gzip",
347 Encoding::Br => "br",
348 })
349 }
350}
351
352/// Flags for which encodings to resolve.
353#[derive(Debug, Copy, Clone)]
354pub struct AcceptEncoding {
355 /// Look for `.gz` files.
356 pub gzip: bool,
357 /// Look for `.br` files.
358 pub br: bool,
359}
360
361impl AcceptEncoding {
362 /// Return an `AcceptEncoding` with all flags set.
363 pub const fn all() -> Self {
364 Self {
365 gzip: true,
366 br: true,
367 }
368 }
369
370 /// Return an `AcceptEncoding` with no flags set.
371 pub const fn none() -> Self {
372 Self {
373 gzip: false,
374 br: false,
375 }
376 }
377
378 /// Fill an `AcceptEncoding` struct from a header value.
379 pub fn from_header_value(value: &HeaderValue) -> Self {
380 let mut res = Self::none();
381 if let Ok(value) = value.to_str() {
382 for enc in value.split(',') {
383 // TODO: Handle weights (q=)
384 match enc.split(';').next().unwrap().trim() {
385 "gzip" => res.gzip = true,
386 "br" => res.br = true,
387 _ => {}
388 }
389 }
390 }
391 res
392 }
393}
394
395impl BitAnd for AcceptEncoding {
396 type Output = Self;
397 fn bitand(self, rhs: Self) -> Self {
398 Self {
399 gzip: self.gzip && rhs.gzip,
400 br: self.br && rhs.br,
401 }
402 }
403}
404
405fn set_charset(mimetype: Mime) -> Mime {
406 if mimetype == mime::APPLICATION_JAVASCRIPT {
407 return mime::APPLICATION_JAVASCRIPT_UTF_8;
408 }
409 if mimetype == mime::TEXT_JAVASCRIPT {
410 return "text/javascript; charset=utf-8".parse().unwrap();
411 }
412 mimetype
413}