Skip to main content

static_web_server/
control_headers.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// This file is part of Static Web Server.
3// See https://static-web-server.net/ for more information
4// Copyright (C) 2019-present Jose Quintana <joseluisq.net>
5
6//! It provides an arbitrary `Cache-Control` headers functionality
7//! for incoming requests based on a set of file types.
8//!
9
10use hyper::{
11    Request, Response,
12    header::{CACHE_CONTROL, HeaderValue},
13};
14
15use crate::body::Body;
16use crate::{Error, handler::RequestHandlerOpts};
17
18// Pre-computed static Cache-Control header values
19static CACHE_CONTROL_DEFAULT: HeaderValue = HeaderValue::from_static("no-cache");
20static CACHE_CONTROL_ONE_HOUR: HeaderValue = HeaderValue::from_static("max-age=3600");
21static CACHE_CONTROL_ONE_YEAR: HeaderValue = HeaderValue::from_static("max-age=31536000");
22
23// `Cache-Control` list of extensions (arrays must be alphabetically sorted)
24const CACHE_EXT_ONE_HOUR: [&str; 2] = ["atom", "rss"];
25const CACHE_EXT_ONE_YEAR: [&str; 32] = [
26    "avif", "bmp", "bz2", "css", "doc", "gif", "gz", "htc", "ico", "jpeg", "jpg", "js", "jxl",
27    "map", "mjs", "mp3", "mp4", "ogg", "ogv", "pdf", "png", "rar", "rtf", "tar", "tgz", "wav",
28    "weba", "webm", "webp", "woff", "woff2", "zip",
29];
30
31pub(crate) fn init(enabled: bool, handler_opts: &mut RequestHandlerOpts) {
32    handler_opts.cache_control_headers = enabled;
33    tracing::info!(enabled, "cache control headers");
34}
35
36/// Appends `Cache-Control` header to a response if necessary
37pub(crate) fn post_process<T>(
38    opts: &RequestHandlerOpts,
39    req: &Request<T>,
40    mut resp: Response<Body>,
41) -> Result<Response<Body>, Error> {
42    if opts.cache_control_headers {
43        append_headers(req.uri().path(), &mut resp);
44    }
45    Ok(resp)
46}
47
48/// It appends a `Cache-Control` header to a response if that one is part of a set of file types.
49pub fn append_headers(uri: &str, resp: &mut Response<Body>) {
50    let header_value = get_cache_control_header(uri);
51    resp.headers_mut()
52        .insert(CACHE_CONTROL, header_value.clone());
53}
54
55/// Gets the file extension for a URI.
56///
57/// This assumes the extension contains a single dot. e.g. for "/file.tar.gz" it returns "gz".
58#[inline(always)]
59fn get_file_extension(uri: &str) -> Option<&str> {
60    uri.rsplit_once('.').map(|(_, rest)| rest)
61}
62
63/// Returns the pre-computed static Cache-Control header value for the given URI.
64#[inline(always)]
65fn get_cache_control_header(uri: &str) -> &'static HeaderValue {
66    if let Some(extension) = get_file_extension(uri) {
67        // Zero-allocation stack buffer optimization for lowercase conversion
68        let mut buf = [0u8; 16];
69        if extension.len() <= buf.len() {
70            let ext_bytes = &mut buf[..extension.len()];
71            ext_bytes.copy_from_slice(extension.as_bytes());
72            ext_bytes.make_ascii_lowercase();
73
74            if let Ok(ext_lower) = std::str::from_utf8(ext_bytes) {
75                if CACHE_EXT_ONE_HOUR.binary_search(&ext_lower).is_ok() {
76                    return &CACHE_CONTROL_ONE_HOUR;
77                } else if CACHE_EXT_ONE_YEAR.binary_search(&ext_lower).is_ok() {
78                    return &CACHE_CONTROL_ONE_YEAR;
79                }
80            }
81        } else {
82            // Fallback allocations for abnormally long extensions
83            let ext_lower = extension.to_ascii_lowercase();
84            if CACHE_EXT_ONE_HOUR
85                .binary_search(&ext_lower.as_str())
86                .is_ok()
87            {
88                return &CACHE_CONTROL_ONE_HOUR;
89            } else if CACHE_EXT_ONE_YEAR
90                .binary_search(&ext_lower.as_str())
91                .is_ok()
92            {
93                return &CACHE_CONTROL_ONE_YEAR;
94            }
95        }
96    }
97    &CACHE_CONTROL_DEFAULT
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103    use hyper::{Response, StatusCode};
104
105    #[test]
106    fn test_arrays_are_sorted() {
107        assert!(
108            CACHE_EXT_ONE_HOUR.windows(2).all(|w| w[0] < w[1]),
109            "CACHE_EXT_ONE_HOUR is not sorted!"
110        );
111        assert!(
112            CACHE_EXT_ONE_YEAR.windows(2).all(|w| w[0] < w[1]),
113            "CACHE_EXT_ONE_YEAR is not sorted!"
114        );
115    }
116
117    #[test]
118    fn headers_case_insensitivity() {
119        let mut resp = Response::new(crate::body::empty());
120        append_headers("/assets/script.JS", &mut resp);
121        let cache_control = resp.headers().get(CACHE_CONTROL).unwrap();
122        assert_eq!(cache_control.to_str().unwrap(), "max-age=31536000");
123
124        append_headers("/assets/IMAGE.PNG", &mut resp);
125        let cache_control = resp.headers().get(CACHE_CONTROL).unwrap();
126        assert_eq!(cache_control.to_str().unwrap(), "max-age=31536000");
127    }
128
129    #[test]
130    fn headers_one_hour() {
131        let mut resp = Response::new(crate::body::empty());
132        *resp.status_mut() = StatusCode::OK;
133
134        for ext in CACHE_EXT_ONE_HOUR.iter() {
135            append_headers(&["/some.", ext].concat(), &mut resp);
136            let cache_control = resp.headers().get(CACHE_CONTROL).unwrap();
137            assert_eq!(cache_control.to_str().unwrap(), "max-age=3600");
138        }
139    }
140
141    #[test]
142    fn headers_default_fallback() {
143        let mut resp = Response::new(crate::body::empty());
144        *resp.status_mut() = StatusCode::OK;
145
146        append_headers("/", &mut resp);
147        assert_eq!(
148            resp.headers().get(CACHE_CONTROL).unwrap().to_str().unwrap(),
149            "no-cache"
150        );
151
152        append_headers("/index.html", &mut resp);
153        assert_eq!(
154            resp.headers().get(CACHE_CONTROL).unwrap().to_str().unwrap(),
155            "no-cache"
156        );
157
158        append_headers("/config.json", &mut resp);
159        assert_eq!(
160            resp.headers().get(CACHE_CONTROL).unwrap().to_str().unwrap(),
161            "no-cache"
162        );
163
164        append_headers("/api/data", &mut resp);
165        assert_eq!(
166            resp.headers().get(CACHE_CONTROL).unwrap().to_str().unwrap(),
167            "no-cache"
168        );
169    }
170
171    #[test]
172    fn headers_one_year() {
173        let mut resp = Response::new(crate::body::empty());
174        *resp.status_mut() = StatusCode::OK;
175
176        for ext in CACHE_EXT_ONE_YEAR.iter() {
177            append_headers(&["/some.", ext].concat(), &mut resp);
178            let cache_control = resp.headers().get(CACHE_CONTROL).unwrap();
179            assert_eq!(cache_control.to_str().unwrap(), "max-age=31536000");
180        }
181    }
182}