Skip to main content

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