static_web_server/directory_listing/
dir.rs1use 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
24const PERCENT_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC
28 .remove(b'_')
29 .remove(b'-')
30 .remove(b'.')
31 .remove(b'~');
32
33#[derive(Debug, Serialize, Deserialize, Clone, ValueEnum)]
35#[serde(rename_all = "lowercase")]
36pub enum DirListFmt {
37 Html,
39 Json,
41}
42
43pub struct DirListOpts<'a> {
45 pub root_path: &'a Path,
47 pub method: &'a Method,
49 pub current_path: &'a str,
51 pub uri_query: Option<&'a str>,
53 pub filepath: &'a Path,
55 pub dir_listing_order: u8,
57 pub dir_listing_format: &'a DirListFmt,
59 #[cfg(feature = "directory-listing-download")]
60 pub dir_listing_download: &'a [DirDownloadFmt],
62 pub include_hidden: bool,
64 pub follow_symlinks: bool,
66}
67
68pub(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
83pub(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 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 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 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 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 if let Some(q) = opt.uri_query {
209 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 let content = match opt.content_format {
224 DirListFmt::Json => {
225 resp.headers_mut()
227 .typed_insert(ContentType::from(mime::APPLICATION_JSON));
228
229 json_auto_index(&mut file_entries, opt.order_code)?
230 }
231 _ => {
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 if opt.is_head {
253 return Ok(resp);
254 }
255
256 *resp.body_mut() = crate::body::full(content);
257
258 Ok(resp)
259}