Skip to main content

static_web_server/directory_listing/
dir.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
6use chrono::{DateTime, Local};
7use clap::ValueEnum;
8use headers::{ContentLength, ContentType, HeaderMapExt};
9use hyper::Method;
10use hyper::{Body, Response};
11use mime_guess::mime;
12use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, percent_encode};
13use serde::{Deserialize, Serialize};
14use std::path::Path;
15
16use crate::directory_listing::autoindex::{html_auto_index, json_auto_index};
17use crate::directory_listing::file::{FileEntry, FileType};
18use crate::{Context, Result};
19
20#[cfg(feature = "directory-listing-download")]
21use crate::directory_listing_download::DirDownloadFmt;
22
23/// Non-alphanumeric characters to be percent-encoded
24/// excluding the "unreserved characters" because allowed in a URI.
25/// See 2.3.  Unreserved Characters - <https://www.ietf.org/rfc/rfc3986.txt>
26const PERCENT_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC
27    .remove(b'_')
28    .remove(b'-')
29    .remove(b'.')
30    .remove(b'~');
31
32/// Directory listing output format for file entries.
33#[derive(Debug, Serialize, Deserialize, Clone, ValueEnum)]
34#[serde(rename_all = "lowercase")]
35pub enum DirListFmt {
36    /// HTML format to display (default).
37    Html,
38    /// JSON format to display.
39    Json,
40}
41
42/// Directory listing options.
43pub struct DirListOpts<'a> {
44    /// Request method.
45    pub root_path: &'a Path,
46    /// Request method.
47    pub method: &'a Method,
48    /// Current Request path.
49    pub current_path: &'a str,
50    /// URI Request query
51    pub uri_query: Option<&'a str>,
52    /// Request file path.
53    pub filepath: &'a Path,
54    /// Directory listing order.
55    pub dir_listing_order: u8,
56    /// Directory listing format.
57    pub dir_listing_format: &'a DirListFmt,
58    #[cfg(feature = "directory-listing-download")]
59    /// Directory listing download.
60    pub dir_listing_download: &'a [DirDownloadFmt],
61    /// Ignore hidden files (dotfiles).
62    pub ignore_hidden_files: bool,
63    /// Prevent following symlinks for files and directories.
64    pub disable_symlinks: bool,
65}
66
67/// Defines read directory entries.
68pub(crate) struct DirEntryOpts<'a> {
69    pub(crate) root_path: &'a Path,
70    pub(crate) dir_reader: std::fs::ReadDir,
71    pub(crate) base_path: &'a str,
72    pub(crate) uri_query: Option<&'a str>,
73    pub(crate) is_head: bool,
74    pub(crate) order_code: u8,
75    pub(crate) content_format: &'a DirListFmt,
76    pub(crate) ignore_hidden_files: bool,
77    pub(crate) disable_symlinks: bool,
78    #[cfg(feature = "directory-listing-download")]
79    pub(crate) download: &'a [DirDownloadFmt],
80}
81
82/// It reads a list of directory entries and create an index page content.
83/// Otherwise it returns a status error.
84pub(crate) fn read_dir_entries(mut opt: DirEntryOpts<'_>) -> Result<Response<Body>> {
85    let mut dirs_count: usize = 0;
86    let mut files_count: usize = 0;
87    // The root directory is canonicalized once at startup (see
88    // `server.rs`). To avoid an extra `canonicalize()` syscall per
89    // request, we resolve the absolute form lazily — only when a symlink
90    // entry is actually encountered (the uncommon case).
91    let mut root_path_abs: Option<std::path::PathBuf> = None;
92    let (entries_hint, _) = opt.dir_reader.size_hint();
93    let mut file_entries: Vec<FileEntry> = Vec::with_capacity(entries_hint);
94
95    for dir_entry in opt.dir_reader {
96        let dir_entry = dir_entry.with_context(|| "unable to read directory entry")?;
97        let meta = match dir_entry.metadata() {
98            Ok(m) => m,
99            Err(err) => {
100                tracing::error!(
101                    "unable to resolve metadata for file or directory entry (skipped): {:?}",
102                    err
103                );
104                continue;
105            }
106        };
107
108        let name = dir_entry.file_name();
109
110        // Check and ignore the current hidden file/directory (dotfile) if feature enabled
111        if opt.ignore_hidden_files && name.as_encoded_bytes().first().is_some_and(|c| *c == b'.') {
112            continue;
113        }
114
115        let (r#type, size) = if meta.is_dir() {
116            dirs_count += 1;
117            (FileType::Directory, None)
118        } else if meta.is_file() {
119            files_count += 1;
120            (FileType::File, Some(meta.len()))
121        } else if !opt.disable_symlinks && meta.file_type().is_symlink() {
122            // NOTE: we resolve the symlink path below to just know if is a directory or not.
123            // However, we are still showing the symlink name but not the resolved name.
124
125            let symlink_path = dir_entry.path();
126            let symlink_path = match symlink_path.canonicalize() {
127                Ok(v) => v,
128                Err(err) => {
129                    tracing::error!(
130                        "unable resolve symlink path for `{}` (skipped): {:?}",
131                        symlink_path.display(),
132                        err,
133                    );
134                    continue;
135                }
136            };
137            if !symlink_path.starts_with(root_path_abs.get_or_insert_with(|| {
138                opt.root_path
139                    .canonicalize()
140                    .unwrap_or_else(|_| opt.root_path.to_path_buf())
141            })) {
142                tracing::warn!(
143                    "unable to follow symlink {}, access denied",
144                    symlink_path.display()
145                );
146                continue;
147            }
148            let symlink_meta = match std::fs::symlink_metadata(&symlink_path) {
149                Ok(v) => v,
150                Err(err) => {
151                    tracing::error!(
152                        "unable to resolve metadata for `{}` symlink (skipped): {:?}",
153                        symlink_path.display(),
154                        err,
155                    );
156                    continue;
157                }
158            };
159            if symlink_meta.is_dir() {
160                dirs_count += 1;
161                (FileType::Directory, None)
162            } else {
163                files_count += 1;
164                (FileType::File, Some(symlink_meta.len()))
165            }
166        } else {
167            continue;
168        };
169
170        let name_encoded = percent_encode(name.as_encoded_bytes(), PERCENT_ENCODE_SET).to_string();
171
172        // NOTE: Use relative paths by default independently of
173        // the "redirect trailing slash" feature.
174        // However, when "redirect trailing slash" is disabled
175        // and a request path doesn't contain a trailing slash then
176        // entries should contain the "parent/entry-name" as a link format.
177        // Otherwise, we just use the "entry-name" as a link (default behavior).
178        // Note that in both cases, we add a trailing slash if the entry is a directory.
179        let mut uri = if !opt.base_path.ends_with('/') && !opt.base_path.is_empty() {
180            let parent = opt
181                .base_path
182                .rsplit_once('/')
183                .map(|(_, parent)| parent)
184                .unwrap_or(opt.base_path);
185            format!("{parent}/{name_encoded}")
186        } else {
187            name_encoded
188        };
189
190        if r#type == FileType::Directory {
191            uri.push('/');
192        }
193
194        let mtime = meta.modified().ok().map(DateTime::<Local>::from);
195
196        let entry = FileEntry {
197            name,
198            mtime,
199            size,
200            r#type,
201            uri,
202        };
203        file_entries.push(entry);
204    }
205
206    // Check the query request uri for a sorting type. E.g https://blah/?sort=5
207    if let Some(q) = opt.uri_query {
208        // NOTE: we just pick up the first `sort` pair.
209        // Avoid calling `.count()` (which consumes the iterator) and then
210        // re-parsing the query string a second time.
211        if let Some(code) = form_urlencoded::parse(q.as_bytes())
212            .find(|(key, _)| key == "sort")
213            .and_then(|(_, value)| value.trim().parse::<u8>().ok())
214        {
215            opt.order_code = code;
216        }
217    }
218
219    let mut resp = Response::new(Body::empty());
220
221    // Handle directory listing content format
222    let content = match opt.content_format {
223        DirListFmt::Json => {
224            // JSON
225            resp.headers_mut()
226                .typed_insert(ContentType::from(mime::APPLICATION_JSON));
227
228            json_auto_index(&mut file_entries, opt.order_code)?
229        }
230        // HTML (default)
231        _ => {
232            resp.headers_mut()
233                .typed_insert(ContentType::from(mime::TEXT_HTML_UTF_8));
234
235            html_auto_index(
236                opt.base_path,
237                dirs_count,
238                files_count,
239                &mut file_entries,
240                opt.order_code,
241                #[cfg(feature = "directory-listing-download")]
242                opt.download,
243            )
244        }
245    };
246
247    resp.headers_mut()
248        .typed_insert(ContentLength(content.len() as u64));
249
250    // We skip the body for HEAD requests
251    if opt.is_head {
252        return Ok(resp);
253    }
254
255    *resp.body_mut() = Body::from(content);
256
257    Ok(resp)
258}