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}