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