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 let without_stale_range;
99 let request = if range_is_stale(request) {
100 let mut copy = request.clone();
101 copy.headers.retain(|h| !h.name.eq_ignore_ascii_case(Header::_RANGE));
102 without_stale_range = copy;
103 &without_stale_range
104 } else {
105 request
106 };
107 if let Some(served) = StaticResourceController::serve_regular_file(request, &mut response) {
108 return served;
109 }
110 let boxed_content_range_list = StaticResourceController::process_static_resources(&request);
111 if boxed_content_range_list.is_ok() {
112 let content_range_list = boxed_content_range_list.unwrap();
113
114 if content_range_list.len() != 0 {
115
116 let mut status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok;
117
118 let does_request_include_range_header = request.get_header(Header::_RANGE.to_string()).is_some();
119 if does_request_include_range_header {
120 status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n206_partial_content;
121 }
122
123 let is_options_request = request.method == METHOD.options;
124 if is_options_request {
125 status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n204_no_content;
126 }
127
128
129
130 let url = request.request_uri.to_string();
131
132 let boxed_url_components = URL::parse_request_target(&url);
133 if boxed_url_components.is_err() {
134 let message = boxed_url_components.as_ref().err().unwrap().to_string();
135 println!("unexpected error, {}", message);
137 }
138
139 let components = boxed_url_components.unwrap();
140
141 let static_filepath = static_fs_path(&components.path).unwrap_or_default();
144 let boxed_modified_date_time = file_modified_utc(&static_filepath);
145
146 if boxed_modified_date_time.is_ok() {
147 let modified_date_time = boxed_modified_date_time.unwrap();
148 let last_modified_unix_nanos = Header{ name: Header::_LAST_MODIFIED_UNIX_EPOCH_NANOS.to_string(), value: modified_date_time.to_string() };
149 response.headers.push(last_modified_unix_nanos);
150
151 let file_size = metadata(&static_filepath).map(|m| m.len()).unwrap_or(0);
152 let etag_value = format!("\"{}-{}\"", modified_date_time, file_size);
153
154 let if_none_match = request.get_header(Header::_IF_NONE_MATCH.to_string());
155 if let Some(inm) = if_none_match {
156 if if_none_match_matches(&inm.value, &etag_value) {
157 response.status_code = *STATUS_CODE_REASON_PHRASE.n304_not_modified.status_code;
158 response.reason_phrase = STATUS_CODE_REASON_PHRASE.n304_not_modified.reason_phrase.to_string();
159 response.headers.push(Header { name: Header::_ETAG.to_string(), value: etag_value });
160 return response;
161 }
162 }
163
164 response.headers.push(Header { name: Header::_ETAG.to_string(), value: etag_value });
165
166 let is_range_request = request.get_header(Header::_RANGE.to_string()).is_some();
169 if file_size > StaticResourceController::STREAM_THRESHOLD && !is_range_request {
170 let mime = MimeType::detect_mime_type(&static_filepath);
171 response.headers.push(Header {
172 name: Header::_CONTENT_TYPE.to_string(),
173 value: mime,
174 });
175 response.headers.push(Header {
176 name: Header::_CONTENT_LENGTH.to_string(),
177 value: file_size.to_string(),
178 });
179 response.status_code = *status_code_reason_phrase.status_code;
180 response.reason_phrase = status_code_reason_phrase.reason_phrase.to_string();
181 response.stream_file = Some(static_filepath);
182 return response;
183 }
184 }
185
186 response.status_code = *status_code_reason_phrase.status_code;
187 response.reason_phrase = status_code_reason_phrase.reason_phrase.to_string();
188 response.content_range_list = content_range_list;
189
190 }
191 } else {
192 let error : Error = boxed_content_range_list.err().unwrap();
193 let body = error.message;
194
195 let content_range = Range::get_content_range(
196 Vec::from(body.as_bytes()),
197 MimeType::TEXT_HTML.to_string()
198 );
199
200 let content_range_list = vec![content_range];
201
202 response.status_code = *error.status_code_reason_phrase.status_code;
203 response.reason_phrase = error.status_code_reason_phrase.reason_phrase.to_string();
204 response.content_range_list = content_range_list;
205
206 if response.status_code == 416 {
208 let length = URL::parse_request_target(&request.request_uri).ok()
209 .and_then(|c| static_fs_path(&c.path).ok())
210 .and_then(|p| metadata(p).ok())
211 .map(|md| md.len());
212 if let Some(length) = length {
213 response.headers.push(Header { name: Header::_CONTENT_RANGE.to_string(), value: format!("bytes */{}", length) });
214 }
215 }
216 }
217
218
219 response
220 }
221}
222
223impl StaticResourceController {
225
226 pub fn is_matching_request(request: &Request) -> bool {
227 let boxed_static_filepath = static_fs_path(&request.request_uri);
228 if boxed_static_filepath.is_err() {
229 return false
230 }
231
232 let static_filepath = boxed_static_filepath.unwrap();
233
234 let boxed_md = metadata(&static_filepath);
235 if boxed_md.is_err() || !inside_root(&static_filepath) {
236 return false
237 }
238
239 let md = boxed_md.unwrap();
240 if md.is_dir() {
241 return false
242 }
243
244 let boxed_file = File::open(&static_filepath);
245
246 let is_get = request.method == METHOD.get;
247 let is_head = request.method == METHOD.head;
248 let is_options = request.method == METHOD.options;
249 boxed_file.is_ok() && (is_get || is_head || is_options && request.request_uri != SYMBOL.slash)
250 }
251
252 pub fn process_request(request: &Request, mut response: Response) -> Response {
253 if let Some(streamed) = StaticResourceController::serve_regular_file(request, &mut response) {
254 return streamed;
255 }
256 let boxed_content_range_list = StaticResourceController::process_static_resources(&request);
257 if boxed_content_range_list.is_ok() {
258 let content_range_list = boxed_content_range_list.unwrap();
259
260 if content_range_list.len() != 0 {
261
262 let mut status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok;
263
264 let does_request_include_range_header = request.get_header(Header::_RANGE.to_string()).is_some();
265 if does_request_include_range_header {
266 status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n206_partial_content;
267 }
268
269 let is_options_request = request.method == METHOD.options;
270 if is_options_request {
271 status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n204_no_content;
272 }
273
274
275 let static_filepath = static_fs_path(&request.request_uri).unwrap_or_default();
276 let boxed_modified_date_time = file_modified_utc(&static_filepath);
277
278 if boxed_modified_date_time.is_ok() {
279 let modified_date_time = boxed_modified_date_time.unwrap();
280 let last_modified_unix_nanos = Header{ name: Header::_LAST_MODIFIED_UNIX_EPOCH_NANOS.to_string(), value: modified_date_time.to_string() };
281 response.headers.push(last_modified_unix_nanos);
282
283 let file_size = metadata(&static_filepath).map(|m| m.len()).unwrap_or(0);
284 let etag_value = format!("\"{}-{}\"", modified_date_time, file_size);
285
286 let if_none_match = request.get_header(Header::_IF_NONE_MATCH.to_string());
287 if let Some(inm) = if_none_match {
288 if if_none_match_matches(&inm.value, &etag_value) {
289 response.status_code = *STATUS_CODE_REASON_PHRASE.n304_not_modified.status_code;
290 response.reason_phrase = STATUS_CODE_REASON_PHRASE.n304_not_modified.reason_phrase.to_string();
291 response.headers.push(Header { name: Header::_ETAG.to_string(), value: etag_value });
292 return response;
293 }
294 }
295
296 response.headers.push(Header { name: Header::_ETAG.to_string(), value: etag_value });
297
298 let is_range_request = request.get_header(Header::_RANGE.to_string()).is_some();
299 if file_size > StaticResourceController::STREAM_THRESHOLD && !is_range_request {
300 let mime = MimeType::detect_mime_type(&static_filepath);
301 response.headers.push(Header {
302 name: Header::_CONTENT_TYPE.to_string(),
303 value: mime,
304 });
305 response.headers.push(Header {
306 name: Header::_CONTENT_LENGTH.to_string(),
307 value: file_size.to_string(),
308 });
309 response.status_code = *status_code_reason_phrase.status_code;
310 response.reason_phrase = status_code_reason_phrase.reason_phrase.to_string();
311 response.stream_file = Some(static_filepath);
312 return response;
313 }
314 }
315
316 response.status_code = *status_code_reason_phrase.status_code;
317 response.reason_phrase = status_code_reason_phrase.reason_phrase.to_string();
318 response.content_range_list = content_range_list;
319
320 }
321 } else {
322 let error : Error = boxed_content_range_list.err().unwrap();
323 let body = error.message;
324
325 let content_range = Range::get_content_range(
326 Vec::from(body.as_bytes()),
327 MimeType::TEXT_HTML.to_string()
328 );
329
330 let content_range_list = vec![content_range];
331
332 response.status_code = *error.status_code_reason_phrase.status_code;
333 response.reason_phrase = error.status_code_reason_phrase.reason_phrase.to_string();
334 response.content_range_list = content_range_list;
335
336 }
337
338
339 response
340 }
341
342 pub const STREAM_THRESHOLD: u64 = 256 * 1024;
346
347 fn serve_regular_file(request: &Request, response: &mut Response) -> Option<Response> {
357 if request.method != METHOD.get && request.method != METHOD.head {
358 return None;
359 }
360 if request.get_header(Header::_RANGE.to_string()).is_some() {
361 return None;
362 }
363 let path = URL::parse_request_target(&request.request_uri).ok()?.path;
364 let fs_path = static_fs_path(&path.replace(SYMBOL.slash, &FileExt::get_path_separator())).ok()?;
365 let mime = MimeType::detect_mime_type(&fs_path);
369 let follow_symlinks = mime != MimeType::APPLICATION_OCTET_STREAM;
370 let file = open_file_cache::open(&fs_path, follow_symlinks)?;
371 let state = file.state()?;
372
373 let mut response = std::mem::replace(response, Response::get_response(STATUS_CODE_REASON_PHRASE.n200_ok, None, None));
374 let modified = state.modified;
375 let etag_value = format!("\"{}-{}\"", modified, state.len);
376 response.headers.push(Header { name: Header::_LAST_MODIFIED_UNIX_EPOCH_NANOS.to_string(), value: modified.to_string() });
377 let not_modified = request
378 .get_header(Header::_IF_NONE_MATCH.to_string())
379 .map_or(false, |inm| if_none_match_matches(&inm.value, &etag_value));
380 response.headers.push(Header { name: Header::_ETAG.to_string(), value: etag_value });
381 if not_modified {
382 response.status_code = *STATUS_CODE_REASON_PHRASE.n304_not_modified.status_code;
383 response.reason_phrase = STATUS_CODE_REASON_PHRASE.n304_not_modified.reason_phrase.to_string();
384 return Some(response);
385 }
386 response.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
387 response.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
388
389 if state.len > StaticResourceController::STREAM_THRESHOLD {
390 response.headers.push(Header { name: Header::_CONTENT_TYPE.to_string(), value: mime });
391 response.headers.push(Header { name: Header::_CONTENT_LENGTH.to_string(), value: state.len.to_string() });
392 response.stream_file = Some(fs_path);
393 return Some(response);
394 }
395
396 let body = file.read_all(state.len).ok()?;
397 response.content_range_list = vec![Range::get_content_range(body, mime)];
398 Some(response)
399 }
400
401 pub fn process_static_resources(request: &Request) -> Result<Vec<ContentRange>, Error> {
402
403 let url = request.request_uri.to_string();
404
405 let boxed_url_components = URL::parse_request_target(&url);
406 if boxed_url_components.is_err() {
407 let message = boxed_url_components.as_ref().err().unwrap().to_string();
408 println!("unexpected error, {}", message);
410 }
411
412 let components = boxed_url_components.unwrap();
413
414 let os_specific_separator : String = FileExt::get_path_separator();
415 let os_specific_path = &components.path.replace(SYMBOL.slash, os_specific_separator.as_str());
416
417 let static_filepath = match static_fs_path(&os_specific_path) {
418 Ok(path) => path,
419 Err(message) => {
420 return Err(Error { status_code_reason_phrase: STATUS_CODE_REASON_PHRASE.n404_not_found, message });
421 }
422 };
423
424 let mut content_range_list = Vec::new();
425
426
427 let mut boxed_md = metadata(&static_filepath);
428 if boxed_md.is_err() {
429 let dot_html = format!("{}{}", &static_filepath, ".html");
430 boxed_md = metadata(&dot_html);
431
432 if boxed_md.is_err() {
433 let slash_index_html = format!("{}{}{}", &static_filepath, os_specific_separator, "index.html");
434 boxed_md = metadata(&slash_index_html);
435 }
436 }
437 if boxed_md.is_ok() {
438 let md = boxed_md.unwrap();
439
440 if md.is_dir() {
441 let mut directory_index : String = "index.html".to_string();
442
443 let last_char = components.path.chars().last().unwrap();
444 if last_char != '/' {
445 let index : String = "index.html".to_string();
446 directory_index = format!("{}{}", os_specific_separator, index);
447 }
448 let index_html_in_directory = format!("{}{}", os_specific_path, directory_index);
449 let index_html_fs_path = format!("{}{}", static_filepath, directory_index);
450
451 if File::open(&index_html_fs_path).is_ok() {
452 let mut range_header = &Header {
453 name: Header::_RANGE.to_string(),
454 value: "bytes=0-".to_string()
455 };
456
457 let boxed_header = request.get_header(Header::_RANGE.to_string());
458 if boxed_header.is_some() {
459 range_header = boxed_header.unwrap();
460 }
461
462 let boxed_content_range_list = Range::get_content_range_list(&index_html_in_directory, range_header);
463 if boxed_content_range_list.is_ok() {
464 content_range_list = boxed_content_range_list.unwrap();
465 } else {
466 let error = boxed_content_range_list.err().unwrap();
467 return Err(error)
468 }
469 } else {
470 let listing_html = StaticResourceController::render_directory_listing(&static_filepath, &components.path);
471 let content_range = Range::get_content_range(listing_html.into_bytes(), MimeType::TEXT_HTML.to_string());
472 content_range_list = vec![content_range];
473 }
474
475 return Ok(content_range_list);
476 }
477
478 let boxed_file = File::open(&static_filepath);
479 if boxed_file.is_ok() {
480 let md = metadata(&static_filepath).unwrap();
481 if md.is_dir() {
482 let mut range_header = &Header {
483 name: Header::_RANGE.to_string(),
484 value: "bytes=0-".to_string()
485 };
486
487 let boxed_header = request.get_header(Header::_RANGE.to_string());
488 if boxed_header.is_some() {
489 range_header = boxed_header.unwrap();
490 }
491
492 let mut directory_index : String = "index.html".to_string();
493
494 let last_char = components.path.chars().last().unwrap();
495 if last_char != '/' {
496 let index : String = "index.html".to_string();
497 directory_index = format!("{}{}", os_specific_separator, index);
498 }
499 let index_html_in_directory = format!("{}{}", os_specific_path, directory_index);
500
501
502 let boxed_content_range_list = Range::get_content_range_list(&index_html_in_directory, range_header);
503 if boxed_content_range_list.is_ok() {
504 content_range_list = boxed_content_range_list.unwrap();
505 } else {
506 let error = boxed_content_range_list.err().unwrap();
507 return Err(error)
508 }
509 }
510
511 if md.is_file() {
512 let mut range_header = &Header {
513 name: Header::_RANGE.to_string(),
514 value: "bytes=0-".to_string()
515 };
516
517 let boxed_header = request.get_header(Header::_RANGE.to_string());
518 if boxed_header.is_some() {
519 range_header = boxed_header.unwrap();
520 }
521
522 let boxed_content_range_list = Range::get_content_range_list(&request.request_uri, range_header);
523 if boxed_content_range_list.is_ok() {
524 content_range_list = boxed_content_range_list.unwrap();
525 } else {
526 let error = boxed_content_range_list.err().unwrap();
527 return Err(error)
528 }
529 }
530 }
531
532
533 if boxed_file.is_err() {
534 let static_filepath = static_fs_path(&format!("{}.html", components.path)).unwrap_or_default();
537
538 let boxed_file = File::open(&static_filepath);
539 if boxed_file.is_ok() {
540 let md = metadata(&static_filepath).unwrap();
541 if md.is_file() {
542 let mut range_header = &Header {
543 name: Header::_RANGE.to_string(),
544 value: "bytes=0-".to_string()
545 };
546
547 let boxed_header = request.get_header(Header::_RANGE.to_string());
548 if boxed_header.is_some() {
549 range_header = boxed_header.unwrap();
550 }
551
552 let url = request.request_uri.to_string();
553
554 let boxed_url_components = URL::parse_request_target(&url);
555 if boxed_url_components.is_err() {
556 let message = boxed_url_components.as_ref().err().unwrap().to_string();
557 println!("unexpected error, {}", message);
559 }
560
561 let components = boxed_url_components.unwrap();
562
563 let html_file = [components.path.as_str(), ".html"].join(SYMBOL.empty_string);
567 let boxed_content_range_list = Range::get_content_range_list(html_file.as_str(), range_header);
568 if boxed_content_range_list.is_ok() {
569 content_range_list = boxed_content_range_list.unwrap();
570 } else {
571 let error = boxed_content_range_list.err().unwrap();
572 return Err(error)
573 }
574 }
575 }
576 }
577 }
578
579 if content_range_list.is_empty() {
583 if let Some(fallback_url_path) = spa_fallback_url_path(&components.path) {
584 let mut range_header = &Header {
585 name: Header::_RANGE.to_string(),
586 value: "bytes=0-".to_string()
587 };
588
589 let boxed_header = request.get_header(Header::_RANGE.to_string());
590 if boxed_header.is_some() {
591 range_header = boxed_header.unwrap();
592 }
593
594 if let Ok(list) = Range::get_content_range_list(&fallback_url_path, range_header) {
595 content_range_list = list;
596 }
597 }
598 }
599
600 Ok(content_range_list)
601 }
602}
603
604impl StaticResourceController {
608 pub fn render_directory_listing(fs_dir_path: &str, request_path: &str) -> String {
613 let normalized_request_path = if request_path.ends_with('/') {
614 request_path.to_string()
615 } else {
616 format!("{}/", request_path)
617 };
618
619 struct Entry {
620 name: String,
621 is_dir: bool,
622 size: u64,
623 modified_epoch_secs: u64,
624 }
625
626 let mut entries: Vec<Entry> = Vec::new();
627 if let Ok(dir_entries) = read_dir(fs_dir_path) {
628 for dir_entry in dir_entries.flatten() {
629 let name = dir_entry.file_name().to_string_lossy().to_string();
630 if name.starts_with('.') {
631 continue;
632 }
633
634 if let Ok(md) = dir_entry.metadata() {
635 let modified_epoch_secs = md.modified()
636 .ok()
637 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
638 .map(|d| d.as_secs())
639 .unwrap_or(0);
640
641 entries.push(Entry {
642 name,
643 is_dir: md.is_dir(),
644 size: md.len(),
645 modified_epoch_secs,
646 });
647 }
648 }
649 }
650
651 entries.sort_by(|a, b| {
652 match (a.is_dir, b.is_dir) {
653 (true, false) => std::cmp::Ordering::Less,
654 (false, true) => std::cmp::Ordering::Greater,
655 _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
656 }
657 });
658
659 let segments: Vec<&str> = normalized_request_path.split('/').filter(|s| !s.is_empty()).collect();
660
661 let mut breadcrumb_html = String::from("<a href=\"/\">~</a>");
662 let mut accumulated_path = String::new();
663 for segment in &segments {
664 accumulated_path.push('/');
665 accumulated_path.push_str(segment);
666 breadcrumb_html.push_str(&format!(
667 " <span class=\"sep\">/</span> <a href=\"{}/\">{}</a>",
668 accumulated_path, html_escape(segment)
669 ));
670 }
671
672 let parent_row = if !segments.is_empty() {
673 let parent_path = if segments.len() > 1 {
674 format!("/{}/", segments[..segments.len() - 1].join("/"))
675 } else {
676 "/".to_string()
677 };
678 format!(
679 "<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",
680 parent_path
681 )
682 } else {
683 String::new()
684 };
685
686 let entry_count = entries.len();
687
688 let mut rows = String::new();
689 for entry in &entries {
690 let escaped_name = html_escape(&entry.name);
691 let encoded_name = URL::percent_encode(&entry.name);
692 let href = if entry.is_dir {
693 format!("{}{}/", normalized_request_path, encoded_name)
694 } else {
695 format!("{}{}", normalized_request_path, encoded_name)
696 };
697 let size_display = if entry.is_dir { "\u{2014}".to_string() } else { human_size(entry.size) };
698 let modified_display = format_modified(entry.modified_epoch_secs);
699 let icon = icon_for(&entry.name, entry.is_dir);
700 let filter_key = html_escape(&entry.name.to_lowercase());
701
702 rows.push_str(&format!(
703 "<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",
704 filter_key, icon, href, escaped_name, if entry.is_dir { "/" } else { "" }, size_display, modified_display
705 ));
706 }
707
708 let mut html = String::new();
709 html.push_str(DIRECTORY_LISTING_HEAD);
710 html.push_str(&format!("<div class=\"breadcrumb\">{}</div>\n", breadcrumb_html));
711 html.push_str(&format!("<h1>Index of {}</h1>\n", html_escape(&normalized_request_path)));
712 html.push_str(DIRECTORY_LISTING_TOOLBAR);
713 html.push_str("<div class=\"card\"><table><thead><tr><th>Name</th><th>Size</th><th>Modified</th></tr></thead><tbody id=\"rows\">\n");
714 html.push_str(&parent_row);
715 html.push_str(&rows);
716 html.push_str("</tbody></table></div>\n");
717 html.push_str(&format!(
718 "<footer>{} item{} · served by rws</footer>\n",
719 entry_count, if entry_count == 1 { "" } else { "s" }
720 ));
721 html.push_str(DIRECTORY_LISTING_TAIL);
722
723 html
724 }
725}
726
727fn path_has_extension(path: &str) -> bool {
734 path.rsplit('/').next().unwrap_or("").contains('.')
735}
736
737fn spa_fallback_url_path(url_path: &str) -> Option<String> {
746 if path_has_extension(url_path) {
747 return None;
748 }
749
750 let fallback_name = crate::entry_point::get_spa_fallback()?;
751
752 let exclude_prefixes = crate::entry_point::get_spa_fallback_exclude_prefixes();
753 if exclude_prefixes.iter().any(|prefix| url_path.starts_with(prefix.as_str())) {
754 return None;
755 }
756
757 let separator = FileExt::get_path_separator();
758 let normalized_name = fallback_name.trim_start_matches('/').replace('/', &separator);
759 let fallback_url_path = format!("{}{}", separator, normalized_name);
760
761 let boxed_fs_path = static_fs_path(&fallback_url_path);
762 let fs_path = boxed_fs_path.ok()?;
763 if File::open(&fs_path).is_ok() {
764 Some(fallback_url_path)
765 } else {
766 None
767 }
768}
769
770fn html_escape(s: &str) -> String {
771 let mut out = String::with_capacity(s.len());
772 for c in s.chars() {
773 match c {
774 '&' => out.push_str("&"),
775 '<' => out.push_str("<"),
776 '>' => out.push_str(">"),
777 '"' => out.push_str("""),
778 '\'' => out.push_str("'"),
779 _ => out.push(c),
780 }
781 }
782 out
783}
784
785fn human_size(bytes: u64) -> String {
786 const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
787 if bytes < 1024 {
788 return format!("{} {}", bytes, UNITS[0]);
789 }
790
791 let mut size = bytes as f64;
792 let mut unit_index = 0;
793 while size >= 1024.0 && unit_index < UNITS.len() - 1 {
794 size /= 1024.0;
795 unit_index += 1;
796 }
797
798 format!("{:.1} {}", size, UNITS[unit_index])
799}
800
801fn format_modified(epoch_secs: u64) -> String {
802 let (sec, min, hour, day, month, _dow) = crate::scheduler::cron::epoch_to_datetime(epoch_secs);
803 let (year, _, _) = crate::scheduler::cron::days_to_ymd(epoch_secs / 86400);
804 format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02}", year, month, day, hour, min, sec)
805}
806
807fn icon_for(name: &str, is_dir: bool) -> &'static str {
808 if is_dir {
809 return "\u{1F4C1}";
810 }
811
812 let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
813 match ext.as_str() {
814 "png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "ico" | "bmp" => "\u{1F5BC}\u{FE0F}",
815 "mp4" | "mov" | "avi" | "mkv" | "webm" => "\u{1F3A5}",
816 "mp3" | "wav" | "flac" | "m4a" | "ogg" => "\u{1F3A7}",
817 "zip" | "tar" | "gz" | "rar" | "7z" | "bz2" => "\u{1F4E6}",
818 "pdf" => "\u{1F4D5}",
819 "html" | "htm" | "css" | "js" | "ts" | "rs" | "py" | "json" | "toml" | "yaml" | "yml" | "sh" => "\u{1F9E9}",
820 _ => "\u{1F4C4}",
821 }
822}
823
824const DIRECTORY_LISTING_HEAD: &str = concat!(
829 "<!DOCTYPE html>\n",
830 "<html lang=\"en\">\n",
831 "<head>\n",
832 "<meta charset=\"UTF-8\">\n",
833 "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n",
834 "<title>Directory listing</title>\n",
835 "<link rel=\"stylesheet\" href=\"/rws-directory-listing.css\">\n",
836 "</head>\n",
837 "<body>\n",
838 "<div class=\"wrap\">\n",
839);
840
841const DIRECTORY_LISTING_TOOLBAR: &str = "<div class=\"toolbar\"><input type=\"search\" id=\"filter\" placeholder=\"Filter entries...\" autocomplete=\"off\"></div>\n";
842
843const DIRECTORY_LISTING_TAIL: &str = concat!(
844 "</div>\n",
845 "<script src=\"/rws-directory-listing.js\" defer></script>\n",
846 "</body>\n",
847 "</html>\n",
848);
849
850pub(crate) fn working_directory() -> &'static str {
856 static DIR: std::sync::OnceLock<String> = std::sync::OnceLock::new();
857 DIR.get_or_init(|| {
858 std::env::current_dir()
859 .map(|d| d.to_string_lossy().into_owned())
860 .unwrap_or_default()
861 })
862}
863
864pub(crate) fn if_none_match_matches(header_value: &str, etag: &str) -> bool {
869 let opaque = |tag: &str| tag.trim().strip_prefix("W/").unwrap_or(tag.trim()).to_string();
870 let current = opaque(etag);
871 header_value.split(',').any(|candidate| {
872 let candidate = candidate.trim();
873 candidate == "*" || (!candidate.is_empty() && opaque(candidate) == current)
874 })
875}
876
877pub(crate) fn range_is_stale(request: &Request) -> bool {
883 let Some(if_range) = request.get_header(Header::_IF_RANGE.to_string()) else { return false };
884 if request.get_header(Header::_RANGE.to_string()).is_none() {
885 return false;
886 }
887 let validator = if_range.value.trim();
888 if !validator.starts_with('"') {
889 return true;
890 }
891 let Ok(components) = URL::parse_request_target(&request.request_uri) else { return true };
892 let Ok(fs_path) = static_fs_path(&components.path) else { return true };
893 match (file_modified_utc(&fs_path), metadata(&fs_path)) {
894 (Ok(modified), Ok(md)) => validator != format!("\"{}-{}\"", modified, md.len()),
895 _ => true,
896 }
897}
898
899pub(crate) fn static_fs_path(path: &str) -> Result<String, String> {
914 let path = percent_decode_path(path)?;
915 check_request_path(&path)?;
916 Ok(format!("{}{}", working_directory(), path))
917}
918
919pub(crate) fn percent_decode_path(path: &str) -> Result<std::borrow::Cow<'_, str>, String> {
924 if !path.contains('%') {
925 return Ok(std::borrow::Cow::Borrowed(path));
926 }
927 let hex = |b: u8| (b as char).to_digit(16).map(|d| d as u8);
928 let bytes = path.as_bytes();
929 let mut decoded = Vec::with_capacity(bytes.len());
930 let mut i = 0;
931 while i < bytes.len() {
932 if bytes[i] == b'%' && i + 2 < bytes.len() {
933 if let (Some(high), Some(low)) = (hex(bytes[i + 1]), hex(bytes[i + 2])) {
934 decoded.push(high << 4 | low);
935 i += 3;
936 continue;
937 }
938 }
939 decoded.push(bytes[i]);
940 i += 1;
941 }
942 String::from_utf8(decoded)
943 .map(std::borrow::Cow::Owned)
944 .map_err(|_| "path is not UTF-8 after percent-decoding".to_string())
945}
946
947pub(crate) fn check_request_path(path: &str) -> Result<(), String> {
950 for segment in path.split(['/', '\\']) {
951 if segment == ".." {
952 return Err("path leaves the web root".to_string());
953 }
954 if segment.starts_with('.') && segment != "." && segment != ".well-known" && !serve_dotfiles() {
955 return Err("hidden files are not served".to_string());
956 }
957 if segment.contains('\0') {
958 return Err("invalid path".to_string());
959 }
960 }
961 let name = path.rsplit(['/', '\\']).next().unwrap_or("").to_ascii_lowercase();
963 if name.ends_with(".pem") || name.ends_with(".key") || name == "rws.config.toml" {
964 return Err("key and config files are not served".to_string());
965 }
966 Ok(())
967}
968
969fn serve_dotfiles() -> bool {
971 static SERVE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
972 *SERVE.get_or_init(|| {
973 std::env::var("RWS_CONFIG_SERVE_DOTFILES").is_ok_and(|v| v.trim().eq_ignore_ascii_case("true"))
974 })
975}
976
977pub(crate) fn inside_root(fs_path: &str) -> bool {
982 static ROOT: std::sync::OnceLock<Option<std::path::PathBuf>> = std::sync::OnceLock::new();
983 let Some(root) = ROOT.get_or_init(|| std::fs::canonicalize(working_directory()).ok()) else {
984 return false;
985 };
986 std::fs::canonicalize(fs_path).is_ok_and(|resolved| resolved.starts_with(root))
987}
988
989pub(crate) fn file_modified_utc(path: &str) -> Result<u128, String> {
992 let modified = metadata(path)
993 .and_then(|md| md.modified())
994 .map_err(|e| format!("<p>Unable to open file: {}</p> <p>error: {}</p>", path, e))?;
995 Ok(modified
996 .duration_since(std::time::UNIX_EPOCH)
997 .map(|d| d.as_nanos())
998 .unwrap_or(0))
999}
1000