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;
18mod etag;
19pub(crate) mod dictionaries;
20pub(crate) mod open_file_cache;
21
22pub struct StaticResourceController;
23
24impl Controller for StaticResourceController {
25 fn is_matching(request: &Request, _connection: &ConnectionInfo) -> bool {
26 let is_get = request.method == METHOD.get;
29 let is_head = request.method == METHOD.head;
30 let is_options = request.method == METHOD.options;
31
32 let is_matching_method = (is_get || is_head || is_options) && (request.request_uri != SYMBOL.slash);
33 if !is_matching_method {
34 return false;
35 }
36
37 let path = match URL::request_target_path(&request.request_uri) {
40 Some(path) => path,
41 None => return false,
42 };
43
44 let boxed_static_filepath = static_fs_path(&os_specific_path(path));
45 if boxed_static_filepath.is_err() {
46 return false
47 }
48
49 let static_filepath = boxed_static_filepath.unwrap();
50
51 let mut is_directory = false;
54
55 let mut is_file = open_file_cache::is_cached_file(&static_filepath);
58 if !is_file {
59 if let Ok(md) = metadata(&static_filepath) {
60 if !inside_root(&static_filepath) {
62 return false;
63 }
64 is_directory = md.is_dir();
65 is_file = md.is_file();
66 }
67 }
68
69 if is_file || is_directory {
70 true
71 } else {
72 if !static_filepath.ends_with(".html") {
74 let html_suffix = ".html";
75 let mut html_file = os_specific_path(path).into_owned();
76 html_file.push_str(html_suffix);
77 if let Ok(html_static_filepath) = static_fs_path(&html_file) {
78 if File::open(&html_static_filepath).is_ok() {
79 return true;
80 }
81 }
82 }
83
84 spa_fallback_url_path(path).is_some()
89 }
90
91 }
92
93 fn process(request: &Request, mut response: Response, _connection: &ConnectionInfo) -> Response {
94 let config = ServerConfig::current();
95 if let Some(served) = StaticResourceController::serve_regular_file(request, &mut response, &config) {
96 return served;
97 }
98 StaticResourceController::process_general(request, response, &config)
99 }
100}
101
102impl StaticResourceController {
103 #[doc(hidden)]
106 pub fn is_directory_without_index(request: &Request) -> bool {
107 let Some(path) = URL::request_target_path(&request.request_uri) else { return false };
108 let Ok(fs_path) = static_fs_path(&os_specific_path(path)) else { return false };
109 let dir = std::path::Path::new(&fs_path);
110 dir.is_dir() && !dir.join("index.html").is_file()
111 }
112
113 #[doc(hidden)]
117 pub fn process_general(request: &Request, mut response: Response, config: &ServerConfig) -> Response {
118 let without_stale_range;
122 let request = if range_is_stale(request) {
123 let mut copy = request.clone();
124 copy.headers.retain(|h| !h.name.eq_ignore_ascii_case(Header::_RANGE));
125 without_stale_range = copy;
126 &without_stale_range
127 } else {
128 request
129 };
130 let boxed_content_range_list = StaticResourceController::process_static_resources(&request);
131 if boxed_content_range_list.is_ok() {
132 let content_range_list = boxed_content_range_list.unwrap();
133
134 if content_range_list.len() != 0 {
135
136 let mut status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok;
137
138 let does_request_include_range_header = request.header(Header::_RANGE).is_some();
139 if does_request_include_range_header {
140 status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n206_partial_content;
141 }
142
143 let is_options_request = request.method == METHOD.options;
144 if is_options_request {
145 status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n204_no_content;
146 }
147
148
149
150 let url = request.request_uri.to_string();
151
152 let boxed_url_components = URL::parse_request_target(&url);
153 if boxed_url_components.is_err() {
154 let message = boxed_url_components.as_ref().err().unwrap().to_string();
155 println!("unexpected error, {}", message);
157 }
158
159 let components = boxed_url_components.unwrap();
160
161 let static_filepath = static_fs_path(&components.path).unwrap_or_default();
164 let boxed_modified_date_time = file_modified_utc(&static_filepath);
165
166 if boxed_modified_date_time.is_ok() {
167 let modified_date_time = boxed_modified_date_time.unwrap();
168 let file_size = metadata(&static_filepath).map(|m| m.len()).unwrap_or(0);
169 let etag_value = format!("\"{}-{}\"", etag::tag(&static_filepath, file_size, modified_date_time), file_size);
170 set_static_cache_control(&mut response, &config.static_cache_control);
171 if push_validators(request, &mut response, modified_date_time, &etag_value, config.nanos_headers) {
172 response.status_code = *STATUS_CODE_REASON_PHRASE.n304_not_modified.status_code;
173 response.reason_phrase = STATUS_CODE_REASON_PHRASE.n304_not_modified.reason_phrase.to_string();
174 response.headers.push(Header { name: Header::_ETAG.into(), value: etag_value.into() });
175 return response;
176 }
177
178 response.headers.push(Header { name: Header::_ETAG.into(), value: etag_value.into() });
179
180 let is_range_request = request.header(Header::_RANGE).is_some();
183 if file_size > StaticResourceController::STREAM_THRESHOLD && !is_range_request {
184 let mime = MimeType::detect_mime_type(&static_filepath);
185 response.headers.push(Header {
186 name: Header::_CONTENT_TYPE.into(),
187 value: mime.into(),
188 });
189 response.headers.push(Header {
190 name: Header::_CONTENT_LENGTH.into(),
191 value: file_size.to_string().into(),
192 });
193 response.status_code = *status_code_reason_phrase.status_code;
194 response.reason_phrase = status_code_reason_phrase.reason_phrase.to_string();
195 response.stream_file = Some(static_filepath);
196 return response;
197 }
198 }
199
200 response.status_code = *status_code_reason_phrase.status_code;
201 response.reason_phrase = status_code_reason_phrase.reason_phrase.to_string();
202 response.content_range_list = content_range_list;
203
204 }
205 } else {
206 let error : Error = boxed_content_range_list.err().unwrap();
207 let body = error.message;
208
209 let content_range = Range::get_content_range(
210 Vec::from(body.as_bytes()),
211 MimeType::TEXT_HTML.to_string()
212 );
213
214 let content_range_list = vec![content_range];
215
216 response.status_code = *error.status_code_reason_phrase.status_code;
217 response.reason_phrase = error.status_code_reason_phrase.reason_phrase.to_string();
218 response.content_range_list = content_range_list;
219
220 if response.status_code == 416 {
222 let length = URL::parse_request_target(&request.request_uri).ok()
223 .and_then(|c| static_fs_path(&c.path).ok())
224 .and_then(|p| metadata(p).ok())
225 .map(|md| md.len());
226 if let Some(length) = length {
227 response.headers.push(Header { name: Header::_CONTENT_RANGE.into(), value: format!("bytes */{}", length).into() });
228 }
229 }
230 }
231
232
233 response
234 }
235}
236
237impl StaticResourceController {
239
240 pub fn is_matching_request(request: &Request) -> bool {
241 let boxed_static_filepath = static_fs_path(&request.request_uri);
242 if boxed_static_filepath.is_err() {
243 return false
244 }
245
246 let static_filepath = boxed_static_filepath.unwrap();
247
248 let boxed_md = metadata(&static_filepath);
249 if boxed_md.is_err() || !inside_root(&static_filepath) {
250 return false
251 }
252
253 let md = boxed_md.unwrap();
254 if md.is_dir() {
255 return false
256 }
257
258 let boxed_file = File::open(&static_filepath);
259
260 let is_get = request.method == METHOD.get;
261 let is_head = request.method == METHOD.head;
262 let is_options = request.method == METHOD.options;
263 boxed_file.is_ok() && (is_get || is_head || is_options && request.request_uri != SYMBOL.slash)
264 }
265
266 pub fn process_request(request: &Request, mut response: Response) -> Response {
267 let config = ServerConfig::current();
268 if let Some(streamed) = StaticResourceController::serve_regular_file(request, &mut response, &config) {
269 return streamed;
270 }
271 let boxed_content_range_list = StaticResourceController::process_static_resources(&request);
272 if boxed_content_range_list.is_ok() {
273 let content_range_list = boxed_content_range_list.unwrap();
274
275 if content_range_list.len() != 0 {
276
277 let mut status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok;
278
279 let does_request_include_range_header = request.header(Header::_RANGE).is_some();
280 if does_request_include_range_header {
281 status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n206_partial_content;
282 }
283
284 let is_options_request = request.method == METHOD.options;
285 if is_options_request {
286 status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n204_no_content;
287 }
288
289
290 let static_filepath = static_fs_path(&request.request_uri).unwrap_or_default();
291 let boxed_modified_date_time = file_modified_utc(&static_filepath);
292
293 if boxed_modified_date_time.is_ok() {
294 let modified_date_time = boxed_modified_date_time.unwrap();
295 let file_size = metadata(&static_filepath).map(|m| m.len()).unwrap_or(0);
296 let etag_value = format!("\"{}-{}\"", etag::tag(&static_filepath, file_size, modified_date_time), file_size);
297 set_static_cache_control(&mut response, &config.static_cache_control);
298 if push_validators(request, &mut response, modified_date_time, &etag_value, config.nanos_headers) {
299 response.status_code = *STATUS_CODE_REASON_PHRASE.n304_not_modified.status_code;
300 response.reason_phrase = STATUS_CODE_REASON_PHRASE.n304_not_modified.reason_phrase.to_string();
301 response.headers.push(Header { name: Header::_ETAG.into(), value: etag_value.into() });
302 return response;
303 }
304
305 response.headers.push(Header { name: Header::_ETAG.into(), value: etag_value.into() });
306
307 let is_range_request = request.header(Header::_RANGE).is_some();
308 if file_size > StaticResourceController::STREAM_THRESHOLD && !is_range_request {
309 let mime = MimeType::detect_mime_type(&static_filepath);
310 response.headers.push(Header {
311 name: Header::_CONTENT_TYPE.into(),
312 value: mime.into(),
313 });
314 response.headers.push(Header {
315 name: Header::_CONTENT_LENGTH.into(),
316 value: file_size.to_string().into(),
317 });
318 response.status_code = *status_code_reason_phrase.status_code;
319 response.reason_phrase = status_code_reason_phrase.reason_phrase.to_string();
320 response.stream_file = Some(static_filepath);
321 return response;
322 }
323 }
324
325 response.status_code = *status_code_reason_phrase.status_code;
326 response.reason_phrase = status_code_reason_phrase.reason_phrase.to_string();
327 response.content_range_list = content_range_list;
328
329 }
330 } else {
331 let error : Error = boxed_content_range_list.err().unwrap();
332 let body = error.message;
333
334 let content_range = Range::get_content_range(
335 Vec::from(body.as_bytes()),
336 MimeType::TEXT_HTML.to_string()
337 );
338
339 let content_range_list = vec![content_range];
340
341 response.status_code = *error.status_code_reason_phrase.status_code;
342 response.reason_phrase = error.status_code_reason_phrase.reason_phrase.to_string();
343 response.content_range_list = content_range_list;
344
345 }
346
347
348 response
349 }
350
351 pub const STREAM_THRESHOLD: u64 = 256 * 1024;
355
356 #[doc(hidden)]
366 pub fn serve_regular_file(request: &Request, response: &mut Response, config: &ServerConfig) -> Option<Response> {
367 if request.method != METHOD.get && request.method != METHOD.head {
368 return None;
369 }
370 if request.header(Header::_RANGE).is_some() {
371 return None;
372 }
373 let path = URL::request_target_path(&request.request_uri)?;
374 let fs_path = static_fs_path(&os_specific_path(path)).ok()?;
375 let mime = MimeType::mime_type_for(&fs_path);
379 let follow_symlinks = mime != MimeType::APPLICATION_OCTET_STREAM;
380 let file = open_file_cache::open(&fs_path, follow_symlinks)?;
381 let state = file.state()?;
382
383 let encoding = if state.len <= open_file_cache::MAX_GZIPPED_FILE
388 && state.len >= crate::compression::MIN_COMPRESS_SIZE as u64
389 && crate::compression::is_compressible(mime)
390 {
391 crate::compression::negotiate(request, crate::compression::STATIC_PREFERENCE)
392 } else {
393 None
394 };
395 let dictionary_pattern = if config.compression_dictionaries.is_empty()
399 || state.len > crate::compression::dictionary::MAX_SIZE as u64
400 || !crate::compression::is_compressible(mime)
401 {
402 None
403 } else {
404 dictionaries::matching_pattern(&config.compression_dictionaries, path)
405 };
406 let delta = match dictionary_pattern {
407 Some(_) => {
408 let _ = file.dictionary_hash(&state);
409 match (crate::compression::dictionary::available_dictionary(request), crate::compression::dictionary::negotiate(request)) {
410 (Some(held), Some(coding)) => match dictionaries::get(&held) {
411 Some(bytes) => file.delta(&state, coding, held, &bytes).ok()?.map(|delta| (coding, held, delta)),
412 None => None,
413 },
414 _ => None,
415 }
416 }
417 None => None,
418 };
419 let tag = etag::tag(&fs_path, state.len, state.modified);
424 let len = Decimal::new(state.len as u128);
425 let mut etag_value = String::with_capacity(tag.len() + len.as_str().len() + 8);
426 etag_value.push('"');
427 etag_value.push_str(&tag);
428 etag_value.push('-');
429 etag_value.push_str(len.as_str());
430 if let Some((coding, held, _)) = &delta {
431 etag_value.push('-');
433 etag_value.push_str(coding.token());
434 etag_value.push('-');
435 held[..4].iter().for_each(|b| etag_value.push_str(&format!("{b:02x}")));
436 } else if let Some(encoding) = encoding {
437 etag_value.push('-');
438 etag_value.push_str(encoding.token());
439 }
440 etag_value.push('"');
441 let not_modified = is_not_modified(request, state.modified, &etag_value);
442 let mut sent_encoded = None;
449 let mut sent_delta = None;
450 let large = state.len > StaticResourceController::STREAM_THRESHOLD;
451 let body = if not_modified {
452 None
453 } else if let Some((coding, _, bytes)) = &delta {
454 sent_delta = Some(*coding);
455 Some(bytes.to_vec())
456 } else if let Some(encoding) = encoding {
457 match file.encoded(&state, encoding, &fs_path).ok()? {
458 open_file_cache::Encoded::Smaller(bytes) => {
459 sent_encoded = Some(encoding);
460 Some(bytes.to_vec())
461 }
462 open_file_cache::Encoded::NotSmaller if large => None,
463 open_file_cache::Encoded::NotSmaller => Some(file.contents(&state).ok()?),
464 }
465 } else if large {
466 None
467 } else {
468 Some(file.contents(&state).ok()?)
469 };
470
471 let mut response = std::mem::replace(response, Response::empty());
472 set_static_cache_control(&mut response, &config.static_cache_control);
473 if config.nanos_headers {
474 response.headers.push(Header { name: Header::_LAST_MODIFIED_UNIX_EPOCH_NANOS.into(), value: state.modified.to_string().into() });
475 }
476 response.headers.push(Header { name: Header::_LAST_MODIFIED.into(), value: crate::http::date::format((state.modified / 1_000_000_000) as u64).into() });
477 response.headers.push(Header { name: Header::_ETAG.into(), value: etag_value.into() });
478 if not_modified {
479 response.set_status(STATUS_CODE_REASON_PHRASE.n304_not_modified);
480 mark_dictionary(&mut response, dictionary_pattern);
481 return Some(response);
482 }
483 response.set_status(STATUS_CODE_REASON_PHRASE.n200_ok);
484
485 let Some(body) = body else {
486 response.headers.push(Header { name: Header::_CONTENT_TYPE.into(), value: mime.to_string().into() });
487 response.headers.push(Header { name: Header::_CONTENT_LENGTH.into(), value: len.as_str().to_string().into() });
488 response.stream_file = Some(fs_path);
489 mark_dictionary(&mut response, dictionary_pattern);
490 return Some(response);
491 };
492
493 response.content_range_list = vec![Range::get_content_range(body, mime.to_string())];
494 if let Some(coding) = sent_delta {
495 crate::compression::dictionary::mark_encoded(&mut response, coding);
496 } else if let Some(encoding) = sent_encoded {
497 crate::compression::mark_encoded(&mut response, encoding);
498 }
499 mark_dictionary(&mut response, dictionary_pattern);
500 Some(response)
501 }
502
503 #[doc(hidden)]
509 pub fn trailing_punctuation_redirect(request: &Request) -> Option<Response> {
510 const TRAILING: &[char] = &[')', ':', '.', ',', ';', ']', '>', '\'', '"'];
511 if request.method != METHOD.get && request.method != METHOD.head {
512 return None;
513 }
514 let (raw_path, query) = match request.request_uri.split_once('?') {
515 Some((path, query)) => (path, Some(query)),
516 None => (request.request_uri.as_str(), None),
517 };
518 let decoded = percent_decode_path(raw_path).ok()?;
519 let trimmed = decoded.trim_end_matches(TRAILING);
520 if trimmed.len() == decoded.len() || trimmed.is_empty() || !trimmed.starts_with('/') || trimmed.ends_with("..") {
521 return None;
522 }
523 let fs_path = static_fs_path(&os_specific_path(trimmed)).ok()?;
524 if !std::path::Path::new(&fs_path).exists() {
525 return None;
526 }
527 let mut location = String::with_capacity(trimmed.len() + 16);
528 for c in trimmed.chars() {
529 if c.is_ascii_graphic() && !matches!(c, '%' | '"' | '<' | '>' | '\\' | '^' | '`' | '{' | '|' | '}') {
531 location.push(c);
532 } else {
533 let mut buf = [0u8; 4];
534 for b in c.encode_utf8(&mut buf).bytes() {
535 location.push_str(&format!("%{b:02X}"));
536 }
537 }
538 }
539 if let Some(query) = query {
540 location.push('?');
541 location.push_str(query);
542 }
543 let mut response = Response::get_response(STATUS_CODE_REASON_PHRASE.n301_moved_permanently, None, None);
544 response.headers.push(Header { name: Header::_LOCATION.into(), value: location.into() });
545 Some(response)
546 }
547
548 pub fn process_static_resources(request: &Request) -> Result<Vec<ContentRange>, Error> {
549
550 let url = request.request_uri.to_string();
551
552 let boxed_url_components = URL::parse_request_target(&url);
553 if boxed_url_components.is_err() {
554 let message = boxed_url_components.as_ref().err().unwrap().to_string();
555 println!("unexpected error, {}", message);
557 }
558
559 let components = boxed_url_components.unwrap();
560
561 let os_specific_separator : String = FileExt::get_path_separator();
562 let os_specific_path = &components.path.replace(SYMBOL.slash, os_specific_separator.as_str());
563
564 let static_filepath = match static_fs_path(&os_specific_path) {
565 Ok(path) => path,
566 Err(message) => {
567 return Err(Error { status_code_reason_phrase: STATUS_CODE_REASON_PHRASE.n404_not_found, message });
568 }
569 };
570
571 let mut content_range_list = Vec::new();
572
573
574 let mut boxed_md = metadata(&static_filepath);
575 if boxed_md.is_err() {
576 let dot_html = format!("{}{}", &static_filepath, ".html");
577 boxed_md = metadata(&dot_html);
578
579 if boxed_md.is_err() {
580 let slash_index_html = format!("{}{}{}", &static_filepath, os_specific_separator, "index.html");
581 boxed_md = metadata(&slash_index_html);
582 }
583 }
584 if boxed_md.is_ok() {
585 let md = boxed_md.unwrap();
586
587 if md.is_dir() {
588 let mut directory_index : String = "index.html".to_string();
589
590 let last_char = components.path.chars().last().unwrap();
591 if last_char != '/' {
592 let index : String = "index.html".to_string();
593 directory_index = format!("{}{}", os_specific_separator, index);
594 }
595 let index_html_in_directory = format!("{}{}", os_specific_path, directory_index);
596 let index_html_fs_path = format!("{}{}", static_filepath, directory_index);
597
598 if File::open(&index_html_fs_path).is_ok() {
599 let mut range_header = &Header {
600 name: Header::_RANGE.into(),
601 value: "bytes=0-".into()
602 };
603
604 let boxed_header = request.header(Header::_RANGE);
605 if boxed_header.is_some() {
606 range_header = boxed_header.unwrap();
607 }
608
609 let boxed_content_range_list = Range::get_content_range_list(&index_html_in_directory, range_header);
610 if boxed_content_range_list.is_ok() {
611 content_range_list = boxed_content_range_list.unwrap();
612 } else {
613 let error = boxed_content_range_list.err().unwrap();
614 return Err(error)
615 }
616 } else {
617 let listing_html = StaticResourceController::render_directory_listing(&static_filepath, &components.path);
618 let content_range = Range::get_content_range(listing_html.into_bytes(), MimeType::TEXT_HTML.to_string());
619 content_range_list = vec![content_range];
620 }
621
622 return Ok(content_range_list);
623 }
624
625 let boxed_file = File::open(&static_filepath);
626 if boxed_file.is_ok() {
627 let md = metadata(&static_filepath).unwrap();
628 if md.is_dir() {
629 let mut range_header = &Header {
630 name: Header::_RANGE.into(),
631 value: "bytes=0-".into()
632 };
633
634 let boxed_header = request.header(Header::_RANGE);
635 if boxed_header.is_some() {
636 range_header = boxed_header.unwrap();
637 }
638
639 let mut directory_index : String = "index.html".to_string();
640
641 let last_char = components.path.chars().last().unwrap();
642 if last_char != '/' {
643 let index : String = "index.html".to_string();
644 directory_index = format!("{}{}", os_specific_separator, index);
645 }
646 let index_html_in_directory = format!("{}{}", os_specific_path, directory_index);
647
648
649 let boxed_content_range_list = Range::get_content_range_list(&index_html_in_directory, range_header);
650 if boxed_content_range_list.is_ok() {
651 content_range_list = boxed_content_range_list.unwrap();
652 } else {
653 let error = boxed_content_range_list.err().unwrap();
654 return Err(error)
655 }
656 }
657
658 if md.is_file() {
659 let mut range_header = &Header {
660 name: Header::_RANGE.into(),
661 value: "bytes=0-".into()
662 };
663
664 let boxed_header = request.header(Header::_RANGE);
665 if boxed_header.is_some() {
666 range_header = boxed_header.unwrap();
667 }
668
669 let boxed_content_range_list = Range::get_content_range_list(&request.request_uri, range_header);
670 if boxed_content_range_list.is_ok() {
671 content_range_list = boxed_content_range_list.unwrap();
672 } else {
673 let error = boxed_content_range_list.err().unwrap();
674 return Err(error)
675 }
676 }
677 }
678
679
680 if boxed_file.is_err() {
681 let static_filepath = static_fs_path(&format!("{}.html", components.path)).unwrap_or_default();
684
685 let boxed_file = File::open(&static_filepath);
686 if boxed_file.is_ok() {
687 let md = metadata(&static_filepath).unwrap();
688 if md.is_file() {
689 let mut range_header = &Header {
690 name: Header::_RANGE.into(),
691 value: "bytes=0-".into()
692 };
693
694 let boxed_header = request.header(Header::_RANGE);
695 if boxed_header.is_some() {
696 range_header = boxed_header.unwrap();
697 }
698
699 let url = request.request_uri.to_string();
700
701 let boxed_url_components = URL::parse_request_target(&url);
702 if boxed_url_components.is_err() {
703 let message = boxed_url_components.as_ref().err().unwrap().to_string();
704 println!("unexpected error, {}", message);
706 }
707
708 let components = boxed_url_components.unwrap();
709
710 let html_file = [components.path.as_str(), ".html"].join(SYMBOL.empty_string);
714 let boxed_content_range_list = Range::get_content_range_list(html_file.as_str(), range_header);
715 if boxed_content_range_list.is_ok() {
716 content_range_list = boxed_content_range_list.unwrap();
717 } else {
718 let error = boxed_content_range_list.err().unwrap();
719 return Err(error)
720 }
721 }
722 }
723 }
724 }
725
726 if content_range_list.is_empty() {
730 if let Some(fallback_url_path) = spa_fallback_url_path(&components.path) {
731 let mut range_header = &Header {
732 name: Header::_RANGE.into(),
733 value: "bytes=0-".into()
734 };
735
736 let boxed_header = request.header(Header::_RANGE);
737 if boxed_header.is_some() {
738 range_header = boxed_header.unwrap();
739 }
740
741 if let Ok(list) = Range::get_content_range_list(&fallback_url_path, range_header) {
742 content_range_list = list;
743 }
744 }
745 }
746
747 Ok(content_range_list)
748 }
749}
750
751impl StaticResourceController {
755 pub fn render_directory_listing(fs_dir_path: &str, request_path: &str) -> String {
760 let normalized_request_path = if request_path.ends_with('/') {
761 request_path.to_string()
762 } else {
763 format!("{}/", request_path)
764 };
765
766 struct Entry {
767 name: String,
768 is_dir: bool,
769 size: u64,
770 modified_epoch_secs: u64,
771 }
772
773 let mut entries: Vec<Entry> = Vec::new();
774 if let Ok(dir_entries) = read_dir(fs_dir_path) {
775 for dir_entry in dir_entries.flatten() {
776 let name = dir_entry.file_name().to_string_lossy().to_string();
777 if name.starts_with('.') {
778 continue;
779 }
780
781 if let Ok(md) = dir_entry.metadata() {
782 let modified_epoch_secs = md.modified()
783 .ok()
784 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
785 .map(|d| d.as_secs())
786 .unwrap_or(0);
787
788 entries.push(Entry {
789 name,
790 is_dir: md.is_dir(),
791 size: md.len(),
792 modified_epoch_secs,
793 });
794 }
795 }
796 }
797
798 entries.sort_by(|a, b| {
799 match (a.is_dir, b.is_dir) {
800 (true, false) => std::cmp::Ordering::Less,
801 (false, true) => std::cmp::Ordering::Greater,
802 _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
803 }
804 });
805
806 let segments: Vec<&str> = normalized_request_path.split('/').filter(|s| !s.is_empty()).collect();
807
808 let mut breadcrumb_html = String::from("<a href=\"/\">~</a>");
809 let mut accumulated_path = String::new();
810 for segment in &segments {
811 accumulated_path.push('/');
812 accumulated_path.push_str(segment);
813 breadcrumb_html.push_str(&format!(
814 " <span class=\"sep\">/</span> <a href=\"{}/\">{}</a>",
815 accumulated_path, html_escape(segment)
816 ));
817 }
818
819 let parent_row = if !segments.is_empty() {
820 let parent_path = if segments.len() > 1 {
821 format!("/{}/", segments[..segments.len() - 1].join("/"))
822 } else {
823 "/".to_string()
824 };
825 format!(
826 "<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",
827 parent_path
828 )
829 } else {
830 String::new()
831 };
832
833 let entry_count = entries.len();
834
835 let mut rows = String::new();
836 for entry in &entries {
837 let escaped_name = html_escape(&entry.name);
838 let encoded_name = URL::percent_encode(&entry.name);
839 let href = if entry.is_dir {
840 format!("{}{}/", normalized_request_path, encoded_name)
841 } else {
842 format!("{}{}", normalized_request_path, encoded_name)
843 };
844 let size_display = if entry.is_dir { "\u{2014}".to_string() } else { human_size(entry.size) };
845 let modified_display = format_modified(entry.modified_epoch_secs);
846 let icon = icon_for(&entry.name, entry.is_dir);
847 let filter_key = html_escape(&entry.name.to_lowercase());
848
849 rows.push_str(&format!(
850 "<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",
851 filter_key, icon, href, escaped_name, if entry.is_dir { "/" } else { "" }, size_display, modified_display
852 ));
853 }
854
855 let mut html = String::new();
856 html.push_str(DIRECTORY_LISTING_HEAD);
857 html.push_str(&format!("<div class=\"breadcrumb\">{}</div>\n", breadcrumb_html));
858 html.push_str(&format!("<h1>Index of {}</h1>\n", html_escape(&normalized_request_path)));
859 html.push_str(DIRECTORY_LISTING_TOOLBAR);
860 html.push_str("<div class=\"card\"><table><thead><tr><th>Name</th><th>Size</th><th>Modified</th></tr></thead><tbody id=\"rows\">\n");
861 html.push_str(&parent_row);
862 html.push_str(&rows);
863 html.push_str("</tbody></table></div>\n");
864 html.push_str(&format!(
865 "<footer>{} item{} · served by rws</footer>\n",
866 entry_count, if entry_count == 1 { "" } else { "s" }
867 ));
868 html.push_str(DIRECTORY_LISTING_TAIL);
869
870 html
871 }
872}
873
874fn path_has_extension(path: &str) -> bool {
881 path.rsplit('/').next().unwrap_or("").contains('.')
882}
883
884fn spa_fallback_url_path(url_path: &str) -> Option<String> {
893 if path_has_extension(url_path) {
894 return None;
895 }
896
897 let fallback_name = crate::entry_point::get_spa_fallback()?;
898
899 let exclude_prefixes = crate::entry_point::get_spa_fallback_exclude_prefixes();
900 if exclude_prefixes.iter().any(|prefix| url_path.starts_with(prefix.as_str())) {
901 return None;
902 }
903
904 let separator = FileExt::get_path_separator();
905 let normalized_name = fallback_name.trim_start_matches('/').replace('/', &separator);
906 let fallback_url_path = format!("{}{}", separator, normalized_name);
907
908 let boxed_fs_path = static_fs_path(&fallback_url_path);
909 let fs_path = boxed_fs_path.ok()?;
910 if File::open(&fs_path).is_ok() {
911 Some(fallback_url_path)
912 } else {
913 None
914 }
915}
916
917fn html_escape(s: &str) -> String {
918 let mut out = String::with_capacity(s.len());
919 for c in s.chars() {
920 match c {
921 '&' => out.push_str("&"),
922 '<' => out.push_str("<"),
923 '>' => out.push_str(">"),
924 '"' => out.push_str("""),
925 '\'' => out.push_str("'"),
926 _ => out.push(c),
927 }
928 }
929 out
930}
931
932fn human_size(bytes: u64) -> String {
933 const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
934 if bytes < 1024 {
935 return format!("{} {}", bytes, UNITS[0]);
936 }
937
938 let mut size = bytes as f64;
939 let mut unit_index = 0;
940 while size >= 1024.0 && unit_index < UNITS.len() - 1 {
941 size /= 1024.0;
942 unit_index += 1;
943 }
944
945 format!("{:.1} {}", size, UNITS[unit_index])
946}
947
948fn format_modified(epoch_secs: u64) -> String {
949 let (sec, min, hour, day, month, _dow) = crate::scheduler::cron::epoch_to_datetime(epoch_secs);
950 let (year, _, _) = crate::http::date::days_to_ymd(epoch_secs / 86400);
951 format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02}", year, month, day, hour, min, sec)
952}
953
954fn icon_for(name: &str, is_dir: bool) -> &'static str {
955 if is_dir {
956 return "\u{1F4C1}";
957 }
958
959 let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
960 match ext.as_str() {
961 "png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "ico" | "bmp" => "\u{1F5BC}\u{FE0F}",
962 "mp4" | "mov" | "avi" | "mkv" | "webm" => "\u{1F3A5}",
963 "mp3" | "wav" | "flac" | "m4a" | "ogg" => "\u{1F3A7}",
964 "zip" | "tar" | "gz" | "rar" | "7z" | "bz2" => "\u{1F4E6}",
965 "pdf" => "\u{1F4D5}",
966 "html" | "htm" | "css" | "js" | "ts" | "rs" | "py" | "json" | "toml" | "yaml" | "yml" | "sh" => "\u{1F9E9}",
967 _ => "\u{1F4C4}",
968 }
969}
970
971const DIRECTORY_LISTING_HEAD: &str = concat!(
976 "<!DOCTYPE html>\n",
977 "<html lang=\"en\">\n",
978 "<head>\n",
979 "<meta charset=\"UTF-8\">\n",
980 "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n",
981 "<title>Directory listing</title>\n",
982 "<link rel=\"stylesheet\" href=\"/rws-directory-listing.css\">\n",
983 "</head>\n",
984 "<body>\n",
985 "<div class=\"wrap\">\n",
986);
987
988const DIRECTORY_LISTING_TOOLBAR: &str = "<div class=\"toolbar\"><input type=\"search\" id=\"filter\" placeholder=\"Filter entries...\" autocomplete=\"off\"></div>\n";
989
990const DIRECTORY_LISTING_TAIL: &str = concat!(
991 "</div>\n",
992 "<script src=\"/rws-directory-listing.js\" defer></script>\n",
993 "</body>\n",
994 "</html>\n",
995);
996
997fn push_validators(request: &Request, response: &mut Response, modified_nanos: u128, etag: &str, nanos_headers: bool) -> bool {
1006 if nanos_headers {
1007 response.headers.push(Header { name: Header::_LAST_MODIFIED_UNIX_EPOCH_NANOS.into(), value: modified_nanos.to_string().into() });
1008 }
1009 response.headers.push(Header { name: Header::_LAST_MODIFIED.into(), value: crate::http::date::format((modified_nanos / 1_000_000_000) as u64).into() });
1010 is_not_modified(request, modified_nanos, etag)
1011}
1012
1013fn is_not_modified(request: &Request, modified_nanos: u128, etag: &str) -> bool {
1017 if let Some(inm) = request.header(Header::_IF_NONE_MATCH) {
1018 return if_none_match_matches(&inm.value, etag);
1019 }
1020 if request.method != METHOD.get && request.method != METHOD.head {
1021 return false;
1022 }
1023 request
1024 .header(Header::_IF_MODIFIED_SINCE)
1025 .and_then(|ims| crate::http::date::parse(&ims.value))
1026 .is_some_and(|since| (modified_nanos / 1_000_000_000) as u64 <= since)
1027}
1028
1029fn set_static_cache_control(response: &mut Response, value: &str) {
1032 if value.is_empty() {
1033 return;
1034 }
1035 if let Some(header) = response.headers.iter_mut().find(|h| h.name == Header::_CACHE_CONTROL) {
1036 header.value = if value == crate::server_config::STATIC_CACHE_CONTROL_DEFAULT {
1037 std::borrow::Cow::Borrowed(crate::server_config::STATIC_CACHE_CONTROL_DEFAULT)
1038 } else {
1039 std::borrow::Cow::Owned(value.to_string())
1040 };
1041 }
1042}
1043
1044
1045#[doc(hidden)]
1050pub fn if_none_match_matches(header_value: &str, etag: &str) -> bool {
1051 let opaque = |tag: &str| tag.trim().strip_prefix("W/").unwrap_or(tag.trim()).to_string();
1052 let current = opaque(etag);
1053 header_value.split(',').any(|candidate| {
1054 let candidate = candidate.trim();
1055 candidate == "*" || (!candidate.is_empty() && opaque(candidate) == current)
1056 })
1057}
1058
1059#[doc(hidden)]
1067pub fn range_is_stale(request: &Request) -> bool {
1068 let Some(if_range) = request.header(Header::_IF_RANGE) else { return false };
1069 if request.header(Header::_RANGE).is_none() {
1070 return false;
1071 }
1072 let validator = if_range.value.trim();
1073 let Some(path) = URL::request_target_path(&request.request_uri) else { return true };
1074 let Ok(fs_path) = static_fs_path(&os_specific_path(path)) else { return true };
1075 let (Ok(modified), Ok(md)) = (file_modified_utc(&fs_path), metadata(&fs_path)) else { return true };
1076 if validator.starts_with('"') {
1077 return validator != format!("\"{}-{}\"", etag::tag(&fs_path, md.len(), modified), md.len());
1078 }
1079 let modified_secs = (modified / 1_000_000_000) as u64;
1080 let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
1081 match crate::http::date::parse(validator) {
1082 Some(date) => date != modified_secs || now < modified_secs + 1,
1083 None => true,
1084 }
1085}
1086
1087#[doc(hidden)]
1088pub use rws_http::static_path::{os_specific_path, percent_decode_path, static_fs_path, working_directory};
1089#[cfg(test)]
1090use rws_http::static_path::check_request_path;
1091
1092#[doc(hidden)]
1097pub fn inside_root(fs_path: &str) -> bool {
1098 static ROOT: std::sync::OnceLock<Option<std::path::PathBuf>> = std::sync::OnceLock::new();
1099 let Some(root) = ROOT.get_or_init(|| std::fs::canonicalize(working_directory()).ok()) else {
1100 return false;
1101 };
1102 std::fs::canonicalize(fs_path).is_ok_and(|resolved| resolved.starts_with(root))
1103}
1104
1105#[doc(hidden)]
1108pub fn file_modified_utc(path: &str) -> Result<u128, String> {
1109 let modified = metadata(path)
1110 .and_then(|md| md.modified())
1111 .map_err(|e| format!("<p>Unable to open file: {}</p> <p>error: {}</p>", path, e))?;
1112 Ok(modified
1113 .duration_since(std::time::UNIX_EPOCH)
1114 .map(|d| d.as_nanos())
1115 .unwrap_or(0))
1116}
1117
1118
1119fn mark_dictionary(response: &mut Response, pattern: Option<&str>) {
1122 let Some(pattern) = pattern else { return };
1123 response.headers.push(Header { name: "Use-As-Dictionary".into(), value: dictionaries::use_as_dictionary(pattern).into() });
1124 crate::compression::dictionary::add_vary(response, "Accept-Encoding");
1125 crate::compression::dictionary::add_vary(response, "Available-Dictionary");
1126}