Skip to main content

renox_core/
embedded.rs

1//! Views, translations and public files compiled into the binary with
2//! `renox::embedded!()`, so a release build is a single file to deploy.
3
4use std::collections::HashMap;
5use std::sync::Arc;
6
7use axum::http::header::{CACHE_CONTROL, CONTENT_TYPE};
8use axum::http::{StatusCode, Uri};
9use axum::response::{IntoResponse, Response};
10
11use crate::Error;
12
13/// Files from `resources/views`, `resources/lang` and `public`, as
14/// `(relative path, contents)`. Built by `renox::embedded!()`.
15#[derive(Debug, Clone, Copy, Default)]
16pub struct Embedded {
17    /// Templates from `resources/views`.
18    pub views: &'static [(&'static str, &'static str)],
19    /// Translation files from `resources/lang`.
20    pub lang: &'static [(&'static str, &'static str)],
21    /// Files from `public`, served at the site root.
22    pub public: &'static [(&'static str, &'static [u8])],
23}
24
25/// A content type for a public file, from its extension.
26pub(crate) fn content_type(path: &str) -> &'static str {
27    let ext = path.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase());
28    match ext.as_deref() {
29        Some("css") => "text/css; charset=utf-8",
30        Some("js" | "mjs") => "text/javascript; charset=utf-8",
31        Some("html" | "htm") => "text/html; charset=utf-8",
32        Some("json" | "map") => "application/json",
33        Some("txt") => "text/plain; charset=utf-8",
34        Some("svg") => "image/svg+xml",
35        Some("png") => "image/png",
36        Some("jpg" | "jpeg") => "image/jpeg",
37        Some("gif") => "image/gif",
38        Some("webp") => "image/webp",
39        Some("ico") => "image/x-icon",
40        Some("woff") => "font/woff",
41        Some("woff2") => "font/woff2",
42        Some("pdf") => "application/pdf",
43        Some("xml") => "application/xml",
44        _ => "application/octet-stream",
45    }
46}
47
48pub(crate) type PublicFiles = Arc<HashMap<&'static str, &'static [u8]>>;
49
50pub(crate) fn public_map(files: &'static [(&'static str, &'static [u8])]) -> PublicFiles {
51    Arc::new(files.iter().copied().collect())
52}
53
54/// Serves an embedded public file for the request path, or 404.
55pub(crate) fn serve(files: &PublicFiles, uri: &Uri) -> Response {
56    let path = uri.path().trim_start_matches('/');
57    let path = if path.is_empty() { "index.html" } else { path };
58    match files.get(path) {
59        Some(bytes) => (
60            StatusCode::OK,
61            [
62                (CONTENT_TYPE, content_type(path)),
63                (CACHE_CONTROL, cache_control(uri)),
64            ],
65            *bytes,
66        )
67            .into_response(),
68        None => Error::NotFound.into_response(),
69    }
70}
71
72/// Content versions for `asset()` URLs (`/app.css?v=1a2b3c4d`): a changed
73/// file gets a new URL, so browsers may keep each version for a year.
74pub(crate) enum AssetVersions {
75    /// Compiled in: hashed once at boot.
76    Embedded(HashMap<&'static str, String>),
77    /// Read from `PUBLIC_PATH`: hashed when first asked for and again after
78    /// the file changes (its modified time).
79    Disk {
80        root: std::path::PathBuf,
81        seen: std::sync::Mutex<HashMap<String, (std::time::SystemTime, String)>>,
82    },
83}
84
85fn short_hash(bytes: &[u8]) -> String {
86    crate::webhook::sha256_hex(bytes)[..8].to_owned()
87}
88
89impl AssetVersions {
90    pub(crate) fn new(
91        public: &std::path::Path,
92        embedded: Option<&'static [(&'static str, &'static [u8])]>,
93    ) -> Self {
94        match embedded {
95            Some(files) => Self::Embedded(
96                files
97                    .iter()
98                    .map(|(path, bytes)| (*path, short_hash(bytes)))
99                    .collect(),
100            ),
101            None => Self::Disk {
102                root: public.to_path_buf(),
103                seen: std::sync::Mutex::default(),
104            },
105        }
106    }
107
108    /// The version of the public file at `path`, if there is one.
109    pub(crate) fn version(&self, path: &str) -> Option<String> {
110        let path = path.trim_start_matches('/');
111        match self {
112            Self::Embedded(files) => files.get(path).cloned(),
113            Self::Disk { root, seen } => {
114                let file = root.join(path);
115                if !file.starts_with(root) || path.split('/').any(|part| part == "..") {
116                    return None;
117                }
118                let modified = std::fs::metadata(&file).and_then(|m| m.modified()).ok()?;
119                let mut seen = seen.lock().unwrap_or_else(|e| e.into_inner());
120                if let Some((at, hash)) = seen.get(path)
121                    && *at == modified
122                {
123                    return Some(hash.clone());
124                }
125                let hash = short_hash(&std::fs::read(&file).ok()?);
126                seen.insert(path.to_owned(), (modified, hash.clone()));
127                Some(hash)
128            }
129        }
130    }
131}
132
133/// Whether the request asks for a versioned file (`?v=…`), which can be
134/// cached for good.
135pub(crate) fn is_versioned(uri: &Uri) -> bool {
136    uri.query()
137        .is_some_and(|query| query.split('&').any(|pair| pair.starts_with("v=")))
138}
139
140/// `Cache-Control` for a public file: a year for versioned URLs.
141pub(crate) fn cache_control(uri: &Uri) -> &'static str {
142    if is_versioned(uri) {
143        "public, max-age=31536000, immutable"
144    } else {
145        "public, max-age=3600"
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn public_files_get_their_content_type() {
155        for (path, expected) in [
156            ("logo.PNG", "image/png"),
157            ("a.jpg", "image/jpeg"),
158            ("a.jpeg", "image/jpeg"),
159            ("a.gif", "image/gif"),
160            ("a.webp", "image/webp"),
161            ("favicon.ico", "image/x-icon"),
162            ("f.woff", "font/woff"),
163            ("f.woff2", "font/woff2"),
164            ("terms.pdf", "application/pdf"),
165            ("sitemap.xml", "application/xml"),
166            ("archive.zip", "application/octet-stream"),
167            ("README", "application/octet-stream"),
168        ] {
169            assert_eq!(content_type(path), expected, "{path}");
170        }
171    }
172
173    /// Versions of files on disk: read once and kept until the file
174    /// changes; a path out of the public folder has none.
175    #[test]
176    fn disk_versions_are_cached_and_stay_in_the_public_folder() {
177        let dir = tempfile::tempdir().unwrap();
178        std::fs::write(dir.path().join("app.css"), "a{}").unwrap();
179        let versions = AssetVersions::new(dir.path(), None);
180        let first = versions.version("/app.css").expect("a version");
181        assert_eq!(versions.version("app.css"), Some(first));
182        assert_eq!(versions.version("../secret.txt"), None);
183        assert_eq!(versions.version("missing.css"), None);
184    }
185}