1use std::fs::{File, metadata, read_dir};
2use std::time::UNIX_EPOCH;
3use file_ext::FileExt;
4use crate::controller::Controller;
5use crate::header::Header;
6use crate::mime_type::MimeType;
7use crate::range::{ContentRange, Range};
8use crate::request::{METHOD, Request};
9use crate::response::{Error, Response, STATUS_CODE_REASON_PHRASE};
10use crate::server::ConnectionInfo;
11use crate::symbol::SYMBOL;
12use crate::url::URL;
13
14#[cfg(test)]
15mod tests;
16pub(crate) mod open_file_cache;
17
18pub struct StaticResourceController;
19
20impl Controller for StaticResourceController {
21 fn is_matching(request: &Request, _connection: &ConnectionInfo) -> bool {
22 if !request.request_uri.starts_with('/') {
25 return false;
26 }
27 let url = request.request_uri.to_string();
28
29 let boxed_url_components = URL::parse_request_target(&url);
30 if boxed_url_components.is_err() {
31 let message = boxed_url_components.as_ref().err().unwrap().to_string();
32 println!("unexpected error, {}", message);
34 }
35
36 let components = boxed_url_components.unwrap();
37
38 let os_specific_separator : String = FileExt::get_path_separator();
39 let os_specific_path = &components.path.replace(SYMBOL.slash, os_specific_separator.as_str());
40
41 let boxed_static_filepath = static_fs_path(&os_specific_path);
42 if boxed_static_filepath.is_err() {
43 return false
44 }
45
46 let static_filepath = boxed_static_filepath.unwrap();
47
48 let mut is_directory = false;
51
52 let mut is_file = open_file_cache::is_cached_file(&static_filepath);
55 if !is_file {
56 if let Ok(md) = metadata(&static_filepath) {
57 if !inside_root(&static_filepath) {
59 return false;
60 }
61 is_directory = md.is_dir();
62 is_file = md.is_file();
63 }
64 }
65
66 let is_get = request.method == METHOD.get;
67 let is_head = request.method == METHOD.head;
68 let is_options = request.method == METHOD.options;
69
70 let is_matching_method = (is_get || is_head || is_options) && (request.request_uri != SYMBOL.slash);
71
72 if is_file || is_directory {
73 is_matching_method
74 } else {
75 if !static_filepath.ends_with(".html") {
77 let html_suffix = ".html";
78 let html_file = [&components.path.replace(SYMBOL.slash, &FileExt::get_path_separator()), html_suffix].join(SYMBOL.empty_string);
79 if let Ok(html_static_filepath) = static_fs_path(&html_file) {
80 if File::open(&html_static_filepath).is_ok() {
81 return is_matching_method;
82 }
83 }
84 }
85
86 is_matching_method && spa_fallback_url_path(&components.path).is_some()
91 }
92
93 }
94
95 fn process(request: &Request, mut response: Response, _connection: &ConnectionInfo) -> Response {
96 if let Some(served) = StaticResourceController::serve_regular_file(request, &mut response) {
97 return served;
98 }
99 let boxed_content_range_list = StaticResourceController::process_static_resources(&request);
100 if boxed_content_range_list.is_ok() {
101 let content_range_list = boxed_content_range_list.unwrap();
102
103 if content_range_list.len() != 0 {
104
105 let mut status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok;
106
107 let does_request_include_range_header = request.get_header(Header::_RANGE.to_string()).is_some();
108 if does_request_include_range_header {
109 status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n206_partial_content;
110 }
111
112 let is_options_request = request.method == METHOD.options;
113 if is_options_request {
114 status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n204_no_content;
115 }
116
117
118 let dir = std::path::PathBuf::from(working_directory());
119 let working_directory = dir.as_path().to_str().unwrap();
120
121 let url = request.request_uri.to_string();
122
123 let boxed_url_components = URL::parse_request_target(&url);
124 if boxed_url_components.is_err() {
125 let message = boxed_url_components.as_ref().err().unwrap().to_string();
126 println!("unexpected error, {}", message);
128 }
129
130 let components = boxed_url_components.unwrap();
131
132 let static_filepath = [working_directory, components.path.as_str()].join(SYMBOL.empty_string);
133 let boxed_modified_date_time = file_modified_utc(&static_filepath);
134
135 if boxed_modified_date_time.is_ok() {
136 let modified_date_time = boxed_modified_date_time.unwrap();
137 let last_modified_unix_nanos = Header{ name: Header::_LAST_MODIFIED_UNIX_EPOCH_NANOS.to_string(), value: modified_date_time.to_string() };
138 response.headers.push(last_modified_unix_nanos);
139
140 let file_size = metadata(&static_filepath).map(|m| m.len()).unwrap_or(0);
141 let etag_value = format!("\"{}-{}\"", modified_date_time, file_size);
142
143 let if_none_match = request.get_header(Header::_IF_NONE_MATCH.to_string());
144 if let Some(inm) = if_none_match {
145 if inm.value == etag_value || inm.value == "*" {
146 response.status_code = *STATUS_CODE_REASON_PHRASE.n304_not_modified.status_code;
147 response.reason_phrase = STATUS_CODE_REASON_PHRASE.n304_not_modified.reason_phrase.to_string();
148 response.headers.push(Header { name: Header::_ETAG.to_string(), value: etag_value });
149 return response;
150 }
151 }
152
153 response.headers.push(Header { name: Header::_ETAG.to_string(), value: etag_value });
154
155 let is_range_request = request.get_header(Header::_RANGE.to_string()).is_some();
158 if file_size > StaticResourceController::STREAM_THRESHOLD && !is_range_request {
159 let mime = MimeType::detect_mime_type(&static_filepath);
160 response.headers.push(Header {
161 name: Header::_CONTENT_TYPE.to_string(),
162 value: mime,
163 });
164 response.headers.push(Header {
165 name: Header::_CONTENT_LENGTH.to_string(),
166 value: file_size.to_string(),
167 });
168 response.status_code = *status_code_reason_phrase.status_code;
169 response.reason_phrase = status_code_reason_phrase.reason_phrase.to_string();
170 response.stream_file = Some(static_filepath);
171 return response;
172 }
173 }
174
175 response.status_code = *status_code_reason_phrase.status_code;
176 response.reason_phrase = status_code_reason_phrase.reason_phrase.to_string();
177 response.content_range_list = content_range_list;
178
179 }
180 } else {
181 let error : Error = boxed_content_range_list.err().unwrap();
182 let body = error.message;
183
184 let content_range = Range::get_content_range(
185 Vec::from(body.as_bytes()),
186 MimeType::TEXT_HTML.to_string()
187 );
188
189 let content_range_list = vec![content_range];
190
191 response.status_code = *error.status_code_reason_phrase.status_code;
192 response.reason_phrase = error.status_code_reason_phrase.reason_phrase.to_string();
193 response.content_range_list = content_range_list;
194
195 }
196
197
198 response
199 }
200}
201
202impl StaticResourceController {
204
205 pub fn is_matching_request(request: &Request) -> bool {
206 let boxed_static_filepath = static_fs_path(&request.request_uri);
207 if boxed_static_filepath.is_err() {
208 return false
209 }
210
211 let static_filepath = boxed_static_filepath.unwrap();
212
213 let boxed_md = metadata(&static_filepath);
214 if boxed_md.is_err() || !inside_root(&static_filepath) {
215 return false
216 }
217
218 let md = boxed_md.unwrap();
219 if md.is_dir() {
220 return false
221 }
222
223 let boxed_file = File::open(&static_filepath);
224
225 let is_get = request.method == METHOD.get;
226 let is_head = request.method == METHOD.head;
227 let is_options = request.method == METHOD.options;
228 boxed_file.is_ok() && (is_get || is_head || is_options && request.request_uri != SYMBOL.slash)
229 }
230
231 pub fn process_request(request: &Request, mut response: Response) -> Response {
232 if let Some(streamed) = StaticResourceController::serve_regular_file(request, &mut response) {
233 return streamed;
234 }
235 let boxed_content_range_list = StaticResourceController::process_static_resources(&request);
236 if boxed_content_range_list.is_ok() {
237 let content_range_list = boxed_content_range_list.unwrap();
238
239 if content_range_list.len() != 0 {
240
241 let mut status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok;
242
243 let does_request_include_range_header = request.get_header(Header::_RANGE.to_string()).is_some();
244 if does_request_include_range_header {
245 status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n206_partial_content;
246 }
247
248 let is_options_request = request.method == METHOD.options;
249 if is_options_request {
250 status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n204_no_content;
251 }
252
253
254 let dir = std::path::PathBuf::from(working_directory());
255 let working_directory = dir.as_path().to_str().unwrap();
256 let static_filepath = [working_directory, request.request_uri.as_str()].join(SYMBOL.empty_string);
257 let boxed_modified_date_time = file_modified_utc(&static_filepath);
258
259 if boxed_modified_date_time.is_ok() {
260 let modified_date_time = boxed_modified_date_time.unwrap();
261 let last_modified_unix_nanos = Header{ name: Header::_LAST_MODIFIED_UNIX_EPOCH_NANOS.to_string(), value: modified_date_time.to_string() };
262 response.headers.push(last_modified_unix_nanos);
263
264 let file_size = metadata(&static_filepath).map(|m| m.len()).unwrap_or(0);
265 let etag_value = format!("\"{}-{}\"", modified_date_time, file_size);
266
267 let if_none_match = request.get_header(Header::_IF_NONE_MATCH.to_string());
268 if let Some(inm) = if_none_match {
269 if inm.value == etag_value || inm.value == "*" {
270 response.status_code = *STATUS_CODE_REASON_PHRASE.n304_not_modified.status_code;
271 response.reason_phrase = STATUS_CODE_REASON_PHRASE.n304_not_modified.reason_phrase.to_string();
272 response.headers.push(Header { name: Header::_ETAG.to_string(), value: etag_value });
273 return response;
274 }
275 }
276
277 response.headers.push(Header { name: Header::_ETAG.to_string(), value: etag_value });
278
279 let is_range_request = request.get_header(Header::_RANGE.to_string()).is_some();
280 if file_size > StaticResourceController::STREAM_THRESHOLD && !is_range_request {
281 let mime = MimeType::detect_mime_type(&static_filepath);
282 response.headers.push(Header {
283 name: Header::_CONTENT_TYPE.to_string(),
284 value: mime,
285 });
286 response.headers.push(Header {
287 name: Header::_CONTENT_LENGTH.to_string(),
288 value: file_size.to_string(),
289 });
290 response.status_code = *status_code_reason_phrase.status_code;
291 response.reason_phrase = status_code_reason_phrase.reason_phrase.to_string();
292 response.stream_file = Some(static_filepath);
293 return response;
294 }
295 }
296
297 response.status_code = *status_code_reason_phrase.status_code;
298 response.reason_phrase = status_code_reason_phrase.reason_phrase.to_string();
299 response.content_range_list = content_range_list;
300
301 }
302 } else {
303 let error : Error = boxed_content_range_list.err().unwrap();
304 let body = error.message;
305
306 let content_range = Range::get_content_range(
307 Vec::from(body.as_bytes()),
308 MimeType::TEXT_HTML.to_string()
309 );
310
311 let content_range_list = vec![content_range];
312
313 response.status_code = *error.status_code_reason_phrase.status_code;
314 response.reason_phrase = error.status_code_reason_phrase.reason_phrase.to_string();
315 response.content_range_list = content_range_list;
316
317 }
318
319
320 response
321 }
322
323 pub const STREAM_THRESHOLD: u64 = 256 * 1024;
327
328 fn serve_regular_file(request: &Request, response: &mut Response) -> Option<Response> {
338 if request.method != METHOD.get && request.method != METHOD.head {
339 return None;
340 }
341 if request.get_header(Header::_RANGE.to_string()).is_some() {
342 return None;
343 }
344 let path = URL::parse_request_target(&request.request_uri).ok()?.path;
345 let fs_path = static_fs_path(&path.replace(SYMBOL.slash, &FileExt::get_path_separator())).ok()?;
346 let mime = MimeType::detect_mime_type(&fs_path);
350 let follow_symlinks = mime != MimeType::APPLICATION_OCTET_STREAM;
351 let file = open_file_cache::open(&fs_path, follow_symlinks)?;
352 let state = file.state()?;
353
354 let mut response = std::mem::replace(response, Response::get_response(STATUS_CODE_REASON_PHRASE.n200_ok, None, None));
355 let modified = state.modified;
356 let etag_value = format!("\"{}-{}\"", modified, state.len);
357 response.headers.push(Header { name: Header::_LAST_MODIFIED_UNIX_EPOCH_NANOS.to_string(), value: modified.to_string() });
358 let not_modified = request
359 .get_header(Header::_IF_NONE_MATCH.to_string())
360 .map_or(false, |inm| inm.value == etag_value || inm.value == "*");
361 response.headers.push(Header { name: Header::_ETAG.to_string(), value: etag_value });
362 if not_modified {
363 response.status_code = *STATUS_CODE_REASON_PHRASE.n304_not_modified.status_code;
364 response.reason_phrase = STATUS_CODE_REASON_PHRASE.n304_not_modified.reason_phrase.to_string();
365 return Some(response);
366 }
367 response.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
368 response.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
369
370 if state.len > StaticResourceController::STREAM_THRESHOLD {
371 response.headers.push(Header { name: Header::_CONTENT_TYPE.to_string(), value: mime });
372 response.headers.push(Header { name: Header::_CONTENT_LENGTH.to_string(), value: state.len.to_string() });
373 response.stream_file = Some(fs_path);
374 return Some(response);
375 }
376
377 let body = file.read_all(state.len).ok()?;
378 response.content_range_list = vec![Range::get_content_range(body, mime)];
379 Some(response)
380 }
381
382 pub fn process_static_resources(request: &Request) -> Result<Vec<ContentRange>, Error> {
383 let dir = std::path::PathBuf::from(working_directory());
384 let working_directory = dir.as_path().to_str().unwrap();
385
386 let url = request.request_uri.to_string();
387
388 let boxed_url_components = URL::parse_request_target(&url);
389 if boxed_url_components.is_err() {
390 let message = boxed_url_components.as_ref().err().unwrap().to_string();
391 println!("unexpected error, {}", message);
393 }
394
395 let components = boxed_url_components.unwrap();
396
397 let os_specific_separator : String = FileExt::get_path_separator();
398 let os_specific_path = &components.path.replace(SYMBOL.slash, os_specific_separator.as_str());
399
400 let static_filepath = match static_fs_path(&os_specific_path) {
401 Ok(path) => path,
402 Err(message) => {
403 return Err(Error { status_code_reason_phrase: STATUS_CODE_REASON_PHRASE.n404_not_found, message });
404 }
405 };
406
407 let mut content_range_list = Vec::new();
408
409
410 let mut boxed_md = metadata(&static_filepath);
411 if boxed_md.is_err() {
412 let dot_html = format!("{}{}", &static_filepath, ".html");
413 boxed_md = metadata(&dot_html);
414
415 if boxed_md.is_err() {
416 let slash_index_html = format!("{}{}{}", &static_filepath, os_specific_separator, "index.html");
417 boxed_md = metadata(&slash_index_html);
418 }
419 }
420 if boxed_md.is_ok() {
421 let md = boxed_md.unwrap();
422
423 if md.is_dir() {
424 let mut directory_index : String = "index.html".to_string();
425
426 let last_char = components.path.chars().last().unwrap();
427 if last_char != '/' {
428 let index : String = "index.html".to_string();
429 directory_index = format!("{}{}", os_specific_separator, index);
430 }
431 let index_html_in_directory = format!("{}{}", os_specific_path, directory_index);
432 let index_html_fs_path = format!("{}{}", static_filepath, directory_index);
433
434 if File::open(&index_html_fs_path).is_ok() {
435 let mut range_header = &Header {
436 name: Header::_RANGE.to_string(),
437 value: "bytes=0-".to_string()
438 };
439
440 let boxed_header = request.get_header(Header::_RANGE.to_string());
441 if boxed_header.is_some() {
442 range_header = boxed_header.unwrap();
443 }
444
445 let boxed_content_range_list = Range::get_content_range_list(&index_html_in_directory, range_header);
446 if boxed_content_range_list.is_ok() {
447 content_range_list = boxed_content_range_list.unwrap();
448 } else {
449 let error = boxed_content_range_list.err().unwrap();
450 return Err(error)
451 }
452 } else {
453 let listing_html = StaticResourceController::render_directory_listing(&static_filepath, &components.path);
454 let content_range = Range::get_content_range(listing_html.into_bytes(), MimeType::TEXT_HTML.to_string());
455 content_range_list = vec![content_range];
456 }
457
458 return Ok(content_range_list);
459 }
460
461 let boxed_file = File::open(&static_filepath);
462 if boxed_file.is_ok() {
463 let md = metadata(&static_filepath).unwrap();
464 if md.is_dir() {
465 let mut range_header = &Header {
466 name: Header::_RANGE.to_string(),
467 value: "bytes=0-".to_string()
468 };
469
470 let boxed_header = request.get_header(Header::_RANGE.to_string());
471 if boxed_header.is_some() {
472 range_header = boxed_header.unwrap();
473 }
474
475 let mut directory_index : String = "index.html".to_string();
476
477 let last_char = components.path.chars().last().unwrap();
478 if last_char != '/' {
479 let index : String = "index.html".to_string();
480 directory_index = format!("{}{}", os_specific_separator, index);
481 }
482 let index_html_in_directory = format!("{}{}", os_specific_path, directory_index);
483
484
485 let boxed_content_range_list = Range::get_content_range_list(&index_html_in_directory, range_header);
486 if boxed_content_range_list.is_ok() {
487 content_range_list = boxed_content_range_list.unwrap();
488 } else {
489 let error = boxed_content_range_list.err().unwrap();
490 return Err(error)
491 }
492 }
493
494 if md.is_file() {
495 let mut range_header = &Header {
496 name: Header::_RANGE.to_string(),
497 value: "bytes=0-".to_string()
498 };
499
500 let boxed_header = request.get_header(Header::_RANGE.to_string());
501 if boxed_header.is_some() {
502 range_header = boxed_header.unwrap();
503 }
504
505 let boxed_content_range_list = Range::get_content_range_list(&request.request_uri, range_header);
506 if boxed_content_range_list.is_ok() {
507 content_range_list = boxed_content_range_list.unwrap();
508 } else {
509 let error = boxed_content_range_list.err().unwrap();
510 return Err(error)
511 }
512 }
513 }
514
515
516 if boxed_file.is_err() {
517 let static_filepath = [working_directory, components.path.as_str(), ".html"].join(SYMBOL.empty_string);
519
520 let boxed_file = File::open(&static_filepath);
521 if boxed_file.is_ok() {
522 let md = metadata(&static_filepath).unwrap();
523 if md.is_file() {
524 let mut range_header = &Header {
525 name: Header::_RANGE.to_string(),
526 value: "bytes=0-".to_string()
527 };
528
529 let boxed_header = request.get_header(Header::_RANGE.to_string());
530 if boxed_header.is_some() {
531 range_header = boxed_header.unwrap();
532 }
533
534 let url = request.request_uri.to_string();
535
536 let boxed_url_components = URL::parse_request_target(&url);
537 if boxed_url_components.is_err() {
538 let message = boxed_url_components.as_ref().err().unwrap().to_string();
539 println!("unexpected error, {}", message);
541 }
542
543 let components = boxed_url_components.unwrap();
544
545 let html_file = [components.path.as_str(), ".html"].join(SYMBOL.empty_string);
549 let boxed_content_range_list = Range::get_content_range_list(html_file.as_str(), range_header);
550 if boxed_content_range_list.is_ok() {
551 content_range_list = boxed_content_range_list.unwrap();
552 } else {
553 let error = boxed_content_range_list.err().unwrap();
554 return Err(error)
555 }
556 }
557 }
558 }
559 }
560
561 if content_range_list.is_empty() {
565 if let Some(fallback_url_path) = spa_fallback_url_path(&components.path) {
566 let mut range_header = &Header {
567 name: Header::_RANGE.to_string(),
568 value: "bytes=0-".to_string()
569 };
570
571 let boxed_header = request.get_header(Header::_RANGE.to_string());
572 if boxed_header.is_some() {
573 range_header = boxed_header.unwrap();
574 }
575
576 if let Ok(list) = Range::get_content_range_list(&fallback_url_path, range_header) {
577 content_range_list = list;
578 }
579 }
580 }
581
582 Ok(content_range_list)
583 }
584}
585
586impl StaticResourceController {
590 pub fn render_directory_listing(fs_dir_path: &str, request_path: &str) -> String {
595 let normalized_request_path = if request_path.ends_with('/') {
596 request_path.to_string()
597 } else {
598 format!("{}/", request_path)
599 };
600
601 struct Entry {
602 name: String,
603 is_dir: bool,
604 size: u64,
605 modified_epoch_secs: u64,
606 }
607
608 let mut entries: Vec<Entry> = Vec::new();
609 if let Ok(dir_entries) = read_dir(fs_dir_path) {
610 for dir_entry in dir_entries.flatten() {
611 let name = dir_entry.file_name().to_string_lossy().to_string();
612 if name.starts_with('.') {
613 continue;
614 }
615
616 if let Ok(md) = dir_entry.metadata() {
617 let modified_epoch_secs = md.modified()
618 .ok()
619 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
620 .map(|d| d.as_secs())
621 .unwrap_or(0);
622
623 entries.push(Entry {
624 name,
625 is_dir: md.is_dir(),
626 size: md.len(),
627 modified_epoch_secs,
628 });
629 }
630 }
631 }
632
633 entries.sort_by(|a, b| {
634 match (a.is_dir, b.is_dir) {
635 (true, false) => std::cmp::Ordering::Less,
636 (false, true) => std::cmp::Ordering::Greater,
637 _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
638 }
639 });
640
641 let segments: Vec<&str> = normalized_request_path.split('/').filter(|s| !s.is_empty()).collect();
642
643 let mut breadcrumb_html = String::from("<a href=\"/\">~</a>");
644 let mut accumulated_path = String::new();
645 for segment in &segments {
646 accumulated_path.push('/');
647 accumulated_path.push_str(segment);
648 breadcrumb_html.push_str(&format!(
649 " <span class=\"sep\">/</span> <a href=\"{}/\">{}</a>",
650 accumulated_path, html_escape(segment)
651 ));
652 }
653
654 let parent_row = if !segments.is_empty() {
655 let parent_path = if segments.len() > 1 {
656 format!("/{}/", segments[..segments.len() - 1].join("/"))
657 } else {
658 "/".to_string()
659 };
660 format!(
661 "<tr class=\"entry parent\"><td class=\"name\"><span class=\"icon\">\u{21A9}\u{FE0F}</span><a href=\"{}\">.. (parent directory)</a></td><td class=\"size\">\u{2014}</td><td class=\"modified\">\u{2014}</td></tr>\n",
662 parent_path
663 )
664 } else {
665 String::new()
666 };
667
668 let entry_count = entries.len();
669
670 let mut rows = String::new();
671 for entry in &entries {
672 let escaped_name = html_escape(&entry.name);
673 let encoded_name = URL::percent_encode(&entry.name);
674 let href = if entry.is_dir {
675 format!("{}{}/", normalized_request_path, encoded_name)
676 } else {
677 format!("{}{}", normalized_request_path, encoded_name)
678 };
679 let size_display = if entry.is_dir { "\u{2014}".to_string() } else { human_size(entry.size) };
680 let modified_display = format_modified(entry.modified_epoch_secs);
681 let icon = icon_for(&entry.name, entry.is_dir);
682 let filter_key = html_escape(&entry.name.to_lowercase());
683
684 rows.push_str(&format!(
685 "<tr class=\"entry\" data-name=\"{}\"><td class=\"name\"><span class=\"icon\">{}</span><a href=\"{}\">{}{}</a></td><td class=\"size\">{}</td><td class=\"modified\">{}</td></tr>\n",
686 filter_key, icon, href, escaped_name, if entry.is_dir { "/" } else { "" }, size_display, modified_display
687 ));
688 }
689
690 let mut html = String::new();
691 html.push_str(DIRECTORY_LISTING_HEAD);
692 html.push_str(&format!("<div class=\"breadcrumb\">{}</div>\n", breadcrumb_html));
693 html.push_str(&format!("<h1>Index of {}</h1>\n", html_escape(&normalized_request_path)));
694 html.push_str(DIRECTORY_LISTING_TOOLBAR);
695 html.push_str("<div class=\"card\"><table><thead><tr><th>Name</th><th>Size</th><th>Modified</th></tr></thead><tbody id=\"rows\">\n");
696 html.push_str(&parent_row);
697 html.push_str(&rows);
698 html.push_str("</tbody></table></div>\n");
699 html.push_str(&format!(
700 "<footer>{} item{} · served by rws</footer>\n",
701 entry_count, if entry_count == 1 { "" } else { "s" }
702 ));
703 html.push_str(DIRECTORY_LISTING_TAIL);
704
705 html
706 }
707}
708
709fn path_has_extension(path: &str) -> bool {
716 path.rsplit('/').next().unwrap_or("").contains('.')
717}
718
719fn spa_fallback_url_path(url_path: &str) -> Option<String> {
728 if path_has_extension(url_path) {
729 return None;
730 }
731
732 let fallback_name = crate::entry_point::get_spa_fallback()?;
733
734 let exclude_prefixes = crate::entry_point::get_spa_fallback_exclude_prefixes();
735 if exclude_prefixes.iter().any(|prefix| url_path.starts_with(prefix.as_str())) {
736 return None;
737 }
738
739 let separator = FileExt::get_path_separator();
740 let normalized_name = fallback_name.trim_start_matches('/').replace('/', &separator);
741 let fallback_url_path = format!("{}{}", separator, normalized_name);
742
743 let boxed_fs_path = static_fs_path(&fallback_url_path);
744 let fs_path = boxed_fs_path.ok()?;
745 if File::open(&fs_path).is_ok() {
746 Some(fallback_url_path)
747 } else {
748 None
749 }
750}
751
752fn html_escape(s: &str) -> String {
753 let mut out = String::with_capacity(s.len());
754 for c in s.chars() {
755 match c {
756 '&' => out.push_str("&"),
757 '<' => out.push_str("<"),
758 '>' => out.push_str(">"),
759 '"' => out.push_str("""),
760 '\'' => out.push_str("'"),
761 _ => out.push(c),
762 }
763 }
764 out
765}
766
767fn human_size(bytes: u64) -> String {
768 const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
769 if bytes < 1024 {
770 return format!("{} {}", bytes, UNITS[0]);
771 }
772
773 let mut size = bytes as f64;
774 let mut unit_index = 0;
775 while size >= 1024.0 && unit_index < UNITS.len() - 1 {
776 size /= 1024.0;
777 unit_index += 1;
778 }
779
780 format!("{:.1} {}", size, UNITS[unit_index])
781}
782
783fn format_modified(epoch_secs: u64) -> String {
784 let (sec, min, hour, day, month, _dow) = crate::scheduler::cron::epoch_to_datetime(epoch_secs);
785 let (year, _, _) = crate::scheduler::cron::days_to_ymd(epoch_secs / 86400);
786 format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02}", year, month, day, hour, min, sec)
787}
788
789fn icon_for(name: &str, is_dir: bool) -> &'static str {
790 if is_dir {
791 return "\u{1F4C1}";
792 }
793
794 let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
795 match ext.as_str() {
796 "png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "ico" | "bmp" => "\u{1F5BC}\u{FE0F}",
797 "mp4" | "mov" | "avi" | "mkv" | "webm" => "\u{1F3A5}",
798 "mp3" | "wav" | "flac" | "m4a" | "ogg" => "\u{1F3A7}",
799 "zip" | "tar" | "gz" | "rar" | "7z" | "bz2" => "\u{1F4E6}",
800 "pdf" => "\u{1F4D5}",
801 "html" | "htm" | "css" | "js" | "ts" | "rs" | "py" | "json" | "toml" | "yaml" | "yml" | "sh" => "\u{1F9E9}",
802 _ => "\u{1F4C4}",
803 }
804}
805
806const DIRECTORY_LISTING_HEAD: &str = concat!(
811 "<!DOCTYPE html>\n",
812 "<html lang=\"en\">\n",
813 "<head>\n",
814 "<meta charset=\"UTF-8\">\n",
815 "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n",
816 "<title>Directory listing</title>\n",
817 "<link rel=\"stylesheet\" href=\"/rws-directory-listing.css\">\n",
818 "</head>\n",
819 "<body>\n",
820 "<div class=\"wrap\">\n",
821);
822
823const DIRECTORY_LISTING_TOOLBAR: &str = "<div class=\"toolbar\"><input type=\"search\" id=\"filter\" placeholder=\"Filter entries...\" autocomplete=\"off\"></div>\n";
824
825const DIRECTORY_LISTING_TAIL: &str = concat!(
826 "</div>\n",
827 "<script src=\"/rws-directory-listing.js\" defer></script>\n",
828 "</body>\n",
829 "</html>\n",
830);
831
832pub(crate) fn working_directory() -> &'static str {
838 static DIR: std::sync::OnceLock<String> = std::sync::OnceLock::new();
839 DIR.get_or_init(|| {
840 std::env::current_dir()
841 .map(|d| d.to_string_lossy().into_owned())
842 .unwrap_or_default()
843 })
844}
845
846pub(crate) fn static_fs_path(path: &str) -> Result<String, String> {
857 check_request_path(path)?;
858 Ok(format!("{}{}", working_directory(), path))
859}
860
861pub(crate) fn check_request_path(path: &str) -> Result<(), String> {
864 for segment in path.split(['/', '\\']) {
865 if segment == ".." {
866 return Err("path leaves the web root".to_string());
867 }
868 if segment.starts_with('.') && segment != "." && segment != ".well-known" && !serve_dotfiles() {
869 return Err("hidden files are not served".to_string());
870 }
871 if segment.contains('\0') {
872 return Err("invalid path".to_string());
873 }
874 }
875 let name = path.rsplit(['/', '\\']).next().unwrap_or("").to_ascii_lowercase();
877 if name.ends_with(".pem") || name.ends_with(".key") || name == "rws.config.toml" {
878 return Err("key and config files are not served".to_string());
879 }
880 Ok(())
881}
882
883fn serve_dotfiles() -> bool {
885 static SERVE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
886 *SERVE.get_or_init(|| {
887 std::env::var("RWS_CONFIG_SERVE_DOTFILES").is_ok_and(|v| v.trim().eq_ignore_ascii_case("true"))
888 })
889}
890
891pub(crate) fn inside_root(fs_path: &str) -> bool {
896 static ROOT: std::sync::OnceLock<Option<std::path::PathBuf>> = std::sync::OnceLock::new();
897 let Some(root) = ROOT.get_or_init(|| std::fs::canonicalize(working_directory()).ok()) else {
898 return false;
899 };
900 std::fs::canonicalize(fs_path).is_ok_and(|resolved| resolved.starts_with(root))
901}
902
903pub(crate) fn file_modified_utc(path: &str) -> Result<u128, String> {
906 let modified = metadata(path)
907 .and_then(|md| md.modified())
908 .map_err(|e| format!("<p>Unable to open file: {}</p> <p>error: {}</p>", path, e))?;
909 Ok(modified
910 .duration_since(std::time::UNIX_EPOCH)
911 .map(|d| d.as_nanos())
912 .unwrap_or(0))
913}
914