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::ext::decimal::Decimal;
6use crate::header::Header;
7use crate::mime_type::MimeType;
8use crate::range::{ContentRange, Range};
9use crate::request::{METHOD, Request};
10use crate::response::{Error, Response, STATUS_CODE_REASON_PHRASE};
11use crate::server::ConnectionInfo;
12use crate::server_config::ServerConfig;
13use crate::symbol::SYMBOL;
14use crate::url::URL;
15
16#[cfg(test)]
17mod tests;
18pub(crate) mod open_file_cache;
19
20pub struct StaticResourceController;
21
22impl Controller for StaticResourceController {
23    fn is_matching(request: &Request, _connection: &ConnectionInfo) -> bool {
24        // Every branch below answers "no" for other methods and for "/":
25        // check that before touching the filesystem.
26        let is_get = request.method == METHOD.get;
27        let is_head = request.method == METHOD.head;
28        let is_options = request.method == METHOD.options;
29
30        let is_matching_method = (is_get || is_head || is_options) && (request.request_uri != SYMBOL.slash);
31        if !is_matching_method {
32            return false;
33        }
34
35        // Only origin-form targets name a file ("*" and anything a caller
36        // built by hand don't).
37        let path = match URL::request_target_path(&request.request_uri) {
38            Some(path) => path,
39            None => return false,
40        };
41
42        let boxed_static_filepath = static_fs_path(&os_specific_path(path));
43        if boxed_static_filepath.is_err() {
44            return false
45        }
46
47        let static_filepath = boxed_static_filepath.unwrap();
48
49        // Any existing directory matches now — with an `index.html` it is served as
50        // before, otherwise `process_static_resources` renders a directory listing.
51        let mut is_directory = false;
52
53        // A file the open-file cache holds needs no stat; otherwise one stat
54        // answers both "exists?" and "directory?" — no open().
55        let mut is_file = open_file_cache::is_cached_file(&static_filepath);
56        if !is_file {
57            if let Ok(md) = metadata(&static_filepath) {
58                // Cached files were checked when opened; the rest here.
59                if !inside_root(&static_filepath) {
60                    return false;
61                }
62                is_directory = md.is_dir();
63                is_file = md.is_file();
64            }
65        }
66
67        if is_file || is_directory {
68            true
69        } else {
70            // check if file with same name and .html extension exists
71            if !static_filepath.ends_with(".html") {
72                let html_suffix = ".html";
73                let mut html_file = os_specific_path(path).into_owned();
74                html_file.push_str(html_suffix);
75                if let Ok(html_static_filepath) = static_fs_path(&html_file) {
76                    if File::open(&html_static_filepath).is_ok() {
77                        return true;
78                    }
79                }
80            }
81
82            // 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            spa_fallback_url_path(path).is_some()
87        }
88
89    }
90
91    fn process(request: &Request, mut response: Response, _connection: &ConnectionInfo) -> Response {
92        let config = ServerConfig::current();
93        if let Some(served) = StaticResourceController::serve_regular_file(request, &mut response, &config) {
94            return served;
95        }
96        StaticResourceController::process_general(request, response, &config)
97    }
98}
99
100impl StaticResourceController {
101    /// `true` for a request naming a directory with no `index.html`: what
102    /// [`ServerConfig::directory_listing`] = `false` answers with a 404.
103    pub(crate) fn is_directory_without_index(request: &Request) -> bool {
104        let Some(path) = URL::request_target_path(&request.request_uri) else { return false };
105        let Ok(fs_path) = static_fs_path(&os_specific_path(path)) else { return false };
106        let dir = std::path::Path::new(&fs_path);
107        dir.is_dir() && !dir.join("index.html").is_file()
108    }
109
110    /// [`Controller::process`] minus the [`Self::serve_regular_file`] fast
111    /// path: directories, `.html` resolution, ranges, OPTIONS, symlinks with
112    /// an untyped name, the SPA fallback. For a caller that already tried it.
113    pub(crate) fn process_general(request: &Request, mut response: Response, config: &ServerConfig) -> Response {
114        // A Range with an If-Range that no longer matches is answered as if
115        // there were no Range: the whole, current file (#56). (The fast path
116        // never takes a Range, so only this path needs it.)
117        let without_stale_range;
118        let request = if range_is_stale(request) {
119            let mut copy = request.clone();
120            copy.headers.retain(|h| !h.name.eq_ignore_ascii_case(Header::_RANGE));
121            without_stale_range = copy;
122            &without_stale_range
123        } else {
124            request
125        };
126        let boxed_content_range_list = StaticResourceController::process_static_resources(&request);
127        if boxed_content_range_list.is_ok() {
128            let content_range_list = boxed_content_range_list.unwrap();
129
130            if content_range_list.len() != 0 {
131
132                let mut status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok;
133
134                let does_request_include_range_header = request.header(Header::_RANGE).is_some();
135                if does_request_include_range_header {
136                    status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n206_partial_content;
137                }
138
139                let is_options_request = request.method == METHOD.options;
140                if is_options_request {
141                    status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n204_no_content;
142                }
143
144
145
146                let url = request.request_uri.to_string();
147
148                let boxed_url_components = URL::parse_request_target(&url);
149                if boxed_url_components.is_err() {
150                    let message = boxed_url_components.as_ref().err().unwrap().to_string();
151                    // unfallable
152                    println!("unexpected error, {}", message);
153                }
154
155                let components = boxed_url_components.unwrap();
156
157                // Decoded and checked like the lookup itself (#55): the raw
158                // path names no file when it has escapes.
159                let static_filepath = static_fs_path(&components.path).unwrap_or_default();
160                let boxed_modified_date_time = file_modified_utc(&static_filepath);
161
162                if boxed_modified_date_time.is_ok() {
163                    let modified_date_time = boxed_modified_date_time.unwrap();
164                    let file_size = metadata(&static_filepath).map(|m| m.len()).unwrap_or(0);
165                    let etag_value = format!("\"{}-{}\"", modified_date_time, file_size);
166                    set_static_cache_control(&mut response, &config.static_cache_control);
167                    if push_validators(request, &mut response, modified_date_time, &etag_value, config.nanos_headers) {
168                        response.status_code = *STATUS_CODE_REASON_PHRASE.n304_not_modified.status_code;
169                        response.reason_phrase = STATUS_CODE_REASON_PHRASE.n304_not_modified.reason_phrase.to_string();
170                        response.headers.push(Header { name: Header::_ETAG.into(), value: etag_value.into() });
171                        return response;
172                    }
173
174                    response.headers.push(Header { name: Header::_ETAG.into(), value: etag_value.into() });
175
176                    // Stream large files (> 8 MB) without loading into memory, unless it's
177                    // a range request (which needs precise byte slicing from the loaded body).
178                    let is_range_request = request.header(Header::_RANGE).is_some();
179                    if file_size > StaticResourceController::STREAM_THRESHOLD && !is_range_request {
180                        let mime = MimeType::detect_mime_type(&static_filepath);
181                        response.headers.push(Header {
182                            name: Header::_CONTENT_TYPE.into(),
183                            value: mime.into(),
184                        });
185                        response.headers.push(Header {
186                            name: Header::_CONTENT_LENGTH.into(),
187                            value: file_size.to_string().into(),
188                        });
189                        response.status_code = *status_code_reason_phrase.status_code;
190                        response.reason_phrase = status_code_reason_phrase.reason_phrase.to_string();
191                        response.stream_file = Some(static_filepath);
192                        return response;
193                    }
194                }
195
196                response.status_code = *status_code_reason_phrase.status_code;
197                response.reason_phrase = status_code_reason_phrase.reason_phrase.to_string();
198                response.content_range_list = content_range_list;
199
200            }
201        } else {
202            let error : Error = boxed_content_range_list.err().unwrap();
203            let body = error.message;
204
205            let content_range = Range::get_content_range(
206                Vec::from(body.as_bytes()),
207                MimeType::TEXT_HTML.to_string()
208            );
209
210            let content_range_list = vec![content_range];
211
212            response.status_code = *error.status_code_reason_phrase.status_code;
213            response.reason_phrase = error.status_code_reason_phrase.reason_phrase.to_string();
214            response.content_range_list = content_range_list;
215
216            // A 416 says how long the file is (RFC 9110 §15.5.17, #56).
217            if response.status_code == 416 {
218                let length = URL::parse_request_target(&request.request_uri).ok()
219                    .and_then(|c| static_fs_path(&c.path).ok())
220                    .and_then(|p| metadata(p).ok())
221                    .map(|md| md.len());
222                if let Some(length) = length {
223                    response.headers.push(Header { name: Header::_CONTENT_RANGE.into(), value: format!("bytes */{}", length).into() });
224                }
225            }
226        }
227
228
229        response
230    }
231}
232
233//backward compatability
234impl StaticResourceController {
235
236    pub fn is_matching_request(request: &Request) -> bool {
237        let boxed_static_filepath = static_fs_path(&request.request_uri);
238        if boxed_static_filepath.is_err() {
239            return false
240        }
241
242        let static_filepath = boxed_static_filepath.unwrap();
243
244        let boxed_md = metadata(&static_filepath);
245        if boxed_md.is_err() || !inside_root(&static_filepath) {
246            return false
247        }
248
249        let md = boxed_md.unwrap();
250        if md.is_dir() {
251            return false
252        }
253
254        let boxed_file = File::open(&static_filepath);
255
256        let is_get = request.method == METHOD.get;
257        let is_head = request.method == METHOD.head;
258        let is_options = request.method == METHOD.options;
259        boxed_file.is_ok() && (is_get || is_head || is_options && request.request_uri != SYMBOL.slash)
260    }
261
262    pub fn process_request(request: &Request, mut response: Response) -> Response {
263        let config = ServerConfig::current();
264        if let Some(streamed) = StaticResourceController::serve_regular_file(request, &mut response, &config) {
265            return streamed;
266        }
267        let boxed_content_range_list = StaticResourceController::process_static_resources(&request);
268        if boxed_content_range_list.is_ok() {
269            let content_range_list = boxed_content_range_list.unwrap();
270
271            if content_range_list.len() != 0 {
272
273                let mut status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok;
274
275                let does_request_include_range_header = request.header(Header::_RANGE).is_some();
276                if does_request_include_range_header {
277                    status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n206_partial_content;
278                }
279
280                let is_options_request = request.method == METHOD.options;
281                if is_options_request {
282                    status_code_reason_phrase = STATUS_CODE_REASON_PHRASE.n204_no_content;
283                }
284
285
286                let static_filepath = static_fs_path(&request.request_uri).unwrap_or_default();
287                let boxed_modified_date_time = file_modified_utc(&static_filepath);
288
289                if boxed_modified_date_time.is_ok() {
290                    let modified_date_time = boxed_modified_date_time.unwrap();
291                    let file_size = metadata(&static_filepath).map(|m| m.len()).unwrap_or(0);
292                    let etag_value = format!("\"{}-{}\"", modified_date_time, file_size);
293                    set_static_cache_control(&mut response, &config.static_cache_control);
294                    if push_validators(request, &mut response, modified_date_time, &etag_value, config.nanos_headers) {
295                        response.status_code = *STATUS_CODE_REASON_PHRASE.n304_not_modified.status_code;
296                        response.reason_phrase = STATUS_CODE_REASON_PHRASE.n304_not_modified.reason_phrase.to_string();
297                        response.headers.push(Header { name: Header::_ETAG.into(), value: etag_value.into() });
298                        return response;
299                    }
300
301                    response.headers.push(Header { name: Header::_ETAG.into(), value: etag_value.into() });
302
303                    let is_range_request = request.header(Header::_RANGE).is_some();
304                    if file_size > StaticResourceController::STREAM_THRESHOLD && !is_range_request {
305                        let mime = MimeType::detect_mime_type(&static_filepath);
306                        response.headers.push(Header {
307                            name: Header::_CONTENT_TYPE.into(),
308                            value: mime.into(),
309                        });
310                        response.headers.push(Header {
311                            name: Header::_CONTENT_LENGTH.into(),
312                            value: file_size.to_string().into(),
313                        });
314                        response.status_code = *status_code_reason_phrase.status_code;
315                        response.reason_phrase = status_code_reason_phrase.reason_phrase.to_string();
316                        response.stream_file = Some(static_filepath);
317                        return response;
318                    }
319                }
320
321                response.status_code = *status_code_reason_phrase.status_code;
322                response.reason_phrase = status_code_reason_phrase.reason_phrase.to_string();
323                response.content_range_list = content_range_list;
324
325            }
326        } else {
327            let error : Error = boxed_content_range_list.err().unwrap();
328            let body = error.message;
329
330            let content_range = Range::get_content_range(
331                Vec::from(body.as_bytes()),
332                MimeType::TEXT_HTML.to_string()
333            );
334
335            let content_range_list = vec![content_range];
336
337            response.status_code = *error.status_code_reason_phrase.status_code;
338            response.reason_phrase = error.status_code_reason_phrase.reason_phrase.to_string();
339            response.content_range_list = content_range_list;
340
341        }
342
343
344        response
345    }
346
347    /// Files larger than this are streamed from disk (`Response::stream_file`)
348    /// rather than read into memory — 64 concurrent downloads of a 1 MiB file
349    /// otherwise hold ~64 MiB plus copies.
350    pub const STREAM_THRESHOLD: u64 = 256 * 1024;
351
352    /// Fast path for a GET/HEAD of a regular file without a `Range` header:
353    /// one `open` and one `fstat` — size and modified time come from the open
354    /// handle, and a small file is read from that same handle — like nginx.
355    /// (The general path below stats the file five or six times and opens it
356    /// twice.) Files over [`Self::STREAM_THRESHOLD`] are answered with
357    /// `stream_file` before anything is read. The `Content-Type` comes from
358    /// the requested name. `None` = not applicable; the general path handles
359    /// it (directories, `.html` resolution, ranges, OPTIONS, symlinks whose
360    /// name has no known type).
361    pub(crate) fn serve_regular_file(request: &Request, response: &mut Response, config: &ServerConfig) -> Option<Response> {
362        if request.method != METHOD.get && request.method != METHOD.head {
363            return None;
364        }
365        if request.header(Header::_RANGE).is_some() {
366            return None;
367        }
368        let path = URL::request_target_path(&request.request_uri)?;
369        let fs_path = static_fs_path(&os_specific_path(path)).ok()?;
370        // A symlink whose own name has no recognizable type goes to the
371        // general path, which takes the type from the link target: such names
372        // are opened with O_NOFOLLOW, so a symlink simply fails to open.
373        let mime = MimeType::mime_type_for(&fs_path);
374        let follow_symlinks = mime != MimeType::APPLICATION_OCTET_STREAM;
375        let file = open_file_cache::open(&fs_path, follow_symlinks)?;
376        let state = file.state()?;
377
378        // A compressible text file for a client that accepts br, zstd or gzip
379        // is sent from the cached compressed form (or a precompressed
380        // sibling), made once per file version instead of per request. Only
381        // a request with Accept-Encoding pays for the negotiation.
382        let encoding = if state.len <= open_file_cache::MAX_GZIPPED_FILE
383            && state.len >= crate::compression::MIN_COMPRESS_SIZE as u64
384            && crate::compression::is_compressible(mime)
385        {
386            crate::compression::negotiate(request, crate::compression::STATIC_PREFERENCE)
387        } else {
388            None
389        };
390        // ETag "<modified>-<len>", plus "-<coding>" when the file goes out
391        // compressed (each representation needs its own strong ETag), and the
392        // nanosecond Last-Modified, built without format!: this runs for
393        // every request.
394        let modified = Decimal::new(state.modified);
395        let len = Decimal::new(state.len as u128);
396        let mut etag_value = String::with_capacity(modified.as_str().len() + len.as_str().len() + 8);
397        etag_value.push('"');
398        etag_value.push_str(modified.as_str());
399        etag_value.push('-');
400        etag_value.push_str(len.as_str());
401        if let Some(encoding) = encoding {
402            etag_value.push('-');
403            etag_value.push_str(encoding.token());
404        }
405        etag_value.push('"');
406        let not_modified = is_not_modified(request, state.modified, &etag_value);
407        // Everything that can fail happens before `response` is taken: on
408        // `None` the general path carries on with it.
409        // A body of None means: stream the file (sendfile over plain TCP).
410        // Compressible files up to MAX_GZIPPED_FILE (above the streaming
411        // threshold too) go to gzip clients compressed — a JS bundle of a few
412        // hundred KiB used to be sent uncompressed.
413        let mut sent_encoded = None;
414        let large = state.len > StaticResourceController::STREAM_THRESHOLD;
415        let body = if not_modified {
416            None
417        } else if let Some(encoding) = encoding {
418            match file.encoded(&state, encoding, &fs_path).ok()? {
419                open_file_cache::Encoded::Smaller(bytes) => {
420                    sent_encoded = Some(encoding);
421                    Some(bytes.to_vec())
422                }
423                open_file_cache::Encoded::NotSmaller if large => None,
424                open_file_cache::Encoded::NotSmaller => Some(file.contents(&state).ok()?),
425            }
426        } else if large {
427            None
428        } else {
429            Some(file.contents(&state).ok()?)
430        };
431
432        let mut response = std::mem::replace(response, Response::empty());
433        set_static_cache_control(&mut response, &config.static_cache_control);
434        if config.nanos_headers {
435            response.headers.push(Header { name: Header::_LAST_MODIFIED_UNIX_EPOCH_NANOS.into(), value: modified.as_str().to_string().into() });
436        }
437        response.headers.push(Header { name: Header::_LAST_MODIFIED.into(), value: crate::http::date::format((state.modified / 1_000_000_000) as u64).into() });
438        response.headers.push(Header { name: Header::_ETAG.into(), value: etag_value.into() });
439        if not_modified {
440            response.set_status(STATUS_CODE_REASON_PHRASE.n304_not_modified);
441            return Some(response);
442        }
443        response.set_status(STATUS_CODE_REASON_PHRASE.n200_ok);
444
445        let Some(body) = body else {
446            response.headers.push(Header { name: Header::_CONTENT_TYPE.into(), value: mime.to_string().into() });
447            response.headers.push(Header { name: Header::_CONTENT_LENGTH.into(), value: len.as_str().to_string().into() });
448            response.stream_file = Some(fs_path);
449            return Some(response);
450        };
451
452        response.content_range_list = vec![Range::get_content_range(body, mime.to_string())];
453        if let Some(encoding) = sent_encoded {
454            crate::compression::mark_encoded(&mut response, encoding);
455        }
456        Some(response)
457    }
458
459    pub fn process_static_resources(request: &Request) -> Result<Vec<ContentRange>, Error> {
460
461        let url = request.request_uri.to_string();
462
463        let boxed_url_components = URL::parse_request_target(&url);
464        if boxed_url_components.is_err() {
465            let message = boxed_url_components.as_ref().err().unwrap().to_string();
466            // unfallable
467            println!("unexpected error, {}", message);
468        }
469
470        let components = boxed_url_components.unwrap();
471
472        let os_specific_separator : String = FileExt::get_path_separator();
473        let os_specific_path = &components.path.replace(SYMBOL.slash, os_specific_separator.as_str());
474
475        let static_filepath = match static_fs_path(&os_specific_path) {
476            Ok(path) => path,
477            Err(message) => {
478                return Err(Error { status_code_reason_phrase: STATUS_CODE_REASON_PHRASE.n404_not_found, message });
479            }
480        };
481
482        let mut content_range_list = Vec::new();
483
484
485        let mut boxed_md = metadata(&static_filepath);
486        if boxed_md.is_err() {
487            let dot_html = format!("{}{}", &static_filepath, ".html");
488            boxed_md = metadata(&dot_html);
489
490            if boxed_md.is_err() {
491                let slash_index_html = format!("{}{}{}", &static_filepath, os_specific_separator,  "index.html");
492                boxed_md = metadata(&slash_index_html);
493            }
494        }
495        if boxed_md.is_ok() {
496            let md = boxed_md.unwrap();
497
498            if md.is_dir() {
499                let mut directory_index : String = "index.html".to_string();
500
501                let last_char = components.path.chars().last().unwrap();
502                if last_char != '/' {
503                    let index : String = "index.html".to_string();
504                    directory_index = format!("{}{}", os_specific_separator, index);
505                }
506                let index_html_in_directory = format!("{}{}", os_specific_path, directory_index);
507                let index_html_fs_path = format!("{}{}", static_filepath, directory_index);
508
509                if File::open(&index_html_fs_path).is_ok() {
510                    let mut range_header = &Header {
511                        name: Header::_RANGE.into(),
512                        value: "bytes=0-".into()
513                    };
514
515                    let boxed_header = request.header(Header::_RANGE);
516                    if boxed_header.is_some() {
517                        range_header = boxed_header.unwrap();
518                    }
519
520                    let boxed_content_range_list = Range::get_content_range_list(&index_html_in_directory, range_header);
521                    if boxed_content_range_list.is_ok() {
522                        content_range_list = boxed_content_range_list.unwrap();
523                    } else {
524                        let error = boxed_content_range_list.err().unwrap();
525                        return Err(error)
526                    }
527                } else {
528                    let listing_html = StaticResourceController::render_directory_listing(&static_filepath, &components.path);
529                    let content_range = Range::get_content_range(listing_html.into_bytes(), MimeType::TEXT_HTML.to_string());
530                    content_range_list = vec![content_range];
531                }
532
533                return Ok(content_range_list);
534            }
535
536            let boxed_file = File::open(&static_filepath);
537            if boxed_file.is_ok()  {
538                let md = metadata(&static_filepath).unwrap();
539                if md.is_dir() {
540                    let mut range_header = &Header {
541                        name: Header::_RANGE.into(),
542                        value: "bytes=0-".into()
543                    };
544
545                    let boxed_header = request.header(Header::_RANGE);
546                    if boxed_header.is_some() {
547                        range_header = boxed_header.unwrap();
548                    }
549
550                    let mut directory_index : String = "index.html".to_string();
551
552                    let last_char = components.path.chars().last().unwrap();
553                    if last_char != '/' {
554                        let index : String = "index.html".to_string();
555                        directory_index = format!("{}{}", os_specific_separator, index);
556                    }
557                    let index_html_in_directory = format!("{}{}", os_specific_path, directory_index);
558
559
560                    let boxed_content_range_list = Range::get_content_range_list(&index_html_in_directory, range_header);
561                    if boxed_content_range_list.is_ok() {
562                        content_range_list = boxed_content_range_list.unwrap();
563                    } else {
564                        let error = boxed_content_range_list.err().unwrap();
565                        return Err(error)
566                    }
567                }
568
569                if md.is_file() {
570                    let mut range_header = &Header {
571                        name: Header::_RANGE.into(),
572                        value: "bytes=0-".into()
573                    };
574
575                    let boxed_header = request.header(Header::_RANGE);
576                    if boxed_header.is_some() {
577                        range_header = boxed_header.unwrap();
578                    }
579
580                    let boxed_content_range_list = Range::get_content_range_list(&request.request_uri, range_header);
581                    if boxed_content_range_list.is_ok() {
582                        content_range_list = boxed_content_range_list.unwrap();
583                    } else {
584                        let error = boxed_content_range_list.err().unwrap();
585                        return Err(error)
586                    }
587                }
588            }
589
590
591            if boxed_file.is_err() {
592                //check if .html file exists
593                // Decoded like every lookup (#55): `/my%20page` is `my page.html`.
594                let static_filepath = static_fs_path(&format!("{}.html", components.path)).unwrap_or_default();
595
596                let boxed_file = File::open(&static_filepath);
597                if boxed_file.is_ok()  {
598                    let md = metadata(&static_filepath).unwrap();
599                    if md.is_file() {
600                        let mut range_header = &Header {
601                            name: Header::_RANGE.into(),
602                            value: "bytes=0-".into()
603                        };
604
605                        let boxed_header = request.header(Header::_RANGE);
606                        if boxed_header.is_some() {
607                            range_header = boxed_header.unwrap();
608                        }
609
610                        let url = request.request_uri.to_string();
611
612                        let boxed_url_components = URL::parse_request_target(&url);
613                        if boxed_url_components.is_err() {
614                            let message = boxed_url_components.as_ref().err().unwrap().to_string();
615                            // unfallable
616                            println!("unexpected error, {}", message);
617                        }
618
619                        let components = boxed_url_components.unwrap();
620
621                        // let html_file = [SYMBOL.slash, ].join(SYMBOL.empty_string);
622
623
624                        let html_file = [components.path.as_str(), ".html"].join(SYMBOL.empty_string);
625                        let boxed_content_range_list = Range::get_content_range_list(html_file.as_str(), range_header);
626                        if boxed_content_range_list.is_ok() {
627                            content_range_list = boxed_content_range_list.unwrap();
628                        } else {
629                            let error = boxed_content_range_list.err().unwrap();
630                            return Err(error)
631                        }
632                    }
633                }
634            }
635        }
636
637        // Nothing on disk matched — try the configured SPA fallback (a no-op,
638        // returning the untouched empty list, when it's unconfigured or doesn't
639        // apply to this request).
640        if content_range_list.is_empty() {
641            if let Some(fallback_url_path) = spa_fallback_url_path(&components.path) {
642                let mut range_header = &Header {
643                    name: Header::_RANGE.into(),
644                    value: "bytes=0-".into()
645                };
646
647                let boxed_header = request.header(Header::_RANGE);
648                if boxed_header.is_some() {
649                    range_header = boxed_header.unwrap();
650                }
651
652                if let Ok(list) = Range::get_content_range_list(&fallback_url_path, range_header) {
653                    content_range_list = list;
654                }
655            }
656        }
657
658        Ok(content_range_list)
659    }
660}
661
662/// Directory listing generation — used by [`StaticResourceController`] whenever a
663/// requested directory has no `index.html`. Self-contained HTML (inline CSS/JS, no
664/// external requests), dark/light adaptive via `prefers-color-scheme`.
665impl StaticResourceController {
666    /// Renders a directory listing page for `fs_dir_path` (absolute filesystem path)
667    /// requested at `request_path` (the URL path, used to build links and breadcrumbs).
668    /// Hidden entries (dotfiles) are omitted. Directories sort before files;
669    /// each group is sorted case-insensitively by name.
670    pub fn render_directory_listing(fs_dir_path: &str, request_path: &str) -> String {
671        let normalized_request_path = if request_path.ends_with('/') {
672            request_path.to_string()
673        } else {
674            format!("{}/", request_path)
675        };
676
677        struct Entry {
678            name: String,
679            is_dir: bool,
680            size: u64,
681            modified_epoch_secs: u64,
682        }
683
684        let mut entries: Vec<Entry> = Vec::new();
685        if let Ok(dir_entries) = read_dir(fs_dir_path) {
686            for dir_entry in dir_entries.flatten() {
687                let name = dir_entry.file_name().to_string_lossy().to_string();
688                if name.starts_with('.') {
689                    continue;
690                }
691
692                if let Ok(md) = dir_entry.metadata() {
693                    let modified_epoch_secs = md.modified()
694                        .ok()
695                        .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
696                        .map(|d| d.as_secs())
697                        .unwrap_or(0);
698
699                    entries.push(Entry {
700                        name,
701                        is_dir: md.is_dir(),
702                        size: md.len(),
703                        modified_epoch_secs,
704                    });
705                }
706            }
707        }
708
709        entries.sort_by(|a, b| {
710            match (a.is_dir, b.is_dir) {
711                (true, false) => std::cmp::Ordering::Less,
712                (false, true) => std::cmp::Ordering::Greater,
713                _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
714            }
715        });
716
717        let segments: Vec<&str> = normalized_request_path.split('/').filter(|s| !s.is_empty()).collect();
718
719        let mut breadcrumb_html = String::from("<a href=\"/\">~</a>");
720        let mut accumulated_path = String::new();
721        for segment in &segments {
722            accumulated_path.push('/');
723            accumulated_path.push_str(segment);
724            breadcrumb_html.push_str(&format!(
725                " <span class=\"sep\">/</span> <a href=\"{}/\">{}</a>",
726                accumulated_path, html_escape(segment)
727            ));
728        }
729
730        let parent_row = if !segments.is_empty() {
731            let parent_path = if segments.len() > 1 {
732                format!("/{}/", segments[..segments.len() - 1].join("/"))
733            } else {
734                "/".to_string()
735            };
736            format!(
737                "<tr class=\"entry parent\"><td class=\"name\"><span class=\"icon\">\u{21A9}\u{FE0F}</span><a href=\"{}\">.. (parent directory)</a></td><td class=\"size\">\u{2014}</td><td class=\"modified\">\u{2014}</td></tr>\n",
738                parent_path
739            )
740        } else {
741            String::new()
742        };
743
744        let entry_count = entries.len();
745
746        let mut rows = String::new();
747        for entry in &entries {
748            let escaped_name = html_escape(&entry.name);
749            let encoded_name = URL::percent_encode(&entry.name);
750            let href = if entry.is_dir {
751                format!("{}{}/", normalized_request_path, encoded_name)
752            } else {
753                format!("{}{}", normalized_request_path, encoded_name)
754            };
755            let size_display = if entry.is_dir { "\u{2014}".to_string() } else { human_size(entry.size) };
756            let modified_display = format_modified(entry.modified_epoch_secs);
757            let icon = icon_for(&entry.name, entry.is_dir);
758            let filter_key = html_escape(&entry.name.to_lowercase());
759
760            rows.push_str(&format!(
761                "<tr class=\"entry\" data-name=\"{}\"><td class=\"name\"><span class=\"icon\">{}</span><a href=\"{}\">{}{}</a></td><td class=\"size\">{}</td><td class=\"modified\">{}</td></tr>\n",
762                filter_key, icon, href, escaped_name, if entry.is_dir { "/" } else { "" }, size_display, modified_display
763            ));
764        }
765
766        let mut html = String::new();
767        html.push_str(DIRECTORY_LISTING_HEAD);
768        html.push_str(&format!("<div class=\"breadcrumb\">{}</div>\n", breadcrumb_html));
769        html.push_str(&format!("<h1>Index of {}</h1>\n", html_escape(&normalized_request_path)));
770        html.push_str(DIRECTORY_LISTING_TOOLBAR);
771        html.push_str("<div class=\"card\"><table><thead><tr><th>Name</th><th>Size</th><th>Modified</th></tr></thead><tbody id=\"rows\">\n");
772        html.push_str(&parent_row);
773        html.push_str(&rows);
774        html.push_str("</tbody></table></div>\n");
775        html.push_str(&format!(
776            "<footer>{} item{} &middot; served by rws</footer>\n",
777            entry_count, if entry_count == 1 { "" } else { "s" }
778        ));
779        html.push_str(DIRECTORY_LISTING_TAIL);
780
781        html
782    }
783}
784
785/// `true` if the last `/`-separated segment of `path` has a file extension
786/// (contains a `.` after the final `/`) — the standard heuristic (also used
787/// by webpack-dev-server's `historyApiFallback`, `sirv`, `vite preview`)
788/// distinguishing a client-side route (`/dashboard/settings`, no extension)
789/// from a missed static asset (`/logo.png`, has one), so the SPA fallback
790/// doesn't silently swallow the latter's real 404.
791fn path_has_extension(path: &str) -> bool {
792    path.rsplit('/').next().unwrap_or("").contains('.')
793}
794
795/// Resolves the SPA-fallback "URL path" to pass to [`Range::get_content_range_list`]
796/// for a request at `url_path` — e.g. `"/index.html"` for the common
797/// `RWS_CONFIG_SPA_FALLBACK=index.html` case — or `None` if the fallback doesn't
798/// apply: unconfigured, `url_path` is under an excluded prefix
799/// (`RWS_CONFIG_SPA_FALLBACK_EXCLUDE_PREFIXES`), `url_path` looks like a missed
800/// static asset (see [`path_has_extension`]), or the configured file doesn't
801/// actually exist on disk. Method filtering (GET/HEAD only) is the caller's
802/// responsibility — both call sites already have it via `is_matching_method`.
803fn spa_fallback_url_path(url_path: &str) -> Option<String> {
804    if path_has_extension(url_path) {
805        return None;
806    }
807
808    let fallback_name = crate::entry_point::get_spa_fallback()?;
809
810    let exclude_prefixes = crate::entry_point::get_spa_fallback_exclude_prefixes();
811    if exclude_prefixes.iter().any(|prefix| url_path.starts_with(prefix.as_str())) {
812        return None;
813    }
814
815    let separator = FileExt::get_path_separator();
816    let normalized_name = fallback_name.trim_start_matches('/').replace('/', &separator);
817    let fallback_url_path = format!("{}{}", separator, normalized_name);
818
819    let boxed_fs_path = static_fs_path(&fallback_url_path);
820    let fs_path = boxed_fs_path.ok()?;
821    if File::open(&fs_path).is_ok() {
822        Some(fallback_url_path)
823    } else {
824        None
825    }
826}
827
828fn html_escape(s: &str) -> String {
829    let mut out = String::with_capacity(s.len());
830    for c in s.chars() {
831        match c {
832            '&' => out.push_str("&amp;"),
833            '<' => out.push_str("&lt;"),
834            '>' => out.push_str("&gt;"),
835            '"' => out.push_str("&quot;"),
836            '\'' => out.push_str("&#39;"),
837            _ => out.push(c),
838        }
839    }
840    out
841}
842
843fn human_size(bytes: u64) -> String {
844    const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
845    if bytes < 1024 {
846        return format!("{} {}", bytes, UNITS[0]);
847    }
848
849    let mut size = bytes as f64;
850    let mut unit_index = 0;
851    while size >= 1024.0 && unit_index < UNITS.len() - 1 {
852        size /= 1024.0;
853        unit_index += 1;
854    }
855
856    format!("{:.1} {}", size, UNITS[unit_index])
857}
858
859fn format_modified(epoch_secs: u64) -> String {
860    let (sec, min, hour, day, month, _dow) = crate::scheduler::cron::epoch_to_datetime(epoch_secs);
861    let (year, _, _) = crate::scheduler::cron::days_to_ymd(epoch_secs / 86400);
862    format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02}", year, month, day, hour, min, sec)
863}
864
865fn icon_for(name: &str, is_dir: bool) -> &'static str {
866    if is_dir {
867        return "\u{1F4C1}";
868    }
869
870    let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
871    match ext.as_str() {
872        "png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "ico" | "bmp" => "\u{1F5BC}\u{FE0F}",
873        "mp4" | "mov" | "avi" | "mkv" | "webm" => "\u{1F3A5}",
874        "mp3" | "wav" | "flac" | "m4a" | "ogg" => "\u{1F3A7}",
875        "zip" | "tar" | "gz" | "rar" | "7z" | "bz2" => "\u{1F4E6}",
876        "pdf" => "\u{1F4D5}",
877        "html" | "htm" | "css" | "js" | "ts" | "rs" | "py" | "json" | "toml" | "yaml" | "yml" | "sh" => "\u{1F9E9}",
878        _ => "\u{1F4C4}",
879    }
880}
881
882// CSS/JS are served as same-origin `<link>`/`<script src>` assets (see
883// `crate::app::controller::directory_listing::DirectoryListingAssetsController`)
884// rather than inlined here — inline `<style>`/`<script>` would be silently
885// blocked under the framework's default `Content-Security-Policy: default-src 'self'`.
886const DIRECTORY_LISTING_HEAD: &str = concat!(
887    "<!DOCTYPE html>\n",
888    "<html lang=\"en\">\n",
889    "<head>\n",
890    "<meta charset=\"UTF-8\">\n",
891    "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n",
892    "<title>Directory listing</title>\n",
893    "<link rel=\"stylesheet\" href=\"/rws-directory-listing.css\">\n",
894    "</head>\n",
895    "<body>\n",
896    "<div class=\"wrap\">\n",
897);
898
899const DIRECTORY_LISTING_TOOLBAR: &str = "<div class=\"toolbar\"><input type=\"search\" id=\"filter\" placeholder=\"Filter entries...\" autocomplete=\"off\"></div>\n";
900
901const DIRECTORY_LISTING_TAIL: &str = concat!(
902    "</div>\n",
903    "<script src=\"/rws-directory-listing.js\" defer></script>\n",
904    "</body>\n",
905    "</html>\n",
906);
907
908/// The process working directory — the static-file root — read once.
909/// `rws` never changes it after startup, and `std::env::current_dir()` is
910/// several syscalls (on macOS it opens "."): the static-file path used to ask
911/// for it four to six times per request. A library user who changes the
912/// working directory after the first request keeps the original root.
913/// Push `Last-Modified` (and `Last-Modified-Unix-Epoch-Nanos` with
914/// `nanos_headers`), and tell whether the request's validators say the
915/// client's copy is current ([`is_not_modified`]).
916fn push_validators(request: &Request, response: &mut Response, modified_nanos: u128, etag: &str, nanos_headers: bool) -> bool {
917    if nanos_headers {
918        response.headers.push(Header { name: Header::_LAST_MODIFIED_UNIX_EPOCH_NANOS.into(), value: modified_nanos.to_string().into() });
919    }
920    response.headers.push(Header { name: Header::_LAST_MODIFIED.into(), value: crate::http::date::format((modified_nanos / 1_000_000_000) as u64).into() });
921    is_not_modified(request, modified_nanos, etag)
922}
923
924/// RFC 9110 §13.1: `If-None-Match` (the ETag, or `*`) decides when present;
925/// otherwise `If-Modified-Since` for GET/HEAD — the file's modification
926/// second is not after the given date. An unparseable date is ignored.
927fn is_not_modified(request: &Request, modified_nanos: u128, etag: &str) -> bool {
928    if let Some(inm) = request.header(Header::_IF_NONE_MATCH) {
929        return if_none_match_matches(&inm.value, etag);
930    }
931    if request.method != METHOD.get && request.method != METHOD.head {
932        return false;
933    }
934    request
935        .header(Header::_IF_MODIFIED_SINCE)
936        .and_then(|ims| crate::http::date::parse(&ims.value))
937        .is_some_and(|since| (modified_nanos / 1_000_000_000) as u64 <= since)
938}
939
940/// The static-file `Cache-Control` (`RWS_CONFIG_STATIC_CACHE_CONTROL`) in
941/// place of the default `no-store, …`; an empty setting keeps the default.
942fn set_static_cache_control(response: &mut Response, value: &str) {
943    if value.is_empty() {
944        return;
945    }
946    if let Some(header) = response.headers.iter_mut().find(|h| h.name == Header::_CACHE_CONTROL) {
947        header.value = if value == crate::server_config::STATIC_CACHE_CONTROL_DEFAULT {
948            std::borrow::Cow::Borrowed(crate::server_config::STATIC_CACHE_CONTROL_DEFAULT)
949        } else {
950            std::borrow::Cow::Owned(value.to_string())
951        };
952    }
953}
954
955pub(crate) fn working_directory() -> &'static str {
956    static DIR: std::sync::OnceLock<String> = std::sync::OnceLock::new();
957    DIR.get_or_init(|| {
958        std::env::current_dir()
959            .map(|d| d.to_string_lossy().into_owned())
960            .unwrap_or_default()
961    })
962}
963
964/// `If-None-Match` against the current entity tag (RFC 9110 §13.1.2): a
965/// comma-separated list, or `*`, compared weakly — `W/"x"` matches `"x"`
966/// (#56). Before 17.113.2 only a header equal to the tag matched, so a
967/// client sending several tags, or a weak one, never got a `304`.
968pub(crate) fn if_none_match_matches(header_value: &str, etag: &str) -> bool {
969    let opaque = |tag: &str| tag.trim().strip_prefix("W/").unwrap_or(tag.trim()).to_string();
970    let current = opaque(etag);
971    header_value.split(',').any(|candidate| {
972        let candidate = candidate.trim();
973        candidate == "*" || (!candidate.is_empty() && opaque(candidate) == current)
974    })
975}
976
977/// `true` when the request has a `Range` and an `If-Range` that doesn't
978/// match the file as it is now (RFC 9110 §13.1.5): an entity tag must match
979/// the current `ETag` strongly (a weak tag never does); a date must equal the
980/// file's `Last-Modified` and be a strong validator — at least a second
981/// older than now, so a second write within that second can't share it.
982/// Then the `Range` is ignored and the whole file is sent: the client's
983/// piece is out of date.
984pub(crate) fn range_is_stale(request: &Request) -> bool {
985    let Some(if_range) = request.header(Header::_IF_RANGE) else { return false };
986    if request.header(Header::_RANGE).is_none() {
987        return false;
988    }
989    let validator = if_range.value.trim();
990    let Some(path) = URL::request_target_path(&request.request_uri) else { return true };
991    let Ok(fs_path) = static_fs_path(&os_specific_path(path)) else { return true };
992    let (Ok(modified), Ok(md)) = (file_modified_utc(&fs_path), metadata(&fs_path)) else { return true };
993    if validator.starts_with('"') {
994        return validator != format!("\"{}-{}\"", modified, md.len());
995    }
996    let modified_secs = (modified / 1_000_000_000) as u64;
997    let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
998    match crate::http::date::parse(validator) {
999        Some(date) => date != modified_secs || now < modified_secs + 1,
1000        None => true,
1001    }
1002}
1003
1004/// `FileExt::get_static_filepath` without the per-call `current_dir()`:
1005/// the working directory followed by `path` — refused (`Err`) when `path`
1006/// could leave the web root or names a hidden file (#43):
1007///
1008/// - a `..` segment (`/../etc/passwd`): served before 17.113.1;
1009/// - a segment starting with `.` (`.git/`, `.env`), except `.well-known`,
1010///   unless `RWS_CONFIG_SERVE_DOTFILES=true`.
1011///
1012/// A symlink inside the root may still point outside it; [`inside_root`]
1013/// checks the resolved path where files are opened.
1014///
1015/// `path` is the request path as sent: it's percent-decoded here, once,
1016/// before those checks (#55) — `/my%20file.txt` is `my file.txt`, and
1017/// `/%2e%2e/` is refused like `/../`.
1018pub(crate) fn static_fs_path(path: &str) -> Result<String, String> {
1019    let path = percent_decode_path(path)?;
1020    check_request_path(&path)?;
1021    let working_directory = working_directory();
1022    let mut fs_path = String::with_capacity(working_directory.len() + path.len());
1023    fs_path.push_str(working_directory);
1024    fs_path.push_str(&path);
1025    Ok(fs_path)
1026}
1027
1028/// A URL path with `/` turned into the OS path separator — borrowed (no
1029/// copy) where the separator is `/`.
1030pub(crate) fn os_specific_path(path: &str) -> std::borrow::Cow<'_, str> {
1031    if std::path::MAIN_SEPARATOR == '/' {
1032        std::borrow::Cow::Borrowed(path)
1033    } else {
1034        std::borrow::Cow::Owned(path.replace('/', std::path::MAIN_SEPARATOR_STR))
1035    }
1036}
1037
1038/// Percent-decode a request path (RFC 3986 §2.1). `+` stays `+` (it means
1039/// a space only in form-encoded queries); a `%` not followed by two hex
1040/// digits is kept as is; a result that isn't UTF-8 is an error. Borrows
1041/// when there is nothing to decode.
1042pub(crate) fn percent_decode_path(path: &str) -> Result<std::borrow::Cow<'_, str>, String> {
1043    if !path.contains('%') {
1044        return Ok(std::borrow::Cow::Borrowed(path));
1045    }
1046    let hex = |b: u8| (b as char).to_digit(16).map(|d| d as u8);
1047    let bytes = path.as_bytes();
1048    let mut decoded = Vec::with_capacity(bytes.len());
1049    let mut i = 0;
1050    while i < bytes.len() {
1051        if bytes[i] == b'%' && i + 2 < bytes.len() {
1052            if let (Some(high), Some(low)) = (hex(bytes[i + 1]), hex(bytes[i + 2])) {
1053                decoded.push(high << 4 | low);
1054                i += 3;
1055                continue;
1056            }
1057        }
1058        decoded.push(bytes[i]);
1059        i += 1;
1060    }
1061    String::from_utf8(decoded)
1062        .map(std::borrow::Cow::Owned)
1063        .map_err(|_| "path is not UTF-8 after percent-decoding".to_string())
1064}
1065
1066/// The segment rules of [`static_fs_path`], on a path using `/` or the
1067/// platform separator.
1068pub(crate) fn check_request_path(path: &str) -> Result<(), String> {
1069    for segment in path.split(['/', '\\']) {
1070        if segment == ".." {
1071            return Err("path leaves the web root".to_string());
1072        }
1073        if segment.starts_with('.') && segment != "." && segment != ".well-known" && !serve_dotfiles() {
1074            return Err("hidden files are not served".to_string());
1075        }
1076        if segment.contains('\0') {
1077            return Err("invalid path".to_string());
1078        }
1079    }
1080    // Keys and the config file, wherever they sit in the root (#45).
1081    let name = path.rsplit(['/', '\\']).next().unwrap_or("").to_ascii_lowercase();
1082    if name.ends_with(".pem") || name.ends_with(".key") || name == "rws.config.toml" {
1083        return Err("key and config files are not served".to_string());
1084    }
1085    Ok(())
1086}
1087
1088/// `RWS_CONFIG_SERVE_DOTFILES` (default `false`), read once.
1089fn serve_dotfiles() -> bool {
1090    static SERVE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1091    *SERVE.get_or_init(|| {
1092        std::env::var("RWS_CONFIG_SERVE_DOTFILES").is_ok_and(|v| v.trim().eq_ignore_ascii_case("true"))
1093    })
1094}
1095
1096/// `true` when `fs_path` exists and, symlinks resolved, lies inside the web
1097/// root (the working directory). A symlink in the root pointing outside it
1098/// (`assets -> /etc`) is not followed out (#43). One `realpath`: done where
1099/// a file is opened, not per request on the cached fast path.
1100pub(crate) fn inside_root(fs_path: &str) -> bool {
1101    static ROOT: std::sync::OnceLock<Option<std::path::PathBuf>> = std::sync::OnceLock::new();
1102    let Some(root) = ROOT.get_or_init(|| std::fs::canonicalize(working_directory()).ok()) else {
1103        return false;
1104    };
1105    std::fs::canonicalize(fs_path).is_ok_and(|resolved| resolved.starts_with(root))
1106}
1107
1108/// `FileExt::file_modified_utc` (nanoseconds since the Unix epoch) from a
1109/// `stat`, without opening the file.
1110pub(crate) fn file_modified_utc(path: &str) -> Result<u128, String> {
1111    let modified = metadata(path)
1112        .and_then(|md| md.modified())
1113        .map_err(|e| format!("<p>Unable to open file: {}</p> <p>error: {}</p>", path, e))?;
1114    Ok(modified
1115        .duration_since(std::time::UNIX_EPOCH)
1116        .map(|d| d.as_nanos())
1117        .unwrap_or(0))
1118}
1119