static_web_server/
static_files.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// This file is part of Static Web Server.
3// See https://static-web-server.net/ for more information
4// Copyright (C) 2019-present Jose Quintana <joseluisq.net>
5
6//! The static file module which powers the web server.
7//!
8
9// Part of the file is borrowed and adapted at a convenience from
10// https://github.com/seanmonstar/warp/blob/master/src/filters/fs.rs
11
12use headers::{AcceptRanges, HeaderMap, HeaderMapExt, HeaderValue};
13use hyper::{header::CONTENT_ENCODING, header::CONTENT_LENGTH, Body, Method, Response, StatusCode};
14use std::fs::{File, Metadata};
15use std::io;
16use std::path::PathBuf;
17
18use crate::conditional_headers::ConditionalHeaders;
19use crate::fs::meta::{try_metadata, try_metadata_with_html_suffix, FileMetadata};
20use crate::fs::path::{sanitize_path, PathExt};
21use crate::http_ext::{MethodExt, HTTP_SUPPORTED_METHODS};
22use crate::response::response_body;
23use crate::Result;
24
25#[cfg(feature = "experimental")]
26use crate::mem_cache::{cache, cache::MemCacheOpts};
27
28#[cfg(any(
29    feature = "compression",
30    feature = "compression-deflate",
31    feature = "compression-gzip",
32    feature = "compression-brotli",
33    feature = "compression-zstd"
34))]
35use crate::compression_static;
36
37#[cfg(feature = "directory-listing")]
38use crate::{
39    directory_listing,
40    directory_listing::{DirListFmt, DirListOpts},
41};
42
43const DEFAULT_INDEX_FILES: &[&str; 1] = &["index.html"];
44
45/// Defines all options needed by the static-files handler.
46pub struct HandleOpts<'a> {
47    /// Request method.
48    pub method: &'a Method,
49    /// In-memory files cache feature (experimental).
50    #[cfg(feature = "experimental")]
51    pub memory_cache: Option<&'a MemCacheOpts>,
52    /// Request headers.
53    pub headers: &'a HeaderMap<HeaderValue>,
54    /// Request base path.
55    pub base_path: &'a PathBuf,
56    /// Request base path.
57    pub uri_path: &'a str,
58    /// Index files.
59    pub index_files: &'a [&'a str],
60    /// Request URI query.
61    pub uri_query: Option<&'a str>,
62    /// Directory listing feature.
63    #[cfg(feature = "directory-listing")]
64    #[cfg_attr(docsrs, doc(cfg(feature = "directory-listing")))]
65    pub dir_listing: bool,
66    /// Directory listing order feature.
67    #[cfg(feature = "directory-listing")]
68    #[cfg_attr(docsrs, doc(cfg(feature = "directory-listing")))]
69    pub dir_listing_order: u8,
70    /// Directory listing format feature.
71    #[cfg(feature = "directory-listing")]
72    #[cfg_attr(docsrs, doc(cfg(feature = "directory-listing")))]
73    pub dir_listing_format: &'a DirListFmt,
74    /// Redirect trailing slash feature.
75    pub redirect_trailing_slash: bool,
76    /// Compression static feature.
77    pub compression_static: bool,
78    /// Ignore hidden files feature.
79    pub ignore_hidden_files: bool,
80    /// Prevent following symlinks for files and directories.
81    pub disable_symlinks: bool,
82}
83
84/// Static file response type with additional data.
85pub struct StaticFileResponse {
86    /// Inner HTTP response.
87    pub resp: Response<Body>,
88    /// The file path of the inner HTTP response.
89    pub file_path: PathBuf,
90}
91
92/// The server entry point to handle incoming requests which map to specific files
93/// on file system and return a file response.
94pub async fn handle(opts: &HandleOpts<'_>) -> Result<StaticFileResponse, StatusCode> {
95    let method = opts.method;
96    let uri_path = opts.uri_path;
97
98    // Check if current HTTP method for incoming request is supported
99    if !method.is_allowed() {
100        return Err(StatusCode::METHOD_NOT_ALLOWED);
101    }
102
103    let headers_opt = opts.headers;
104    let mut file_path = sanitize_path(opts.base_path, uri_path)?;
105
106    // In-memory file cache feature with eviction policy
107    #[cfg(feature = "experimental")]
108    if opts.memory_cache.is_some() {
109        // NOTE: we only support a default auto index for directory requests
110        // when working on a memory-cache context.
111        if opts.redirect_trailing_slash && uri_path.ends_with('/') {
112            file_path.push("index.html");
113        }
114
115        if let Some(result) = cache::get_or_acquire(file_path.as_path(), headers_opt).await {
116            return Ok(StaticFileResponse {
117                resp: result?,
118                // file_path: resp_file_path,
119                file_path,
120            });
121        }
122    }
123
124    let FileMetadata {
125        file_path,
126        metadata,
127        is_dir,
128        precompressed_variant,
129    } = get_composed_file_metadata(
130        &mut file_path,
131        headers_opt,
132        opts.compression_static,
133        opts.index_files,
134        opts.disable_symlinks,
135    )?;
136
137    // Check for a hidden file/directory (dotfile) and ignore it if feature enabled
138    if opts.ignore_hidden_files && file_path.is_hidden() {
139        return Err(StatusCode::NOT_FOUND);
140    }
141
142    let resp_file_path = file_path.to_owned();
143
144    // Check for a trailing slash on the current directory path
145    // and redirect if that path doesn't end with the slash char
146    if is_dir && opts.redirect_trailing_slash && !uri_path.ends_with('/') {
147        let query = opts.uri_query.map_or(String::new(), |s| ["?", s].concat());
148        let uri = [uri_path, "/", query.as_str()].concat();
149        let loc = match HeaderValue::from_str(uri.as_str()) {
150            Ok(val) => val,
151            Err(err) => {
152                tracing::error!("invalid header value from current uri: {:?}", err);
153                return Err(StatusCode::INTERNAL_SERVER_ERROR);
154            }
155        };
156
157        let mut resp = Response::new(Body::empty());
158        resp.headers_mut().insert(hyper::header::LOCATION, loc);
159        *resp.status_mut() = StatusCode::PERMANENT_REDIRECT;
160
161        tracing::trace!("uri doesn't end with a slash so redirecting permanently");
162        return Ok(StaticFileResponse {
163            resp,
164            file_path: resp_file_path,
165        });
166    }
167
168    // Respond with the permitted communication methods
169    if method.is_options() {
170        let mut resp = Response::new(Body::empty());
171        *resp.status_mut() = StatusCode::NO_CONTENT;
172        resp.headers_mut()
173            .typed_insert(headers::Allow::from_iter(HTTP_SUPPORTED_METHODS.clone()));
174        resp.headers_mut().typed_insert(AcceptRanges::bytes());
175
176        return Ok(StaticFileResponse {
177            resp,
178            file_path: resp_file_path,
179        });
180    }
181
182    // Directory listing
183    // Check if "directory listing" feature is enabled,
184    // if current path is a valid directory and
185    // if it does not contain an `index.html` file (if a proper auto index is generated)
186    #[cfg(feature = "directory-listing")]
187    if is_dir && opts.dir_listing && !file_path.exists() {
188        let resp = directory_listing::auto_index(DirListOpts {
189            method,
190            current_path: uri_path,
191            uri_query: opts.uri_query,
192            filepath: file_path,
193            dir_listing_order: opts.dir_listing_order,
194            dir_listing_format: opts.dir_listing_format,
195            ignore_hidden_files: opts.ignore_hidden_files,
196            disable_symlinks: opts.disable_symlinks,
197        })?;
198
199        return Ok(StaticFileResponse {
200            resp,
201            file_path: resp_file_path,
202        });
203    }
204
205    // Check for a pre-compressed file variant if present under the `opts.compression_static` context
206    if let Some(precompressed_meta) = precompressed_variant {
207        let (precomp_path, precomp_encoding) = precompressed_meta;
208        let mut resp = file_reply(
209            headers_opt,
210            file_path,
211            &metadata,
212            Some(precomp_path),
213            #[cfg(feature = "experimental")]
214            opts.memory_cache,
215        )?;
216
217        // Prepare corresponding headers to let know how to decode the payload
218        resp.headers_mut().remove(CONTENT_LENGTH);
219        let encoding = match HeaderValue::from_str(precomp_encoding.as_str()) {
220            Ok(val) => val,
221            Err(err) => {
222                tracing::error!(
223                    "unable to parse header value from content encoding: {:?}",
224                    err
225                );
226                return Err(StatusCode::INTERNAL_SERVER_ERROR);
227            }
228        };
229        resp.headers_mut().insert(CONTENT_ENCODING, encoding);
230
231        return Ok(StaticFileResponse {
232            resp,
233            file_path: resp_file_path,
234        });
235    }
236
237    #[cfg(feature = "experimental")]
238    let resp = file_reply(headers_opt, file_path, &metadata, None, opts.memory_cache)?;
239
240    #[cfg(not(feature = "experimental"))]
241    let resp = file_reply(headers_opt, file_path, &metadata, None)?;
242
243    Ok(StaticFileResponse {
244        resp,
245        file_path: resp_file_path,
246    })
247}
248
249/// Returns the final composed metadata containing
250/// the current `file_path` with its file metadata
251/// as well as its optional pre-compressed variant.
252fn get_composed_file_metadata<'a>(
253    mut file_path: &'a mut PathBuf,
254    _headers: &'a HeaderMap<HeaderValue>,
255    _compression_static: bool,
256    mut index_files: &'a [&'a str],
257    disable_symlinks: bool,
258) -> Result<FileMetadata<'a>, StatusCode> {
259    tracing::trace!("getting metadata for file {}", file_path.display());
260
261    // Prevent symlinks access if option is enabled
262    if disable_symlinks && file_path.is_symlink() {
263        tracing::warn!(
264            "file path {} is a symlink, access denied",
265            file_path.display()
266        );
267        return Err(StatusCode::FORBIDDEN);
268    }
269
270    // Try to find the file path on the file system
271    match try_metadata(file_path) {
272        Ok((mut metadata, is_dir)) => {
273            if is_dir {
274                // Try every index file variant in order
275                if index_files.is_empty() {
276                    index_files = DEFAULT_INDEX_FILES;
277                }
278                let mut index_found = false;
279                for index in index_files {
280                    // Append a HTML index page by default if it's a directory path (`autoindex`)
281                    tracing::debug!("dir: appending {} to the directory path", index);
282                    file_path.push(index);
283
284                    // Pre-compressed variant check for the autoindex
285                    #[cfg(any(
286                        feature = "compression",
287                        feature = "compression-deflate",
288                        feature = "compression-gzip",
289                        feature = "compression-brotli",
290                        feature = "compression-zstd"
291                    ))]
292                    if _compression_static {
293                        if let Some(p) =
294                            compression_static::precompressed_variant(file_path, _headers)
295                        {
296                            return Ok(FileMetadata {
297                                file_path,
298                                metadata: p.metadata,
299                                is_dir: false,
300                                precompressed_variant: Some((p.file_path, p.encoding)),
301                            });
302                        }
303                    }
304
305                    // Otherwise, just fallback to finding the index.html
306                    // and overwrite the current `meta`
307                    // Also noting that it's still a directory request
308                    if let Ok(meta_res) = try_metadata(file_path) {
309                        (metadata, _) = meta_res;
310                        index_found = true;
311                        break;
312                    }
313
314                    // We remove only the appended index file
315                    file_path.pop();
316                    let new_meta: Option<Metadata>;
317                    (file_path, new_meta) = try_metadata_with_html_suffix(file_path);
318                    if let Some(new_meta) = new_meta {
319                        metadata = new_meta;
320                        index_found = true;
321                        break;
322                    }
323                }
324
325                // In case no index was found then we append the last index
326                // of the list to preserve the previous behavior
327                if !index_found && !index_files.is_empty() {
328                    file_path.push(index_files.last().unwrap());
329                }
330            } else {
331                // Fallback pre-compressed variant check for the specific file
332                #[cfg(any(
333                    feature = "compression",
334                    feature = "compression-deflate",
335                    feature = "compression-gzip",
336                    feature = "compression-brotli",
337                    feature = "compression-zstd"
338                ))]
339                if _compression_static {
340                    if let Some(p) = compression_static::precompressed_variant(file_path, _headers)
341                    {
342                        return Ok(FileMetadata {
343                            file_path,
344                            metadata: p.metadata,
345                            is_dir: false,
346                            precompressed_variant: Some((p.file_path, p.encoding)),
347                        });
348                    }
349                }
350            }
351
352            Ok(FileMetadata {
353                file_path,
354                metadata,
355                is_dir,
356                precompressed_variant: None,
357            })
358        }
359        Err(err) => {
360            // Pre-compressed variant check for the file not found
361            #[cfg(any(
362                feature = "compression",
363                feature = "compression-deflate",
364                feature = "compression-gzip",
365                feature = "compression-brotli",
366                feature = "compression-zstd"
367            ))]
368            if _compression_static {
369                if let Some(p) = compression_static::precompressed_variant(file_path, _headers) {
370                    return Ok(FileMetadata {
371                        file_path,
372                        metadata: p.metadata,
373                        is_dir: false,
374                        precompressed_variant: Some((p.file_path, p.encoding)),
375                    });
376                }
377            }
378
379            // Otherwise, if the file path doesn't exist then
380            // we try to find the path suffixed with `.html`.
381            // For example: `/posts/article` will fallback to `/posts/article.html`
382            let new_meta: Option<Metadata>;
383            (file_path, new_meta) = try_metadata_with_html_suffix(file_path);
384
385            #[cfg(any(
386                feature = "compression",
387                feature = "compression-deflate",
388                feature = "compression-gzip",
389                feature = "compression-brotli",
390                feature = "compression-zstd"
391            ))]
392            match new_meta {
393                Some(new_meta) => {
394                    return Ok(FileMetadata {
395                        file_path,
396                        metadata: new_meta,
397                        is_dir: false,
398                        precompressed_variant: None,
399                    })
400                }
401                _ => {
402                    // Last pre-compressed variant check or the suffixed file not found
403                    if _compression_static {
404                        if let Some(p) =
405                            compression_static::precompressed_variant(file_path, _headers)
406                        {
407                            return Ok(FileMetadata {
408                                file_path,
409                                metadata: p.metadata,
410                                is_dir: false,
411                                precompressed_variant: Some((p.file_path, p.encoding)),
412                            });
413                        }
414                    }
415                }
416            }
417            #[cfg(not(feature = "compression"))]
418            if let Some(new_meta) = new_meta {
419                return Ok(FileMetadata {
420                    file_path,
421                    metadata: new_meta,
422                    is_dir: false,
423                    precompressed_variant: None,
424                });
425            }
426
427            Err(err)
428        }
429    }
430}
431
432/// Reply with the corresponding file content taking into account
433/// its precompressed variant if any.
434/// The `path` param should contains always the original requested file path and
435/// the `meta` param value should corresponds to it.
436/// However, if `path_precompressed` contains some value then
437/// the `meta` param  value will belong to the `path_precompressed` (precompressed file variant).
438fn file_reply<'a>(
439    headers: &'a HeaderMap<HeaderValue>,
440    path: &'a PathBuf,
441    meta: &'a Metadata,
442    path_precompressed: Option<PathBuf>,
443    #[cfg(feature = "experimental")] memory_cache: Option<&'a MemCacheOpts>,
444) -> Result<Response<Body>, StatusCode> {
445    let conditionals = ConditionalHeaders::new(headers);
446    let file_path = path_precompressed.as_ref().unwrap_or(path);
447
448    match File::open(file_path) {
449        Ok(file) => {
450            #[cfg(feature = "experimental")]
451            let resp = response_body(file, path, meta, conditionals, memory_cache);
452
453            #[cfg(not(feature = "experimental"))]
454            let resp = response_body(file, path, meta, conditionals);
455
456            resp
457        }
458        Err(err) => {
459            let status = match err.kind() {
460                io::ErrorKind::NotFound => {
461                    tracing::debug!("file can't be opened or not found: {:?}", path.display());
462                    StatusCode::NOT_FOUND
463                }
464                io::ErrorKind::PermissionDenied => {
465                    tracing::warn!("file permission denied: {:?}", path.display());
466                    StatusCode::FORBIDDEN
467                }
468                _ => {
469                    tracing::error!("file open error (path={:?}): {} ", path.display(), err);
470                    StatusCode::INTERNAL_SERVER_ERROR
471                }
472            };
473            Err(status)
474        }
475    }
476}