static_files_module/
metadata.rs

1// Copyright 2024 Wladimir Palant
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! File metadata handling
16
17use http::header;
18use httpdate::fmt_http_date;
19use mime_guess::MimeGuess;
20use pingora_http::{ResponseHeader, StatusCode};
21use pingora_proxy::Session;
22use std::io::{Error, ErrorKind};
23use std::path::Path;
24use std::time::SystemTime;
25
26/// Helper wrapping file metadata information
27#[derive(Debug)]
28pub struct Metadata {
29    /// Guessed MIME types (if any) for the file
30    pub mime: MimeGuess,
31    /// File size in bytes
32    pub size: u64,
33    /// Last modified time of the file in the format `Fri, 15 May 2015 15:34:21 GMT` if the time
34    /// can be retrieved
35    pub modified: Option<String>,
36    /// ETag header for the file, encoding last modified time and file size
37    pub etag: String,
38}
39
40impl Metadata {
41    /// Collects the metadata for a file. If `orig_path` is present, it will be used to determine
42    /// the MIME type instead of `path`.
43    ///
44    /// This method will return any errors produced by [`std::fs::metadata()`]. It will also result
45    /// in a [`ErrorKind::InvalidInput`] error if the path given doesn’t point to a regular file.
46    pub fn from_path<P: AsRef<Path> + ?Sized>(
47        path: &P,
48        orig_path: Option<&P>,
49    ) -> Result<Self, Error> {
50        let meta = path.as_ref().metadata()?;
51
52        if !meta.is_file() {
53            return Err(ErrorKind::InvalidInput.into());
54        }
55
56        let mime = mime_guess::from_path(orig_path.unwrap_or(path));
57        let size = meta.len();
58        let modified = meta.modified().ok().map(fmt_http_date);
59        let etag = format!(
60            "\"{:x}-{:x}\"",
61            meta.modified()
62                .ok()
63                .and_then(|modified| modified.duration_since(SystemTime::UNIX_EPOCH).ok())
64                .map_or(0, |duration| duration.as_secs()),
65            meta.len()
66        );
67
68        Ok(Self {
69            mime,
70            size,
71            modified,
72            etag,
73        })
74    }
75
76    /// Checks `If-Match` and `If-Unmodified-Since` headers of the request to determine whether
77    /// a `412 Precondition Failed` response should be produced.
78    pub fn has_failed_precondition(&self, session: &Session) -> bool {
79        let headers = &session.req_header().headers;
80        if let Some(value) = headers
81            .get(header::IF_MATCH)
82            .and_then(|value| value.to_str().ok())
83        {
84            value != "*"
85                && value
86                    .split(',')
87                    .map(str::trim)
88                    .all(|value| value != self.etag)
89        } else if let Some(value) = headers
90            .get(header::IF_UNMODIFIED_SINCE)
91            .and_then(|value| value.to_str().ok())
92        {
93            self.modified
94                .as_ref()
95                .is_some_and(|modified| modified != value)
96        } else {
97            false
98        }
99    }
100
101    /// Checks `If-None-Match` and `If-Modified-Since` headers of the request to determine whether
102    /// a `304 Not Modified` response should be produced.
103    pub fn is_not_modified(&self, session: &Session) -> bool {
104        let headers = &session.req_header().headers;
105        if let Some(value) = headers
106            .get(header::IF_NONE_MATCH)
107            .and_then(|value| value.to_str().ok())
108        {
109            value == "*"
110                || value
111                    .split(',')
112                    .map(str::trim)
113                    .any(|value| value == self.etag)
114        } else if let Some(value) = headers
115            .get(header::IF_MODIFIED_SINCE)
116            .and_then(|value| value.to_str().ok())
117        {
118            self.modified
119                .as_ref()
120                .is_some_and(|modified| modified == value)
121        } else {
122            false
123        }
124    }
125
126    #[inline(always)]
127    fn add_common_headers(
128        &self,
129        header: &mut ResponseHeader,
130    ) -> Result<(), Box<pingora_core::Error>> {
131        header.append_header(
132            header::CONTENT_TYPE,
133            self.mime.first_or_octet_stream().as_ref(),
134        )?;
135        if let Some(modified) = &self.modified {
136            header.append_header(header::LAST_MODIFIED, modified)?;
137        }
138        header.append_header(header::ETAG, &self.etag)?;
139        Ok(())
140    }
141
142    /// Produces a `200 OK` response and adds headers according to file metadata.
143    pub(crate) fn to_response_header(
144        &self,
145    ) -> Result<Box<ResponseHeader>, Box<pingora_core::Error>> {
146        let mut header = ResponseHeader::build(StatusCode::OK, Some(8))?;
147        header.append_header(header::CONTENT_LENGTH, self.size.to_string())?;
148        header.append_header(header::ACCEPT_RANGES, "bytes")?;
149        self.add_common_headers(&mut header)?;
150        Ok(Box::new(header))
151    }
152
153    /// Produces a `206 Partial Content` response and adds headers according to file metadata.
154    pub(crate) fn to_partial_content_header(
155        &self,
156        start: u64,
157        end: u64,
158    ) -> Result<Box<ResponseHeader>, Box<pingora_core::Error>> {
159        let mut header = ResponseHeader::build(StatusCode::PARTIAL_CONTENT, Some(8))?;
160        header.append_header(header::CONTENT_LENGTH, (end - start + 1).to_string())?;
161        header.append_header(
162            header::CONTENT_RANGE,
163            format!("bytes {start}-{end}/{}", self.size),
164        )?;
165        self.add_common_headers(&mut header)?;
166        Ok(Box::new(header))
167    }
168
169    /// Produces a response with specified status code and no response body (all headers added
170    /// except `Content-Length``).
171    pub(crate) fn to_custom_header(
172        &self,
173        status: StatusCode,
174    ) -> Result<Box<ResponseHeader>, Box<pingora_core::Error>> {
175        let mut header = ResponseHeader::build(status, Some(4))?;
176        self.add_common_headers(&mut header)?;
177        Ok(Box::new(header))
178    }
179}