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