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    let mut file_entries: Vec<FileEntry> = vec![];
88    let root_path_abs = opt.root_path.canonicalize()?;
89
90    for dir_entry in opt.dir_reader {
91        let dir_entry = dir_entry.with_context(|| "unable to read directory entry")?;
92        let meta = match dir_entry.metadata() {
93            Ok(m) => m,
94            Err(err) => {
95                tracing::error!(
96                    "unable to resolve metadata for file or directory entry (skipped): {:?}",
97                    err
98                );
99                continue;
100            }
101        };
102
103        let name = dir_entry.file_name();
104
105        // Check and ignore the current hidden file/directory (dotfile) if feature enabled
106        if opt.ignore_hidden_files && name.as_encoded_bytes().first().is_some_and(|c| *c == b'.') {
107            continue;
108        }
109
110        let (r#type, size) = if meta.is_dir() {
111            dirs_count += 1;
112            (FileType::Directory, None)
113        } else if meta.is_file() {
114            files_count += 1;
115            (FileType::File, Some(meta.len()))
116        } else if !opt.disable_symlinks && meta.file_type().is_symlink() {
117            // NOTE: we resolve the symlink path below to just know if is a directory or not.
118            // However, we are still showing the symlink name but not the resolved name.
119
120            let symlink_path = dir_entry.path();
121            let symlink_path = match symlink_path.canonicalize() {
122                Ok(v) => v,
123                Err(err) => {
124                    tracing::error!(
125                        "unable resolve symlink path for `{}` (skipped): {:?}",
126                        symlink_path.display(),
127                        err,
128                    );
129                    continue;
130                }
131            };
132            if !symlink_path.starts_with(&root_path_abs) {
133                tracing::warn!(
134                    "unable to follow symlink {}, access denied",
135                    symlink_path.display()
136                );
137                continue;
138            }
139            let symlink_meta = match std::fs::symlink_metadata(&symlink_path) {
140                Ok(v) => v,
141                Err(err) => {
142                    tracing::error!(
143                        "unable to resolve metadata for `{}` symlink (skipped): {:?}",
144                        symlink_path.display(),
145                        err,
146                    );
147                    continue;
148                }
149            };
150            if symlink_meta.is_dir() {
151                dirs_count += 1;
152                (FileType::Directory, None)
153            } else {
154                files_count += 1;
155                (FileType::File, Some(symlink_meta.len()))
156            }
157        } else {
158            continue;
159        };
160
161        let name_encoded = percent_encode(name.as_encoded_bytes(), PERCENT_ENCODE_SET).to_string();
162
163        // NOTE: Use relative paths by default independently of
164        // the "redirect trailing slash" feature.
165        // However, when "redirect trailing slash" is disabled
166        // and a request path doesn't contain a trailing slash then
167        // entries should contain the "parent/entry-name" as a link format.
168        // Otherwise, we just use the "entry-name" as a link (default behavior).
169        // Note that in both cases, we add a trailing slash if the entry is a directory.
170        let mut uri = if !opt.base_path.ends_with('/') && !opt.base_path.is_empty() {
171            let parent = opt
172                .base_path
173                .rsplit_once('/')
174                .map(|(_, parent)| parent)
175                .unwrap_or(opt.base_path);
176            format!("{parent}/{name_encoded}")
177        } else {
178            name_encoded
179        };
180
181        if r#type == FileType::Directory {
182            uri.push('/');
183        }
184
185        let mtime = meta.modified().ok().map(DateTime::<Local>::from);
186
187        let entry = FileEntry {
188            name,
189            mtime,
190            size,
191            r#type,
192            uri,
193        };
194        file_entries.push(entry);
195    }
196
197    // Check the query request uri for a sorting type. E.g https://blah/?sort=5
198    if let Some(q) = opt.uri_query {
199        let mut parts = form_urlencoded::parse(q.as_bytes());
200        if parts.count() > 0 {
201            // NOTE: we just pick up the first value (pair)
202            if let Some(sort) = parts.next() {
203                if sort.0 == "sort" && !sort.1.trim().is_empty() {
204                    match sort.1.parse::<u8>() {
205                        Ok(code) => opt.order_code = code,
206                        Err(err) => {
207                            tracing::error!(
208                                "sorting: query value error when converting to u8: {:?}",
209                                err
210                            );
211                        }
212                    }
213                }
214            }
215        }
216    }
217
218    let mut resp = Response::new(Body::empty());
219
220    // Handle directory listing content format
221    let content = match opt.content_format {
222        DirListFmt::Json => {
223            // JSON
224            resp.headers_mut()
225                .typed_insert(ContentType::from(mime::APPLICATION_JSON));
226
227            json_auto_index(&mut file_entries, opt.order_code)?
228        }
229        // HTML (default)
230        _ => {
231            resp.headers_mut()
232                .typed_insert(ContentType::from(mime::TEXT_HTML_UTF_8));
233
234            html_auto_index(
235                opt.base_path,
236                dirs_count,
237                files_count,
238                &mut file_entries,
239                opt.order_code,
240                #[cfg(feature = "directory-listing-download")]
241                opt.download,
242            )
243        }
244    };
245
246    resp.headers_mut()
247        .typed_insert(ContentLength(content.len() as u64));
248
249    // We skip the body for HEAD requests
250    if opt.is_head {
251        return Ok(resp);
252    }
253
254    *resp.body_mut() = Body::from(content);
255
256    Ok(resp)
257}