1use 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#[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 pub fn cache_control(mut self, value: &str) -> Self {
54 self.cache_control = value.to_string();
55 self
56 }
57
58 pub fn precompressed(mut self) -> Self {
62 self.precompressed = true;
63 self
64 }
65
66 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 pub fn immutable_dir(mut self, dir: &str) -> Self {
77 self.immutable_dirs.push(dir.trim_matches('/').to_string());
78 self
79 }
80
81 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 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
111fn 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
123pub 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 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 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 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 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 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}