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