Skip to main content

rust_web_server/app/controller/static_resource/
mod.rs

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