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 return false
72 }
73
74 let html_suffix = ".html";
75 let html_file = [&components.path.replace(SYMBOL.slash, &FileExt::get_path_separator()), html_suffix].join(SYMBOL.empty_string);
76 let boxed_static_filepath = FileExt::get_static_filepath(&html_file);
77 if boxed_static_filepath.is_err() {
78 return false
79 }
80
81 let static_filepath = boxed_static_filepath.unwrap();
82 let boxed_file = File::open(&static_filepath);
83
84 boxed_file.is_ok() && is_matching_method
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
493 Ok(content_range_list)
494 }
495}
496
497impl StaticResourceController {
501 pub fn render_directory_listing(fs_dir_path: &str, request_path: &str) -> String {
506 let normalized_request_path = if request_path.ends_with('/') {
507 request_path.to_string()
508 } else {
509 format!("{}/", request_path)
510 };
511
512 struct Entry {
513 name: String,
514 is_dir: bool,
515 size: u64,
516 modified_epoch_secs: u64,
517 }
518
519 let mut entries: Vec<Entry> = Vec::new();
520 if let Ok(dir_entries) = read_dir(fs_dir_path) {
521 for dir_entry in dir_entries.flatten() {
522 let name = dir_entry.file_name().to_string_lossy().to_string();
523 if name.starts_with('.') {
524 continue;
525 }
526
527 if let Ok(md) = dir_entry.metadata() {
528 let modified_epoch_secs = md.modified()
529 .ok()
530 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
531 .map(|d| d.as_secs())
532 .unwrap_or(0);
533
534 entries.push(Entry {
535 name,
536 is_dir: md.is_dir(),
537 size: md.len(),
538 modified_epoch_secs,
539 });
540 }
541 }
542 }
543
544 entries.sort_by(|a, b| {
545 match (a.is_dir, b.is_dir) {
546 (true, false) => std::cmp::Ordering::Less,
547 (false, true) => std::cmp::Ordering::Greater,
548 _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
549 }
550 });
551
552 let segments: Vec<&str> = normalized_request_path.split('/').filter(|s| !s.is_empty()).collect();
553
554 let mut breadcrumb_html = String::from("<a href=\"/\">~</a>");
555 let mut accumulated_path = String::new();
556 for segment in &segments {
557 accumulated_path.push('/');
558 accumulated_path.push_str(segment);
559 breadcrumb_html.push_str(&format!(
560 " <span class=\"sep\">/</span> <a href=\"{}/\">{}</a>",
561 accumulated_path, html_escape(segment)
562 ));
563 }
564
565 let parent_row = if !segments.is_empty() {
566 let parent_path = if segments.len() > 1 {
567 format!("/{}/", segments[..segments.len() - 1].join("/"))
568 } else {
569 "/".to_string()
570 };
571 format!(
572 "<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",
573 parent_path
574 )
575 } else {
576 String::new()
577 };
578
579 let entry_count = entries.len();
580
581 let mut rows = String::new();
582 for entry in &entries {
583 let escaped_name = html_escape(&entry.name);
584 let encoded_name = URL::percent_encode(&entry.name);
585 let href = if entry.is_dir {
586 format!("{}{}/", normalized_request_path, encoded_name)
587 } else {
588 format!("{}{}", normalized_request_path, encoded_name)
589 };
590 let size_display = if entry.is_dir { "\u{2014}".to_string() } else { human_size(entry.size) };
591 let modified_display = format_modified(entry.modified_epoch_secs);
592 let icon = icon_for(&entry.name, entry.is_dir);
593 let filter_key = html_escape(&entry.name.to_lowercase());
594
595 rows.push_str(&format!(
596 "<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",
597 filter_key, icon, href, escaped_name, if entry.is_dir { "/" } else { "" }, size_display, modified_display
598 ));
599 }
600
601 let mut html = String::new();
602 html.push_str(DIRECTORY_LISTING_HEAD);
603 html.push_str(&format!("<div class=\"breadcrumb\">{}</div>\n", breadcrumb_html));
604 html.push_str(&format!("<h1>Index of {}</h1>\n", html_escape(&normalized_request_path)));
605 html.push_str(DIRECTORY_LISTING_TOOLBAR);
606 html.push_str("<div class=\"card\"><table><thead><tr><th>Name</th><th>Size</th><th>Modified</th></tr></thead><tbody id=\"rows\">\n");
607 html.push_str(&parent_row);
608 html.push_str(&rows);
609 html.push_str("</tbody></table></div>\n");
610 html.push_str(&format!(
611 "<footer>{} item{} · served by rws</footer>\n",
612 entry_count, if entry_count == 1 { "" } else { "s" }
613 ));
614 html.push_str(DIRECTORY_LISTING_TAIL);
615
616 html
617 }
618}
619
620fn html_escape(s: &str) -> String {
621 let mut out = String::with_capacity(s.len());
622 for c in s.chars() {
623 match c {
624 '&' => out.push_str("&"),
625 '<' => out.push_str("<"),
626 '>' => out.push_str(">"),
627 '"' => out.push_str("""),
628 '\'' => out.push_str("'"),
629 _ => out.push(c),
630 }
631 }
632 out
633}
634
635fn human_size(bytes: u64) -> String {
636 const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
637 if bytes < 1024 {
638 return format!("{} {}", bytes, UNITS[0]);
639 }
640
641 let mut size = bytes as f64;
642 let mut unit_index = 0;
643 while size >= 1024.0 && unit_index < UNITS.len() - 1 {
644 size /= 1024.0;
645 unit_index += 1;
646 }
647
648 format!("{:.1} {}", size, UNITS[unit_index])
649}
650
651fn format_modified(epoch_secs: u64) -> String {
652 let (sec, min, hour, day, month, _dow) = crate::scheduler::cron::epoch_to_datetime(epoch_secs);
653 let (year, _, _) = crate::scheduler::cron::days_to_ymd(epoch_secs / 86400);
654 format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02}", year, month, day, hour, min, sec)
655}
656
657fn icon_for(name: &str, is_dir: bool) -> &'static str {
658 if is_dir {
659 return "\u{1F4C1}";
660 }
661
662 let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
663 match ext.as_str() {
664 "png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "ico" | "bmp" => "\u{1F5BC}\u{FE0F}",
665 "mp4" | "mov" | "avi" | "mkv" | "webm" => "\u{1F3A5}",
666 "mp3" | "wav" | "flac" | "m4a" | "ogg" => "\u{1F3A7}",
667 "zip" | "tar" | "gz" | "rar" | "7z" | "bz2" => "\u{1F4E6}",
668 "pdf" => "\u{1F4D5}",
669 "html" | "htm" | "css" | "js" | "ts" | "rs" | "py" | "json" | "toml" | "yaml" | "yml" | "sh" => "\u{1F9E9}",
670 _ => "\u{1F4C4}",
671 }
672}
673
674const DIRECTORY_LISTING_HEAD: &str = concat!(
679 "<!DOCTYPE html>\n",
680 "<html lang=\"en\">\n",
681 "<head>\n",
682 "<meta charset=\"UTF-8\">\n",
683 "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n",
684 "<title>Directory listing</title>\n",
685 "<link rel=\"stylesheet\" href=\"/rws-directory-listing.css\">\n",
686 "</head>\n",
687 "<body>\n",
688 "<div class=\"wrap\">\n",
689);
690
691const DIRECTORY_LISTING_TOOLBAR: &str = "<div class=\"toolbar\"><input type=\"search\" id=\"filter\" placeholder=\"Filter entries...\" autocomplete=\"off\"></div>\n";
692
693const DIRECTORY_LISTING_TAIL: &str = concat!(
694 "</div>\n",
695 "<script src=\"/rws-directory-listing.js\" defer></script>\n",
696 "</body>\n",
697 "</html>\n",
698);