Skip to main content

rust_web_server/app/controller/static_resource/
mod.rs

1use std::fs::{File, metadata, read_dir};
2use std::time::UNIX_EPOCH;
3use file_ext::FileExt;
4use crate::controller::Controller;
5use crate::header::Header;
6use crate::mime_type::MimeType;
7use crate::range::{ContentRange, Range};
8use crate::request::{METHOD, Request};
9use crate::response::{Error, Response, STATUS_CODE_REASON_PHRASE};
10use crate::server::ConnectionInfo;
11use crate::symbol::SYMBOL;
12use crate::url::URL;
13
14#[cfg(test)]
15mod tests;
16pub(crate) mod open_file_cache;
17
18pub struct StaticResourceController;
19
20impl Controller for StaticResourceController {
21    fn is_matching(request: &Request, _connection: &ConnectionInfo) -> bool {
22        // Only origin-form targets name a file ("*" and anything a caller
23        // built by hand don't); URL::parse below expects "http://localhost/...".
24        if !request.request_uri.starts_with('/') {
25            return false;
26        }
27        let url = request.request_uri.to_string();
28
29        let boxed_url_components = URL::parse_request_target(&url);
30        if boxed_url_components.is_err() {
31            let message = boxed_url_components.as_ref().err().unwrap().to_string();
32            // unfallable
33            println!("unexpected error, {}", message);
34        }
35
36        let components = boxed_url_components.unwrap();
37
38        let os_specific_separator : String = FileExt::get_path_separator();
39        let os_specific_path = &components.path.replace(SYMBOL.slash, os_specific_separator.as_str());
40
41        let boxed_static_filepath = static_fs_path(&os_specific_path);
42        if boxed_static_filepath.is_err() {
43            return false
44        }
45
46        let static_filepath = boxed_static_filepath.unwrap();
47
48        // Any existing directory matches now — with an `index.html` it is served as
49        // before, otherwise `process_static_resources` renders a directory listing.
50        let mut is_directory = false;
51
52        // A file the open-file cache holds needs no stat; otherwise one stat
53        // answers both "exists?" and "directory?" — no open().
54        let mut is_file = open_file_cache::is_cached_file(&static_filepath);
55        if !is_file {
56            if let Ok(md) = metadata(&static_filepath) {
57                // Cached files were checked when opened; the rest here.
58                if !inside_root(&static_filepath) {
59                    return false;
60                }
61                is_directory = md.is_dir();
62                is_file = md.is_file();
63            }
64        }
65
66        let is_get = request.method == METHOD.get;
67        let is_head = request.method == METHOD.head;
68        let is_options = request.method == METHOD.options;
69
70        let is_matching_method = (is_get || is_head || is_options) && (request.request_uri != SYMBOL.slash);
71
72        if is_file || is_directory {
73            is_matching_method
74        } else {
75            // check if file with same name and .html extension exists
76            if !static_filepath.ends_with(".html") {
77                let html_suffix = ".html";
78                let html_file = [&components.path.replace(SYMBOL.slash, &FileExt::get_path_separator()), html_suffix].join(SYMBOL.empty_string);
79                if let Ok(html_static_filepath) = static_fs_path(&html_file) {
80                    if File::open(&html_static_filepath).is_ok() {
81                        return is_matching_method;
82                    }
83                }
84            }
85
86            // Last resort: the configured SPA fallback file (RWS_CONFIG_SPA_FALLBACK),
87            // for a GET/HEAD request that doesn't look like a missed static asset and
88            // isn't under an excluded prefix. Returns false (unchanged prior behavior)
89            // whenever the fallback isn't configured or doesn't apply.
90            is_matching_method && spa_fallback_url_path(&components.path).is_some()
91        }
92
93    }
94
95    fn process(request: &Request, mut response: Response, _connection: &ConnectionInfo) -> Response {
96        if let Some(served) = StaticResourceController::serve_regular_file(request, &mut response) {
97            return served;
98        }
99        let boxed_content_range_list = StaticResourceController::process_static_resources(&request);
100        if boxed_content_range_list.is_ok() {
101            let content_range_list = boxed_content_range_list.unwrap();
102
103            if content_range_list.len() != 0 {
104
105                let mut status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok;
106
107                let does_request_include_range_header = request.get_header(Header::_RANGE.to_string()).is_some();
108                if does_request_include_range_header {
109                    status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n206_partial_content;
110                }
111
112                let is_options_request = request.method == METHOD.options;
113                if is_options_request {
114                    status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n204_no_content;
115                }
116
117
118                let dir = std::path::PathBuf::from(working_directory());
119                let working_directory = dir.as_path().to_str().unwrap();
120
121                let url = request.request_uri.to_string();
122
123                let boxed_url_components = URL::parse_request_target(&url);
124                if boxed_url_components.is_err() {
125                    let message = boxed_url_components.as_ref().err().unwrap().to_string();
126                    // unfallable
127                    println!("unexpected error, {}", message);
128                }
129
130                let components = boxed_url_components.unwrap();
131
132                let static_filepath = [working_directory, components.path.as_str()].join(SYMBOL.empty_string);
133                let boxed_modified_date_time = file_modified_utc(&static_filepath);
134
135                if boxed_modified_date_time.is_ok() {
136                    let modified_date_time = boxed_modified_date_time.unwrap();
137                    let last_modified_unix_nanos = Header{ name: Header::_LAST_MODIFIED_UNIX_EPOCH_NANOS.to_string(), value: modified_date_time.to_string() };
138                    response.headers.push(last_modified_unix_nanos);
139
140                    let file_size = metadata(&static_filepath).map(|m| m.len()).unwrap_or(0);
141                    let etag_value = format!("\"{}-{}\"", modified_date_time, file_size);
142
143                    let if_none_match = request.get_header(Header::_IF_NONE_MATCH.to_string());
144                    if let Some(inm) = if_none_match {
145                        if inm.value == etag_value || inm.value == "*" {
146                            response.status_code = *STATUS_CODE_REASON_PHRASE.n304_not_modified.status_code;
147                            response.reason_phrase = STATUS_CODE_REASON_PHRASE.n304_not_modified.reason_phrase.to_string();
148                            response.headers.push(Header { name: Header::_ETAG.to_string(), value: etag_value });
149                            return response;
150                        }
151                    }
152
153                    response.headers.push(Header { name: Header::_ETAG.to_string(), value: etag_value });
154
155                    // Stream large files (> 8 MB) without loading into memory, unless it's
156                    // a range request (which needs precise byte slicing from the loaded body).
157                    let is_range_request = request.get_header(Header::_RANGE.to_string()).is_some();
158                    if file_size > StaticResourceController::STREAM_THRESHOLD && !is_range_request {
159                        let mime = MimeType::detect_mime_type(&static_filepath);
160                        response.headers.push(Header {
161                            name: Header::_CONTENT_TYPE.to_string(),
162                            value: mime,
163                        });
164                        response.headers.push(Header {
165                            name: Header::_CONTENT_LENGTH.to_string(),
166                            value: file_size.to_string(),
167                        });
168                        response.status_code = *status_code_reason_phrase.status_code;
169                        response.reason_phrase = status_code_reason_phrase.reason_phrase.to_string();
170                        response.stream_file = Some(static_filepath);
171                        return response;
172                    }
173                }
174
175                response.status_code = *status_code_reason_phrase.status_code;
176                response.reason_phrase = status_code_reason_phrase.reason_phrase.to_string();
177                response.content_range_list = content_range_list;
178
179            }
180        } else {
181            let error : Error = boxed_content_range_list.err().unwrap();
182            let body = error.message;
183
184            let content_range = Range::get_content_range(
185                Vec::from(body.as_bytes()),
186                MimeType::TEXT_HTML.to_string()
187            );
188
189            let content_range_list = vec![content_range];
190
191            response.status_code = *error.status_code_reason_phrase.status_code;
192            response.reason_phrase = error.status_code_reason_phrase.reason_phrase.to_string();
193            response.content_range_list = content_range_list;
194
195        }
196
197
198        response
199    }
200}
201
202//backward compatability
203impl StaticResourceController {
204
205    pub fn is_matching_request(request: &Request) -> bool {
206        let boxed_static_filepath = static_fs_path(&request.request_uri);
207        if boxed_static_filepath.is_err() {
208            return false
209        }
210
211        let static_filepath = boxed_static_filepath.unwrap();
212
213        let boxed_md = metadata(&static_filepath);
214        if boxed_md.is_err() || !inside_root(&static_filepath) {
215            return false
216        }
217
218        let md = boxed_md.unwrap();
219        if md.is_dir() {
220            return false
221        }
222
223        let boxed_file = File::open(&static_filepath);
224
225        let is_get = request.method == METHOD.get;
226        let is_head = request.method == METHOD.head;
227        let is_options = request.method == METHOD.options;
228        boxed_file.is_ok() && (is_get || is_head || is_options && request.request_uri != SYMBOL.slash)
229    }
230
231    pub fn process_request(request: &Request, mut response: Response) -> Response {
232        if let Some(streamed) = StaticResourceController::serve_regular_file(request, &mut response) {
233            return streamed;
234        }
235        let boxed_content_range_list = StaticResourceController::process_static_resources(&request);
236        if boxed_content_range_list.is_ok() {
237            let content_range_list = boxed_content_range_list.unwrap();
238
239            if content_range_list.len() != 0 {
240
241                let mut status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok;
242
243                let does_request_include_range_header = request.get_header(Header::_RANGE.to_string()).is_some();
244                if does_request_include_range_header {
245                    status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n206_partial_content;
246                }
247
248                let is_options_request = request.method == METHOD.options;
249                if is_options_request {
250                    status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n204_no_content;
251                }
252
253
254                let dir = std::path::PathBuf::from(working_directory());
255                let working_directory = dir.as_path().to_str().unwrap();
256                let static_filepath = [working_directory, request.request_uri.as_str()].join(SYMBOL.empty_string);
257                let boxed_modified_date_time = file_modified_utc(&static_filepath);
258
259                if boxed_modified_date_time.is_ok() {
260                    let modified_date_time = boxed_modified_date_time.unwrap();
261                    let last_modified_unix_nanos = Header{ name: Header::_LAST_MODIFIED_UNIX_EPOCH_NANOS.to_string(), value: modified_date_time.to_string() };
262                    response.headers.push(last_modified_unix_nanos);
263
264                    let file_size = metadata(&static_filepath).map(|m| m.len()).unwrap_or(0);
265                    let etag_value = format!("\"{}-{}\"", modified_date_time, file_size);
266
267                    let if_none_match = request.get_header(Header::_IF_NONE_MATCH.to_string());
268                    if let Some(inm) = if_none_match {
269                        if inm.value == etag_value || inm.value == "*" {
270                            response.status_code = *STATUS_CODE_REASON_PHRASE.n304_not_modified.status_code;
271                            response.reason_phrase = STATUS_CODE_REASON_PHRASE.n304_not_modified.reason_phrase.to_string();
272                            response.headers.push(Header { name: Header::_ETAG.to_string(), value: etag_value });
273                            return response;
274                        }
275                    }
276
277                    response.headers.push(Header { name: Header::_ETAG.to_string(), value: etag_value });
278
279                    let is_range_request = request.get_header(Header::_RANGE.to_string()).is_some();
280                    if file_size > StaticResourceController::STREAM_THRESHOLD && !is_range_request {
281                        let mime = MimeType::detect_mime_type(&static_filepath);
282                        response.headers.push(Header {
283                            name: Header::_CONTENT_TYPE.to_string(),
284                            value: mime,
285                        });
286                        response.headers.push(Header {
287                            name: Header::_CONTENT_LENGTH.to_string(),
288                            value: file_size.to_string(),
289                        });
290                        response.status_code = *status_code_reason_phrase.status_code;
291                        response.reason_phrase = status_code_reason_phrase.reason_phrase.to_string();
292                        response.stream_file = Some(static_filepath);
293                        return response;
294                    }
295                }
296
297                response.status_code = *status_code_reason_phrase.status_code;
298                response.reason_phrase = status_code_reason_phrase.reason_phrase.to_string();
299                response.content_range_list = content_range_list;
300
301            }
302        } else {
303            let error : Error = boxed_content_range_list.err().unwrap();
304            let body = error.message;
305
306            let content_range = Range::get_content_range(
307                Vec::from(body.as_bytes()),
308                MimeType::TEXT_HTML.to_string()
309            );
310
311            let content_range_list = vec![content_range];
312
313            response.status_code = *error.status_code_reason_phrase.status_code;
314            response.reason_phrase = error.status_code_reason_phrase.reason_phrase.to_string();
315            response.content_range_list = content_range_list;
316
317        }
318
319
320        response
321    }
322
323    /// Files larger than this are streamed from disk (`Response::stream_file`)
324    /// rather than read into memory — 64 concurrent downloads of a 1 MiB file
325    /// otherwise hold ~64 MiB plus copies.
326    pub const STREAM_THRESHOLD: u64 = 256 * 1024;
327
328    /// Fast path for a GET/HEAD of a regular file without a `Range` header:
329    /// one `open` and one `fstat` — size and modified time come from the open
330    /// handle, and a small file is read from that same handle — like nginx.
331    /// (The general path below stats the file five or six times and opens it
332    /// twice.) Files over [`Self::STREAM_THRESHOLD`] are answered with
333    /// `stream_file` before anything is read. The `Content-Type` comes from
334    /// the requested name. `None` = not applicable; the general path handles
335    /// it (directories, `.html` resolution, ranges, OPTIONS, symlinks whose
336    /// name has no known type).
337    fn serve_regular_file(request: &Request, response: &mut Response) -> Option<Response> {
338        if request.method != METHOD.get && request.method != METHOD.head {
339            return None;
340        }
341        if request.get_header(Header::_RANGE.to_string()).is_some() {
342            return None;
343        }
344        let path = URL::parse_request_target(&request.request_uri).ok()?.path;
345        let fs_path = static_fs_path(&path.replace(SYMBOL.slash, &FileExt::get_path_separator())).ok()?;
346        // A symlink whose own name has no recognizable type goes to the
347        // general path, which takes the type from the link target: such names
348        // are opened with O_NOFOLLOW, so a symlink simply fails to open.
349        let mime = MimeType::detect_mime_type(&fs_path);
350        let follow_symlinks = mime != MimeType::APPLICATION_OCTET_STREAM;
351        let file = open_file_cache::open(&fs_path, follow_symlinks)?;
352        let state = file.state()?;
353
354        let mut response = std::mem::replace(response, Response::get_response(STATUS_CODE_REASON_PHRASE.n200_ok, None, None));
355        let modified = state.modified;
356        let etag_value = format!("\"{}-{}\"", modified, state.len);
357        response.headers.push(Header { name: Header::_LAST_MODIFIED_UNIX_EPOCH_NANOS.to_string(), value: modified.to_string() });
358        let not_modified = request
359            .get_header(Header::_IF_NONE_MATCH.to_string())
360            .map_or(false, |inm| inm.value == etag_value || inm.value == "*");
361        response.headers.push(Header { name: Header::_ETAG.to_string(), value: etag_value });
362        if not_modified {
363            response.status_code = *STATUS_CODE_REASON_PHRASE.n304_not_modified.status_code;
364            response.reason_phrase = STATUS_CODE_REASON_PHRASE.n304_not_modified.reason_phrase.to_string();
365            return Some(response);
366        }
367        response.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
368        response.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
369
370        if state.len > StaticResourceController::STREAM_THRESHOLD {
371            response.headers.push(Header { name: Header::_CONTENT_TYPE.to_string(), value: mime });
372            response.headers.push(Header { name: Header::_CONTENT_LENGTH.to_string(), value: state.len.to_string() });
373            response.stream_file = Some(fs_path);
374            return Some(response);
375        }
376
377        let body = file.read_all(state.len).ok()?;
378        response.content_range_list = vec![Range::get_content_range(body, mime)];
379        Some(response)
380    }
381
382    pub fn process_static_resources(request: &Request) -> Result<Vec<ContentRange>, Error> {
383        let dir = std::path::PathBuf::from(working_directory());
384        let working_directory = dir.as_path().to_str().unwrap();
385
386        let url = request.request_uri.to_string();
387
388        let boxed_url_components = URL::parse_request_target(&url);
389        if boxed_url_components.is_err() {
390            let message = boxed_url_components.as_ref().err().unwrap().to_string();
391            // unfallable
392            println!("unexpected error, {}", message);
393        }
394
395        let components = boxed_url_components.unwrap();
396
397        let os_specific_separator : String = FileExt::get_path_separator();
398        let os_specific_path = &components.path.replace(SYMBOL.slash, os_specific_separator.as_str());
399
400        let static_filepath = match static_fs_path(&os_specific_path) {
401            Ok(path) => path,
402            Err(message) => {
403                return Err(Error { status_code_reason_phrase: STATUS_CODE_REASON_PHRASE.n404_not_found, message });
404            }
405        };
406
407        let mut content_range_list = Vec::new();
408
409
410        let mut boxed_md = metadata(&static_filepath);
411        if boxed_md.is_err() {
412            let dot_html = format!("{}{}", &static_filepath, ".html");
413            boxed_md = metadata(&dot_html);
414
415            if boxed_md.is_err() {
416                let slash_index_html = format!("{}{}{}", &static_filepath, os_specific_separator,  "index.html");
417                boxed_md = metadata(&slash_index_html);
418            }
419        }
420        if boxed_md.is_ok() {
421            let md = boxed_md.unwrap();
422
423            if md.is_dir() {
424                let mut directory_index : String = "index.html".to_string();
425
426                let last_char = components.path.chars().last().unwrap();
427                if last_char != '/' {
428                    let index : String = "index.html".to_string();
429                    directory_index = format!("{}{}", os_specific_separator, index);
430                }
431                let index_html_in_directory = format!("{}{}", os_specific_path, directory_index);
432                let index_html_fs_path = format!("{}{}", static_filepath, directory_index);
433
434                if File::open(&index_html_fs_path).is_ok() {
435                    let mut range_header = &Header {
436                        name: Header::_RANGE.to_string(),
437                        value: "bytes=0-".to_string()
438                    };
439
440                    let boxed_header = request.get_header(Header::_RANGE.to_string());
441                    if boxed_header.is_some() {
442                        range_header = boxed_header.unwrap();
443                    }
444
445                    let boxed_content_range_list = Range::get_content_range_list(&index_html_in_directory, range_header);
446                    if boxed_content_range_list.is_ok() {
447                        content_range_list = boxed_content_range_list.unwrap();
448                    } else {
449                        let error = boxed_content_range_list.err().unwrap();
450                        return Err(error)
451                    }
452                } else {
453                    let listing_html = StaticResourceController::render_directory_listing(&static_filepath, &components.path);
454                    let content_range = Range::get_content_range(listing_html.into_bytes(), MimeType::TEXT_HTML.to_string());
455                    content_range_list = vec![content_range];
456                }
457
458                return Ok(content_range_list);
459            }
460
461            let boxed_file = File::open(&static_filepath);
462            if boxed_file.is_ok()  {
463                let md = metadata(&static_filepath).unwrap();
464                if md.is_dir() {
465                    let mut range_header = &Header {
466                        name: Header::_RANGE.to_string(),
467                        value: "bytes=0-".to_string()
468                    };
469
470                    let boxed_header = request.get_header(Header::_RANGE.to_string());
471                    if boxed_header.is_some() {
472                        range_header = boxed_header.unwrap();
473                    }
474
475                    let mut directory_index : String = "index.html".to_string();
476
477                    let last_char = components.path.chars().last().unwrap();
478                    if last_char != '/' {
479                        let index : String = "index.html".to_string();
480                        directory_index = format!("{}{}", os_specific_separator, index);
481                    }
482                    let index_html_in_directory = format!("{}{}", os_specific_path, directory_index);
483
484
485                    let boxed_content_range_list = Range::get_content_range_list(&index_html_in_directory, range_header);
486                    if boxed_content_range_list.is_ok() {
487                        content_range_list = boxed_content_range_list.unwrap();
488                    } else {
489                        let error = boxed_content_range_list.err().unwrap();
490                        return Err(error)
491                    }
492                }
493
494                if md.is_file() {
495                    let mut range_header = &Header {
496                        name: Header::_RANGE.to_string(),
497                        value: "bytes=0-".to_string()
498                    };
499
500                    let boxed_header = request.get_header(Header::_RANGE.to_string());
501                    if boxed_header.is_some() {
502                        range_header = boxed_header.unwrap();
503                    }
504
505                    let boxed_content_range_list = Range::get_content_range_list(&request.request_uri, range_header);
506                    if boxed_content_range_list.is_ok() {
507                        content_range_list = boxed_content_range_list.unwrap();
508                    } else {
509                        let error = boxed_content_range_list.err().unwrap();
510                        return Err(error)
511                    }
512                }
513            }
514
515
516            if boxed_file.is_err() {
517                //check if .html file exists
518                let static_filepath = [working_directory, components.path.as_str(), ".html"].join(SYMBOL.empty_string);
519
520                let boxed_file = File::open(&static_filepath);
521                if boxed_file.is_ok()  {
522                    let md = metadata(&static_filepath).unwrap();
523                    if md.is_file() {
524                        let mut range_header = &Header {
525                            name: Header::_RANGE.to_string(),
526                            value: "bytes=0-".to_string()
527                        };
528
529                        let boxed_header = request.get_header(Header::_RANGE.to_string());
530                        if boxed_header.is_some() {
531                            range_header = boxed_header.unwrap();
532                        }
533
534                        let url = request.request_uri.to_string();
535
536                        let boxed_url_components = URL::parse_request_target(&url);
537                        if boxed_url_components.is_err() {
538                            let message = boxed_url_components.as_ref().err().unwrap().to_string();
539                            // unfallable
540                            println!("unexpected error, {}", message);
541                        }
542
543                        let components = boxed_url_components.unwrap();
544
545                        // let html_file = [SYMBOL.slash, ].join(SYMBOL.empty_string);
546
547
548                        let html_file = [components.path.as_str(), ".html"].join(SYMBOL.empty_string);
549                        let boxed_content_range_list = Range::get_content_range_list(html_file.as_str(), range_header);
550                        if boxed_content_range_list.is_ok() {
551                            content_range_list = boxed_content_range_list.unwrap();
552                        } else {
553                            let error = boxed_content_range_list.err().unwrap();
554                            return Err(error)
555                        }
556                    }
557                }
558            }
559        }
560
561        // Nothing on disk matched — try the configured SPA fallback (a no-op,
562        // returning the untouched empty list, when it's unconfigured or doesn't
563        // apply to this request).
564        if content_range_list.is_empty() {
565            if let Some(fallback_url_path) = spa_fallback_url_path(&components.path) {
566                let mut range_header = &Header {
567                    name: Header::_RANGE.to_string(),
568                    value: "bytes=0-".to_string()
569                };
570
571                let boxed_header = request.get_header(Header::_RANGE.to_string());
572                if boxed_header.is_some() {
573                    range_header = boxed_header.unwrap();
574                }
575
576                if let Ok(list) = Range::get_content_range_list(&fallback_url_path, range_header) {
577                    content_range_list = list;
578                }
579            }
580        }
581
582        Ok(content_range_list)
583    }
584}
585
586/// Directory listing generation — used by [`StaticResourceController`] whenever a
587/// requested directory has no `index.html`. Self-contained HTML (inline CSS/JS, no
588/// external requests), dark/light adaptive via `prefers-color-scheme`.
589impl StaticResourceController {
590    /// Renders a directory listing page for `fs_dir_path` (absolute filesystem path)
591    /// requested at `request_path` (the URL path, used to build links and breadcrumbs).
592    /// Hidden entries (dotfiles) are omitted. Directories sort before files;
593    /// each group is sorted case-insensitively by name.
594    pub fn render_directory_listing(fs_dir_path: &str, request_path: &str) -> String {
595        let normalized_request_path = if request_path.ends_with('/') {
596            request_path.to_string()
597        } else {
598            format!("{}/", request_path)
599        };
600
601        struct Entry {
602            name: String,
603            is_dir: bool,
604            size: u64,
605            modified_epoch_secs: u64,
606        }
607
608        let mut entries: Vec<Entry> = Vec::new();
609        if let Ok(dir_entries) = read_dir(fs_dir_path) {
610            for dir_entry in dir_entries.flatten() {
611                let name = dir_entry.file_name().to_string_lossy().to_string();
612                if name.starts_with('.') {
613                    continue;
614                }
615
616                if let Ok(md) = dir_entry.metadata() {
617                    let modified_epoch_secs = md.modified()
618                        .ok()
619                        .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
620                        .map(|d| d.as_secs())
621                        .unwrap_or(0);
622
623                    entries.push(Entry {
624                        name,
625                        is_dir: md.is_dir(),
626                        size: md.len(),
627                        modified_epoch_secs,
628                    });
629                }
630            }
631        }
632
633        entries.sort_by(|a, b| {
634            match (a.is_dir, b.is_dir) {
635                (true, false) => std::cmp::Ordering::Less,
636                (false, true) => std::cmp::Ordering::Greater,
637                _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
638            }
639        });
640
641        let segments: Vec<&str> = normalized_request_path.split('/').filter(|s| !s.is_empty()).collect();
642
643        let mut breadcrumb_html = String::from("<a href=\"/\">~</a>");
644        let mut accumulated_path = String::new();
645        for segment in &segments {
646            accumulated_path.push('/');
647            accumulated_path.push_str(segment);
648            breadcrumb_html.push_str(&format!(
649                " <span class=\"sep\">/</span> <a href=\"{}/\">{}</a>",
650                accumulated_path, html_escape(segment)
651            ));
652        }
653
654        let parent_row = if !segments.is_empty() {
655            let parent_path = if segments.len() > 1 {
656                format!("/{}/", segments[..segments.len() - 1].join("/"))
657            } else {
658                "/".to_string()
659            };
660            format!(
661                "<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",
662                parent_path
663            )
664        } else {
665            String::new()
666        };
667
668        let entry_count = entries.len();
669
670        let mut rows = String::new();
671        for entry in &entries {
672            let escaped_name = html_escape(&entry.name);
673            let encoded_name = URL::percent_encode(&entry.name);
674            let href = if entry.is_dir {
675                format!("{}{}/", normalized_request_path, encoded_name)
676            } else {
677                format!("{}{}", normalized_request_path, encoded_name)
678            };
679            let size_display = if entry.is_dir { "\u{2014}".to_string() } else { human_size(entry.size) };
680            let modified_display = format_modified(entry.modified_epoch_secs);
681            let icon = icon_for(&entry.name, entry.is_dir);
682            let filter_key = html_escape(&entry.name.to_lowercase());
683
684            rows.push_str(&format!(
685                "<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",
686                filter_key, icon, href, escaped_name, if entry.is_dir { "/" } else { "" }, size_display, modified_display
687            ));
688        }
689
690        let mut html = String::new();
691        html.push_str(DIRECTORY_LISTING_HEAD);
692        html.push_str(&format!("<div class=\"breadcrumb\">{}</div>\n", breadcrumb_html));
693        html.push_str(&format!("<h1>Index of {}</h1>\n", html_escape(&normalized_request_path)));
694        html.push_str(DIRECTORY_LISTING_TOOLBAR);
695        html.push_str("<div class=\"card\"><table><thead><tr><th>Name</th><th>Size</th><th>Modified</th></tr></thead><tbody id=\"rows\">\n");
696        html.push_str(&parent_row);
697        html.push_str(&rows);
698        html.push_str("</tbody></table></div>\n");
699        html.push_str(&format!(
700            "<footer>{} item{} &middot; served by rws</footer>\n",
701            entry_count, if entry_count == 1 { "" } else { "s" }
702        ));
703        html.push_str(DIRECTORY_LISTING_TAIL);
704
705        html
706    }
707}
708
709/// `true` if the last `/`-separated segment of `path` has a file extension
710/// (contains a `.` after the final `/`) — the standard heuristic (also used
711/// by webpack-dev-server's `historyApiFallback`, `sirv`, `vite preview`)
712/// distinguishing a client-side route (`/dashboard/settings`, no extension)
713/// from a missed static asset (`/logo.png`, has one), so the SPA fallback
714/// doesn't silently swallow the latter's real 404.
715fn path_has_extension(path: &str) -> bool {
716    path.rsplit('/').next().unwrap_or("").contains('.')
717}
718
719/// Resolves the SPA-fallback "URL path" to pass to [`Range::get_content_range_list`]
720/// for a request at `url_path` — e.g. `"/index.html"` for the common
721/// `RWS_CONFIG_SPA_FALLBACK=index.html` case — or `None` if the fallback doesn't
722/// apply: unconfigured, `url_path` is under an excluded prefix
723/// (`RWS_CONFIG_SPA_FALLBACK_EXCLUDE_PREFIXES`), `url_path` looks like a missed
724/// static asset (see [`path_has_extension`]), or the configured file doesn't
725/// actually exist on disk. Method filtering (GET/HEAD only) is the caller's
726/// responsibility — both call sites already have it via `is_matching_method`.
727fn spa_fallback_url_path(url_path: &str) -> Option<String> {
728    if path_has_extension(url_path) {
729        return None;
730    }
731
732    let fallback_name = crate::entry_point::get_spa_fallback()?;
733
734    let exclude_prefixes = crate::entry_point::get_spa_fallback_exclude_prefixes();
735    if exclude_prefixes.iter().any(|prefix| url_path.starts_with(prefix.as_str())) {
736        return None;
737    }
738
739    let separator = FileExt::get_path_separator();
740    let normalized_name = fallback_name.trim_start_matches('/').replace('/', &separator);
741    let fallback_url_path = format!("{}{}", separator, normalized_name);
742
743    let boxed_fs_path = static_fs_path(&fallback_url_path);
744    let fs_path = boxed_fs_path.ok()?;
745    if File::open(&fs_path).is_ok() {
746        Some(fallback_url_path)
747    } else {
748        None
749    }
750}
751
752fn html_escape(s: &str) -> String {
753    let mut out = String::with_capacity(s.len());
754    for c in s.chars() {
755        match c {
756            '&' => out.push_str("&amp;"),
757            '<' => out.push_str("&lt;"),
758            '>' => out.push_str("&gt;"),
759            '"' => out.push_str("&quot;"),
760            '\'' => out.push_str("&#39;"),
761            _ => out.push(c),
762        }
763    }
764    out
765}
766
767fn human_size(bytes: u64) -> String {
768    const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
769    if bytes < 1024 {
770        return format!("{} {}", bytes, UNITS[0]);
771    }
772
773    let mut size = bytes as f64;
774    let mut unit_index = 0;
775    while size >= 1024.0 && unit_index < UNITS.len() - 1 {
776        size /= 1024.0;
777        unit_index += 1;
778    }
779
780    format!("{:.1} {}", size, UNITS[unit_index])
781}
782
783fn format_modified(epoch_secs: u64) -> String {
784    let (sec, min, hour, day, month, _dow) = crate::scheduler::cron::epoch_to_datetime(epoch_secs);
785    let (year, _, _) = crate::scheduler::cron::days_to_ymd(epoch_secs / 86400);
786    format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02}", year, month, day, hour, min, sec)
787}
788
789fn icon_for(name: &str, is_dir: bool) -> &'static str {
790    if is_dir {
791        return "\u{1F4C1}";
792    }
793
794    let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
795    match ext.as_str() {
796        "png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "ico" | "bmp" => "\u{1F5BC}\u{FE0F}",
797        "mp4" | "mov" | "avi" | "mkv" | "webm" => "\u{1F3A5}",
798        "mp3" | "wav" | "flac" | "m4a" | "ogg" => "\u{1F3A7}",
799        "zip" | "tar" | "gz" | "rar" | "7z" | "bz2" => "\u{1F4E6}",
800        "pdf" => "\u{1F4D5}",
801        "html" | "htm" | "css" | "js" | "ts" | "rs" | "py" | "json" | "toml" | "yaml" | "yml" | "sh" => "\u{1F9E9}",
802        _ => "\u{1F4C4}",
803    }
804}
805
806// CSS/JS are served as same-origin `<link>`/`<script src>` assets (see
807// `crate::app::controller::directory_listing::DirectoryListingAssetsController`)
808// rather than inlined here — inline `<style>`/`<script>` would be silently
809// blocked under the framework's default `Content-Security-Policy: default-src 'self'`.
810const DIRECTORY_LISTING_HEAD: &str = concat!(
811    "<!DOCTYPE html>\n",
812    "<html lang=\"en\">\n",
813    "<head>\n",
814    "<meta charset=\"UTF-8\">\n",
815    "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n",
816    "<title>Directory listing</title>\n",
817    "<link rel=\"stylesheet\" href=\"/rws-directory-listing.css\">\n",
818    "</head>\n",
819    "<body>\n",
820    "<div class=\"wrap\">\n",
821);
822
823const DIRECTORY_LISTING_TOOLBAR: &str = "<div class=\"toolbar\"><input type=\"search\" id=\"filter\" placeholder=\"Filter entries...\" autocomplete=\"off\"></div>\n";
824
825const DIRECTORY_LISTING_TAIL: &str = concat!(
826    "</div>\n",
827    "<script src=\"/rws-directory-listing.js\" defer></script>\n",
828    "</body>\n",
829    "</html>\n",
830);
831
832/// The process working directory — the static-file root — read once.
833/// `rws` never changes it after startup, and `std::env::current_dir()` is
834/// several syscalls (on macOS it opens "."): the static-file path used to ask
835/// for it four to six times per request. A library user who changes the
836/// working directory after the first request keeps the original root.
837pub(crate) fn working_directory() -> &'static str {
838    static DIR: std::sync::OnceLock<String> = std::sync::OnceLock::new();
839    DIR.get_or_init(|| {
840        std::env::current_dir()
841            .map(|d| d.to_string_lossy().into_owned())
842            .unwrap_or_default()
843    })
844}
845
846/// `FileExt::get_static_filepath` without the per-call `current_dir()`:
847/// the working directory followed by `path` — refused (`Err`) when `path`
848/// could leave the web root or names a hidden file (#43):
849///
850/// - a `..` segment (`/../etc/passwd`): served before 17.113.1;
851/// - a segment starting with `.` (`.git/`, `.env`), except `.well-known`,
852///   unless `RWS_CONFIG_SERVE_DOTFILES=true`.
853///
854/// A symlink inside the root may still point outside it; [`inside_root`]
855/// checks the resolved path where files are opened.
856pub(crate) fn static_fs_path(path: &str) -> Result<String, String> {
857    check_request_path(path)?;
858    Ok(format!("{}{}", working_directory(), path))
859}
860
861/// The segment rules of [`static_fs_path`], on a path using `/` or the
862/// platform separator.
863pub(crate) fn check_request_path(path: &str) -> Result<(), String> {
864    for segment in path.split(['/', '\\']) {
865        if segment == ".." {
866            return Err("path leaves the web root".to_string());
867        }
868        if segment.starts_with('.') && segment != "." && segment != ".well-known" && !serve_dotfiles() {
869            return Err("hidden files are not served".to_string());
870        }
871        if segment.contains('\0') {
872            return Err("invalid path".to_string());
873        }
874    }
875    // Keys and the config file, wherever they sit in the root (#45).
876    let name = path.rsplit(['/', '\\']).next().unwrap_or("").to_ascii_lowercase();
877    if name.ends_with(".pem") || name.ends_with(".key") || name == "rws.config.toml" {
878        return Err("key and config files are not served".to_string());
879    }
880    Ok(())
881}
882
883/// `RWS_CONFIG_SERVE_DOTFILES` (default `false`), read once.
884fn serve_dotfiles() -> bool {
885    static SERVE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
886    *SERVE.get_or_init(|| {
887        std::env::var("RWS_CONFIG_SERVE_DOTFILES").is_ok_and(|v| v.trim().eq_ignore_ascii_case("true"))
888    })
889}
890
891/// `true` when `fs_path` exists and, symlinks resolved, lies inside the web
892/// root (the working directory). A symlink in the root pointing outside it
893/// (`assets -> /etc`) is not followed out (#43). One `realpath`: done where
894/// a file is opened, not per request on the cached fast path.
895pub(crate) fn inside_root(fs_path: &str) -> bool {
896    static ROOT: std::sync::OnceLock<Option<std::path::PathBuf>> = std::sync::OnceLock::new();
897    let Some(root) = ROOT.get_or_init(|| std::fs::canonicalize(working_directory()).ok()) else {
898        return false;
899    };
900    std::fs::canonicalize(fs_path).is_ok_and(|resolved| resolved.starts_with(root))
901}
902
903/// `FileExt::file_modified_utc` (nanoseconds since the Unix epoch) from a
904/// `stat`, without opening the file.
905pub(crate) fn file_modified_utc(path: &str) -> Result<u128, String> {
906    let modified = metadata(path)
907        .and_then(|md| md.modified())
908        .map_err(|e| format!("<p>Unable to open file: {}</p> <p>error: {}</p>", path, e))?;
909    Ok(modified
910        .duration_since(std::time::UNIX_EPOCH)
911        .map(|d| d.as_nanos())
912        .unwrap_or(0))
913}
914