static_web_server/directory_listing/
dir.rs1use 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
23const PERCENT_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC
27 .remove(b'_')
28 .remove(b'-')
29 .remove(b'.')
30 .remove(b'~');
31
32#[derive(Debug, Serialize, Deserialize, Clone, ValueEnum)]
34#[serde(rename_all = "lowercase")]
35pub enum DirListFmt {
36 Html,
38 Json,
40}
41
42pub struct DirListOpts<'a> {
44 pub root_path: &'a Path,
46 pub method: &'a Method,
48 pub current_path: &'a str,
50 pub uri_query: Option<&'a str>,
52 pub filepath: &'a Path,
54 pub dir_listing_order: u8,
56 pub dir_listing_format: &'a DirListFmt,
58 #[cfg(feature = "directory-listing-download")]
59 pub dir_listing_download: &'a [DirDownloadFmt],
61 pub ignore_hidden_files: bool,
63 pub disable_symlinks: bool,
65}
66
67pub(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
82pub(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 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 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 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 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 if let Some(q) = opt.uri_query {
208 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 let content = match opt.content_format {
223 DirListFmt::Json => {
224 resp.headers_mut()
226 .typed_insert(ContentType::from(mime::APPLICATION_JSON));
227
228 json_auto_index(&mut file_entries, opt.order_code)?
229 }
230 _ => {
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 if opt.is_head {
252 return Ok(resp);
253 }
254
255 *resp.body_mut() = Body::from(content);
256
257 Ok(resp)
258}