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::{Body, Request, Response};
11
12use crate::{handler::RequestHandlerOpts, Error};
13
14// Cache-Control `max-age` variants
15const MAX_AGE_ONE_HOUR: u64 = 60 * 60;
16const MAX_AGE_ONE_DAY: u64 = 60 * 60 * 24;
17const MAX_AGE_ONE_YEAR: u64 = 60 * 60 * 24 * 365;
18
19// `Cache-Control` list of extensions
20const CACHE_EXT_ONE_HOUR: [&str; 4] = ["atom", "json", "rss", "xml"];
21const CACHE_EXT_ONE_YEAR: [&str; 32] = [
22    "avif", "bmp", "bz2", "css", "doc", "gif", "gz", "htc", "ico", "jpeg", "jpg", "js", "jxl",
23    "map", "mjs", "mp3", "mp4", "ogg", "ogv", "pdf", "png", "rar", "rtf", "tar", "tgz", "wav",
24    "weba", "webm", "webp", "woff", "woff2", "zip",
25];
26
27pub(crate) fn init(enabled: bool, handler_opts: &mut RequestHandlerOpts) {
28    handler_opts.cache_control_headers = enabled;
29    server_info!("cache control headers: enabled={enabled}");
30}
31
32/// Appends `Cache-Control` header to a response if necessary
33pub(crate) fn post_process<T>(
34    opts: &RequestHandlerOpts,
35    req: &Request<T>,
36    mut resp: Response<Body>,
37) -> Result<Response<Body>, Error> {
38    if opts.cache_control_headers {
39        append_headers(req.uri().path(), &mut resp);
40    }
41    Ok(resp)
42}
43
44/// It appends a `Cache-Control` header to a response if that one is part of a set of file types.
45pub fn append_headers(uri: &str, resp: &mut Response<Body>) {
46    let max_age = get_max_age(uri);
47    resp.headers_mut().insert(
48        "cache-control",
49        format!(
50            "public, max-age={}",
51            // It caps value in seconds at ~136 years
52            std::cmp::min(max_age, u32::MAX as u64)
53        )
54        .parse()
55        .unwrap(),
56    );
57}
58
59/// Gets the file extension for a URI.
60///
61/// This assumes the extension contains a single dot. e.g. for "/file.tar.gz" it returns "gz".
62#[inline(always)]
63fn get_file_extension(uri: &str) -> Option<&str> {
64    uri.rsplit_once('.').map(|(_, rest)| rest)
65}
66
67#[inline(always)]
68fn get_max_age(uri: &str) -> u64 {
69    // Default max-age value in seconds (one day)
70    let mut max_age = MAX_AGE_ONE_DAY;
71
72    if let Some(extension) = get_file_extension(uri) {
73        if CACHE_EXT_ONE_HOUR.binary_search(&extension).is_ok() {
74            max_age = MAX_AGE_ONE_HOUR;
75        } else if CACHE_EXT_ONE_YEAR.binary_search(&extension).is_ok() {
76            max_age = MAX_AGE_ONE_YEAR;
77        }
78    }
79    max_age
80}
81
82#[cfg(test)]
83mod tests {
84    use hyper::{Body, Response, StatusCode};
85
86    use super::{
87        append_headers, get_file_extension, CACHE_EXT_ONE_HOUR, CACHE_EXT_ONE_YEAR,
88        MAX_AGE_ONE_DAY, MAX_AGE_ONE_HOUR, MAX_AGE_ONE_YEAR,
89    };
90
91    #[test]
92    fn headers_one_hour() {
93        let mut resp = Response::new(Body::empty());
94        *resp.status_mut() = StatusCode::OK;
95
96        for ext in CACHE_EXT_ONE_HOUR.iter() {
97            append_headers(&["/some.", ext].concat(), &mut resp);
98
99            let cache_control = resp.headers().get(http::header::CACHE_CONTROL).unwrap();
100            assert_eq!(resp.status(), StatusCode::OK);
101            assert_eq!(
102                cache_control.to_str().unwrap(),
103                format!("public, max-age={MAX_AGE_ONE_HOUR}")
104            );
105        }
106    }
107
108    #[test]
109    fn headers_one_day_default() {
110        let mut resp = Response::new(Body::empty());
111        *resp.status_mut() = StatusCode::OK;
112
113        append_headers("/", &mut resp);
114
115        let cache_control = resp.headers().get(http::header::CACHE_CONTROL).unwrap();
116        assert_eq!(resp.status(), StatusCode::OK);
117        assert_eq!(
118            cache_control.to_str().unwrap(),
119            format!("public, max-age={MAX_AGE_ONE_DAY}")
120        );
121    }
122
123    #[test]
124    fn headers_one_year() {
125        let mut resp = Response::new(Body::empty());
126        *resp.status_mut() = StatusCode::OK;
127
128        for ext in CACHE_EXT_ONE_YEAR.iter() {
129            append_headers(&["/some.", ext].concat(), &mut resp);
130
131            let cache_control = resp.headers().get(http::header::CACHE_CONTROL).unwrap();
132            assert_eq!(resp.status(), StatusCode::OK);
133            assert_eq!(
134                cache_control.to_str().unwrap(),
135                format!("public, max-age={MAX_AGE_ONE_YEAR}")
136            );
137        }
138    }
139
140    #[test]
141    fn find_uri_extension() {
142        assert_eq!(get_file_extension("/potato.zip"), Some("zip"));
143        assert_eq!(get_file_extension("/potato."), Some(""));
144        assert_eq!(get_file_extension("/"), None);
145    }
146}