actix_web_rust_embed_responder/
embed.rs

1use actix_web::{
2    body::{BoxBody, MessageBody},
3    http::Method,
4    HttpRequest, HttpResponse, Responder,
5};
6
7#[cfg(feature = "compression-zstd")]
8use crate::compress_data_zstd;
9use crate::{
10    compress::Compress, compress_data_br, compress_data_gzip, helper::accepts_encoding,
11    is_well_known_compressible_mime_type, parse::parse_if_none_match_value,
12};
13
14/// A common trait used internally to create HTTP responses.
15///
16/// This trait is internally implemented for both `rust-embed` and
17/// `rust-embed-for-web` types. You could also implement it for your own type if
18/// you wish to use the response handling capabilities of this crate without
19/// embedded files.
20pub trait EmbedRespondable {
21    type Data: MessageBody + 'static + AsRef<[u8]>;
22    type DataGzip: MessageBody + 'static + AsRef<[u8]>;
23    type DataBr: MessageBody + 'static + AsRef<[u8]>;
24    type DataZstd: MessageBody + 'static + AsRef<[u8]>;
25    type MimeType: AsRef<str>;
26    type ETag: AsRef<str>;
27    type LastModified: AsRef<str>;
28
29    /// The contents of the embedded file.
30    fn data(&self) -> Self::Data;
31    /// The contents of the file compressed with gzip.
32    ///
33    /// `Some` if precompression has been done, `None` if the file was not precompressed.
34    fn data_gzip(&self) -> Option<Self::DataGzip>;
35    /// The contents of the file compressed with brotli.
36    ///
37    /// `Some` if precompression has been done, `None` if the file was not precompressed.
38    fn data_br(&self) -> Option<Self::DataBr>;
39    /// The contents of the file compressed with zstd.
40    ///
41    /// `Some` if precompression has been done, `None` if the file was not precompressed.
42    fn data_zstd(&self) -> Option<Self::DataZstd>;
43    /// The UNIX timestamp of when the file was last modified.
44    fn last_modified_timestamp(&self) -> Option<i64>;
45    /// The rfc2822 encoded last modified date.
46    fn last_modified(&self) -> Option<Self::LastModified>;
47    /// The ETag value for the file, based on its hash.
48    fn etag(&self) -> Self::ETag;
49    /// The mime type for the file, if one has been guessed.
50    fn mime_type(&self) -> Option<Self::MimeType>;
51}
52
53/// An opaque wrapper around the embedded file.
54///
55/// You don't manually create these objects, you should use `.into_response()`
56/// or `.into()` to convert an embedded file into an `EmbedResponse`.
57pub struct EmbedResponse<T: EmbedRespondable> {
58    pub(crate) file: Option<T>,
59    pub(crate) compress: Compress,
60}
61
62enum ShouldCompress {
63    Zstd,
64    Brotli,
65    Gzip,
66    No,
67}
68
69fn should_compress<T: EmbedRespondable>(
70    req: &HttpRequest,
71    file: &T,
72    compress: &Compress,
73) -> ShouldCompress {
74    let should_compress_for_encoding =
75        |is_precompressed_for_encoding: bool, mime_type: Option<T::MimeType>, encoding: &str| {
76            accepts_encoding(req, encoding)
77                && match compress {
78                    Compress::Never => false,
79                    Compress::IfPrecompressed => is_precompressed_for_encoding,
80                    Compress::IfWellKnown => mime_type
81                        .map(|v| is_well_known_compressible_mime_type(v.as_ref()))
82                        .unwrap_or(false),
83                    Compress::Always => true,
84                }
85        };
86
87    if should_compress_for_encoding(file.data_zstd().is_some(), file.mime_type(), "zstd") {
88        ShouldCompress::Zstd
89    } else if should_compress_for_encoding(file.data_br().is_some(), file.mime_type(), "br") {
90        ShouldCompress::Brotli
91    } else if should_compress_for_encoding(file.data_gzip().is_some(), file.mime_type(), "gzip") {
92        ShouldCompress::Gzip
93    } else {
94        ShouldCompress::No
95    }
96}
97
98fn send_response<T: EmbedRespondable>(
99    req: &HttpRequest,
100    file: &T,
101    compress: Compress,
102) -> HttpResponse {
103    let mut resp = HttpResponse::Ok();
104
105    resp.append_header(("ETag", file.etag().as_ref()));
106    if let Some(last_modified) = file.last_modified() {
107        resp.append_header(("Last-Modified", last_modified.as_ref()));
108    }
109    if let Some(mime_type) = file.mime_type() {
110        resp.append_header(("Content-Type", mime_type.as_ref()));
111    }
112
113    // This doesn't actually mean "no caching", it means revalidate before
114    // using. If we don't add this, web browsers don't try to revalidate assets
115    // like attached scripts and images. The users of this crate may or may not
116    // be using fingerprinting or versioning on their assets, without this their
117    // caching could break.
118    resp.append_header(("Cache-Control", "no-cache"));
119
120    if req.method() == Method::HEAD {
121        // For HEAD requests, we only need to send the headers and not the data.
122        resp.finish()
123    } else {
124        // For GET requests, we do send the file body. Depending on whether the
125        // client accepts compressed files or not, we may send the compressed
126        // version.
127        let encoding_choice = should_compress(req, file, &compress);
128        match encoding_choice {
129            #[cfg(feature = "compression-zstd")]
130            ShouldCompress::Zstd => {
131                resp.append_header(("Content-Encoding", "zstd"));
132                match file.data_zstd() {
133                    Some(data_zstd) => resp.body(data_zstd),
134                    None => resp.body(compress_data_zstd(
135                        file.etag().as_ref(),
136                        file.data().as_ref(),
137                    )),
138                }
139            }
140            ShouldCompress::Brotli => {
141                resp.append_header(("Content-Encoding", "br"));
142                match file.data_br() {
143                    Some(data_br) => resp.body(data_br),
144                    None => resp.body(compress_data_br(file.etag().as_ref(), file.data().as_ref())),
145                }
146            }
147            ShouldCompress::Gzip => {
148                resp.append_header(("Content-Encoding", "gzip"));
149                match file.data_gzip() {
150                    Some(data_gzip) => resp.body(data_gzip),
151                    None => resp.body(compress_data_gzip(
152                        file.etag().as_ref(),
153                        file.data().as_ref(),
154                    )),
155                }
156            }
157            #[cfg(not(feature = "compression-zstd"))]
158            ShouldCompress::Zstd => {
159                // This should never happen, but if it does, just serve uncompressed
160                resp.body(file.data())
161            }
162            ShouldCompress::No => resp.body(file.data()),
163        }
164    }
165}
166
167impl<T: EmbedRespondable> Responder for EmbedResponse<T> {
168    type Body = BoxBody;
169
170    fn respond_to(self, req: &HttpRequest) -> HttpResponse<Self::Body> {
171        match self.file {
172            Some(file) => {
173                // This responder can't respond to anything other than GET and HEAD requests.
174                if req.method() != Method::GET && req.method() != Method::HEAD {
175                    return HttpResponse::NotImplemented().finish();
176                }
177
178                // For the ETag we are using the sha256 hash of the file, encoded with
179                // base64. We surround it with quotes as per the spec.
180                let e = file.etag();
181                let etag = e.as_ref();
182
183                let last_modified_timestamp = file.last_modified_timestamp();
184
185                // Handle If-None-Match condition. If the client has the file cached
186                // already, it can send back the ETag to ask for the file only if it has
187                // changed.
188                //
189                // We first check If-None-Match because the spec specifies that it gets
190                // priority over If-Modified-Since.
191                if let Some(req_etags) = req
192                    .headers()
193                    .get("If-None-Match")
194                    .and_then(parse_if_none_match_value)
195                {
196                    if req_etags.contains(&etag) {
197                        return HttpResponse::NotModified().finish();
198                    } else {
199                        return send_response(req, &file, self.compress);
200                    }
201                }
202                // If there was no `If-None-Match` condition, check for
203                // `If-Unmodified-Since` condition next. As a fallback to ETag,
204                // the client can also check if a file has been modified using
205                // the last modified time of the file.
206                if let Some(last_modified_timestamp) = last_modified_timestamp {
207                    if let Some(if_unmodified_since) = req
208                        .headers()
209                        .get("If-Unmodified-Since")
210                        .and_then(|v| v.to_str().ok())
211                        .and_then(|v| chrono::DateTime::parse_from_rfc2822(v).ok())
212                    {
213                        // It's been modified since then
214                        if last_modified_timestamp > if_unmodified_since.timestamp() {
215                            return send_response(req, &file, self.compress);
216                        } else {
217                            return HttpResponse::NotModified().finish();
218                        }
219                    }
220                }
221                // If there was no `If-Unmodified-Since` header either, that
222                // means the client does not have this file cached.
223                send_response(req, &file, self.compress)
224            }
225            None => HttpResponse::NotFound().finish(),
226        }
227    }
228}
229
230impl<T: EmbedRespondable> EmbedResponse<T> {
231    /// Set the compression option to use for this response. Please see the
232    /// Compress type for allowed options.
233    pub fn use_compression(mut self, option: Compress) -> Self {
234        self.compress = option;
235        self
236    }
237}
238
239/// A specialized version of `Into`, which can help you avoid specifying the type in `Into'.
240pub trait IntoResponse<T: EmbedRespondable> {
241    /// A specialized version of `Into::into`.
242    fn into_response(self) -> EmbedResponse<T>;
243}