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