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 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 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 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 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 if let Some(q) = opt.uri_query {
199 let mut parts = form_urlencoded::parse(q.as_bytes());
200 if parts.count() > 0 {
201 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 let content = match opt.content_format {
222 DirListFmt::Json => {
223 resp.headers_mut()
225 .typed_insert(ContentType::from(mime::APPLICATION_JSON));
226
227 json_auto_index(&mut file_entries, opt.order_code)?
228 }
229 _ => {
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 if opt.is_head {
251 return Ok(resp);
252 }
253
254 *resp.body_mut() = Body::from(content);
255
256 Ok(resp)
257}