poem/web/
static_file.rs

1use std::{
2    collections::Bound,
3    fs::Metadata,
4    io::{Seek, SeekFrom},
5    path::Path,
6    str::FromStr,
7    time::{SystemTime, UNIX_EPOCH},
8};
9
10use bytes::Bytes;
11use headers::{
12    ContentRange, ETag, HeaderMapExt, IfMatch, IfModifiedSince, IfNoneMatch, IfUnmodifiedSince,
13    Range,
14};
15use http::{header, StatusCode};
16use httpdate::HttpDate;
17use mime::Mime;
18use tokio::{fs::File, io::AsyncReadExt};
19
20use crate::{
21    error::StaticFileError, Body, FromRequest, IntoResponse, Request, RequestBody, Response, Result,
22};
23
24/// A response for static file extractor.
25#[derive(Debug)]
26pub enum StaticFileResponse {
27    /// 200 OK
28    Ok {
29        /// Response body
30        body: Body,
31        /// Content length
32        content_length: u64,
33        /// Content type
34        content_type: Option<String>,
35        /// `ETag` header value
36        etag: Option<String>,
37        /// `Last-Modified` header value
38        last_modified: Option<String>,
39        /// `Content-Range` header value
40        content_range: Option<(std::ops::Range<u64>, u64)>,
41    },
42    /// 304 NOT MODIFIED
43    NotModified,
44}
45
46impl StaticFileResponse {
47    /// Set the content type
48    pub fn with_content_type(mut self, ct: impl Into<String>) -> Self {
49        if let StaticFileResponse::Ok { content_type, .. } = &mut self {
50            *content_type = Some(ct.into());
51        }
52        self
53    }
54}
55
56impl IntoResponse for StaticFileResponse {
57    fn into_response(self) -> Response {
58        match self {
59            StaticFileResponse::Ok {
60                body,
61                content_length,
62                content_type,
63                etag,
64                last_modified,
65                content_range,
66            } => {
67                let mut builder = Response::builder()
68                    .header(header::ACCEPT_RANGES, "bytes")
69                    .header(header::CONTENT_LENGTH, content_length);
70
71                if let Some(content_type) = content_type {
72                    builder = builder.content_type(content_type);
73                }
74                if let Some(etag) = etag {
75                    builder = builder.header(header::ETAG, etag);
76                }
77                if let Some(last_modified) = last_modified {
78                    builder = builder.header(header::LAST_MODIFIED, last_modified);
79                }
80
81                if let Some((range, size)) = content_range {
82                    builder = builder
83                        .status(StatusCode::PARTIAL_CONTENT)
84                        .typed_header(ContentRange::bytes(range, size).unwrap());
85                }
86
87                builder.body(body)
88            }
89            StaticFileResponse::NotModified => StatusCode::NOT_MODIFIED.into(),
90        }
91    }
92}
93
94/// An extractor for responding static files.
95#[derive(Debug)]
96pub struct StaticFileRequest {
97    if_match: Option<IfMatch>,
98    if_unmodified_since: Option<IfUnmodifiedSince>,
99    if_none_match: Option<IfNoneMatch>,
100    if_modified_since: Option<IfModifiedSince>,
101    range: Option<Range>,
102}
103
104impl<'a> FromRequest<'a> for StaticFileRequest {
105    async fn from_request(req: &'a Request, _body: &mut RequestBody) -> Result<Self> {
106        Ok(Self {
107            if_match: req.headers().typed_get::<IfMatch>(),
108            if_unmodified_since: req.headers().typed_get::<IfUnmodifiedSince>(),
109            if_none_match: req.headers().typed_get::<IfNoneMatch>(),
110            if_modified_since: req.headers().typed_get::<IfModifiedSince>(),
111            range: req.headers().typed_get::<Range>(),
112        })
113    }
114}
115
116impl StaticFileRequest {
117    /// Create static file response.
118    ///
119    /// `prefer_utf8` - Specifies whether text responses should signal a UTF-8
120    /// encoding.
121    pub fn create_response_from_data(
122        self,
123        data: impl AsRef<[u8]>,
124    ) -> Result<StaticFileResponse, StaticFileError> {
125        let data = data.as_ref();
126
127        // content length
128        let mut content_length = data.len() as u64;
129        let mut content_range = None;
130
131        let body = if let Some((start, end)) = self
132            .range
133            .and_then(|range| range.satisfiable_ranges(data.len() as u64).next())
134        {
135            let start = match start {
136                Bound::Included(n) => n,
137                Bound::Excluded(n) => n + 1,
138                Bound::Unbounded => 0,
139            };
140            let end = match end {
141                Bound::Included(n) => n + 1,
142                Bound::Excluded(n) => n,
143                Bound::Unbounded => content_length,
144            };
145            if end < start || end > content_length {
146                return Err(StaticFileError::RangeNotSatisfiable {
147                    size: content_length,
148                });
149            }
150
151            if start != 0 || end != content_length {
152                content_range = Some((start..end, content_length));
153            }
154
155            content_length = end - start;
156            Body::from_bytes(Bytes::copy_from_slice(
157                &data[start as usize..(start + content_length) as usize],
158            ))
159        } else {
160            Body::from_bytes(Bytes::copy_from_slice(data))
161        };
162
163        Ok(StaticFileResponse::Ok {
164            body,
165            content_length,
166            content_type: None,
167            etag: None,
168            last_modified: None,
169            content_range,
170        })
171    }
172
173    /// Create static file response.
174    ///
175    /// `prefer_utf8` - Specifies whether text responses should signal a UTF-8
176    /// encoding.
177    pub fn create_response(
178        self,
179        path: impl AsRef<Path>,
180        prefer_utf8: bool,
181    ) -> Result<StaticFileResponse, StaticFileError> {
182        let path = path.as_ref();
183        if !path.exists() || !path.is_file() {
184            return Err(StaticFileError::NotFound);
185        }
186        let guess = mime_guess::from_path(path);
187        let mut file = std::fs::File::open(path)?;
188        let metadata = file.metadata()?;
189
190        // content length
191        let mut content_length = metadata.len();
192
193        // content type
194        let content_type = guess.first().map(|mime| {
195            if prefer_utf8 {
196                equiv_utf8_text(mime).to_string()
197            } else {
198                mime.to_string()
199            }
200        });
201
202        // etag and last modified
203        let mut etag_str = String::new();
204        let mut last_modified_str = String::new();
205
206        if let Ok(modified) = metadata.modified() {
207            etag_str = etag(ino(&metadata), &modified, metadata.len());
208            let etag = ETag::from_str(&etag_str).unwrap();
209
210            if let Some(if_match) = self.if_match {
211                if !if_match.precondition_passes(&etag) {
212                    return Err(StaticFileError::PreconditionFailed);
213                }
214            }
215
216            if let Some(if_unmodified_since) = self.if_unmodified_since {
217                if !if_unmodified_since.precondition_passes(modified) {
218                    return Err(StaticFileError::PreconditionFailed);
219                }
220            }
221
222            if let Some(if_non_match) = self.if_none_match {
223                if !if_non_match.precondition_passes(&etag) {
224                    return Ok(StaticFileResponse::NotModified);
225                }
226            } else if let Some(if_modified_since) = self.if_modified_since {
227                if !if_modified_since.is_modified(modified) {
228                    return Ok(StaticFileResponse::NotModified);
229                }
230            }
231
232            last_modified_str = HttpDate::from(modified).to_string();
233        }
234
235        let mut content_range = None;
236
237        let body = if let Some((start, end)) = self
238            .range
239            .and_then(|range| range.satisfiable_ranges(metadata.len()).next())
240        {
241            let start = match start {
242                Bound::Included(n) => n,
243                Bound::Excluded(n) => n + 1,
244                Bound::Unbounded => 0,
245            };
246            let end = match end {
247                Bound::Included(n) => n + 1,
248                Bound::Excluded(n) => n,
249                Bound::Unbounded => metadata.len(),
250            };
251            if end < start || end > metadata.len() {
252                return Err(StaticFileError::RangeNotSatisfiable {
253                    size: metadata.len(),
254                });
255            }
256
257            if start != 0 || end != metadata.len() {
258                content_range = Some((start..end, metadata.len()));
259            }
260
261            content_length = end - start;
262            file.seek(SeekFrom::Start(start))?;
263            Body::from_async_read(File::from_std(file).take(end - start))
264        } else {
265            Body::from_async_read(File::from_std(file))
266        };
267
268        Ok(StaticFileResponse::Ok {
269            body,
270            content_length,
271            content_type,
272            etag: if !etag_str.is_empty() {
273                Some(etag_str)
274            } else {
275                None
276            },
277            last_modified: if !last_modified_str.is_empty() {
278                Some(last_modified_str)
279            } else {
280                None
281            },
282            content_range,
283        })
284    }
285}
286
287fn equiv_utf8_text(ct: Mime) -> Mime {
288    if ct == mime::APPLICATION_JAVASCRIPT {
289        return mime::APPLICATION_JAVASCRIPT_UTF_8;
290    }
291
292    if ct == mime::TEXT_HTML {
293        return mime::TEXT_HTML_UTF_8;
294    }
295
296    if ct == mime::TEXT_CSS {
297        return mime::TEXT_CSS_UTF_8;
298    }
299
300    if ct == mime::TEXT_PLAIN {
301        return mime::TEXT_PLAIN_UTF_8;
302    }
303
304    if ct == mime::TEXT_CSV {
305        return mime::TEXT_CSV_UTF_8;
306    }
307
308    if ct == mime::TEXT_TAB_SEPARATED_VALUES {
309        return mime::TEXT_TAB_SEPARATED_VALUES_UTF_8;
310    }
311
312    ct
313}
314
315#[allow(unused_variables)]
316fn ino(md: &Metadata) -> u64 {
317    #[cfg(unix)]
318    {
319        std::os::unix::fs::MetadataExt::ino(md)
320    }
321    #[cfg(not(unix))]
322    {
323        0
324    }
325}
326
327fn etag(ino: u64, modified: &SystemTime, len: u64) -> String {
328    let dur = modified
329        .duration_since(UNIX_EPOCH)
330        .expect("modification time must be after epoch");
331
332    format!(
333        "\"{:x}:{:x}:{:x}:{:x}\"",
334        ino,
335        len,
336        dur.as_secs(),
337        dur.subsec_nanos()
338    )
339}
340
341#[cfg(test)]
342mod tests {
343    use std::{path::Path, time::Duration};
344
345    use super::*;
346
347    impl StaticFileResponse {
348        fn etag(&self) -> String {
349            match self {
350                StaticFileResponse::Ok { etag, .. } => etag.clone().unwrap(),
351                _ => panic!(),
352            }
353        }
354
355        fn last_modified(&self) -> String {
356            match self {
357                StaticFileResponse::Ok { last_modified, .. } => last_modified.clone().unwrap(),
358                _ => panic!(),
359            }
360        }
361    }
362
363    #[test]
364    fn test_equiv_utf8_text() {
365        assert_eq!(
366            equiv_utf8_text(mime::APPLICATION_JAVASCRIPT),
367            mime::APPLICATION_JAVASCRIPT_UTF_8
368        );
369        assert_eq!(equiv_utf8_text(mime::TEXT_HTML), mime::TEXT_HTML_UTF_8);
370        assert_eq!(equiv_utf8_text(mime::TEXT_CSS), mime::TEXT_CSS_UTF_8);
371        assert_eq!(equiv_utf8_text(mime::TEXT_PLAIN), mime::TEXT_PLAIN_UTF_8);
372        assert_eq!(equiv_utf8_text(mime::TEXT_CSV), mime::TEXT_CSV_UTF_8);
373        assert_eq!(
374            equiv_utf8_text(mime::TEXT_TAB_SEPARATED_VALUES),
375            mime::TEXT_TAB_SEPARATED_VALUES_UTF_8
376        );
377
378        assert_eq!(equiv_utf8_text(mime::TEXT_XML), mime::TEXT_XML);
379        assert_eq!(equiv_utf8_text(mime::IMAGE_PNG), mime::IMAGE_PNG);
380    }
381
382    async fn check_response(req: Request) -> Result<StaticFileResponse, StaticFileError> {
383        let static_file = StaticFileRequest::from_request_without_body(&req)
384            .await
385            .unwrap();
386        static_file.create_response(Path::new("Cargo.toml"), false)
387    }
388
389    #[tokio::test]
390    async fn test_if_none_match() {
391        let resp = check_response(Request::default()).await.unwrap();
392        assert!(matches!(resp, StaticFileResponse::Ok { .. }));
393        let etag = resp.etag();
394
395        let resp = check_response(Request::builder().header("if-none-match", etag).finish())
396            .await
397            .unwrap();
398        assert!(matches!(resp, StaticFileResponse::NotModified));
399
400        let resp = check_response(Request::builder().header("if-none-match", "abc").finish())
401            .await
402            .unwrap();
403        assert!(matches!(resp, StaticFileResponse::Ok { .. }));
404    }
405
406    #[tokio::test]
407    async fn test_if_modified_since() {
408        let resp = check_response(Request::default()).await.unwrap();
409        assert!(matches!(resp, StaticFileResponse::Ok { .. }));
410        let modified = resp.last_modified();
411
412        let resp = check_response(
413            Request::builder()
414                .header("if-modified-since", &modified)
415                .finish(),
416        )
417        .await
418        .unwrap();
419        assert!(matches!(resp, StaticFileResponse::NotModified));
420
421        let mut t: SystemTime = HttpDate::from_str(&modified).unwrap().into();
422        t -= Duration::from_secs(1);
423
424        let resp = check_response(
425            Request::builder()
426                .header("if-modified-since", HttpDate::from(t).to_string())
427                .finish(),
428        )
429        .await
430        .unwrap();
431        assert!(matches!(resp, StaticFileResponse::Ok { .. }));
432
433        let mut t: SystemTime = HttpDate::from_str(&modified).unwrap().into();
434        t += Duration::from_secs(1);
435
436        let resp = check_response(
437            Request::builder()
438                .header("if-modified-since", HttpDate::from(t).to_string())
439                .finish(),
440        )
441        .await
442        .unwrap();
443        assert!(matches!(resp, StaticFileResponse::NotModified));
444    }
445
446    #[tokio::test]
447    async fn test_if_match() {
448        let resp = check_response(Request::default()).await.unwrap();
449        assert!(matches!(resp, StaticFileResponse::Ok { .. }));
450        let etag = resp.etag();
451
452        let resp = check_response(Request::builder().header("if-match", etag).finish())
453            .await
454            .unwrap();
455        assert!(matches!(resp, StaticFileResponse::Ok { .. }));
456
457        let err = check_response(Request::builder().header("if-match", "abc").finish())
458            .await
459            .unwrap_err();
460        assert!(matches!(err, StaticFileError::PreconditionFailed));
461    }
462
463    #[tokio::test]
464    async fn test_if_unmodified_since() {
465        let resp = check_response(Request::default()).await.unwrap();
466        assert!(matches!(resp, StaticFileResponse::Ok { .. }));
467        let modified = resp.last_modified();
468
469        let resp = check_response(
470            Request::builder()
471                .header("if-unmodified-since", &modified)
472                .finish(),
473        )
474        .await
475        .unwrap();
476        assert!(matches!(resp, StaticFileResponse::Ok { .. }));
477
478        let mut t: SystemTime = HttpDate::from_str(&modified).unwrap().into();
479        t += Duration::from_secs(1);
480        let resp = check_response(
481            Request::builder()
482                .header("if-unmodified-since", HttpDate::from(t).to_string())
483                .finish(),
484        )
485        .await
486        .unwrap();
487        assert!(matches!(resp, StaticFileResponse::Ok { .. }));
488
489        let mut t: SystemTime = HttpDate::from_str(&modified).unwrap().into();
490        t -= Duration::from_secs(1);
491        let err = check_response(
492            Request::builder()
493                .header("if-unmodified-since", HttpDate::from(t).to_string())
494                .finish(),
495        )
496        .await
497        .unwrap_err();
498        assert!(matches!(err, StaticFileError::PreconditionFailed));
499    }
500
501    #[tokio::test]
502    async fn test_range_partial_content() {
503        let static_file = StaticFileRequest::from_request_without_body(
504            &Request::builder()
505                .typed_header(Range::bytes(0..10).unwrap())
506                .finish(),
507        )
508        .await
509        .unwrap();
510        let resp = static_file
511            .create_response(Path::new("Cargo.toml"), false)
512            .unwrap();
513        match resp {
514            StaticFileResponse::Ok { content_range, .. } => {
515                assert_eq!(content_range.unwrap().0, 0..10);
516            }
517            StaticFileResponse::NotModified => panic!(),
518        }
519    }
520
521    #[tokio::test]
522    async fn test_range_full_content() {
523        let md = std::fs::metadata("Cargo.toml").unwrap();
524
525        let static_file = StaticFileRequest::from_request_without_body(
526            &Request::builder()
527                .typed_header(Range::bytes(0..md.len()).unwrap())
528                .finish(),
529        )
530        .await
531        .unwrap();
532        let resp = static_file
533            .create_response(Path::new("Cargo.toml"), false)
534            .unwrap();
535        match resp {
536            StaticFileResponse::Ok { content_range, .. } => {
537                assert!(content_range.is_none());
538            }
539            StaticFileResponse::NotModified => panic!(),
540        }
541    }
542
543    #[tokio::test]
544    async fn test_range_413() {
545        let md = std::fs::metadata("Cargo.toml").unwrap();
546
547        let static_file = StaticFileRequest::from_request_without_body(
548            &Request::builder()
549                .typed_header(Range::bytes(0..md.len() + 1).unwrap())
550                .finish(),
551        )
552        .await
553        .unwrap();
554        let err = static_file
555            .create_response(Path::new("Cargo.toml"), false)
556            .unwrap_err();
557
558        match err {
559            StaticFileError::RangeNotSatisfiable { size } => assert_eq!(size, md.len()),
560            _ => panic!(),
561        }
562    }
563}