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
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    DOWNLOAD_PARAM_KEY, DirDownloadFmt, DirDownloadOpts, archive_reply,
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    // Prevent symlinks access if option is enabled
134    if file_path.is_symlink() {
135        if opts.disable_symlinks {
136            tracing::warn!(
137                "file path {} is a symlink, access denied",
138                file_path.display()
139            );
140            return Err(StatusCode::FORBIDDEN);
141        }
142
143        let symlink_file_path = file_path.canonicalize().map_err(|err| {
144            tracing::error!(
145                "unable to resolve `{}` symlink path: {}",
146                file_path.display(),
147                err,
148            );
149            StatusCode::NOT_FOUND
150        })?;
151
152        let base_path = opts.base_path.canonicalize().map_err(|err| {
153            tracing::error!(
154                "unable to resolve `{}` symlink path: {}",
155                file_path.display(),
156                err,
157            );
158            StatusCode::NOT_FOUND
159        })?;
160
161        if !symlink_file_path.starts_with(base_path) {
162            tracing::error!(
163                "file path {} is a symlink, access denied",
164                symlink_file_path.display()
165            );
166            return Err(StatusCode::NOT_FOUND);
167        }
168    }
169
170    let FileMetadata {
171        file_path,
172        metadata,
173        is_dir,
174        precompressed_variant,
175    } = get_composed_file_metadata(
176        &mut file_path,
177        headers_opt,
178        opts.compression_static,
179        opts.index_files,
180    )?;
181
182    // Check for a hidden file/directory (dotfile) and ignore it if feature enabled
183    if opts.ignore_hidden_files && file_path.is_hidden() {
184        return Err(StatusCode::NOT_FOUND);
185    }
186
187    let resp_file_path = file_path.to_owned();
188
189    // Check for a trailing slash on the current directory path
190    // and redirect if that path doesn't end with the slash char
191    if is_dir && opts.redirect_trailing_slash && !uri_path.ends_with('/') {
192        let query = opts.uri_query.map_or(String::new(), |s| ["?", s].concat());
193        let uri = [uri_path, "/", query.as_str()].concat();
194        let loc = match HeaderValue::from_str(uri.as_str()) {
195            Ok(val) => val,
196            Err(err) => {
197                tracing::error!("invalid header value from current uri: {:?}", err);
198                return Err(StatusCode::INTERNAL_SERVER_ERROR);
199            }
200        };
201
202        let mut resp = Response::new(Body::empty());
203        resp.headers_mut().insert(hyper::header::LOCATION, loc);
204        *resp.status_mut() = StatusCode::PERMANENT_REDIRECT;
205
206        tracing::trace!("uri doesn't end with a slash so redirecting permanently");
207        return Ok(StaticFileResponse {
208            resp,
209            file_path: resp_file_path,
210        });
211    }
212
213    // Respond with the permitted communication methods
214    if method.is_options() {
215        let mut resp = Response::new(Body::empty());
216        *resp.status_mut() = StatusCode::NO_CONTENT;
217        resp.headers_mut()
218            .typed_insert(headers::Allow::from_iter(HTTP_SUPPORTED_METHODS.clone()));
219        resp.headers_mut().typed_insert(AcceptRanges::bytes());
220
221        return Ok(StaticFileResponse {
222            resp,
223            file_path: resp_file_path,
224        });
225    }
226
227    // Directory listing
228    // Check if "directory listing" feature is enabled,
229    // if current path is a valid directory and
230    // if it does not contain an `index.html` file (if a proper auto index is generated)
231    #[cfg(feature = "directory-listing")]
232    if is_dir && opts.dir_listing && !file_path.exists() {
233        // Directory listing download
234        // Check if "directory listing download" feature is enabled,
235        // if current path is a valid directory and
236        // if query string has parameter "download" set
237        #[cfg(feature = "directory-listing-download")]
238        if !opts.dir_listing_download.is_empty() {
239            if let Some((_k, _dl_archive_opt)) =
240                form_urlencoded::parse(opts.uri_query.unwrap_or("").as_bytes())
241                    .find(|(k, _v)| k == DOWNLOAD_PARAM_KEY)
242            {
243                // file path is index.html, need pop
244                let mut fp = file_path.clone();
245                fp.pop();
246                if let Some(filename) = fp.file_name() {
247                    let resp = archive_reply(
248                        filename,
249                        &fp,
250                        DirDownloadOpts {
251                            method,
252                            disable_symlinks: opts.disable_symlinks,
253                            ignore_hidden_files: opts.ignore_hidden_files,
254                        },
255                    );
256                    return Ok(StaticFileResponse {
257                        resp,
258                        file_path: resp_file_path,
259                    });
260                } else {
261                    tracing::error!("Unable to get filename from {}", fp.to_string_lossy());
262                    return Err(StatusCode::INTERNAL_SERVER_ERROR);
263                }
264            }
265        }
266
267        let resp = directory_listing::auto_index(DirListOpts {
268            root_path: opts.base_path.as_path(),
269            method,
270            current_path: uri_path,
271            uri_query: opts.uri_query,
272            filepath: file_path,
273            dir_listing_order: opts.dir_listing_order,
274            dir_listing_format: opts.dir_listing_format,
275            ignore_hidden_files: opts.ignore_hidden_files,
276            disable_symlinks: opts.disable_symlinks,
277            #[cfg(feature = "directory-listing-download")]
278            dir_listing_download: opts.dir_listing_download,
279        })?;
280
281        return Ok(StaticFileResponse {
282            resp,
283            file_path: resp_file_path,
284        });
285    }
286
287    // Check for a pre-compressed file variant if present under the `opts.compression_static` context
288    if let Some(precompressed_meta) = precompressed_variant {
289        let (precomp_path, precomp_encoding) = precompressed_meta;
290        let mut resp = file_reply(
291            headers_opt,
292            file_path,
293            &metadata,
294            Some(precomp_path),
295            #[cfg(feature = "experimental")]
296            opts.memory_cache,
297        )?;
298
299        // Prepare corresponding headers to let know how to decode the payload
300        resp.headers_mut().remove(CONTENT_LENGTH);
301        let encoding = match HeaderValue::from_str(precomp_encoding.as_str()) {
302            Ok(val) => val,
303            Err(err) => {
304                tracing::error!(
305                    "unable to parse header value from content encoding: {:?}",
306                    err
307                );
308                return Err(StatusCode::INTERNAL_SERVER_ERROR);
309            }
310        };
311        resp.headers_mut().insert(CONTENT_ENCODING, encoding);
312
313        return Ok(StaticFileResponse {
314            resp,
315            file_path: resp_file_path,
316        });
317    }
318
319    #[cfg(feature = "experimental")]
320    let resp = file_reply(headers_opt, file_path, &metadata, None, opts.memory_cache)?;
321
322    #[cfg(not(feature = "experimental"))]
323    let resp = file_reply(headers_opt, file_path, &metadata, None)?;
324
325    Ok(StaticFileResponse {
326        resp,
327        file_path: resp_file_path,
328    })
329}
330
331/// Returns the final composed metadata containing
332/// the current `file_path` with its file metadata
333/// as well as its optional pre-compressed variant.
334fn get_composed_file_metadata<'a>(
335    mut file_path: &'a mut PathBuf,
336    _headers: &'a HeaderMap<HeaderValue>,
337    _compression_static: bool,
338    mut index_files: &'a [&'a str],
339) -> Result<FileMetadata<'a>, StatusCode> {
340    tracing::trace!("getting metadata for file {}", file_path.display());
341
342    // Try to find the file path on the file system
343    match try_metadata(file_path) {
344        Ok((mut metadata, is_dir)) => {
345            if is_dir {
346                // Try every index file variant in order
347                if index_files.is_empty() {
348                    index_files = DEFAULT_INDEX_FILES;
349                }
350                let mut index_found = false;
351                for index in index_files {
352                    // Append a HTML index page by default if it's a directory path (`autoindex`)
353                    tracing::debug!("dir: appending {} to the directory path", index);
354                    file_path.push(index);
355
356                    // Pre-compressed variant check for the autoindex
357                    #[cfg(any(
358                        feature = "compression",
359                        feature = "compression-deflate",
360                        feature = "compression-gzip",
361                        feature = "compression-brotli",
362                        feature = "compression-zstd"
363                    ))]
364                    if _compression_static {
365                        if let Some(p) =
366                            compression_static::precompressed_variant(file_path, _headers)
367                        {
368                            return Ok(FileMetadata {
369                                file_path,
370                                metadata: p.metadata,
371                                is_dir: false,
372                                precompressed_variant: Some((p.file_path, p.encoding)),
373                            });
374                        }
375                    }
376
377                    // Otherwise, just fallback to finding the index.html
378                    // and overwrite the current `meta`
379                    // Also noting that it's still a directory request
380                    if let Ok(meta_res) = try_metadata(file_path) {
381                        (metadata, _) = meta_res;
382                        index_found = true;
383                        break;
384                    }
385
386                    // We remove only the appended index file
387                    file_path.pop();
388                    let new_meta: Option<Metadata>;
389                    (file_path, new_meta) = try_metadata_with_html_suffix(file_path);
390                    if let Some(new_meta) = new_meta {
391                        metadata = new_meta;
392                        index_found = true;
393                        break;
394                    }
395                }
396
397                // In case no index was found then we append the last index
398                // of the list to preserve the previous behavior
399                if !index_found && !index_files.is_empty() {
400                    file_path.push(index_files.last().unwrap());
401                }
402            } else {
403                // Fallback pre-compressed variant check for the specific file
404                #[cfg(any(
405                    feature = "compression",
406                    feature = "compression-deflate",
407                    feature = "compression-gzip",
408                    feature = "compression-brotli",
409                    feature = "compression-zstd"
410                ))]
411                if _compression_static {
412                    if let Some(p) = compression_static::precompressed_variant(file_path, _headers)
413                    {
414                        return Ok(FileMetadata {
415                            file_path,
416                            metadata: p.metadata,
417                            is_dir: false,
418                            precompressed_variant: Some((p.file_path, p.encoding)),
419                        });
420                    }
421                }
422            }
423
424            Ok(FileMetadata {
425                file_path,
426                metadata,
427                is_dir,
428                precompressed_variant: None,
429            })
430        }
431        Err(err) => {
432            // Pre-compressed variant check for the file not found
433            #[cfg(any(
434                feature = "compression",
435                feature = "compression-deflate",
436                feature = "compression-gzip",
437                feature = "compression-brotli",
438                feature = "compression-zstd"
439            ))]
440            if _compression_static {
441                if let Some(p) = compression_static::precompressed_variant(file_path, _headers) {
442                    return Ok(FileMetadata {
443                        file_path,
444                        metadata: p.metadata,
445                        is_dir: false,
446                        precompressed_variant: Some((p.file_path, p.encoding)),
447                    });
448                }
449            }
450
451            // Otherwise, if the file path doesn't exist then
452            // we try to find the path suffixed with `.html`.
453            // For example: `/posts/article` will fallback to `/posts/article.html`
454            let new_meta: Option<Metadata>;
455            (file_path, new_meta) = try_metadata_with_html_suffix(file_path);
456
457            #[cfg(any(
458                feature = "compression",
459                feature = "compression-deflate",
460                feature = "compression-gzip",
461                feature = "compression-brotli",
462                feature = "compression-zstd"
463            ))]
464            match new_meta {
465                Some(new_meta) => {
466                    return Ok(FileMetadata {
467                        file_path,
468                        metadata: new_meta,
469                        is_dir: false,
470                        precompressed_variant: None,
471                    });
472                }
473                _ => {
474                    // Last pre-compressed variant check or the suffixed file not found
475                    if _compression_static {
476                        if let Some(p) =
477                            compression_static::precompressed_variant(file_path, _headers)
478                        {
479                            return Ok(FileMetadata {
480                                file_path,
481                                metadata: p.metadata,
482                                is_dir: false,
483                                precompressed_variant: Some((p.file_path, p.encoding)),
484                            });
485                        }
486                    }
487                }
488            }
489            #[cfg(not(feature = "compression"))]
490            if let Some(new_meta) = new_meta {
491                return Ok(FileMetadata {
492                    file_path,
493                    metadata: new_meta,
494                    is_dir: false,
495                    precompressed_variant: None,
496                });
497            }
498
499            Err(err)
500        }
501    }
502}
503
504/// Reply with the corresponding file content taking into account
505/// its precompressed variant if any.
506/// The `path` param should contains always the original requested file path and
507/// the `meta` param value should corresponds to it.
508/// However, if `path_precompressed` contains some value then
509/// the `meta` param  value will belong to the `path_precompressed` (precompressed file variant).
510fn file_reply<'a>(
511    headers: &'a HeaderMap<HeaderValue>,
512    path: &'a PathBuf,
513    meta: &'a Metadata,
514    path_precompressed: Option<PathBuf>,
515    #[cfg(feature = "experimental")] memory_cache: Option<&'a MemCacheOpts>,
516) -> Result<Response<Body>, StatusCode> {
517    let conditionals = ConditionalHeaders::new(headers);
518    let file_path = path_precompressed.as_ref().unwrap_or(path);
519
520    match File::open(file_path) {
521        Ok(file) => {
522            #[cfg(feature = "experimental")]
523            let resp = response_body(file, path, meta, conditionals, memory_cache);
524
525            #[cfg(not(feature = "experimental"))]
526            let resp = response_body(file, path, meta, conditionals);
527
528            resp
529        }
530        Err(err) => {
531            let status = match err.kind() {
532                io::ErrorKind::NotFound => {
533                    tracing::debug!("file can't be opened or not found: {:?}", path.display());
534                    StatusCode::NOT_FOUND
535                }
536                io::ErrorKind::PermissionDenied => {
537                    tracing::warn!("file permission denied: {:?}", path.display());
538                    StatusCode::FORBIDDEN
539                }
540                _ => {
541                    tracing::error!("file open error (path={:?}): {} ", path.display(), err);
542                    StatusCode::INTERNAL_SERVER_ERROR
543                }
544            };
545            Err(status)
546        }
547    }
548}