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 pub(crate) fn is_directory_without_index(request: &Request) -> bool {
105 let Some(path) = URL::request_target_path(&request.request_uri) else { return false };
106 let Ok(fs_path) = static_fs_path(&os_specific_path(path)) else { return false };
107 let dir = std::path::Path::new(&fs_path);
108 dir.is_dir() && !dir.join("index.html").is_file()
109 }
110
111 pub(crate) fn process_general(request: &Request, mut response: Response, config: &ServerConfig) -> Response {
115 let without_stale_range;
119 let request = if range_is_stale(request) {
120 let mut copy = request.clone();
121 copy.headers.retain(|h| !h.name.eq_ignore_ascii_case(Header::_RANGE));
122 without_stale_range = copy;
123 &without_stale_range
124 } else {
125 request
126 };
127 let boxed_content_range_list = StaticResourceController::process_static_resources(&request);
128 if boxed_content_range_list.is_ok() {
129 let content_range_list = boxed_content_range_list.unwrap();
130
131 if content_range_list.len() != 0 {
132
133 let mut status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok;
134
135 let does_request_include_range_header = request.header(Header::_RANGE).is_some();
136 if does_request_include_range_header {
137 status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n206_partial_content;
138 }
139
140 let is_options_request = request.method == METHOD.options;
141 if is_options_request {
142 status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n204_no_content;
143 }
144
145
146
147 let url = request.request_uri.to_string();
148
149 let boxed_url_components = URL::parse_request_target(&url);
150 if boxed_url_components.is_err() {
151 let message = boxed_url_components.as_ref().err().unwrap().to_string();
152 println!("unexpected error, {}", message);
154 }
155
156 let components = boxed_url_components.unwrap();
157
158 let static_filepath = static_fs_path(&components.path).unwrap_or_default();
161 let boxed_modified_date_time = file_modified_utc(&static_filepath);
162
163 if boxed_modified_date_time.is_ok() {
164 let modified_date_time = boxed_modified_date_time.unwrap();
165 let file_size = metadata(&static_filepath).map(|m| m.len()).unwrap_or(0);
166 let etag_value = format!("\"{}-{}\"", modified_date_time, file_size);
167 set_static_cache_control(&mut response, &config.static_cache_control);
168 if push_validators(request, &mut response, modified_date_time, &etag_value, config.nanos_headers) {
169 response.status_code = *STATUS_CODE_REASON_PHRASE.n304_not_modified.status_code;
170 response.reason_phrase = STATUS_CODE_REASON_PHRASE.n304_not_modified.reason_phrase.to_string();
171 response.headers.push(Header { name: Header::_ETAG.into(), value: etag_value.into() });
172 return response;
173 }
174
175 response.headers.push(Header { name: Header::_ETAG.into(), value: etag_value.into() });
176
177 let is_range_request = request.header(Header::_RANGE).is_some();
180 if file_size > StaticResourceController::STREAM_THRESHOLD && !is_range_request {
181 let mime = MimeType::detect_mime_type(&static_filepath);
182 response.headers.push(Header {
183 name: Header::_CONTENT_TYPE.into(),
184 value: mime.into(),
185 });
186 response.headers.push(Header {
187 name: Header::_CONTENT_LENGTH.into(),
188 value: file_size.to_string().into(),
189 });
190 response.status_code = *status_code_reason_phrase.status_code;
191 response.reason_phrase = status_code_reason_phrase.reason_phrase.to_string();
192 response.stream_file = Some(static_filepath);
193 return response;
194 }
195 }
196
197 response.status_code = *status_code_reason_phrase.status_code;
198 response.reason_phrase = status_code_reason_phrase.reason_phrase.to_string();
199 response.content_range_list = content_range_list;
200
201 }
202 } else {
203 let error : Error = boxed_content_range_list.err().unwrap();
204 let body = error.message;
205
206 let content_range = Range::get_content_range(
207 Vec::from(body.as_bytes()),
208 MimeType::TEXT_HTML.to_string()
209 );
210
211 let content_range_list = vec![content_range];
212
213 response.status_code = *error.status_code_reason_phrase.status_code;
214 response.reason_phrase = error.status_code_reason_phrase.reason_phrase.to_string();
215 response.content_range_list = content_range_list;
216
217 if response.status_code == 416 {
219 let length = URL::parse_request_target(&request.request_uri).ok()
220 .and_then(|c| static_fs_path(&c.path).ok())
221 .and_then(|p| metadata(p).ok())
222 .map(|md| md.len());
223 if let Some(length) = length {
224 response.headers.push(Header { name: Header::_CONTENT_RANGE.into(), value: format!("bytes */{}", length).into() });
225 }
226 }
227 }
228
229
230 response
231 }
232}
233
234impl StaticResourceController {
236
237 pub fn is_matching_request(request: &Request) -> bool {
238 let boxed_static_filepath = static_fs_path(&request.request_uri);
239 if boxed_static_filepath.is_err() {
240 return false
241 }
242
243 let static_filepath = boxed_static_filepath.unwrap();
244
245 let boxed_md = metadata(&static_filepath);
246 if boxed_md.is_err() || !inside_root(&static_filepath) {
247 return false
248 }
249
250 let md = boxed_md.unwrap();
251 if md.is_dir() {
252 return false
253 }
254
255 let boxed_file = File::open(&static_filepath);
256
257 let is_get = request.method == METHOD.get;
258 let is_head = request.method == METHOD.head;
259 let is_options = request.method == METHOD.options;
260 boxed_file.is_ok() && (is_get || is_head || is_options && request.request_uri != SYMBOL.slash)
261 }
262
263 pub fn process_request(request: &Request, mut response: Response) -> Response {
264 let config = ServerConfig::current();
265 if let Some(streamed) = StaticResourceController::serve_regular_file(request, &mut response, &config) {
266 return streamed;
267 }
268 let boxed_content_range_list = StaticResourceController::process_static_resources(&request);
269 if boxed_content_range_list.is_ok() {
270 let content_range_list = boxed_content_range_list.unwrap();
271
272 if content_range_list.len() != 0 {
273
274 let mut status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok;
275
276 let does_request_include_range_header = request.header(Header::_RANGE).is_some();
277 if does_request_include_range_header {
278 status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n206_partial_content;
279 }
280
281 let is_options_request = request.method == METHOD.options;
282 if is_options_request {
283 status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n204_no_content;
284 }
285
286
287 let static_filepath = static_fs_path(&request.request_uri).unwrap_or_default();
288 let boxed_modified_date_time = file_modified_utc(&static_filepath);
289
290 if boxed_modified_date_time.is_ok() {
291 let modified_date_time = boxed_modified_date_time.unwrap();
292 let file_size = metadata(&static_filepath).map(|m| m.len()).unwrap_or(0);
293 let etag_value = format!("\"{}-{}\"", modified_date_time, file_size);
294 set_static_cache_control(&mut response, &config.static_cache_control);
295 if push_validators(request, &mut response, modified_date_time, &etag_value, config.nanos_headers) {
296 response.status_code = *STATUS_CODE_REASON_PHRASE.n304_not_modified.status_code;
297 response.reason_phrase = STATUS_CODE_REASON_PHRASE.n304_not_modified.reason_phrase.to_string();
298 response.headers.push(Header { name: Header::_ETAG.into(), value: etag_value.into() });
299 return response;
300 }
301
302 response.headers.push(Header { name: Header::_ETAG.into(), value: etag_value.into() });
303
304 let is_range_request = request.header(Header::_RANGE).is_some();
305 if file_size > StaticResourceController::STREAM_THRESHOLD && !is_range_request {
306 let mime = MimeType::detect_mime_type(&static_filepath);
307 response.headers.push(Header {
308 name: Header::_CONTENT_TYPE.into(),
309 value: mime.into(),
310 });
311 response.headers.push(Header {
312 name: Header::_CONTENT_LENGTH.into(),
313 value: file_size.to_string().into(),
314 });
315 response.status_code = *status_code_reason_phrase.status_code;
316 response.reason_phrase = status_code_reason_phrase.reason_phrase.to_string();
317 response.stream_file = Some(static_filepath);
318 return response;
319 }
320 }
321
322 response.status_code = *status_code_reason_phrase.status_code;
323 response.reason_phrase = status_code_reason_phrase.reason_phrase.to_string();
324 response.content_range_list = content_range_list;
325
326 }
327 } else {
328 let error : Error = boxed_content_range_list.err().unwrap();
329 let body = error.message;
330
331 let content_range = Range::get_content_range(
332 Vec::from(body.as_bytes()),
333 MimeType::TEXT_HTML.to_string()
334 );
335
336 let content_range_list = vec![content_range];
337
338 response.status_code = *error.status_code_reason_phrase.status_code;
339 response.reason_phrase = error.status_code_reason_phrase.reason_phrase.to_string();
340 response.content_range_list = content_range_list;
341
342 }
343
344
345 response
346 }
347
348 pub const STREAM_THRESHOLD: u64 = 256 * 1024;
352
353 pub(crate) fn serve_regular_file(request: &Request, response: &mut Response, config: &ServerConfig) -> Option<Response> {
363 if request.method != METHOD.get && request.method != METHOD.head {
364 return None;
365 }
366 if request.header(Header::_RANGE).is_some() {
367 return None;
368 }
369 let path = URL::request_target_path(&request.request_uri)?;
370 let fs_path = static_fs_path(&os_specific_path(path)).ok()?;
371 let mime = MimeType::mime_type_for(&fs_path);
375 let follow_symlinks = mime != MimeType::APPLICATION_OCTET_STREAM;
376 let file = open_file_cache::open(&fs_path, follow_symlinks)?;
377 let state = file.state()?;
378
379 let encoding = if state.len <= open_file_cache::MAX_GZIPPED_FILE
384 && state.len >= crate::compression::MIN_COMPRESS_SIZE as u64
385 && crate::compression::is_compressible(mime)
386 {
387 crate::compression::negotiate(request, crate::compression::STATIC_PREFERENCE)
388 } else {
389 None
390 };
391 let dictionary_pattern = if config.compression_dictionaries.is_empty()
395 || state.len > crate::compression::dictionary::MAX_SIZE as u64
396 || !crate::compression::is_compressible(mime)
397 {
398 None
399 } else {
400 dictionaries::matching_pattern(&config.compression_dictionaries, path)
401 };
402 let delta = match dictionary_pattern {
403 Some(_) => {
404 let _ = file.dictionary_hash(&state);
405 match (crate::compression::dictionary::available_dictionary(request), crate::compression::dictionary::negotiate(request)) {
406 (Some(held), Some(coding)) => match dictionaries::get(&held) {
407 Some(bytes) => file.delta(&state, coding, held, &bytes).ok()?.map(|delta| (coding, held, delta)),
408 None => None,
409 },
410 _ => None,
411 }
412 }
413 None => None,
414 };
415 let modified = Decimal::new(state.modified);
420 let len = Decimal::new(state.len as u128);
421 let mut etag_value = String::with_capacity(modified.as_str().len() + len.as_str().len() + 8);
422 etag_value.push('"');
423 etag_value.push_str(modified.as_str());
424 etag_value.push('-');
425 etag_value.push_str(len.as_str());
426 if let Some((coding, held, _)) = &delta {
427 etag_value.push('-');
429 etag_value.push_str(coding.token());
430 etag_value.push('-');
431 held[..4].iter().for_each(|b| etag_value.push_str(&format!("{b:02x}")));
432 } else if let Some(encoding) = encoding {
433 etag_value.push('-');
434 etag_value.push_str(encoding.token());
435 }
436 etag_value.push('"');
437 let not_modified = is_not_modified(request, state.modified, &etag_value);
438 let mut sent_encoded = None;
445 let mut sent_delta = None;
446 let large = state.len > StaticResourceController::STREAM_THRESHOLD;
447 let body = if not_modified {
448 None
449 } else if let Some((coding, _, bytes)) = &delta {
450 sent_delta = Some(*coding);
451 Some(bytes.to_vec())
452 } else if let Some(encoding) = encoding {
453 match file.encoded(&state, encoding, &fs_path).ok()? {
454 open_file_cache::Encoded::Smaller(bytes) => {
455 sent_encoded = Some(encoding);
456 Some(bytes.to_vec())
457 }
458 open_file_cache::Encoded::NotSmaller if large => None,
459 open_file_cache::Encoded::NotSmaller => Some(file.contents(&state).ok()?),
460 }
461 } else if large {
462 None
463 } else {
464 Some(file.contents(&state).ok()?)
465 };
466
467 let mut response = std::mem::replace(response, Response::empty());
468 set_static_cache_control(&mut response, &config.static_cache_control);
469 if config.nanos_headers {
470 response.headers.push(Header { name: Header::_LAST_MODIFIED_UNIX_EPOCH_NANOS.into(), value: modified.as_str().to_string().into() });
471 }
472 response.headers.push(Header { name: Header::_LAST_MODIFIED.into(), value: crate::http::date::format((state.modified / 1_000_000_000) as u64).into() });
473 response.headers.push(Header { name: Header::_ETAG.into(), value: etag_value.into() });
474 if not_modified {
475 response.set_status(STATUS_CODE_REASON_PHRASE.n304_not_modified);
476 mark_dictionary(&mut response, dictionary_pattern);
477 return Some(response);
478 }
479 response.set_status(STATUS_CODE_REASON_PHRASE.n200_ok);
480
481 let Some(body) = body else {
482 response.headers.push(Header { name: Header::_CONTENT_TYPE.into(), value: mime.to_string().into() });
483 response.headers.push(Header { name: Header::_CONTENT_LENGTH.into(), value: len.as_str().to_string().into() });
484 response.stream_file = Some(fs_path);
485 mark_dictionary(&mut response, dictionary_pattern);
486 return Some(response);
487 };
488
489 response.content_range_list = vec![Range::get_content_range(body, mime.to_string())];
490 if let Some(coding) = sent_delta {
491 crate::compression::dictionary::mark_encoded(&mut response, coding);
492 } else if let Some(encoding) = sent_encoded {
493 crate::compression::mark_encoded(&mut response, encoding);
494 }
495 mark_dictionary(&mut response, dictionary_pattern);
496 Some(response)
497 }
498
499 pub(crate) fn trailing_punctuation_redirect(request: &Request) -> Option<Response> {
505 const TRAILING: &[char] = &[')', ':', '.', ',', ';', ']', '>', '\'', '"'];
506 if request.method != METHOD.get && request.method != METHOD.head {
507 return None;
508 }
509 let (raw_path, query) = match request.request_uri.split_once('?') {
510 Some((path, query)) => (path, Some(query)),
511 None => (request.request_uri.as_str(), None),
512 };
513 let decoded = percent_decode_path(raw_path).ok()?;
514 let trimmed = decoded.trim_end_matches(TRAILING);
515 if trimmed.len() == decoded.len() || trimmed.is_empty() || !trimmed.starts_with('/') || trimmed.ends_with("..") {
516 return None;
517 }
518 let fs_path = static_fs_path(&os_specific_path(trimmed)).ok()?;
519 if !std::path::Path::new(&fs_path).exists() {
520 return None;
521 }
522 let mut location = String::with_capacity(trimmed.len() + 16);
523 for c in trimmed.chars() {
524 if c.is_ascii_graphic() && !matches!(c, '%' | '"' | '<' | '>' | '\\' | '^' | '`' | '{' | '|' | '}') {
526 location.push(c);
527 } else {
528 let mut buf = [0u8; 4];
529 for b in c.encode_utf8(&mut buf).bytes() {
530 location.push_str(&format!("%{b:02X}"));
531 }
532 }
533 }
534 if let Some(query) = query {
535 location.push('?');
536 location.push_str(query);
537 }
538 let mut response = Response::get_response(STATUS_CODE_REASON_PHRASE.n301_moved_permanently, None, None);
539 response.headers.push(Header { name: Header::_LOCATION.into(), value: location.into() });
540 Some(response)
541 }
542
543 pub fn process_static_resources(request: &Request) -> Result<Vec<ContentRange>, Error> {
544
545 let url = request.request_uri.to_string();
546
547 let boxed_url_components = URL::parse_request_target(&url);
548 if boxed_url_components.is_err() {
549 let message = boxed_url_components.as_ref().err().unwrap().to_string();
550 println!("unexpected error, {}", message);
552 }
553
554 let components = boxed_url_components.unwrap();
555
556 let os_specific_separator : String = FileExt::get_path_separator();
557 let os_specific_path = &components.path.replace(SYMBOL.slash, os_specific_separator.as_str());
558
559 let static_filepath = match static_fs_path(&os_specific_path) {
560 Ok(path) => path,
561 Err(message) => {
562 return Err(Error { status_code_reason_phrase: STATUS_CODE_REASON_PHRASE.n404_not_found, message });
563 }
564 };
565
566 let mut content_range_list = Vec::new();
567
568
569 let mut boxed_md = metadata(&static_filepath);
570 if boxed_md.is_err() {
571 let dot_html = format!("{}{}", &static_filepath, ".html");
572 boxed_md = metadata(&dot_html);
573
574 if boxed_md.is_err() {
575 let slash_index_html = format!("{}{}{}", &static_filepath, os_specific_separator, "index.html");
576 boxed_md = metadata(&slash_index_html);
577 }
578 }
579 if boxed_md.is_ok() {
580 let md = boxed_md.unwrap();
581
582 if md.is_dir() {
583 let mut directory_index : String = "index.html".to_string();
584
585 let last_char = components.path.chars().last().unwrap();
586 if last_char != '/' {
587 let index : String = "index.html".to_string();
588 directory_index = format!("{}{}", os_specific_separator, index);
589 }
590 let index_html_in_directory = format!("{}{}", os_specific_path, directory_index);
591 let index_html_fs_path = format!("{}{}", static_filepath, directory_index);
592
593 if File::open(&index_html_fs_path).is_ok() {
594 let mut range_header = &Header {
595 name: Header::_RANGE.into(),
596 value: "bytes=0-".into()
597 };
598
599 let boxed_header = request.header(Header::_RANGE);
600 if boxed_header.is_some() {
601 range_header = boxed_header.unwrap();
602 }
603
604 let boxed_content_range_list = Range::get_content_range_list(&index_html_in_directory, range_header);
605 if boxed_content_range_list.is_ok() {
606 content_range_list = boxed_content_range_list.unwrap();
607 } else {
608 let error = boxed_content_range_list.err().unwrap();
609 return Err(error)
610 }
611 } else {
612 let listing_html = StaticResourceController::render_directory_listing(&static_filepath, &components.path);
613 let content_range = Range::get_content_range(listing_html.into_bytes(), MimeType::TEXT_HTML.to_string());
614 content_range_list = vec![content_range];
615 }
616
617 return Ok(content_range_list);
618 }
619
620 let boxed_file = File::open(&static_filepath);
621 if boxed_file.is_ok() {
622 let md = metadata(&static_filepath).unwrap();
623 if md.is_dir() {
624 let mut range_header = &Header {
625 name: Header::_RANGE.into(),
626 value: "bytes=0-".into()
627 };
628
629 let boxed_header = request.header(Header::_RANGE);
630 if boxed_header.is_some() {
631 range_header = boxed_header.unwrap();
632 }
633
634 let mut directory_index : String = "index.html".to_string();
635
636 let last_char = components.path.chars().last().unwrap();
637 if last_char != '/' {
638 let index : String = "index.html".to_string();
639 directory_index = format!("{}{}", os_specific_separator, index);
640 }
641 let index_html_in_directory = format!("{}{}", os_specific_path, directory_index);
642
643
644 let boxed_content_range_list = Range::get_content_range_list(&index_html_in_directory, range_header);
645 if boxed_content_range_list.is_ok() {
646 content_range_list = boxed_content_range_list.unwrap();
647 } else {
648 let error = boxed_content_range_list.err().unwrap();
649 return Err(error)
650 }
651 }
652
653 if md.is_file() {
654 let mut range_header = &Header {
655 name: Header::_RANGE.into(),
656 value: "bytes=0-".into()
657 };
658
659 let boxed_header = request.header(Header::_RANGE);
660 if boxed_header.is_some() {
661 range_header = boxed_header.unwrap();
662 }
663
664 let boxed_content_range_list = Range::get_content_range_list(&request.request_uri, range_header);
665 if boxed_content_range_list.is_ok() {
666 content_range_list = boxed_content_range_list.unwrap();
667 } else {
668 let error = boxed_content_range_list.err().unwrap();
669 return Err(error)
670 }
671 }
672 }
673
674
675 if boxed_file.is_err() {
676 let static_filepath = static_fs_path(&format!("{}.html", components.path)).unwrap_or_default();
679
680 let boxed_file = File::open(&static_filepath);
681 if boxed_file.is_ok() {
682 let md = metadata(&static_filepath).unwrap();
683 if md.is_file() {
684 let mut range_header = &Header {
685 name: Header::_RANGE.into(),
686 value: "bytes=0-".into()
687 };
688
689 let boxed_header = request.header(Header::_RANGE);
690 if boxed_header.is_some() {
691 range_header = boxed_header.unwrap();
692 }
693
694 let url = request.request_uri.to_string();
695
696 let boxed_url_components = URL::parse_request_target(&url);
697 if boxed_url_components.is_err() {
698 let message = boxed_url_components.as_ref().err().unwrap().to_string();
699 println!("unexpected error, {}", message);
701 }
702
703 let components = boxed_url_components.unwrap();
704
705 let html_file = [components.path.as_str(), ".html"].join(SYMBOL.empty_string);
709 let boxed_content_range_list = Range::get_content_range_list(html_file.as_str(), range_header);
710 if boxed_content_range_list.is_ok() {
711 content_range_list = boxed_content_range_list.unwrap();
712 } else {
713 let error = boxed_content_range_list.err().unwrap();
714 return Err(error)
715 }
716 }
717 }
718 }
719 }
720
721 if content_range_list.is_empty() {
725 if let Some(fallback_url_path) = spa_fallback_url_path(&components.path) {
726 let mut range_header = &Header {
727 name: Header::_RANGE.into(),
728 value: "bytes=0-".into()
729 };
730
731 let boxed_header = request.header(Header::_RANGE);
732 if boxed_header.is_some() {
733 range_header = boxed_header.unwrap();
734 }
735
736 if let Ok(list) = Range::get_content_range_list(&fallback_url_path, range_header) {
737 content_range_list = list;
738 }
739 }
740 }
741
742 Ok(content_range_list)
743 }
744}
745
746impl StaticResourceController {
750 pub fn render_directory_listing(fs_dir_path: &str, request_path: &str) -> String {
755 let normalized_request_path = if request_path.ends_with('/') {
756 request_path.to_string()
757 } else {
758 format!("{}/", request_path)
759 };
760
761 struct Entry {
762 name: String,
763 is_dir: bool,
764 size: u64,
765 modified_epoch_secs: u64,
766 }
767
768 let mut entries: Vec<Entry> = Vec::new();
769 if let Ok(dir_entries) = read_dir(fs_dir_path) {
770 for dir_entry in dir_entries.flatten() {
771 let name = dir_entry.file_name().to_string_lossy().to_string();
772 if name.starts_with('.') {
773 continue;
774 }
775
776 if let Ok(md) = dir_entry.metadata() {
777 let modified_epoch_secs = md.modified()
778 .ok()
779 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
780 .map(|d| d.as_secs())
781 .unwrap_or(0);
782
783 entries.push(Entry {
784 name,
785 is_dir: md.is_dir(),
786 size: md.len(),
787 modified_epoch_secs,
788 });
789 }
790 }
791 }
792
793 entries.sort_by(|a, b| {
794 match (a.is_dir, b.is_dir) {
795 (true, false) => std::cmp::Ordering::Less,
796 (false, true) => std::cmp::Ordering::Greater,
797 _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
798 }
799 });
800
801 let segments: Vec<&str> = normalized_request_path.split('/').filter(|s| !s.is_empty()).collect();
802
803 let mut breadcrumb_html = String::from("<a href=\"/\">~</a>");
804 let mut accumulated_path = String::new();
805 for segment in &segments {
806 accumulated_path.push('/');
807 accumulated_path.push_str(segment);
808 breadcrumb_html.push_str(&format!(
809 " <span class=\"sep\">/</span> <a href=\"{}/\">{}</a>",
810 accumulated_path, html_escape(segment)
811 ));
812 }
813
814 let parent_row = if !segments.is_empty() {
815 let parent_path = if segments.len() > 1 {
816 format!("/{}/", segments[..segments.len() - 1].join("/"))
817 } else {
818 "/".to_string()
819 };
820 format!(
821 "<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",
822 parent_path
823 )
824 } else {
825 String::new()
826 };
827
828 let entry_count = entries.len();
829
830 let mut rows = String::new();
831 for entry in &entries {
832 let escaped_name = html_escape(&entry.name);
833 let encoded_name = URL::percent_encode(&entry.name);
834 let href = if entry.is_dir {
835 format!("{}{}/", normalized_request_path, encoded_name)
836 } else {
837 format!("{}{}", normalized_request_path, encoded_name)
838 };
839 let size_display = if entry.is_dir { "\u{2014}".to_string() } else { human_size(entry.size) };
840 let modified_display = format_modified(entry.modified_epoch_secs);
841 let icon = icon_for(&entry.name, entry.is_dir);
842 let filter_key = html_escape(&entry.name.to_lowercase());
843
844 rows.push_str(&format!(
845 "<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",
846 filter_key, icon, href, escaped_name, if entry.is_dir { "/" } else { "" }, size_display, modified_display
847 ));
848 }
849
850 let mut html = String::new();
851 html.push_str(DIRECTORY_LISTING_HEAD);
852 html.push_str(&format!("<div class=\"breadcrumb\">{}</div>\n", breadcrumb_html));
853 html.push_str(&format!("<h1>Index of {}</h1>\n", html_escape(&normalized_request_path)));
854 html.push_str(DIRECTORY_LISTING_TOOLBAR);
855 html.push_str("<div class=\"card\"><table><thead><tr><th>Name</th><th>Size</th><th>Modified</th></tr></thead><tbody id=\"rows\">\n");
856 html.push_str(&parent_row);
857 html.push_str(&rows);
858 html.push_str("</tbody></table></div>\n");
859 html.push_str(&format!(
860 "<footer>{} item{} · served by rws</footer>\n",
861 entry_count, if entry_count == 1 { "" } else { "s" }
862 ));
863 html.push_str(DIRECTORY_LISTING_TAIL);
864
865 html
866 }
867}
868
869fn path_has_extension(path: &str) -> bool {
876 path.rsplit('/').next().unwrap_or("").contains('.')
877}
878
879fn spa_fallback_url_path(url_path: &str) -> Option<String> {
888 if path_has_extension(url_path) {
889 return None;
890 }
891
892 let fallback_name = crate::entry_point::get_spa_fallback()?;
893
894 let exclude_prefixes = crate::entry_point::get_spa_fallback_exclude_prefixes();
895 if exclude_prefixes.iter().any(|prefix| url_path.starts_with(prefix.as_str())) {
896 return None;
897 }
898
899 let separator = FileExt::get_path_separator();
900 let normalized_name = fallback_name.trim_start_matches('/').replace('/', &separator);
901 let fallback_url_path = format!("{}{}", separator, normalized_name);
902
903 let boxed_fs_path = static_fs_path(&fallback_url_path);
904 let fs_path = boxed_fs_path.ok()?;
905 if File::open(&fs_path).is_ok() {
906 Some(fallback_url_path)
907 } else {
908 None
909 }
910}
911
912fn html_escape(s: &str) -> String {
913 let mut out = String::with_capacity(s.len());
914 for c in s.chars() {
915 match c {
916 '&' => out.push_str("&"),
917 '<' => out.push_str("<"),
918 '>' => out.push_str(">"),
919 '"' => out.push_str("""),
920 '\'' => out.push_str("'"),
921 _ => out.push(c),
922 }
923 }
924 out
925}
926
927fn human_size(bytes: u64) -> String {
928 const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
929 if bytes < 1024 {
930 return format!("{} {}", bytes, UNITS[0]);
931 }
932
933 let mut size = bytes as f64;
934 let mut unit_index = 0;
935 while size >= 1024.0 && unit_index < UNITS.len() - 1 {
936 size /= 1024.0;
937 unit_index += 1;
938 }
939
940 format!("{:.1} {}", size, UNITS[unit_index])
941}
942
943fn format_modified(epoch_secs: u64) -> String {
944 let (sec, min, hour, day, month, _dow) = crate::scheduler::cron::epoch_to_datetime(epoch_secs);
945 let (year, _, _) = crate::scheduler::cron::days_to_ymd(epoch_secs / 86400);
946 format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02}", year, month, day, hour, min, sec)
947}
948
949fn icon_for(name: &str, is_dir: bool) -> &'static str {
950 if is_dir {
951 return "\u{1F4C1}";
952 }
953
954 let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
955 match ext.as_str() {
956 "png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "ico" | "bmp" => "\u{1F5BC}\u{FE0F}",
957 "mp4" | "mov" | "avi" | "mkv" | "webm" => "\u{1F3A5}",
958 "mp3" | "wav" | "flac" | "m4a" | "ogg" => "\u{1F3A7}",
959 "zip" | "tar" | "gz" | "rar" | "7z" | "bz2" => "\u{1F4E6}",
960 "pdf" => "\u{1F4D5}",
961 "html" | "htm" | "css" | "js" | "ts" | "rs" | "py" | "json" | "toml" | "yaml" | "yml" | "sh" => "\u{1F9E9}",
962 _ => "\u{1F4C4}",
963 }
964}
965
966const DIRECTORY_LISTING_HEAD: &str = concat!(
971 "<!DOCTYPE html>\n",
972 "<html lang=\"en\">\n",
973 "<head>\n",
974 "<meta charset=\"UTF-8\">\n",
975 "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n",
976 "<title>Directory listing</title>\n",
977 "<link rel=\"stylesheet\" href=\"/rws-directory-listing.css\">\n",
978 "</head>\n",
979 "<body>\n",
980 "<div class=\"wrap\">\n",
981);
982
983const DIRECTORY_LISTING_TOOLBAR: &str = "<div class=\"toolbar\"><input type=\"search\" id=\"filter\" placeholder=\"Filter entries...\" autocomplete=\"off\"></div>\n";
984
985const DIRECTORY_LISTING_TAIL: &str = concat!(
986 "</div>\n",
987 "<script src=\"/rws-directory-listing.js\" defer></script>\n",
988 "</body>\n",
989 "</html>\n",
990);
991
992fn push_validators(request: &Request, response: &mut Response, modified_nanos: u128, etag: &str, nanos_headers: bool) -> bool {
1001 if nanos_headers {
1002 response.headers.push(Header { name: Header::_LAST_MODIFIED_UNIX_EPOCH_NANOS.into(), value: modified_nanos.to_string().into() });
1003 }
1004 response.headers.push(Header { name: Header::_LAST_MODIFIED.into(), value: crate::http::date::format((modified_nanos / 1_000_000_000) as u64).into() });
1005 is_not_modified(request, modified_nanos, etag)
1006}
1007
1008fn is_not_modified(request: &Request, modified_nanos: u128, etag: &str) -> bool {
1012 if let Some(inm) = request.header(Header::_IF_NONE_MATCH) {
1013 return if_none_match_matches(&inm.value, etag);
1014 }
1015 if request.method != METHOD.get && request.method != METHOD.head {
1016 return false;
1017 }
1018 request
1019 .header(Header::_IF_MODIFIED_SINCE)
1020 .and_then(|ims| crate::http::date::parse(&ims.value))
1021 .is_some_and(|since| (modified_nanos / 1_000_000_000) as u64 <= since)
1022}
1023
1024fn set_static_cache_control(response: &mut Response, value: &str) {
1027 if value.is_empty() {
1028 return;
1029 }
1030 if let Some(header) = response.headers.iter_mut().find(|h| h.name == Header::_CACHE_CONTROL) {
1031 header.value = if value == crate::server_config::STATIC_CACHE_CONTROL_DEFAULT {
1032 std::borrow::Cow::Borrowed(crate::server_config::STATIC_CACHE_CONTROL_DEFAULT)
1033 } else {
1034 std::borrow::Cow::Owned(value.to_string())
1035 };
1036 }
1037}
1038
1039pub(crate) fn working_directory() -> &'static str {
1040 static DIR: std::sync::OnceLock<String> = std::sync::OnceLock::new();
1041 DIR.get_or_init(|| {
1042 std::env::current_dir()
1043 .map(|d| d.to_string_lossy().into_owned())
1044 .unwrap_or_default()
1045 })
1046}
1047
1048pub(crate) fn if_none_match_matches(header_value: &str, etag: &str) -> bool {
1053 let opaque = |tag: &str| tag.trim().strip_prefix("W/").unwrap_or(tag.trim()).to_string();
1054 let current = opaque(etag);
1055 header_value.split(',').any(|candidate| {
1056 let candidate = candidate.trim();
1057 candidate == "*" || (!candidate.is_empty() && opaque(candidate) == current)
1058 })
1059}
1060
1061pub(crate) fn range_is_stale(request: &Request) -> bool {
1069 let Some(if_range) = request.header(Header::_IF_RANGE) else { return false };
1070 if request.header(Header::_RANGE).is_none() {
1071 return false;
1072 }
1073 let validator = if_range.value.trim();
1074 let Some(path) = URL::request_target_path(&request.request_uri) else { return true };
1075 let Ok(fs_path) = static_fs_path(&os_specific_path(path)) else { return true };
1076 let (Ok(modified), Ok(md)) = (file_modified_utc(&fs_path), metadata(&fs_path)) else { return true };
1077 if validator.starts_with('"') {
1078 return validator != format!("\"{}-{}\"", modified, md.len());
1079 }
1080 let modified_secs = (modified / 1_000_000_000) as u64;
1081 let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
1082 match crate::http::date::parse(validator) {
1083 Some(date) => date != modified_secs || now < modified_secs + 1,
1084 None => true,
1085 }
1086}
1087
1088pub(crate) fn static_fs_path(path: &str) -> Result<String, String> {
1103 let path = percent_decode_path(path)?;
1104 check_request_path(&path)?;
1105 let working_directory = working_directory();
1106 let mut fs_path = String::with_capacity(working_directory.len() + path.len());
1107 fs_path.push_str(working_directory);
1108 fs_path.push_str(&path);
1109 Ok(fs_path)
1110}
1111
1112pub(crate) fn os_specific_path(path: &str) -> std::borrow::Cow<'_, str> {
1115 if std::path::MAIN_SEPARATOR == '/' {
1116 std::borrow::Cow::Borrowed(path)
1117 } else {
1118 std::borrow::Cow::Owned(path.replace('/', std::path::MAIN_SEPARATOR_STR))
1119 }
1120}
1121
1122pub(crate) fn percent_decode_path(path: &str) -> Result<std::borrow::Cow<'_, str>, String> {
1127 if !path.contains('%') {
1128 return Ok(std::borrow::Cow::Borrowed(path));
1129 }
1130 let hex = |b: u8| (b as char).to_digit(16).map(|d| d as u8);
1131 let bytes = path.as_bytes();
1132 let mut decoded = Vec::with_capacity(bytes.len());
1133 let mut i = 0;
1134 while i < bytes.len() {
1135 if bytes[i] == b'%' && i + 2 < bytes.len() {
1136 if let (Some(high), Some(low)) = (hex(bytes[i + 1]), hex(bytes[i + 2])) {
1137 decoded.push(high << 4 | low);
1138 i += 3;
1139 continue;
1140 }
1141 }
1142 decoded.push(bytes[i]);
1143 i += 1;
1144 }
1145 String::from_utf8(decoded)
1146 .map(std::borrow::Cow::Owned)
1147 .map_err(|_| "path is not UTF-8 after percent-decoding".to_string())
1148}
1149
1150pub(crate) fn check_request_path(path: &str) -> Result<(), String> {
1153 for segment in path.split(['/', '\\']) {
1154 if segment == ".." {
1155 return Err("path leaves the web root".to_string());
1156 }
1157 if segment.starts_with('.') && segment != "." && segment != ".well-known" && !serve_dotfiles() {
1158 return Err("hidden files are not served".to_string());
1159 }
1160 if segment.contains('\0') {
1161 return Err("invalid path".to_string());
1162 }
1163 }
1164 let name = path.rsplit(['/', '\\']).next().unwrap_or("").to_ascii_lowercase();
1166 if name.ends_with(".pem") || name.ends_with(".key") || name == "rws.config.toml" {
1167 return Err("key and config files are not served".to_string());
1168 }
1169 Ok(())
1170}
1171
1172fn serve_dotfiles() -> bool {
1174 static SERVE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1175 *SERVE.get_or_init(|| {
1176 std::env::var("RWS_CONFIG_SERVE_DOTFILES").is_ok_and(|v| v.trim().eq_ignore_ascii_case("true"))
1177 })
1178}
1179
1180pub(crate) fn inside_root(fs_path: &str) -> bool {
1185 static ROOT: std::sync::OnceLock<Option<std::path::PathBuf>> = std::sync::OnceLock::new();
1186 let Some(root) = ROOT.get_or_init(|| std::fs::canonicalize(working_directory()).ok()) else {
1187 return false;
1188 };
1189 std::fs::canonicalize(fs_path).is_ok_and(|resolved| resolved.starts_with(root))
1190}
1191
1192pub(crate) fn file_modified_utc(path: &str) -> Result<u128, String> {
1195 let modified = metadata(path)
1196 .and_then(|md| md.modified())
1197 .map_err(|e| format!("<p>Unable to open file: {}</p> <p>error: {}</p>", path, e))?;
1198 Ok(modified
1199 .duration_since(std::time::UNIX_EPOCH)
1200 .map(|d| d.as_nanos())
1201 .unwrap_or(0))
1202}
1203
1204
1205fn mark_dictionary(response: &mut Response, pattern: Option<&str>) {
1208 let Some(pattern) = pattern else { return };
1209 response.headers.push(Header { name: "Use-As-Dictionary".into(), value: dictionaries::use_as_dictionary(pattern).into() });
1210 crate::compression::dictionary::add_vary(response, "Accept-Encoding");
1211 crate::compression::dictionary::add_vary(response, "Available-Dictionary");
1212}