Skip to main content

rust_web_server/app/controller/static_resource/
mod.rs

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