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