1use std::env;
2use std::fs::{File, metadata, read_dir};
3use std::time::UNIX_EPOCH;
4use file_ext::FileExt;
5use crate::controller::Controller;
6use crate::header::Header;
7use crate::mime_type::MimeType;
8use crate::range::{ContentRange, Range};
9use crate::request::{METHOD, Request};
10use crate::response::{Error, Response, STATUS_CODE_REASON_PHRASE};
11use crate::server::ConnectionInfo;
12use crate::symbol::SYMBOL;
13use crate::url::URL;
14
15#[cfg(test)]
16mod tests;
17
18pub struct StaticResourceController;
19
20impl Controller for StaticResourceController {
21 fn is_matching(request: &Request, _connection: &ConnectionInfo) -> bool {
22 let url_array = ["http://", "localhost", &request.request_uri];
23 let url = url_array.join(SYMBOL.empty_string);
24
25 let boxed_url_components = URL::parse(&url);
26 if boxed_url_components.is_err() {
27 let message = boxed_url_components.as_ref().err().unwrap().to_string();
28 println!("unexpected error, {}", message);
30 }
31
32 let components = boxed_url_components.unwrap();
33
34 let os_specific_separator : String = FileExt::get_path_separator();
35 let os_specific_path = &components.path.replace(SYMBOL.slash, os_specific_separator.as_str());
36
37 let boxed_static_filepath = FileExt::get_static_filepath(&os_specific_path);
38 if boxed_static_filepath.is_err() {
39 return false
40 }
41
42 let static_filepath = boxed_static_filepath.unwrap();
43
44 let mut is_directory = false;
47
48 let boxed_md = metadata(&static_filepath);
49 if boxed_md.is_ok() {
50 let md = boxed_md.unwrap();
51 if md.is_dir() {
52 is_directory = true;
53 }
54 }
55
56
57
58 let boxed_file = File::open(&static_filepath);
59
60 let is_get = request.method == METHOD.get;
61 let is_head = request.method == METHOD.head;
62 let is_options = request.method == METHOD.options;
63
64 let is_matching_method = (is_get || is_head || is_options) && (request.request_uri != SYMBOL.slash);
65
66 if boxed_file.is_ok() || is_directory {
67 is_matching_method
68 } else {
69 if !static_filepath.ends_with(".html") {
71 let html_suffix = ".html";
72 let html_file = [&components.path.replace(SYMBOL.slash, &FileExt::get_path_separator()), html_suffix].join(SYMBOL.empty_string);
73 if let Ok(html_static_filepath) = FileExt::get_static_filepath(&html_file) {
74 if File::open(&html_static_filepath).is_ok() {
75 return is_matching_method;
76 }
77 }
78 }
79
80 is_matching_method && spa_fallback_url_path(&components.path).is_some()
85 }
86
87 }
88
89 fn process(request: &Request, mut response: Response, _connection: &ConnectionInfo) -> Response {
90 let boxed_content_range_list = StaticResourceController::process_static_resources(&request);
91 if boxed_content_range_list.is_ok() {
92 let content_range_list = boxed_content_range_list.unwrap();
93
94 if content_range_list.len() != 0 {
95
96 let mut status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok;
97
98 let does_request_include_range_header = request.get_header(Header::_RANGE.to_string()).is_some();
99 if does_request_include_range_header {
100 status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n206_partial_content;
101 }
102
103 let is_options_request = request.method == METHOD.options;
104 if is_options_request {
105 status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n204_no_content;
106 }
107
108
109 let dir = env::current_dir().unwrap();
110 let working_directory = dir.as_path().to_str().unwrap();
111
112 let url_array = ["http://", "localhost", &request.request_uri];
113 let url = url_array.join(SYMBOL.empty_string);
114
115 let boxed_url_components = URL::parse(&url);
116 if boxed_url_components.is_err() {
117 let message = boxed_url_components.as_ref().err().unwrap().to_string();
118 println!("unexpected error, {}", message);
120 }
121
122 let components = boxed_url_components.unwrap();
123
124 let static_filepath = [working_directory, components.path.as_str()].join(SYMBOL.empty_string);
125 let boxed_modified_date_time = FileExt::file_modified_utc(&static_filepath);
126
127 if boxed_modified_date_time.is_ok() {
128 let modified_date_time = boxed_modified_date_time.unwrap();
129 let last_modified_unix_nanos = Header{ name: Header::_LAST_MODIFIED_UNIX_EPOCH_NANOS.to_string(), value: modified_date_time.to_string() };
130 response.headers.push(last_modified_unix_nanos);
131
132 let file_size = metadata(&static_filepath).map(|m| m.len()).unwrap_or(0);
133 let etag_value = format!("\"{}-{}\"", modified_date_time, file_size);
134
135 let if_none_match = request.get_header(Header::_IF_NONE_MATCH.to_string());
136 if let Some(inm) = if_none_match {
137 if inm.value == etag_value || inm.value == "*" {
138 response.status_code = *STATUS_CODE_REASON_PHRASE.n304_not_modified.status_code;
139 response.reason_phrase = STATUS_CODE_REASON_PHRASE.n304_not_modified.reason_phrase.to_string();
140 response.headers.push(Header { name: Header::_ETAG.to_string(), value: etag_value });
141 return response;
142 }
143 }
144
145 response.headers.push(Header { name: Header::_ETAG.to_string(), value: etag_value });
146
147 const STREAM_THRESHOLD: u64 = 8 * 1024 * 1024;
150 let is_range_request = request.get_header(Header::_RANGE.to_string()).is_some();
151 if file_size > STREAM_THRESHOLD && !is_range_request {
152 let mime = MimeType::detect_mime_type(&static_filepath);
153 response.headers.push(Header {
154 name: Header::_CONTENT_TYPE.to_string(),
155 value: mime,
156 });
157 response.headers.push(Header {
158 name: Header::_CONTENT_LENGTH.to_string(),
159 value: file_size.to_string(),
160 });
161 response.status_code = *status_code_reason_phrase.status_code;
162 response.reason_phrase = status_code_reason_phrase.reason_phrase.to_string();
163 response.stream_file = Some(static_filepath);
164 return response;
165 }
166 }
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.content_range_list = content_range_list;
171
172 }
173 } else {
174 let error : Error = boxed_content_range_list.err().unwrap();
175 let body = error.message;
176
177 let content_range = Range::get_content_range(
178 Vec::from(body.as_bytes()),
179 MimeType::TEXT_HTML.to_string()
180 );
181
182 let content_range_list = vec![content_range];
183
184 response.status_code = *error.status_code_reason_phrase.status_code;
185 response.reason_phrase = error.status_code_reason_phrase.reason_phrase.to_string();
186 response.content_range_list = content_range_list;
187
188 }
189
190
191 response
192 }
193}
194
195impl StaticResourceController {
197
198 pub fn is_matching_request(request: &Request) -> bool {
199 let boxed_static_filepath = FileExt::get_static_filepath(&request.request_uri);
200 if boxed_static_filepath.is_err() {
201 return false
202 }
203
204 let static_filepath = boxed_static_filepath.unwrap();
205
206 let boxed_md = metadata(&static_filepath);
207 if boxed_md.is_err() {
208 return false
209 }
210
211 let md = boxed_md.unwrap();
212 if md.is_dir() {
213 return false
214 }
215
216 let boxed_file = File::open(&static_filepath);
217
218 let is_get = request.method == METHOD.get;
219 let is_head = request.method == METHOD.head;
220 let is_options = request.method == METHOD.options;
221 boxed_file.is_ok() && (is_get || is_head || is_options && request.request_uri != SYMBOL.slash)
222 }
223
224 pub fn process_request(request: &Request, mut response: Response) -> Response {
225 let boxed_content_range_list = StaticResourceController::process_static_resources(&request);
226 if boxed_content_range_list.is_ok() {
227 let content_range_list = boxed_content_range_list.unwrap();
228
229 if content_range_list.len() != 0 {
230
231 let mut status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok;
232
233 let does_request_include_range_header = request.get_header(Header::_RANGE.to_string()).is_some();
234 if does_request_include_range_header {
235 status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n206_partial_content;
236 }
237
238 let is_options_request = request.method == METHOD.options;
239 if is_options_request {
240 status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n204_no_content;
241 }
242
243
244 let dir = env::current_dir().unwrap();
245 let working_directory = dir.as_path().to_str().unwrap();
246 let static_filepath = [working_directory, request.request_uri.as_str()].join(SYMBOL.empty_string);
247 let boxed_modified_date_time = FileExt::file_modified_utc(&static_filepath);
248
249 if boxed_modified_date_time.is_ok() {
250 let modified_date_time = boxed_modified_date_time.unwrap();
251 let last_modified_unix_nanos = Header{ name: Header::_LAST_MODIFIED_UNIX_EPOCH_NANOS.to_string(), value: modified_date_time.to_string() };
252 response.headers.push(last_modified_unix_nanos);
253
254 let file_size = metadata(&static_filepath).map(|m| m.len()).unwrap_or(0);
255 let etag_value = format!("\"{}-{}\"", modified_date_time, file_size);
256
257 let if_none_match = request.get_header(Header::_IF_NONE_MATCH.to_string());
258 if let Some(inm) = if_none_match {
259 if inm.value == etag_value || inm.value == "*" {
260 response.status_code = *STATUS_CODE_REASON_PHRASE.n304_not_modified.status_code;
261 response.reason_phrase = STATUS_CODE_REASON_PHRASE.n304_not_modified.reason_phrase.to_string();
262 response.headers.push(Header { name: Header::_ETAG.to_string(), value: etag_value });
263 return response;
264 }
265 }
266
267 response.headers.push(Header { name: Header::_ETAG.to_string(), value: etag_value });
268
269 const STREAM_THRESHOLD: u64 = 8 * 1024 * 1024;
270 let is_range_request = request.get_header(Header::_RANGE.to_string()).is_some();
271 if file_size > STREAM_THRESHOLD && !is_range_request {
272 let mime = MimeType::detect_mime_type(&static_filepath);
273 response.headers.push(Header {
274 name: Header::_CONTENT_TYPE.to_string(),
275 value: mime,
276 });
277 response.headers.push(Header {
278 name: Header::_CONTENT_LENGTH.to_string(),
279 value: file_size.to_string(),
280 });
281 response.status_code = *status_code_reason_phrase.status_code;
282 response.reason_phrase = status_code_reason_phrase.reason_phrase.to_string();
283 response.stream_file = Some(static_filepath);
284 return response;
285 }
286 }
287
288 response.status_code = *status_code_reason_phrase.status_code;
289 response.reason_phrase = status_code_reason_phrase.reason_phrase.to_string();
290 response.content_range_list = content_range_list;
291
292 }
293 } else {
294 let error : Error = boxed_content_range_list.err().unwrap();
295 let body = error.message;
296
297 let content_range = Range::get_content_range(
298 Vec::from(body.as_bytes()),
299 MimeType::TEXT_HTML.to_string()
300 );
301
302 let content_range_list = vec![content_range];
303
304 response.status_code = *error.status_code_reason_phrase.status_code;
305 response.reason_phrase = error.status_code_reason_phrase.reason_phrase.to_string();
306 response.content_range_list = content_range_list;
307
308 }
309
310
311 response
312 }
313
314 pub fn process_static_resources(request: &Request) -> Result<Vec<ContentRange>, Error> {
315 let dir = env::current_dir().unwrap();
316 let working_directory = dir.as_path().to_str().unwrap();
317
318 let url_array = ["http://", "localhost", &request.request_uri];
319 let url = url_array.join(SYMBOL.empty_string);
320
321 let boxed_url_components = URL::parse(&url);
322 if boxed_url_components.is_err() {
323 let message = boxed_url_components.as_ref().err().unwrap().to_string();
324 println!("unexpected error, {}", message);
326 }
327
328 let components = boxed_url_components.unwrap();
329
330 let os_specific_separator : String = FileExt::get_path_separator();
331 let os_specific_path = &components.path.replace(SYMBOL.slash, os_specific_separator.as_str());
332
333 let boxed_static_filepath = FileExt::get_static_filepath(&os_specific_path);
334
335 let static_filepath = boxed_static_filepath.unwrap();
336
337 let mut content_range_list = Vec::new();
338
339
340 let mut boxed_md = metadata(&static_filepath);
341 if boxed_md.is_err() {
342 let dot_html = format!("{}{}", &static_filepath, ".html");
343 boxed_md = metadata(&dot_html);
344
345 if boxed_md.is_err() {
346 let slash_index_html = format!("{}{}{}", &static_filepath, os_specific_separator, "index.html");
347 boxed_md = metadata(&slash_index_html);
348 }
349 }
350 if boxed_md.is_ok() {
351 let md = boxed_md.unwrap();
352
353 if md.is_dir() {
354 let mut directory_index : String = "index.html".to_string();
355
356 let last_char = components.path.chars().last().unwrap();
357 if last_char != '/' {
358 let index : String = "index.html".to_string();
359 directory_index = format!("{}{}", os_specific_separator, index);
360 }
361 let index_html_in_directory = format!("{}{}", os_specific_path, directory_index);
362 let index_html_fs_path = format!("{}{}", static_filepath, directory_index);
363
364 if File::open(&index_html_fs_path).is_ok() {
365 let mut range_header = &Header {
366 name: Header::_RANGE.to_string(),
367 value: "bytes=0-".to_string()
368 };
369
370 let boxed_header = request.get_header(Header::_RANGE.to_string());
371 if boxed_header.is_some() {
372 range_header = boxed_header.unwrap();
373 }
374
375 let boxed_content_range_list = Range::get_content_range_list(&index_html_in_directory, range_header);
376 if boxed_content_range_list.is_ok() {
377 content_range_list = boxed_content_range_list.unwrap();
378 } else {
379 let error = boxed_content_range_list.err().unwrap();
380 return Err(error)
381 }
382 } else {
383 let listing_html = StaticResourceController::render_directory_listing(&static_filepath, &components.path);
384 let content_range = Range::get_content_range(listing_html.into_bytes(), MimeType::TEXT_HTML.to_string());
385 content_range_list = vec![content_range];
386 }
387
388 return Ok(content_range_list);
389 }
390
391 let boxed_file = File::open(&static_filepath);
392 if boxed_file.is_ok() {
393 let md = metadata(&static_filepath).unwrap();
394 if md.is_dir() {
395 let mut range_header = &Header {
396 name: Header::_RANGE.to_string(),
397 value: "bytes=0-".to_string()
398 };
399
400 let boxed_header = request.get_header(Header::_RANGE.to_string());
401 if boxed_header.is_some() {
402 range_header = boxed_header.unwrap();
403 }
404
405 let mut directory_index : String = "index.html".to_string();
406
407 let last_char = components.path.chars().last().unwrap();
408 if last_char != '/' {
409 let index : String = "index.html".to_string();
410 directory_index = format!("{}{}", os_specific_separator, index);
411 }
412 let index_html_in_directory = format!("{}{}", os_specific_path, directory_index);
413
414
415 let boxed_content_range_list = Range::get_content_range_list(&index_html_in_directory, range_header);
416 if boxed_content_range_list.is_ok() {
417 content_range_list = boxed_content_range_list.unwrap();
418 } else {
419 let error = boxed_content_range_list.err().unwrap();
420 return Err(error)
421 }
422 }
423
424 if md.is_file() {
425 let mut range_header = &Header {
426 name: Header::_RANGE.to_string(),
427 value: "bytes=0-".to_string()
428 };
429
430 let boxed_header = request.get_header(Header::_RANGE.to_string());
431 if boxed_header.is_some() {
432 range_header = boxed_header.unwrap();
433 }
434
435 let boxed_content_range_list = Range::get_content_range_list(&request.request_uri, range_header);
436 if boxed_content_range_list.is_ok() {
437 content_range_list = boxed_content_range_list.unwrap();
438 } else {
439 let error = boxed_content_range_list.err().unwrap();
440 return Err(error)
441 }
442 }
443 }
444
445
446 if boxed_file.is_err() {
447 let static_filepath = [working_directory, components.path.as_str(), ".html"].join(SYMBOL.empty_string);
449
450 let boxed_file = File::open(&static_filepath);
451 if boxed_file.is_ok() {
452 let md = metadata(&static_filepath).unwrap();
453 if md.is_file() {
454 let mut range_header = &Header {
455 name: Header::_RANGE.to_string(),
456 value: "bytes=0-".to_string()
457 };
458
459 let boxed_header = request.get_header(Header::_RANGE.to_string());
460 if boxed_header.is_some() {
461 range_header = boxed_header.unwrap();
462 }
463
464 let url_array = ["http://", "localhost", &request.request_uri];
465 let url = url_array.join(SYMBOL.empty_string);
466
467 let boxed_url_components = URL::parse(&url);
468 if boxed_url_components.is_err() {
469 let message = boxed_url_components.as_ref().err().unwrap().to_string();
470 println!("unexpected error, {}", message);
472 }
473
474 let components = boxed_url_components.unwrap();
475
476 let html_file = [components.path.as_str(), ".html"].join(SYMBOL.empty_string);
480 let boxed_content_range_list = Range::get_content_range_list(html_file.as_str(), range_header);
481 if boxed_content_range_list.is_ok() {
482 content_range_list = boxed_content_range_list.unwrap();
483 } else {
484 let error = boxed_content_range_list.err().unwrap();
485 return Err(error)
486 }
487 }
488 }
489 }
490 }
491
492 if content_range_list.is_empty() {
496 if let Some(fallback_url_path) = spa_fallback_url_path(&components.path) {
497 let mut range_header = &Header {
498 name: Header::_RANGE.to_string(),
499 value: "bytes=0-".to_string()
500 };
501
502 let boxed_header = request.get_header(Header::_RANGE.to_string());
503 if boxed_header.is_some() {
504 range_header = boxed_header.unwrap();
505 }
506
507 if let Ok(list) = Range::get_content_range_list(&fallback_url_path, range_header) {
508 content_range_list = list;
509 }
510 }
511 }
512
513 Ok(content_range_list)
514 }
515}
516
517impl StaticResourceController {
521 pub fn render_directory_listing(fs_dir_path: &str, request_path: &str) -> String {
526 let normalized_request_path = if request_path.ends_with('/') {
527 request_path.to_string()
528 } else {
529 format!("{}/", request_path)
530 };
531
532 struct Entry {
533 name: String,
534 is_dir: bool,
535 size: u64,
536 modified_epoch_secs: u64,
537 }
538
539 let mut entries: Vec<Entry> = Vec::new();
540 if let Ok(dir_entries) = read_dir(fs_dir_path) {
541 for dir_entry in dir_entries.flatten() {
542 let name = dir_entry.file_name().to_string_lossy().to_string();
543 if name.starts_with('.') {
544 continue;
545 }
546
547 if let Ok(md) = dir_entry.metadata() {
548 let modified_epoch_secs = md.modified()
549 .ok()
550 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
551 .map(|d| d.as_secs())
552 .unwrap_or(0);
553
554 entries.push(Entry {
555 name,
556 is_dir: md.is_dir(),
557 size: md.len(),
558 modified_epoch_secs,
559 });
560 }
561 }
562 }
563
564 entries.sort_by(|a, b| {
565 match (a.is_dir, b.is_dir) {
566 (true, false) => std::cmp::Ordering::Less,
567 (false, true) => std::cmp::Ordering::Greater,
568 _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
569 }
570 });
571
572 let segments: Vec<&str> = normalized_request_path.split('/').filter(|s| !s.is_empty()).collect();
573
574 let mut breadcrumb_html = String::from("<a href=\"/\">~</a>");
575 let mut accumulated_path = String::new();
576 for segment in &segments {
577 accumulated_path.push('/');
578 accumulated_path.push_str(segment);
579 breadcrumb_html.push_str(&format!(
580 " <span class=\"sep\">/</span> <a href=\"{}/\">{}</a>",
581 accumulated_path, html_escape(segment)
582 ));
583 }
584
585 let parent_row = if !segments.is_empty() {
586 let parent_path = if segments.len() > 1 {
587 format!("/{}/", segments[..segments.len() - 1].join("/"))
588 } else {
589 "/".to_string()
590 };
591 format!(
592 "<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",
593 parent_path
594 )
595 } else {
596 String::new()
597 };
598
599 let entry_count = entries.len();
600
601 let mut rows = String::new();
602 for entry in &entries {
603 let escaped_name = html_escape(&entry.name);
604 let encoded_name = URL::percent_encode(&entry.name);
605 let href = if entry.is_dir {
606 format!("{}{}/", normalized_request_path, encoded_name)
607 } else {
608 format!("{}{}", normalized_request_path, encoded_name)
609 };
610 let size_display = if entry.is_dir { "\u{2014}".to_string() } else { human_size(entry.size) };
611 let modified_display = format_modified(entry.modified_epoch_secs);
612 let icon = icon_for(&entry.name, entry.is_dir);
613 let filter_key = html_escape(&entry.name.to_lowercase());
614
615 rows.push_str(&format!(
616 "<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",
617 filter_key, icon, href, escaped_name, if entry.is_dir { "/" } else { "" }, size_display, modified_display
618 ));
619 }
620
621 let mut html = String::new();
622 html.push_str(DIRECTORY_LISTING_HEAD);
623 html.push_str(&format!("<div class=\"breadcrumb\">{}</div>\n", breadcrumb_html));
624 html.push_str(&format!("<h1>Index of {}</h1>\n", html_escape(&normalized_request_path)));
625 html.push_str(DIRECTORY_LISTING_TOOLBAR);
626 html.push_str("<div class=\"card\"><table><thead><tr><th>Name</th><th>Size</th><th>Modified</th></tr></thead><tbody id=\"rows\">\n");
627 html.push_str(&parent_row);
628 html.push_str(&rows);
629 html.push_str("</tbody></table></div>\n");
630 html.push_str(&format!(
631 "<footer>{} item{} · served by rws</footer>\n",
632 entry_count, if entry_count == 1 { "" } else { "s" }
633 ));
634 html.push_str(DIRECTORY_LISTING_TAIL);
635
636 html
637 }
638}
639
640fn path_has_extension(path: &str) -> bool {
647 path.rsplit('/').next().unwrap_or("").contains('.')
648}
649
650fn spa_fallback_url_path(url_path: &str) -> Option<String> {
659 if path_has_extension(url_path) {
660 return None;
661 }
662
663 let fallback_name = crate::entry_point::get_spa_fallback()?;
664
665 let exclude_prefixes = crate::entry_point::get_spa_fallback_exclude_prefixes();
666 if exclude_prefixes.iter().any(|prefix| url_path.starts_with(prefix.as_str())) {
667 return None;
668 }
669
670 let separator = FileExt::get_path_separator();
671 let normalized_name = fallback_name.trim_start_matches('/').replace('/', &separator);
672 let fallback_url_path = format!("{}{}", separator, normalized_name);
673
674 let boxed_fs_path = FileExt::get_static_filepath(&fallback_url_path);
675 let fs_path = boxed_fs_path.ok()?;
676 if File::open(&fs_path).is_ok() {
677 Some(fallback_url_path)
678 } else {
679 None
680 }
681}
682
683fn html_escape(s: &str) -> String {
684 let mut out = String::with_capacity(s.len());
685 for c in s.chars() {
686 match c {
687 '&' => out.push_str("&"),
688 '<' => out.push_str("<"),
689 '>' => out.push_str(">"),
690 '"' => out.push_str("""),
691 '\'' => out.push_str("'"),
692 _ => out.push(c),
693 }
694 }
695 out
696}
697
698fn human_size(bytes: u64) -> String {
699 const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
700 if bytes < 1024 {
701 return format!("{} {}", bytes, UNITS[0]);
702 }
703
704 let mut size = bytes as f64;
705 let mut unit_index = 0;
706 while size >= 1024.0 && unit_index < UNITS.len() - 1 {
707 size /= 1024.0;
708 unit_index += 1;
709 }
710
711 format!("{:.1} {}", size, UNITS[unit_index])
712}
713
714fn format_modified(epoch_secs: u64) -> String {
715 let (sec, min, hour, day, month, _dow) = crate::scheduler::cron::epoch_to_datetime(epoch_secs);
716 let (year, _, _) = crate::scheduler::cron::days_to_ymd(epoch_secs / 86400);
717 format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02}", year, month, day, hour, min, sec)
718}
719
720fn icon_for(name: &str, is_dir: bool) -> &'static str {
721 if is_dir {
722 return "\u{1F4C1}";
723 }
724
725 let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
726 match ext.as_str() {
727 "png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "ico" | "bmp" => "\u{1F5BC}\u{FE0F}",
728 "mp4" | "mov" | "avi" | "mkv" | "webm" => "\u{1F3A5}",
729 "mp3" | "wav" | "flac" | "m4a" | "ogg" => "\u{1F3A7}",
730 "zip" | "tar" | "gz" | "rar" | "7z" | "bz2" => "\u{1F4E6}",
731 "pdf" => "\u{1F4D5}",
732 "html" | "htm" | "css" | "js" | "ts" | "rs" | "py" | "json" | "toml" | "yaml" | "yml" | "sh" => "\u{1F9E9}",
733 _ => "\u{1F4C4}",
734 }
735}
736
737const DIRECTORY_LISTING_HEAD: &str = concat!(
742 "<!DOCTYPE html>\n",
743 "<html lang=\"en\">\n",
744 "<head>\n",
745 "<meta charset=\"UTF-8\">\n",
746 "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n",
747 "<title>Directory listing</title>\n",
748 "<link rel=\"stylesheet\" href=\"/rws-directory-listing.css\">\n",
749 "</head>\n",
750 "<body>\n",
751 "<div class=\"wrap\">\n",
752);
753
754const DIRECTORY_LISTING_TOOLBAR: &str = "<div class=\"toolbar\"><input type=\"search\" id=\"filter\" placeholder=\"Filter entries...\" autocomplete=\"off\"></div>\n";
755
756const DIRECTORY_LISTING_TAIL: &str = concat!(
757 "</div>\n",
758 "<script src=\"/rws-directory-listing.js\" defer></script>\n",
759 "</body>\n",
760 "</html>\n",
761);