Skip to main content

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