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