Skip to main content

trillium_static/
handler.rs

1use crate::{
2    ResolvedDirectory, StaticConnExt,
3    fs_shims::{File, fs},
4    options::StaticOptions,
5    range,
6};
7use etag::EntityTag;
8use relative_path::RelativePath;
9use std::path::{Path, PathBuf};
10use trillium::{
11    Body, Conn, Handler, HeaderValues,
12    KnownHeaderName::{
13        AcceptEncoding, AcceptRanges, ContentEncoding, ContentRange, ContentType, Etag, IfRange,
14        LastModified, Range, Vary,
15    },
16    Status, conn_unwrap,
17};
18
19/// trillium handler to serve static files from the filesystem
20#[derive(Debug)]
21pub struct StaticFileHandler {
22    fs_root: PathBuf,
23    index_file: Option<String>,
24    root_is_file: bool,
25    options: StaticOptions,
26    /// (encoding token, filename suffix without the leading dot), in match
27    /// priority order. Empty disables precompressed-sidecar serving.
28    precompressed: Vec<(String, String)>,
29}
30
31#[derive(Debug)]
32enum Record {
33    /// (path-for-mime-detection, opened file, optional content-encoding token)
34    File(PathBuf, File, Option<String>),
35    Dir(PathBuf),
36}
37
38fn accept_encoding_allows(accept: &str, encoding: &str) -> bool {
39    let mut wildcard_ok = false;
40    let mut named_ok = None;
41    for part in accept.split(',') {
42        let mut iter = part.trim().split(';');
43        let token = iter.next().unwrap_or("").trim();
44        let q = iter
45            .find_map(|p| {
46                p.trim()
47                    .strip_prefix("q=")
48                    .and_then(|q| q.parse::<f32>().ok())
49            })
50            .unwrap_or(1.0);
51        if token.eq_ignore_ascii_case(encoding) {
52            named_ok = Some(q > 0.0);
53        } else if token == "*" && named_ok.is_none() {
54            wildcard_ok = q > 0.0;
55        }
56    }
57    named_ok.unwrap_or(wildcard_ok)
58}
59
60fn stream_full_body(file: File, len: u64) -> Body {
61    #[cfg(feature = "tokio")]
62    let file = async_compat::Compat::new(file);
63    Body::new_streaming(file, Some(len))
64}
65
66async fn seek_take_body(mut file: File, start: u64, len: u64) -> std::io::Result<Body> {
67    #[cfg(feature = "tokio")]
68    {
69        use tokio_crate::io::AsyncSeekExt as _;
70        file.seek(std::io::SeekFrom::Start(start)).await?;
71    }
72    #[cfg(not(feature = "tokio"))]
73    {
74        use futures_lite::AsyncSeekExt as _;
75        file.seek(std::io::SeekFrom::Start(start)).await?;
76    }
77
78    #[cfg(feature = "tokio")]
79    let file = async_compat::Compat::new(file);
80
81    use futures_lite::AsyncReadExt as _;
82    Ok(Body::new_streaming(file.take(len), Some(len)))
83}
84
85fn append_vary_accept_encoding(mut conn: Conn) -> Conn {
86    let vary = conn
87        .response_headers()
88        .get_str(Vary)
89        .map(|existing| HeaderValues::from(format!("{existing}, Accept-Encoding")))
90        .unwrap_or_else(|| HeaderValues::from("Accept-Encoding"));
91    conn.response_headers_mut().insert(Vary, vary);
92    conn
93}
94
95impl StaticFileHandler {
96    async fn resolve_fs_path(&self, url_path: &str) -> Option<PathBuf> {
97        let mut file_path = self.fs_root.clone();
98        log::trace!(
99            "attempting to resolve {} relative to {}",
100            url_path,
101            file_path.to_str().unwrap()
102        );
103        for segment in RelativePath::new(url_path) {
104            match segment {
105                "." => {}
106                ".." => {
107                    file_path.pop();
108                }
109                _ => {
110                    file_path.push(segment);
111                }
112            };
113        }
114
115        if file_path.starts_with(&self.fs_root) {
116            let path_buf = fs::canonicalize(file_path).await.ok();
117
118            #[cfg(feature = "async-std")]
119            return path_buf.map(Into::into);
120            #[cfg(not(feature = "async-std"))]
121            path_buf
122        } else {
123            None
124        }
125    }
126
127    async fn pick_precompressed(
128        &self,
129        asset_path: &Path,
130        accept_encoding: Option<&str>,
131    ) -> Option<(PathBuf, String)> {
132        if self.precompressed.is_empty() {
133            return None;
134        }
135        let accept = accept_encoding?;
136        for (encoding, suffix) in &self.precompressed {
137            if !accept_encoding_allows(accept, encoding) {
138                continue;
139            }
140            let mut sidecar = asset_path.as_os_str().to_owned();
141            sidecar.push(".");
142            sidecar.push(suffix);
143            let sidecar = PathBuf::from(sidecar);
144            if let Ok(metadata) = fs::metadata(&sidecar).await
145                && metadata.is_file()
146            {
147                return Some((sidecar, encoding.clone()));
148            }
149        }
150        None
151    }
152
153    async fn resolve(&self, url_path: &str, accept_encoding: Option<&str>) -> Option<Record> {
154        let fs_path = self.resolve_fs_path(url_path).await?;
155        let metadata = fs::metadata(&fs_path).await.ok()?;
156        if metadata.is_dir() {
157            log::trace!("resolved {} as dir {}", url_path, fs_path.to_str().unwrap());
158            Some(Record::Dir(fs_path))
159        } else if metadata.is_file() {
160            if let Some((sidecar, encoding)) =
161                self.pick_precompressed(&fs_path, accept_encoding).await
162                && let Ok(file) = File::open(&sidecar).await
163            {
164                return Some(Record::File(fs_path, file, Some(encoding)));
165            }
166            File::open(&fs_path)
167                .await
168                .ok()
169                .map(|file| Record::File(fs_path, file, None))
170        } else {
171            None
172        }
173    }
174
175    /// builds a new StaticFileHandler
176    ///
177    /// If the fs_root is a file instead of a directory, that file will be served at all paths.
178    ///
179    /// ```
180    /// # #[cfg(not(unix))] fn main() {}
181    /// # #[cfg(unix)] fn main() {
182    /// # use trillium::{Handler, Status};
183    /// # trillium_testing::block_on(async {
184    /// use trillium_static::{StaticFileHandler, crate_relative_path};
185    /// use trillium_testing::TestServer;
186    ///
187    /// let mut handler = StaticFileHandler::new(crate_relative_path!("examples/files"));
188    /// let app = TestServer::new(handler).await;
189    ///
190    /// app.get("/").await.assert_status(Status::NotFound); // no index file configured
191    ///
192    /// app.get("/index.html")
193    ///     .await
194    ///     .assert_ok()
195    ///     .assert_body("<h1>hello world</h1>\n")
196    ///     .assert_header("content-type", "text/html; charset=utf-8");
197    /// # }); }
198    /// ```
199    pub fn new(fs_root: impl AsRef<Path>) -> Self {
200        let fs_root = fs_root.as_ref().canonicalize().unwrap();
201        Self {
202            fs_root,
203            index_file: None,
204            root_is_file: false,
205            options: StaticOptions::default(),
206            precompressed: Vec::new(),
207        }
208    }
209
210    /// Enable serving precompressed sidecar files for the standard set of
211    /// content codings: brotli (`.br`), zstd (`.zst`), and gzip (`.gz`), in
212    /// that match-priority order.
213    ///
214    /// For each request whose `Accept-Encoding` allows one of these codings,
215    /// the handler looks for a sibling file at `<asset>.<suffix>` and, if
216    /// present, serves it with `Content-Encoding` set to the coding token.
217    /// The original asset's MIME type is preserved.
218    ///
219    /// When precompression is enabled, every response from this handler sets
220    /// `Vary: Accept-Encoding` — including the uncompressed-original fallback
221    /// — so caches do not serve a compressed response to a client that did
222    /// not ask for one (or vice versa).
223    ///
224    /// Equivalent to chaining three calls:
225    ///
226    /// ```ignore
227    /// handler
228    ///     .with_precompressed_variant("br", "br")
229    ///     .with_precompressed_variant("zstd", "zst")
230    ///     .with_precompressed_variant("gzip", "gz")
231    /// ```
232    ///
233    /// To register additional codings or use only a subset, use
234    /// [`with_precompressed_variant`](Self::with_precompressed_variant)
235    /// directly.
236    ///
237    /// This composes with `trillium-compression`: when this handler sets
238    /// `Content-Encoding`, the compression middleware skips the body and
239    /// passes it through unchanged.
240    pub fn with_precompressed(self) -> Self {
241        self.with_precompressed_variant("br", "br")
242            .with_precompressed_variant("zstd", "zst")
243            .with_precompressed_variant("gzip", "gz")
244    }
245
246    /// Register a precompressed-sidecar variant. Calls are additive and
247    /// preserve registration order — earlier registrations win when a client
248    /// accepts more than one.
249    ///
250    /// `encoding` is the [HTTP content-coding token][content-coding] used in
251    /// the `Content-Encoding` response header (e.g. `"br"`, `"gzip"`,
252    /// `"zstd"`).  `suffix` is the on-disk filename suffix without the
253    /// leading dot (e.g. `"br"`, `"gz"`, `"zst"`).
254    ///
255    /// Most callers want [`with_precompressed`](Self::with_precompressed),
256    /// which registers the standard set with conventional suffixes. Use
257    /// this method to register a custom coding (for example, a non-standard
258    /// suffix from a build pipeline) or to opt into only a subset of the
259    /// defaults.
260    ///
261    /// [content-coding]: https://www.rfc-editor.org/rfc/rfc9110.html#name-content-codings
262    pub fn with_precompressed_variant(
263        mut self,
264        encoding: impl Into<String>,
265        suffix: impl Into<String>,
266    ) -> Self {
267        self.precompressed.push((encoding.into(), suffix.into()));
268        self
269    }
270
271    /// do not set an etag header
272    pub fn without_etag_header(mut self) -> Self {
273        self.options.etag = false;
274        self
275    }
276
277    /// do not set last-modified header
278    pub fn without_modified_header(mut self) -> Self {
279        self.options.modified = false;
280        self
281    }
282
283    async fn serve_range(
284        &self,
285        mut conn: Conn,
286        file: File,
287        source_path: &Path,
288        spec: range::RangeSpec,
289        if_range: Option<&str>,
290    ) -> Conn {
291        let metadata = conn_unwrap!(file.metadata().await.ok(), conn);
292        let total = metadata.len();
293
294        if self.options.modified
295            && let Ok(last_modified) = metadata.modified()
296        {
297            conn.response_headers_mut()
298                .try_insert(LastModified, httpdate::fmt_http_date(last_modified));
299        }
300        let etag_str = self.options.etag.then(|| {
301            let etag = EntityTag::from_file_meta(&metadata).to_string();
302            conn.response_headers_mut().try_insert(Etag, etag.clone());
303            etag
304        });
305        conn.response_headers_mut()
306            .try_insert(AcceptRanges, "bytes");
307
308        let mime_path = source_path.to_path_buf();
309        if let Some(mime) = mime_guess::from_path(&mime_path).first() {
310            use mime_guess::mime::{APPLICATION, HTML, JAVASCRIPT, TEXT};
311            let is_text = matches!(
312                (mime.type_(), mime.subtype()),
313                (APPLICATION, JAVASCRIPT) | (TEXT, _) | (_, HTML)
314            );
315            conn.response_headers_mut().try_insert(
316                ContentType,
317                if is_text {
318                    format!("{mime}; charset=utf-8")
319                } else {
320                    mime.to_string()
321                },
322            );
323        }
324
325        // If-Range gate
326        let last_modified = metadata.modified().ok();
327        let honor = if_range
328            .is_none_or(|hv| range::if_range_matches(hv, etag_str.as_deref(), last_modified));
329
330        if !honor {
331            // Validator mismatch: serve full body 200 from this same file.
332            return conn.ok(stream_full_body(file, total));
333        }
334
335        match range::resolve(spec, total) {
336            Some((start, end)) => {
337                let len = end - start + 1;
338                match seek_take_body(file, start, len).await {
339                    Ok(body) => {
340                        conn.response_headers_mut()
341                            .insert(ContentRange, format!("bytes {start}-{end}/{total}"));
342                        conn.with_status(Status::PartialContent).with_body(body)
343                    }
344                    Err(_) => conn.with_status(Status::InternalServerError),
345                }
346            }
347            None => {
348                conn.response_headers_mut()
349                    .insert(ContentRange, format!("bytes */{total}"));
350                conn.with_status(Status::RequestedRangeNotSatisfiable)
351                    .with_body("")
352            }
353        }
354    }
355
356    /// sets the index file on this StaticFileHandler
357    /// ```
358    /// # #[cfg(not(unix))] fn main() {}
359    /// # #[cfg(unix)] fn main() {
360    /// # use trillium::Handler;
361    /// # use trillium_testing::TestServer;
362    /// # trillium_testing::block_on(async {
363    ///
364    /// use trillium_static::{StaticFileHandler, crate_relative_path};
365    ///
366    /// let handler = StaticFileHandler::new(crate_relative_path!("examples/files"))
367    ///     .with_index_file("index.html");
368    /// let app = TestServer::new(handler).await;
369    ///
370    /// app.get("/")
371    ///     .await
372    ///     .assert_ok()
373    ///     .assert_body("<h1>hello world</h1>\n")
374    ///     .assert_header("content-type", "text/html; charset=utf-8");
375    /// # }); }
376    /// ```
377    pub fn with_index_file(mut self, file: &str) -> Self {
378        self.index_file = Some(file.to_string());
379        self
380    }
381}
382
383impl Handler for StaticFileHandler {
384    async fn init(&mut self, _info: &mut trillium::Info) {
385        self.root_is_file = match self.resolve("/", None).await {
386            Some(Record::File(path, _, _)) => {
387                log::info!("serving {:?} for all paths", path);
388                true
389            }
390
391            Some(Record::Dir(dir)) => {
392                log::info!("serving files within {:?}", dir);
393                false
394            }
395
396            None => {
397                log::error!(
398                    "could not find {:?} on init, continuing anyway",
399                    self.fs_root
400                );
401                false
402            }
403        };
404    }
405
406    async fn run(&self, conn: Conn) -> Conn {
407        let accept_encoding = conn
408            .request_headers()
409            .get_str(AcceptEncoding)
410            .map(str::to_owned);
411        let range_spec = conn.request_headers().get_str(Range).and_then(range::parse);
412        let if_range = conn.request_headers().get_str(IfRange).map(str::to_owned);
413        let precompressed_enabled = !self.precompressed.is_empty();
414
415        // When a parsable Range is present, bypass sidecar selection — the
416        // range applies to the identity representation.
417        let accept_for_resolve = if range_spec.is_some() {
418            None
419        } else {
420            accept_encoding.as_deref()
421        };
422
423        let conn = match self.resolve(conn.path(), accept_for_resolve).await {
424            Some(Record::File(path, file, encoding)) => {
425                if let Some(spec) = range_spec {
426                    self.serve_range(conn, file, &path, spec, if_range.as_deref())
427                        .await
428                } else {
429                    let conn = match encoding {
430                        Some(enc) => conn.with_response_header(ContentEncoding, enc),
431                        None => conn,
432                    };
433                    let mut conn = conn
434                        .send_file_with_options(file, &self.options)
435                        .await
436                        .with_mime_from_path(path);
437                    conn.response_headers_mut()
438                        .try_insert(AcceptRanges, "bytes");
439                    conn
440                }
441            }
442
443            Some(Record::Dir(path)) => {
444                let Some(index) = self.index_file.as_ref() else {
445                    return conn.with_state(ResolvedDirectory::new(path));
446                };
447                let index_path = path.join(index);
448
449                if let Some(spec) = range_spec {
450                    let Some(file) = File::open(&index_path).await.ok() else {
451                        return conn.with_state(ResolvedDirectory::new(path));
452                    };
453                    self.serve_range(conn, file, &index_path, spec, if_range.as_deref())
454                        .await
455                } else {
456                    let (open_path, encoding) = match self
457                        .pick_precompressed(&index_path, accept_encoding.as_deref())
458                        .await
459                    {
460                        Some((sidecar, encoding)) => (sidecar, Some(encoding)),
461                        None => (index_path.clone(), None),
462                    };
463                    let Some(file) = File::open(&open_path).await.ok() else {
464                        return conn.with_state(ResolvedDirectory::new(path));
465                    };
466                    let conn = match encoding {
467                        Some(enc) => conn.with_response_header(ContentEncoding, enc),
468                        None => conn,
469                    };
470                    let mut conn = conn
471                        .send_file_with_options(file, &self.options)
472                        .await
473                        .with_mime_from_path(index_path);
474                    conn.response_headers_mut()
475                        .try_insert(AcceptRanges, "bytes");
476                    conn
477                }
478            }
479
480            None => return conn,
481        };
482
483        if precompressed_enabled {
484            append_vary_accept_encoding(conn)
485        } else {
486            conn
487        }
488    }
489}