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#[derive(Debug)]
21pub struct StaticFileHandler {
22 fs_root: PathBuf,
23 index_file: Option<String>,
24 root_is_file: bool,
25 options: StaticOptions,
26 precompressed: Vec<(String, String)>,
29}
30
31#[derive(Debug)]
32enum Record {
33 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 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 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 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 pub fn without_etag_header(mut self) -> Self {
273 self.options.etag = false;
274 self
275 }
276
277 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 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 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 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 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}