Skip to main content

ullun/
static_files.rs

1//! Static file serving
2
3use crate::error::{Error, Result};
4use crate::files::{serve_file, FileOptions};
5use crate::handler::Handler;
6use crate::request::Request;
7use crate::response::Response;
8use async_trait::async_trait;
9use std::path::{Path, PathBuf};
10use tokio::fs;
11
12/// How a directory is served, see [`App::serve_static_with`](crate::app::App::serve_static_with)
13///
14/// ```
15/// use ullun::static_files::StaticOptions;
16///
17/// // a single-page app built by a bundler
18/// let options = StaticOptions::new()
19///     .precompressed()                   // use app.js.br / app.js.gz when the client accepts them
20///     .immutable_dir("assets")           // assets/* never change under the same name
21///     .spa_fallback("index.html");       // /orders/17 -> index.html for browsers
22/// # let _ = options;
23/// ```
24#[derive(Clone)]
25pub struct StaticOptions {
26    cache_control: String,
27    immutable_cache_control: String,
28    precompressed: bool,
29    spa_fallback: Option<String>,
30    immutable_dirs: Vec<String>,
31    hashed_names: bool,
32}
33
34impl Default for StaticOptions {
35    fn default() -> Self {
36        Self::new()
37    }
38}
39
40impl StaticOptions {
41    pub fn new() -> Self {
42        Self {
43            cache_control: "public, max-age=3600".into(),
44            immutable_cache_control: "public, max-age=31536000, immutable".into(),
45            precompressed: false,
46            spa_fallback: None,
47            immutable_dirs: Vec::new(),
48            hashed_names: false,
49        }
50    }
51
52    /// `Cache-Control` for ordinary files (default `public, max-age=3600`)
53    pub fn cache_control(mut self, value: &str) -> Self {
54        self.cache_control = value.to_string();
55        self
56    }
57
58    /// Serve `file.br` or `file.gz` next to `file` when the client accepts that
59    /// encoding (Brotli preferred), with `Content-Encoding` and `Vary: Accept-Encoding`.
60    /// Compress at build time; nothing is compressed per request.
61    pub fn precompressed(mut self) -> Self {
62        self.precompressed = true;
63        self
64    }
65
66    /// Answer navigations to unknown paths (`GET` accepting `text/html`, no file
67    /// extension) with this file, usually `index.html`, so client-side routes
68    /// survive a reload. Missing assets (`/app.js`) still give `404`.
69    pub fn spa_fallback(mut self, file: &str) -> Self {
70        self.spa_fallback = Some(file.trim_start_matches('/').to_string());
71        self
72    }
73
74    /// Files under this directory (relative to the root) are cached for a year
75    /// and marked `immutable`: use it for bundler output whose names change with content
76    pub fn immutable_dir(mut self, dir: &str) -> Self {
77        self.immutable_dirs.push(dir.trim_matches('/').to_string());
78        self
79    }
80
81    /// Treat files whose name contains a content hash (a dot- or dash-separated
82    /// segment of 8 or more hex digits, at least one a digit, as in
83    /// `app.3f9a2c1b.js`) as immutable
84    pub fn hashed_names(mut self) -> Self {
85        self.hashed_names = true;
86        self
87    }
88
89    fn is_immutable(&self, relative: &str) -> bool {
90        let in_dir = self.immutable_dirs.iter().any(|d| {
91            relative == d
92                || relative
93                    .strip_prefix(d.as_str())
94                    .is_some_and(|r| r.starts_with('/'))
95        });
96        in_dir || (self.hashed_names && has_hash_segment(relative))
97    }
98}
99
100fn has_hash_segment(path: &str) -> bool {
101    let name = path.rsplit('/').next().unwrap_or("");
102    // the extension is not a hash
103    let stem = name.rsplit_once('.').map_or(name, |(stem, _)| stem);
104    stem.split(['.', '-']).any(|seg| {
105        seg.len() >= 8
106            && seg.bytes().all(|b| b.is_ascii_hexdigit())
107            && seg.bytes().any(|b| b.is_ascii_digit())
108    })
109}
110
111/// Whether an `Accept-Encoding` value allows `encoding` (explicit `q=0` refuses it)
112fn accepts_encoding(header: &str, encoding: &str) -> bool {
113    header.split(',').any(|item| {
114        let (name, params) = item.split_once(';').unwrap_or((item, ""));
115        let q_zero = params
116            .split(';')
117            .filter_map(|p| p.trim().strip_prefix("q="))
118            .any(|q| q.trim().parse::<f32>().is_ok_and(|q| q <= 0.0));
119        name.trim().eq_ignore_ascii_case(encoding) && !q_zero
120    })
121}
122
123/// Serves a directory: files, `index.html` for directories, ranges, conditional
124/// requests and streaming for large files (see [`crate::files`]).
125///
126/// Dotfiles (`.env`, `.git/`, ...) are never served, and requests that try to
127/// leave the directory (`..`, absolute paths, symlinks pointing outside) get `404`.
128pub struct StaticFileHandler {
129    root_dir: PathBuf,
130    options: StaticOptions,
131}
132
133impl StaticFileHandler {
134    pub fn new(root_dir: &str) -> Self {
135        Self::with_options(root_dir, StaticOptions::new())
136    }
137
138    pub fn with_options(root_dir: &str, options: StaticOptions) -> Self {
139        Self {
140            root_dir: PathBuf::from(root_dir),
141            options,
142        }
143    }
144
145    /// A browser navigation to a path that is not a file
146    fn wants_fallback(&self, req: &Request, file_path: &str) -> bool {
147        let last = file_path.rsplit('/').next().unwrap_or("");
148        self.options.spa_fallback.is_some()
149            && req.method == http::Method::GET
150            && !last.contains('.')
151            && req
152                .headers
153                .get(http::header::ACCEPT)
154                .and_then(|v| v.to_str().ok())
155                .is_some_and(|a| a.contains("text/html"))
156    }
157
158    async fn serve(&self, req: &Request, file_path: &str) -> Result<Response> {
159        let not_found = || Error::not_found("File not found");
160
161        // %20 etc. in file names; decoded before validating, so %2e%2e is caught too
162        let decoded = urlencoding::decode(file_path).map_err(|_| not_found())?;
163        let mut relative = PathBuf::new();
164        for segment in decoded.split('/').filter(|s| !s.is_empty()) {
165            let unsafe_segment = segment.starts_with('.')
166                || segment.contains(['\\', '\0', ':'])
167                || segment.chars().any(char::is_control);
168            if unsafe_segment {
169                return Err(not_found());
170            }
171            relative.push(segment);
172        }
173
174        let canonical_root = fs::canonicalize(&self.root_dir)
175            .await
176            .map_err(|_| Error::not_found("Static directory not found"))?;
177        let mut canonical = fs::canonicalize(canonical_root.join(&relative))
178            .await
179            .map_err(|_| not_found())?;
180        // 404 rather than 403: do not reveal whether the target exists
181        if !canonical.starts_with(&canonical_root) {
182            return Err(not_found());
183        }
184
185        let meta = fs::metadata(&canonical).await.map_err(|_| not_found())?;
186        if meta.is_dir() {
187            let index = canonical.join("index.html");
188            if fs::metadata(&index).await.is_ok_and(|m| m.is_file()) {
189                canonical = index;
190            } else {
191                return Err(Error::forbidden("Directory listing not allowed"));
192            }
193        }
194
195        self.respond(req, &canonical_root, &canonical).await
196    }
197
198    /// Serve `file` (already validated to be inside the root) with the cache
199    /// policy and, if enabled, its precompressed variant
200    async fn respond(&self, req: &Request, root: &Path, file: &Path) -> Result<Response> {
201        let relative = file
202            .strip_prefix(root)
203            .map(|p| p.to_string_lossy().replace('\\', "/"))
204            .unwrap_or_default();
205        let cache = if self.options.is_immutable(&relative) {
206            &self.options.immutable_cache_control
207        } else {
208            &self.options.cache_control
209        };
210        let mut file_options = FileOptions::new().cache_control(cache.clone());
211        let mut target = file.to_path_buf();
212
213        if self.options.precompressed {
214            let accept = req
215                .headers
216                .get(http::header::ACCEPT_ENCODING)
217                .and_then(|v| v.to_str().ok())
218                .unwrap_or("");
219            for (encoding, ext) in [("br", "br"), ("gzip", "gz")] {
220                if !accepts_encoding(accept, encoding) {
221                    continue;
222                }
223                let mut name = file.as_os_str().to_os_string();
224                name.push(".");
225                name.push(ext);
226                let variant = PathBuf::from(name);
227                // a symlinked variant must not leave the root either
228                let inside = match fs::canonicalize(&variant).await {
229                    Ok(c) => {
230                        c.starts_with(root) && fs::metadata(&c).await.is_ok_and(|m| m.is_file())
231                    }
232                    Err(_) => false,
233                };
234                if inside {
235                    file_options = file_options
236                        .content_type(crate::files::content_type_for(file))
237                        .header("content-encoding", encoding);
238                    target = variant;
239                    break;
240                }
241            }
242            file_options = file_options.header("vary", "accept-encoding");
243        }
244        serve_file(req, &target, &file_options).await
245    }
246}
247
248#[async_trait]
249impl Handler for StaticFileHandler {
250    async fn call(&self, req: Request) -> Result<Response> {
251        let filepath = req.params.get("filepath").unwrap_or("").to_string();
252        match self.serve(&req, &filepath).await {
253            Err(Error::NotFound(_)) if self.wants_fallback(&req, &filepath) => {
254                let index = self.options.spa_fallback.clone().unwrap_or_default();
255                self.serve(&req, &index).await
256            }
257            other => other,
258        }
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use crate::files::content_type_for;
265    use std::path::Path;
266
267    #[test]
268    fn test_content_type_detection() {
269        assert_eq!(
270            content_type_for(Path::new("test.html")),
271            "text/html; charset=utf-8"
272        );
273        assert_eq!(
274            content_type_for(Path::new("test.css")),
275            "text/css; charset=utf-8"
276        );
277        assert_eq!(
278            content_type_for(Path::new("test.js")),
279            "application/javascript; charset=utf-8"
280        );
281        assert_eq!(content_type_for(Path::new("test.json")), "application/json");
282        assert_eq!(content_type_for(Path::new("test.png")), "image/png");
283        assert_eq!(
284            content_type_for(Path::new("unknown")),
285            "application/octet-stream"
286        );
287    }
288}