dhttp/reqres/
file.rs

1use std::io::SeekFrom;
2use std::path::Path;
3use std::ffi::OsStr;
4use std::collections::HashMap;
5use std::sync::LazyLock;
6use std::time::UNIX_EPOCH;
7
8use tokio::io::AsyncSeekExt;
9use tokio::fs::File;
10
11use crate::core::HttpResult;
12use crate::reqres::{HttpRequest, HttpResponse, HttpHeader, HttpBody, StatusCode};
13use crate::util::httpdate;
14
15/// Responds with a file
16pub async fn file(req: &HttpRequest, name: &Path) -> HttpResult {
17    let mut file = File::open(name).await?;
18    let metadata = file.metadata().await?;
19    let mut len = metadata.len();
20
21    // becomes PARTIAL_CONTENT if range was served
22    let mut code = StatusCode::OK;
23    let content_type = get_content_type(name.extension()).unwrap_or_default().to_string();
24    let mut headers = vec![];
25    let mut body;
26
27    // Last-Modified
28    let time = metadata.modified().ok();
29    if let Some(value) = time // fails when field not supported
30        .and_then(httpdate::from_systime) // fails on overflow
31    {
32        headers.push(HttpHeader { name: "Last-Modified".to_string(), value });
33    }
34
35    // Date
36    if let Some(date) = httpdate::now() {
37        headers.push(HttpHeader { name: "Date".to_string(), value: date });
38    }
39
40    // Advertise byte ranges support
41    headers.push(HttpHeader {
42        name: "Accept-Ranges".to_string(),
43        value: "bytes".to_string(),
44    });
45
46    // Parse byte range request
47    if let Some(range) = req.get_header("Range") {
48        if let Some((start, mut end)) = parse_range(range) && start <= len && start <= end {
49            end = end.min(len);
50
51            headers.push(HttpHeader {
52                name: "Content-Range".to_string(),
53                value: format!("bytes {start}-{end}/{len}"),
54            });
55
56            file.seek(SeekFrom::Start(start)).await?;
57            len = end - start + 1;
58            code = StatusCode::PARTIAL_CONTENT;
59        } else {
60            // we have to set Content-Range in case of error too but errors can't have headers in dhttp
61            return Err(StatusCode::RANGE_NOT_SATISFIABLE.into());
62        }
63    }
64
65    body = HttpBody::File { file, len };
66
67    // If-Modified-SinceπŸ›πŸ›πŸ›
68    if let Some(time) = time
69        && let Some(time) = time.duration_since(UNIX_EPOCH).ok()
70        && let Some(if_modified_since) = req.get_header("If-Modified-Since")
71        && let Some(parsed) = httpdate::parse(if_modified_since)
72        && parsed >= time.as_secs() as i64
73    {
74        code = StatusCode::NOT_MODIFIED;
75        body = HttpBody::Empty;
76    }
77
78    Ok(HttpResponse { code, headers, body, content_type })
79}
80
81fn parse_range(range: &str) -> Option<(u64, u64)> {
82    let (start, end) = range.strip_prefix("bytes=")?.split_once('-')?;
83    let start = if start.is_empty() { 0 } else { start.parse().ok()? };
84    let end = if end.is_empty() { u64::MAX } else { end.parse().ok()? };
85    Some((start, end))
86}
87
88// This is only for files loaded/previewed by web browser
89static CONTENT_TYPES: LazyLock<HashMap<&'static OsStr, &'static str>> = LazyLock::new(|| HashMap::from([
90    // text/application
91    (os!("html"), "text/html"),
92    (os!("htm"), "text/html"),
93    (os!("css"), "text/css"),
94    (os!("js"), "application/javascript"),
95    (os!("txt"), "text/plain"),
96    (os!("xml"), "text/xml"),
97    (os!("json"), "application/json"),
98    (os!("wasm"), "application/wasm"),
99    // images
100    (os!("png"), "image/png"),
101    (os!("jpg"), "image/jpeg"),
102    (os!("jpeg"), "image/jpeg"),
103    (os!("webp"), "image/webp"),
104    (os!("avif"), "image/avif"),
105    (os!("jxl"), "image/jxl"),
106    (os!("gif"), "image/gif"),
107    (os!("svg"), "image/svg+xml"),
108    (os!("svgz"), "image/svg+xml"),
109    // videos
110    (os!("mp4"), "video/mp4"),
111    (os!("mkv"), "video/matroska"),
112    (os!("webm"), "video/webm"),
113    (os!("avi"), "video/x-msvideo"),
114    (os!("m3u8"), "application/vnd.apple.mpegurl"),
115    (os!("mov"), "video/quicktime"),
116    // audio
117    (os!("mp3"), "audio/mpeg"),
118    (os!("ogg"), "audio/ogg"),
119    (os!("m4a"), "audio/mp4"),
120    // fonts
121    (os!("woff"), "font/woff"),
122    (os!("woff2"), "font/woff2"),
123    // documents
124    (os!("pdf"), "application/pdf"),
125]));
126
127fn get_content_type(ext: Option<&OsStr>) -> Option<&'static str> {
128    CONTENT_TYPES.get(ext?.to_ascii_lowercase().as_os_str()).copied()
129}
130
131macro_rules! os {
132    ($s:literal) => { OsStr::new($s) }
133}
134use os;