1use std::fs::{File, metadata, read_dir};
2use std::time::UNIX_EPOCH;
3use file_ext::FileExt;
4use crate::controller::Controller;
5use crate::ext::decimal::Decimal;
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::server_config::ServerConfig;
13use crate::symbol::SYMBOL;
14use crate::url::URL;
15
16#[cfg(test)]
17mod tests;
18pub(crate) mod open_file_cache;
19
20pub struct StaticResourceController;
21
22impl Controller for StaticResourceController {
23 fn is_matching(request: &Request, _connection: &ConnectionInfo) -> bool {
24 let is_get = request.method == METHOD.get;
27 let is_head = request.method == METHOD.head;
28 let is_options = request.method == METHOD.options;
29
30 let is_matching_method = (is_get || is_head || is_options) && (request.request_uri != SYMBOL.slash);
31 if !is_matching_method {
32 return false;
33 }
34
35 let path = match URL::request_target_path(&request.request_uri) {
38 Some(path) => path,
39 None => return false,
40 };
41
42 let boxed_static_filepath = static_fs_path(&os_specific_path(path));
43 if boxed_static_filepath.is_err() {
44 return false
45 }
46
47 let static_filepath = boxed_static_filepath.unwrap();
48
49 let mut is_directory = false;
52
53 let mut is_file = open_file_cache::is_cached_file(&static_filepath);
56 if !is_file {
57 if let Ok(md) = metadata(&static_filepath) {
58 if !inside_root(&static_filepath) {
60 return false;
61 }
62 is_directory = md.is_dir();
63 is_file = md.is_file();
64 }
65 }
66
67 if is_file || is_directory {
68 true
69 } else {
70 if !static_filepath.ends_with(".html") {
72 let html_suffix = ".html";
73 let mut html_file = os_specific_path(path).into_owned();
74 html_file.push_str(html_suffix);
75 if let Ok(html_static_filepath) = static_fs_path(&html_file) {
76 if File::open(&html_static_filepath).is_ok() {
77 return true;
78 }
79 }
80 }
81
82 spa_fallback_url_path(path).is_some()
87 }
88
89 }
90
91 fn process(request: &Request, mut response: Response, _connection: &ConnectionInfo) -> Response {
92 let config = ServerConfig::current();
93 if let Some(served) = StaticResourceController::serve_regular_file(request, &mut response, &config) {
94 return served;
95 }
96 StaticResourceController::process_general(request, response, &config)
97 }
98}
99
100impl StaticResourceController {
101 pub(crate) fn process_general(request: &Request, mut response: Response, config: &ServerConfig) -> Response {
105 let without_stale_range;
109 let request = if range_is_stale(request) {
110 let mut copy = request.clone();
111 copy.headers.retain(|h| !h.name.eq_ignore_ascii_case(Header::_RANGE));
112 without_stale_range = copy;
113 &without_stale_range
114 } else {
115 request
116 };
117 let boxed_content_range_list = StaticResourceController::process_static_resources(&request);
118 if boxed_content_range_list.is_ok() {
119 let content_range_list = boxed_content_range_list.unwrap();
120
121 if content_range_list.len() != 0 {
122
123 let mut status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok;
124
125 let does_request_include_range_header = request.header(Header::_RANGE).is_some();
126 if does_request_include_range_header {
127 status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n206_partial_content;
128 }
129
130 let is_options_request = request.method == METHOD.options;
131 if is_options_request {
132 status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n204_no_content;
133 }
134
135
136
137 let url = request.request_uri.to_string();
138
139 let boxed_url_components = URL::parse_request_target(&url);
140 if boxed_url_components.is_err() {
141 let message = boxed_url_components.as_ref().err().unwrap().to_string();
142 println!("unexpected error, {}", message);
144 }
145
146 let components = boxed_url_components.unwrap();
147
148 let static_filepath = static_fs_path(&components.path).unwrap_or_default();
151 let boxed_modified_date_time = file_modified_utc(&static_filepath);
152
153 if boxed_modified_date_time.is_ok() {
154 let modified_date_time = boxed_modified_date_time.unwrap();
155 let file_size = metadata(&static_filepath).map(|m| m.len()).unwrap_or(0);
156 let etag_value = format!("\"{}-{}\"", modified_date_time, file_size);
157 set_static_cache_control(&mut response, &config.static_cache_control);
158 if push_validators(request, &mut response, modified_date_time, &etag_value, config.nanos_headers) {
159 response.status_code = *STATUS_CODE_REASON_PHRASE.n304_not_modified.status_code;
160 response.reason_phrase = STATUS_CODE_REASON_PHRASE.n304_not_modified.reason_phrase.to_string();
161 response.headers.push(Header { name: Header::_ETAG.into(), value: etag_value.into() });
162 return response;
163 }
164
165 response.headers.push(Header { name: Header::_ETAG.into(), value: etag_value.into() });
166
167 let is_range_request = request.header(Header::_RANGE).is_some();
170 if file_size > StaticResourceController::STREAM_THRESHOLD && !is_range_request {
171 let mime = MimeType::detect_mime_type(&static_filepath);
172 response.headers.push(Header {
173 name: Header::_CONTENT_TYPE.into(),
174 value: mime.into(),
175 });
176 response.headers.push(Header {
177 name: Header::_CONTENT_LENGTH.into(),
178 value: file_size.to_string().into(),
179 });
180 response.status_code = *status_code_reason_phrase.status_code;
181 response.reason_phrase = status_code_reason_phrase.reason_phrase.to_string();
182 response.stream_file = Some(static_filepath);
183 return response;
184 }
185 }
186
187 response.status_code = *status_code_reason_phrase.status_code;
188 response.reason_phrase = status_code_reason_phrase.reason_phrase.to_string();
189 response.content_range_list = content_range_list;
190
191 }
192 } else {
193 let error : Error = boxed_content_range_list.err().unwrap();
194 let body = error.message;
195
196 let content_range = Range::get_content_range(
197 Vec::from(body.as_bytes()),
198 MimeType::TEXT_HTML.to_string()
199 );
200
201 let content_range_list = vec![content_range];
202
203 response.status_code = *error.status_code_reason_phrase.status_code;
204 response.reason_phrase = error.status_code_reason_phrase.reason_phrase.to_string();
205 response.content_range_list = content_range_list;
206
207 if response.status_code == 416 {
209 let length = URL::parse_request_target(&request.request_uri).ok()
210 .and_then(|c| static_fs_path(&c.path).ok())
211 .and_then(|p| metadata(p).ok())
212 .map(|md| md.len());
213 if let Some(length) = length {
214 response.headers.push(Header { name: Header::_CONTENT_RANGE.into(), value: format!("bytes */{}", length).into() });
215 }
216 }
217 }
218
219
220 response
221 }
222}
223
224impl StaticResourceController {
226
227 pub fn is_matching_request(request: &Request) -> bool {
228 let boxed_static_filepath = static_fs_path(&request.request_uri);
229 if boxed_static_filepath.is_err() {
230 return false
231 }
232
233 let static_filepath = boxed_static_filepath.unwrap();
234
235 let boxed_md = metadata(&static_filepath);
236 if boxed_md.is_err() || !inside_root(&static_filepath) {
237 return false
238 }
239
240 let md = boxed_md.unwrap();
241 if md.is_dir() {
242 return false
243 }
244
245 let boxed_file = File::open(&static_filepath);
246
247 let is_get = request.method == METHOD.get;
248 let is_head = request.method == METHOD.head;
249 let is_options = request.method == METHOD.options;
250 boxed_file.is_ok() && (is_get || is_head || is_options && request.request_uri != SYMBOL.slash)
251 }
252
253 pub fn process_request(request: &Request, mut response: Response) -> Response {
254 let config = ServerConfig::current();
255 if let Some(streamed) = StaticResourceController::serve_regular_file(request, &mut response, &config) {
256 return streamed;
257 }
258 let boxed_content_range_list = StaticResourceController::process_static_resources(&request);
259 if boxed_content_range_list.is_ok() {
260 let content_range_list = boxed_content_range_list.unwrap();
261
262 if content_range_list.len() != 0 {
263
264 let mut status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok;
265
266 let does_request_include_range_header = request.header(Header::_RANGE).is_some();
267 if does_request_include_range_header {
268 status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n206_partial_content;
269 }
270
271 let is_options_request = request.method == METHOD.options;
272 if is_options_request {
273 status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n204_no_content;
274 }
275
276
277 let static_filepath = static_fs_path(&request.request_uri).unwrap_or_default();
278 let boxed_modified_date_time = file_modified_utc(&static_filepath);
279
280 if boxed_modified_date_time.is_ok() {
281 let modified_date_time = boxed_modified_date_time.unwrap();
282 let file_size = metadata(&static_filepath).map(|m| m.len()).unwrap_or(0);
283 let etag_value = format!("\"{}-{}\"", modified_date_time, file_size);
284 set_static_cache_control(&mut response, &config.static_cache_control);
285 if push_validators(request, &mut response, modified_date_time, &etag_value, config.nanos_headers) {
286 response.status_code = *STATUS_CODE_REASON_PHRASE.n304_not_modified.status_code;
287 response.reason_phrase = STATUS_CODE_REASON_PHRASE.n304_not_modified.reason_phrase.to_string();
288 response.headers.push(Header { name: Header::_ETAG.into(), value: etag_value.into() });
289 return response;
290 }
291
292 response.headers.push(Header { name: Header::_ETAG.into(), value: etag_value.into() });
293
294 let is_range_request = request.header(Header::_RANGE).is_some();
295 if file_size > StaticResourceController::STREAM_THRESHOLD && !is_range_request {
296 let mime = MimeType::detect_mime_type(&static_filepath);
297 response.headers.push(Header {
298 name: Header::_CONTENT_TYPE.into(),
299 value: mime.into(),
300 });
301 response.headers.push(Header {
302 name: Header::_CONTENT_LENGTH.into(),
303 value: file_size.to_string().into(),
304 });
305 response.status_code = *status_code_reason_phrase.status_code;
306 response.reason_phrase = status_code_reason_phrase.reason_phrase.to_string();
307 response.stream_file = Some(static_filepath);
308 return response;
309 }
310 }
311
312 response.status_code = *status_code_reason_phrase.status_code;
313 response.reason_phrase = status_code_reason_phrase.reason_phrase.to_string();
314 response.content_range_list = content_range_list;
315
316 }
317 } else {
318 let error : Error = boxed_content_range_list.err().unwrap();
319 let body = error.message;
320
321 let content_range = Range::get_content_range(
322 Vec::from(body.as_bytes()),
323 MimeType::TEXT_HTML.to_string()
324 );
325
326 let content_range_list = vec![content_range];
327
328 response.status_code = *error.status_code_reason_phrase.status_code;
329 response.reason_phrase = error.status_code_reason_phrase.reason_phrase.to_string();
330 response.content_range_list = content_range_list;
331
332 }
333
334
335 response
336 }
337
338 pub const STREAM_THRESHOLD: u64 = 256 * 1024;
342
343 pub(crate) fn serve_regular_file(request: &Request, response: &mut Response, config: &ServerConfig) -> Option<Response> {
353 if request.method != METHOD.get && request.method != METHOD.head {
354 return None;
355 }
356 if request.header(Header::_RANGE).is_some() {
357 return None;
358 }
359 let path = URL::request_target_path(&request.request_uri)?;
360 let fs_path = static_fs_path(&os_specific_path(path)).ok()?;
361 let mime = MimeType::mime_type_for(&fs_path);
365 let follow_symlinks = mime != MimeType::APPLICATION_OCTET_STREAM;
366 let file = open_file_cache::open(&fs_path, follow_symlinks)?;
367 let state = file.state()?;
368
369 let modified = Decimal::new(state.modified);
372 let len = Decimal::new(state.len as u128);
373 let mut etag_value = String::with_capacity(modified.as_str().len() + len.as_str().len() + 3);
374 etag_value.push('"');
375 etag_value.push_str(modified.as_str());
376 etag_value.push('-');
377 etag_value.push_str(len.as_str());
378 etag_value.push('"');
379 let not_modified = is_not_modified(request, state.modified, &etag_value);
380 let wants_gzip = !not_modified
384 && state.len <= open_file_cache::MAX_GZIPPED_FILE
385 && state.len >= crate::compression::MIN_COMPRESS_SIZE as u64
386 && crate::compression::is_compressible(mime)
387 && crate::compression::accepts_gzip(request);
388 let mut gzipped = false;
395 let large = state.len > StaticResourceController::STREAM_THRESHOLD;
396 let body = if not_modified {
397 None
398 } else if wants_gzip {
399 match file.gzipped(&state).ok()? {
400 open_file_cache::Gzipped::Smaller(bytes) => {
401 gzipped = true;
402 Some(bytes.to_vec())
403 }
404 open_file_cache::Gzipped::NotSmaller if large => None,
405 open_file_cache::Gzipped::NotSmaller => Some(file.contents(&state).ok()?),
406 }
407 } else if large {
408 None
409 } else {
410 Some(file.contents(&state).ok()?)
411 };
412
413 let mut response = std::mem::replace(response, Response::empty());
414 set_static_cache_control(&mut response, &config.static_cache_control);
415 if config.nanos_headers {
416 response.headers.push(Header { name: Header::_LAST_MODIFIED_UNIX_EPOCH_NANOS.into(), value: modified.as_str().to_string().into() });
417 }
418 response.headers.push(Header { name: Header::_LAST_MODIFIED.into(), value: crate::http::date::format((state.modified / 1_000_000_000) as u64).into() });
419 response.headers.push(Header { name: Header::_ETAG.into(), value: etag_value.into() });
420 if not_modified {
421 response.set_status(STATUS_CODE_REASON_PHRASE.n304_not_modified);
422 return Some(response);
423 }
424 response.set_status(STATUS_CODE_REASON_PHRASE.n200_ok);
425
426 let Some(body) = body else {
427 response.headers.push(Header { name: Header::_CONTENT_TYPE.into(), value: mime.to_string().into() });
428 response.headers.push(Header { name: Header::_CONTENT_LENGTH.into(), value: len.as_str().to_string().into() });
429 response.stream_file = Some(fs_path);
430 return Some(response);
431 };
432
433 response.content_range_list = vec![Range::get_content_range(body, mime.to_string())];
434 if gzipped {
435 crate::compression::mark_gzipped(&mut response);
436 }
437 Some(response)
438 }
439
440 pub fn process_static_resources(request: &Request) -> Result<Vec<ContentRange>, Error> {
441
442 let url = request.request_uri.to_string();
443
444 let boxed_url_components = URL::parse_request_target(&url);
445 if boxed_url_components.is_err() {
446 let message = boxed_url_components.as_ref().err().unwrap().to_string();
447 println!("unexpected error, {}", message);
449 }
450
451 let components = boxed_url_components.unwrap();
452
453 let os_specific_separator : String = FileExt::get_path_separator();
454 let os_specific_path = &components.path.replace(SYMBOL.slash, os_specific_separator.as_str());
455
456 let static_filepath = match static_fs_path(&os_specific_path) {
457 Ok(path) => path,
458 Err(message) => {
459 return Err(Error { status_code_reason_phrase: STATUS_CODE_REASON_PHRASE.n404_not_found, message });
460 }
461 };
462
463 let mut content_range_list = Vec::new();
464
465
466 let mut boxed_md = metadata(&static_filepath);
467 if boxed_md.is_err() {
468 let dot_html = format!("{}{}", &static_filepath, ".html");
469 boxed_md = metadata(&dot_html);
470
471 if boxed_md.is_err() {
472 let slash_index_html = format!("{}{}{}", &static_filepath, os_specific_separator, "index.html");
473 boxed_md = metadata(&slash_index_html);
474 }
475 }
476 if boxed_md.is_ok() {
477 let md = boxed_md.unwrap();
478
479 if md.is_dir() {
480 let mut directory_index : String = "index.html".to_string();
481
482 let last_char = components.path.chars().last().unwrap();
483 if last_char != '/' {
484 let index : String = "index.html".to_string();
485 directory_index = format!("{}{}", os_specific_separator, index);
486 }
487 let index_html_in_directory = format!("{}{}", os_specific_path, directory_index);
488 let index_html_fs_path = format!("{}{}", static_filepath, directory_index);
489
490 if File::open(&index_html_fs_path).is_ok() {
491 let mut range_header = &Header {
492 name: Header::_RANGE.into(),
493 value: "bytes=0-".into()
494 };
495
496 let boxed_header = request.header(Header::_RANGE);
497 if boxed_header.is_some() {
498 range_header = boxed_header.unwrap();
499 }
500
501 let boxed_content_range_list = Range::get_content_range_list(&index_html_in_directory, range_header);
502 if boxed_content_range_list.is_ok() {
503 content_range_list = boxed_content_range_list.unwrap();
504 } else {
505 let error = boxed_content_range_list.err().unwrap();
506 return Err(error)
507 }
508 } else {
509 let listing_html = StaticResourceController::render_directory_listing(&static_filepath, &components.path);
510 let content_range = Range::get_content_range(listing_html.into_bytes(), MimeType::TEXT_HTML.to_string());
511 content_range_list = vec![content_range];
512 }
513
514 return Ok(content_range_list);
515 }
516
517 let boxed_file = File::open(&static_filepath);
518 if boxed_file.is_ok() {
519 let md = metadata(&static_filepath).unwrap();
520 if md.is_dir() {
521 let mut range_header = &Header {
522 name: Header::_RANGE.into(),
523 value: "bytes=0-".into()
524 };
525
526 let boxed_header = request.header(Header::_RANGE);
527 if boxed_header.is_some() {
528 range_header = boxed_header.unwrap();
529 }
530
531 let mut directory_index : String = "index.html".to_string();
532
533 let last_char = components.path.chars().last().unwrap();
534 if last_char != '/' {
535 let index : String = "index.html".to_string();
536 directory_index = format!("{}{}", os_specific_separator, index);
537 }
538 let index_html_in_directory = format!("{}{}", os_specific_path, directory_index);
539
540
541 let boxed_content_range_list = Range::get_content_range_list(&index_html_in_directory, range_header);
542 if boxed_content_range_list.is_ok() {
543 content_range_list = boxed_content_range_list.unwrap();
544 } else {
545 let error = boxed_content_range_list.err().unwrap();
546 return Err(error)
547 }
548 }
549
550 if md.is_file() {
551 let mut range_header = &Header {
552 name: Header::_RANGE.into(),
553 value: "bytes=0-".into()
554 };
555
556 let boxed_header = request.header(Header::_RANGE);
557 if boxed_header.is_some() {
558 range_header = boxed_header.unwrap();
559 }
560
561 let boxed_content_range_list = Range::get_content_range_list(&request.request_uri, range_header);
562 if boxed_content_range_list.is_ok() {
563 content_range_list = boxed_content_range_list.unwrap();
564 } else {
565 let error = boxed_content_range_list.err().unwrap();
566 return Err(error)
567 }
568 }
569 }
570
571
572 if boxed_file.is_err() {
573 let static_filepath = static_fs_path(&format!("{}.html", components.path)).unwrap_or_default();
576
577 let boxed_file = File::open(&static_filepath);
578 if boxed_file.is_ok() {
579 let md = metadata(&static_filepath).unwrap();
580 if md.is_file() {
581 let mut range_header = &Header {
582 name: Header::_RANGE.into(),
583 value: "bytes=0-".into()
584 };
585
586 let boxed_header = request.header(Header::_RANGE);
587 if boxed_header.is_some() {
588 range_header = boxed_header.unwrap();
589 }
590
591 let url = request.request_uri.to_string();
592
593 let boxed_url_components = URL::parse_request_target(&url);
594 if boxed_url_components.is_err() {
595 let message = boxed_url_components.as_ref().err().unwrap().to_string();
596 println!("unexpected error, {}", message);
598 }
599
600 let components = boxed_url_components.unwrap();
601
602 let html_file = [components.path.as_str(), ".html"].join(SYMBOL.empty_string);
606 let boxed_content_range_list = Range::get_content_range_list(html_file.as_str(), range_header);
607 if boxed_content_range_list.is_ok() {
608 content_range_list = boxed_content_range_list.unwrap();
609 } else {
610 let error = boxed_content_range_list.err().unwrap();
611 return Err(error)
612 }
613 }
614 }
615 }
616 }
617
618 if content_range_list.is_empty() {
622 if let Some(fallback_url_path) = spa_fallback_url_path(&components.path) {
623 let mut range_header = &Header {
624 name: Header::_RANGE.into(),
625 value: "bytes=0-".into()
626 };
627
628 let boxed_header = request.header(Header::_RANGE);
629 if boxed_header.is_some() {
630 range_header = boxed_header.unwrap();
631 }
632
633 if let Ok(list) = Range::get_content_range_list(&fallback_url_path, range_header) {
634 content_range_list = list;
635 }
636 }
637 }
638
639 Ok(content_range_list)
640 }
641}
642
643impl StaticResourceController {
647 pub fn render_directory_listing(fs_dir_path: &str, request_path: &str) -> String {
652 let normalized_request_path = if request_path.ends_with('/') {
653 request_path.to_string()
654 } else {
655 format!("{}/", request_path)
656 };
657
658 struct Entry {
659 name: String,
660 is_dir: bool,
661 size: u64,
662 modified_epoch_secs: u64,
663 }
664
665 let mut entries: Vec<Entry> = Vec::new();
666 if let Ok(dir_entries) = read_dir(fs_dir_path) {
667 for dir_entry in dir_entries.flatten() {
668 let name = dir_entry.file_name().to_string_lossy().to_string();
669 if name.starts_with('.') {
670 continue;
671 }
672
673 if let Ok(md) = dir_entry.metadata() {
674 let modified_epoch_secs = md.modified()
675 .ok()
676 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
677 .map(|d| d.as_secs())
678 .unwrap_or(0);
679
680 entries.push(Entry {
681 name,
682 is_dir: md.is_dir(),
683 size: md.len(),
684 modified_epoch_secs,
685 });
686 }
687 }
688 }
689
690 entries.sort_by(|a, b| {
691 match (a.is_dir, b.is_dir) {
692 (true, false) => std::cmp::Ordering::Less,
693 (false, true) => std::cmp::Ordering::Greater,
694 _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
695 }
696 });
697
698 let segments: Vec<&str> = normalized_request_path.split('/').filter(|s| !s.is_empty()).collect();
699
700 let mut breadcrumb_html = String::from("<a href=\"/\">~</a>");
701 let mut accumulated_path = String::new();
702 for segment in &segments {
703 accumulated_path.push('/');
704 accumulated_path.push_str(segment);
705 breadcrumb_html.push_str(&format!(
706 " <span class=\"sep\">/</span> <a href=\"{}/\">{}</a>",
707 accumulated_path, html_escape(segment)
708 ));
709 }
710
711 let parent_row = if !segments.is_empty() {
712 let parent_path = if segments.len() > 1 {
713 format!("/{}/", segments[..segments.len() - 1].join("/"))
714 } else {
715 "/".to_string()
716 };
717 format!(
718 "<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",
719 parent_path
720 )
721 } else {
722 String::new()
723 };
724
725 let entry_count = entries.len();
726
727 let mut rows = String::new();
728 for entry in &entries {
729 let escaped_name = html_escape(&entry.name);
730 let encoded_name = URL::percent_encode(&entry.name);
731 let href = if entry.is_dir {
732 format!("{}{}/", normalized_request_path, encoded_name)
733 } else {
734 format!("{}{}", normalized_request_path, encoded_name)
735 };
736 let size_display = if entry.is_dir { "\u{2014}".to_string() } else { human_size(entry.size) };
737 let modified_display = format_modified(entry.modified_epoch_secs);
738 let icon = icon_for(&entry.name, entry.is_dir);
739 let filter_key = html_escape(&entry.name.to_lowercase());
740
741 rows.push_str(&format!(
742 "<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",
743 filter_key, icon, href, escaped_name, if entry.is_dir { "/" } else { "" }, size_display, modified_display
744 ));
745 }
746
747 let mut html = String::new();
748 html.push_str(DIRECTORY_LISTING_HEAD);
749 html.push_str(&format!("<div class=\"breadcrumb\">{}</div>\n", breadcrumb_html));
750 html.push_str(&format!("<h1>Index of {}</h1>\n", html_escape(&normalized_request_path)));
751 html.push_str(DIRECTORY_LISTING_TOOLBAR);
752 html.push_str("<div class=\"card\"><table><thead><tr><th>Name</th><th>Size</th><th>Modified</th></tr></thead><tbody id=\"rows\">\n");
753 html.push_str(&parent_row);
754 html.push_str(&rows);
755 html.push_str("</tbody></table></div>\n");
756 html.push_str(&format!(
757 "<footer>{} item{} · served by rws</footer>\n",
758 entry_count, if entry_count == 1 { "" } else { "s" }
759 ));
760 html.push_str(DIRECTORY_LISTING_TAIL);
761
762 html
763 }
764}
765
766fn path_has_extension(path: &str) -> bool {
773 path.rsplit('/').next().unwrap_or("").contains('.')
774}
775
776fn spa_fallback_url_path(url_path: &str) -> Option<String> {
785 if path_has_extension(url_path) {
786 return None;
787 }
788
789 let fallback_name = crate::entry_point::get_spa_fallback()?;
790
791 let exclude_prefixes = crate::entry_point::get_spa_fallback_exclude_prefixes();
792 if exclude_prefixes.iter().any(|prefix| url_path.starts_with(prefix.as_str())) {
793 return None;
794 }
795
796 let separator = FileExt::get_path_separator();
797 let normalized_name = fallback_name.trim_start_matches('/').replace('/', &separator);
798 let fallback_url_path = format!("{}{}", separator, normalized_name);
799
800 let boxed_fs_path = static_fs_path(&fallback_url_path);
801 let fs_path = boxed_fs_path.ok()?;
802 if File::open(&fs_path).is_ok() {
803 Some(fallback_url_path)
804 } else {
805 None
806 }
807}
808
809fn html_escape(s: &str) -> String {
810 let mut out = String::with_capacity(s.len());
811 for c in s.chars() {
812 match c {
813 '&' => out.push_str("&"),
814 '<' => out.push_str("<"),
815 '>' => out.push_str(">"),
816 '"' => out.push_str("""),
817 '\'' => out.push_str("'"),
818 _ => out.push(c),
819 }
820 }
821 out
822}
823
824fn human_size(bytes: u64) -> String {
825 const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
826 if bytes < 1024 {
827 return format!("{} {}", bytes, UNITS[0]);
828 }
829
830 let mut size = bytes as f64;
831 let mut unit_index = 0;
832 while size >= 1024.0 && unit_index < UNITS.len() - 1 {
833 size /= 1024.0;
834 unit_index += 1;
835 }
836
837 format!("{:.1} {}", size, UNITS[unit_index])
838}
839
840fn format_modified(epoch_secs: u64) -> String {
841 let (sec, min, hour, day, month, _dow) = crate::scheduler::cron::epoch_to_datetime(epoch_secs);
842 let (year, _, _) = crate::scheduler::cron::days_to_ymd(epoch_secs / 86400);
843 format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02}", year, month, day, hour, min, sec)
844}
845
846fn icon_for(name: &str, is_dir: bool) -> &'static str {
847 if is_dir {
848 return "\u{1F4C1}";
849 }
850
851 let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
852 match ext.as_str() {
853 "png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "ico" | "bmp" => "\u{1F5BC}\u{FE0F}",
854 "mp4" | "mov" | "avi" | "mkv" | "webm" => "\u{1F3A5}",
855 "mp3" | "wav" | "flac" | "m4a" | "ogg" => "\u{1F3A7}",
856 "zip" | "tar" | "gz" | "rar" | "7z" | "bz2" => "\u{1F4E6}",
857 "pdf" => "\u{1F4D5}",
858 "html" | "htm" | "css" | "js" | "ts" | "rs" | "py" | "json" | "toml" | "yaml" | "yml" | "sh" => "\u{1F9E9}",
859 _ => "\u{1F4C4}",
860 }
861}
862
863const DIRECTORY_LISTING_HEAD: &str = concat!(
868 "<!DOCTYPE html>\n",
869 "<html lang=\"en\">\n",
870 "<head>\n",
871 "<meta charset=\"UTF-8\">\n",
872 "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n",
873 "<title>Directory listing</title>\n",
874 "<link rel=\"stylesheet\" href=\"/rws-directory-listing.css\">\n",
875 "</head>\n",
876 "<body>\n",
877 "<div class=\"wrap\">\n",
878);
879
880const DIRECTORY_LISTING_TOOLBAR: &str = "<div class=\"toolbar\"><input type=\"search\" id=\"filter\" placeholder=\"Filter entries...\" autocomplete=\"off\"></div>\n";
881
882const DIRECTORY_LISTING_TAIL: &str = concat!(
883 "</div>\n",
884 "<script src=\"/rws-directory-listing.js\" defer></script>\n",
885 "</body>\n",
886 "</html>\n",
887);
888
889fn push_validators(request: &Request, response: &mut Response, modified_nanos: u128, etag: &str, nanos_headers: bool) -> bool {
898 if nanos_headers {
899 response.headers.push(Header { name: Header::_LAST_MODIFIED_UNIX_EPOCH_NANOS.into(), value: modified_nanos.to_string().into() });
900 }
901 response.headers.push(Header { name: Header::_LAST_MODIFIED.into(), value: crate::http::date::format((modified_nanos / 1_000_000_000) as u64).into() });
902 is_not_modified(request, modified_nanos, etag)
903}
904
905fn is_not_modified(request: &Request, modified_nanos: u128, etag: &str) -> bool {
909 if let Some(inm) = request.header(Header::_IF_NONE_MATCH) {
910 return if_none_match_matches(&inm.value, etag);
911 }
912 if request.method != METHOD.get && request.method != METHOD.head {
913 return false;
914 }
915 request
916 .header(Header::_IF_MODIFIED_SINCE)
917 .and_then(|ims| crate::http::date::parse(&ims.value))
918 .is_some_and(|since| (modified_nanos / 1_000_000_000) as u64 <= since)
919}
920
921fn set_static_cache_control(response: &mut Response, value: &str) {
924 if value.is_empty() {
925 return;
926 }
927 if let Some(header) = response.headers.iter_mut().find(|h| h.name == Header::_CACHE_CONTROL) {
928 header.value = if value == crate::server_config::STATIC_CACHE_CONTROL_DEFAULT {
929 std::borrow::Cow::Borrowed(crate::server_config::STATIC_CACHE_CONTROL_DEFAULT)
930 } else {
931 std::borrow::Cow::Owned(value.to_string())
932 };
933 }
934}
935
936pub(crate) fn working_directory() -> &'static str {
937 static DIR: std::sync::OnceLock<String> = std::sync::OnceLock::new();
938 DIR.get_or_init(|| {
939 std::env::current_dir()
940 .map(|d| d.to_string_lossy().into_owned())
941 .unwrap_or_default()
942 })
943}
944
945pub(crate) fn if_none_match_matches(header_value: &str, etag: &str) -> bool {
950 let opaque = |tag: &str| tag.trim().strip_prefix("W/").unwrap_or(tag.trim()).to_string();
951 let current = opaque(etag);
952 header_value.split(',').any(|candidate| {
953 let candidate = candidate.trim();
954 candidate == "*" || (!candidate.is_empty() && opaque(candidate) == current)
955 })
956}
957
958pub(crate) fn range_is_stale(request: &Request) -> bool {
966 let Some(if_range) = request.header(Header::_IF_RANGE) else { return false };
967 if request.header(Header::_RANGE).is_none() {
968 return false;
969 }
970 let validator = if_range.value.trim();
971 let Some(path) = URL::request_target_path(&request.request_uri) else { return true };
972 let Ok(fs_path) = static_fs_path(&os_specific_path(path)) else { return true };
973 let (Ok(modified), Ok(md)) = (file_modified_utc(&fs_path), metadata(&fs_path)) else { return true };
974 if validator.starts_with('"') {
975 return validator != format!("\"{}-{}\"", modified, md.len());
976 }
977 let modified_secs = (modified / 1_000_000_000) as u64;
978 let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
979 match crate::http::date::parse(validator) {
980 Some(date) => date != modified_secs || now < modified_secs + 1,
981 None => true,
982 }
983}
984
985pub(crate) fn static_fs_path(path: &str) -> Result<String, String> {
1000 let path = percent_decode_path(path)?;
1001 check_request_path(&path)?;
1002 let working_directory = working_directory();
1003 let mut fs_path = String::with_capacity(working_directory.len() + path.len());
1004 fs_path.push_str(working_directory);
1005 fs_path.push_str(&path);
1006 Ok(fs_path)
1007}
1008
1009pub(crate) fn os_specific_path(path: &str) -> std::borrow::Cow<'_, str> {
1012 if std::path::MAIN_SEPARATOR == '/' {
1013 std::borrow::Cow::Borrowed(path)
1014 } else {
1015 std::borrow::Cow::Owned(path.replace('/', std::path::MAIN_SEPARATOR_STR))
1016 }
1017}
1018
1019pub(crate) fn percent_decode_path(path: &str) -> Result<std::borrow::Cow<'_, str>, String> {
1024 if !path.contains('%') {
1025 return Ok(std::borrow::Cow::Borrowed(path));
1026 }
1027 let hex = |b: u8| (b as char).to_digit(16).map(|d| d as u8);
1028 let bytes = path.as_bytes();
1029 let mut decoded = Vec::with_capacity(bytes.len());
1030 let mut i = 0;
1031 while i < bytes.len() {
1032 if bytes[i] == b'%' && i + 2 < bytes.len() {
1033 if let (Some(high), Some(low)) = (hex(bytes[i + 1]), hex(bytes[i + 2])) {
1034 decoded.push(high << 4 | low);
1035 i += 3;
1036 continue;
1037 }
1038 }
1039 decoded.push(bytes[i]);
1040 i += 1;
1041 }
1042 String::from_utf8(decoded)
1043 .map(std::borrow::Cow::Owned)
1044 .map_err(|_| "path is not UTF-8 after percent-decoding".to_string())
1045}
1046
1047pub(crate) fn check_request_path(path: &str) -> Result<(), String> {
1050 for segment in path.split(['/', '\\']) {
1051 if segment == ".." {
1052 return Err("path leaves the web root".to_string());
1053 }
1054 if segment.starts_with('.') && segment != "." && segment != ".well-known" && !serve_dotfiles() {
1055 return Err("hidden files are not served".to_string());
1056 }
1057 if segment.contains('\0') {
1058 return Err("invalid path".to_string());
1059 }
1060 }
1061 let name = path.rsplit(['/', '\\']).next().unwrap_or("").to_ascii_lowercase();
1063 if name.ends_with(".pem") || name.ends_with(".key") || name == "rws.config.toml" {
1064 return Err("key and config files are not served".to_string());
1065 }
1066 Ok(())
1067}
1068
1069fn serve_dotfiles() -> bool {
1071 static SERVE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1072 *SERVE.get_or_init(|| {
1073 std::env::var("RWS_CONFIG_SERVE_DOTFILES").is_ok_and(|v| v.trim().eq_ignore_ascii_case("true"))
1074 })
1075}
1076
1077pub(crate) fn inside_root(fs_path: &str) -> bool {
1082 static ROOT: std::sync::OnceLock<Option<std::path::PathBuf>> = std::sync::OnceLock::new();
1083 let Some(root) = ROOT.get_or_init(|| std::fs::canonicalize(working_directory()).ok()) else {
1084 return false;
1085 };
1086 std::fs::canonicalize(fs_path).is_ok_and(|resolved| resolved.starts_with(root))
1087}
1088
1089pub(crate) fn file_modified_utc(path: &str) -> Result<u128, String> {
1092 let modified = metadata(path)
1093 .and_then(|md| md.modified())
1094 .map_err(|e| format!("<p>Unable to open file: {}</p> <p>error: {}</p>", path, e))?;
1095 Ok(modified
1096 .duration_since(std::time::UNIX_EPOCH)
1097 .map(|d| d.as_nanos())
1098 .unwrap_or(0))
1099}
1100