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