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