1use std::fs::File;
14use std::io::{Read, Seek, SeekFrom};
15use std::path::{Path, PathBuf};
16use std::time::SystemTime;
17
18use parse_net::{ByteRange, EntityTag, HttpDate, RangeOutcome};
19use serve_net::{HttpReply, HttpRequest, Method, Outcome};
20
21use crate::url;
22
23pub const INLINE_LIMIT: u64 = 1 << 20;
26
27const PART: usize = 64 << 10;
29
30#[derive(Clone, Debug)]
41pub struct Assets {
42 pub(crate) mount: String,
43 pub(crate) dir: PathBuf,
44 pub(crate) root: PathBuf,
45 pub(crate) cache_control: Box<str>,
46 pub(crate) index: Option<Box<str>>,
47 pub(crate) precompressed: bool,
48 pub(crate) hidden: bool,
49}
50
51impl Assets {
52 #[must_use]
55 pub fn new(mount: &str, dir: impl Into<PathBuf>) -> Self {
56 Self {
57 mount: mount.trim_end_matches('/').to_owned(),
58 dir: dir.into(),
59 root: PathBuf::new(),
60 cache_control: "no-cache".into(),
61 index: None,
62 precompressed: true,
63 hidden: false,
64 }
65 }
66
67 #[must_use]
72 pub fn cache_control(mut self, value: &str) -> Self {
73 self.cache_control = value.into();
74 self
75 }
76
77 #[must_use]
80 pub fn index(mut self, name: &str) -> Self {
81 self.index = Some(name.into());
82 self
83 }
84
85 #[must_use]
88 pub fn precompressed(mut self, on: bool) -> Self {
89 self.precompressed = on;
90 self
91 }
92
93 #[must_use]
96 pub fn hidden_files(mut self, on: bool) -> Self {
97 self.hidden = on;
98 self
99 }
100
101 pub(crate) fn strip<'p>(&self, path: &'p str) -> Option<&'p str> {
104 let rest = path.strip_prefix(self.mount.as_str())?;
105 (rest.is_empty() || rest.starts_with('/')).then_some(rest)
106 }
107}
108
109fn is_device_name(seg: &str) -> bool {
112 let stem = seg.split('.').next().unwrap_or(seg).to_ascii_uppercase();
113 matches!(
114 stem.as_str(),
115 "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$"
116 ) || ((stem.starts_with("COM") || stem.starts_with("LPT"))
117 && stem.len() == 4
118 && stem.as_bytes()[3].is_ascii_digit())
119}
120
121fn safe_segment(seg: &str, hidden: bool) -> bool {
123 !seg.is_empty()
124 && (hidden || !seg.starts_with('.'))
125 && !seg.contains([':', '\\', '/', '\0'])
126 && !seg.ends_with(['.', ' '])
127 && !is_device_name(seg)
128}
129
130pub(crate) struct Found {
132 path: PathBuf,
133 len: u64,
134 modified: Option<SystemTime>,
135 encoding: Option<&'static str>,
136 mime: &'static str,
137 vary: bool,
138}
139
140pub(crate) fn find(assets: &Assets, rel: &str, accept_encoding: Option<&[u8]>) -> Option<Found> {
143 let mut path = assets.root.clone();
144 for raw in rel.split('/').filter(|s| !s.is_empty()) {
145 let seg = url::decode(raw);
146 if !safe_segment(&seg, assets.hidden) {
147 return None;
148 }
149 path.push(seg.as_ref());
150 }
151 let mut meta = inside(&assets.root, &path)?;
152 if meta.1.is_dir() {
153 let index = assets.index.as_deref()?;
154 path = meta.0.join(index);
155 meta = inside(&assets.root, &path)?;
156 }
157 let (file, md) = meta;
158 if !md.is_file() {
159 return None;
160 }
161 let mime = mime_for(&file);
162 let mut found = Found {
163 len: md.len(),
164 modified: md.modified().ok(),
165 path: file,
166 encoding: None,
167 mime,
168 vary: false,
169 };
170 if assets.precompressed {
171 found.vary = true;
172 let offers = ["br", "gzip", "identity"];
173 let pick = accept_encoding.and_then(|v| parse_net::best_encoding(Some(v), &offers));
176 let ext = match pick {
177 Some(0) => Some(("br", "br")),
178 Some(1) => Some(("gzip", "gz")),
179 _ => None,
180 };
181 if let Some((coding, ext)) = ext {
182 let mut alt = found.path.clone().into_os_string();
183 alt.push(".");
184 alt.push(ext);
185 if let Some((p, m)) = inside(&assets.root, Path::new(&alt)) {
186 if m.is_file() {
187 found.path = p;
188 found.len = m.len();
189 found.modified = m.modified().ok();
190 found.encoding = Some(coding);
191 }
192 }
193 }
194 }
195 Some(found)
196}
197
198fn inside(root: &Path, path: &Path) -> Option<(PathBuf, std::fs::Metadata)> {
200 let real = std::fs::canonicalize(path).ok()?;
201 if !real.starts_with(root) {
202 return None;
203 }
204 let md = std::fs::metadata(&real).ok()?;
205 Some((real, md))
206}
207
208fn etag(found: &Found) -> String {
211 let nanos = found
212 .modified
213 .and_then(|m| m.duration_since(SystemTime::UNIX_EPOCH).ok())
214 .map_or(0, |d| d.as_nanos());
215 let coding = match found.encoding {
216 Some("br") => "-br",
217 Some(_) => "-gz",
218 None => "",
219 };
220 format!("\"{:x}-{:x}{coding}\"", found.len, nanos)
221}
222
223pub(crate) fn serve(
225 assets: &Assets,
226 found: &Found,
227 req: &HttpRequest<'_>,
228 reply: &mut HttpReply,
229) -> Outcome {
230 let tag = etag(found);
231 let opaque = &tag.as_bytes()[1..tag.len() - 1];
232 let current = EntityTag::strong(opaque).ok();
233 let last_modified = found
234 .modified
235 .and_then(|m| HttpDate::from_system_time(m).ok());
236
237 let head = |reply: &mut HttpReply, status: u16| {
238 reply
239 .status(status)
240 .header("etag", tag.as_bytes())
241 .header("cache-control", assets.cache_control.as_bytes())
242 .header("x-content-type-options", "nosniff");
243 if let Some(d) = last_modified {
244 reply.header("last-modified", d.format());
245 }
246 if found.vary {
247 reply.header("vary", "accept-encoding");
248 }
249 };
250
251 if let (Some(inm), Some(cur)) = (req.header("if-none-match"), current.as_ref()) {
253 if parse_net::if_none_match(inm, cur).unwrap_or(false) {
254 head(reply, 304);
255 return Outcome::Done;
256 }
257 } else if let (Some(ims), Some(lm)) = (req.header("if-modified-since"), last_modified) {
258 if let Ok(since) = parse_net::http_date(ims) {
259 if lm <= since {
260 head(reply, 304);
261 return Outcome::Done;
262 }
263 }
264 }
265
266 let mut range: Option<ByteRange> = None;
268 if req.method() == Method::Get {
269 if let Some(value) = req.header("range") {
270 let if_range_ok = match req.header("if-range") {
271 None => true,
272 Some(v) => parse_net::range::parse_if_range(v)
273 .map(|ir| ir.matches(current.as_ref(), last_modified))
274 .unwrap_or(false),
275 };
276 if if_range_ok {
277 let mut out = [ByteRange { start: 0, end: 0 }];
278 match parse_net::byte_ranges(value, found.len, &mut out) {
279 Ok(RangeOutcome::Ranges(1)) => range = Some(out[0]),
280 Ok(RangeOutcome::Unsatisfiable) => {
281 head(reply, 416);
282 reply.header("content-range", format!("bytes */{}", found.len));
283 return Outcome::Done;
284 }
285 _ => {}
286 }
287 }
288 }
289 }
290
291 let (status, start, len) = match range {
292 Some(r) => (206, r.start, r.end - r.start + 1),
293 None => (200, 0, found.len),
294 };
295 head(reply, status);
296 reply
297 .header("content-type", found.mime)
298 .header("accept-ranges", "bytes");
299 if let Some(c) = found.encoding {
300 reply.header("content-encoding", c);
301 }
302 if let Some(r) = range {
303 reply.header(
304 "content-range",
305 format!("bytes {}-{}/{}", r.start, r.end, found.len),
306 );
307 }
308 if req.method() == Method::Head {
309 if len <= INLINE_LIMIT {
312 return match read_at(&found.path, start, len as usize) {
313 Some(bytes) => {
314 reply.body(&bytes);
315 Outcome::Done
316 }
317 None => fail(reply),
318 };
319 }
320 reply.stream();
321 return Outcome::Done;
322 }
323 if len <= INLINE_LIMIT {
324 return match read_at(&found.path, start, len as usize) {
325 Some(bytes) => {
326 reply.body(&bytes);
327 Outcome::Done
328 }
329 None => fail(reply),
330 };
331 }
332 let sent = reply.stream().sent();
336 send_part(found, start, len, sent, reply)
337}
338
339fn send_part(found: &Found, start: u64, len: u64, sent: u64, reply: &mut HttpReply) -> Outcome {
340 if sent >= len {
341 return Outcome::Done;
342 }
343 let want = (len - sent).min(PART as u64) as usize;
344 let Some(bytes) = read_at(&found.path, start + sent, want) else {
345 return Outcome::Close;
346 };
347 if bytes.len() != want {
348 return Outcome::Close;
350 }
351 reply.stream().send(&bytes);
352 if sent + want as u64 >= len {
353 Outcome::Done
354 } else {
355 Outcome::More
356 }
357}
358
359fn read_at(path: &Path, start: u64, len: usize) -> Option<Vec<u8>> {
360 let mut f = File::open(path).ok()?;
361 if start > 0 {
362 f.seek(SeekFrom::Start(start)).ok()?;
363 }
364 let mut buf = Vec::with_capacity(len);
365 f.take(len as u64).read_to_end(&mut buf).ok()?;
366 Some(buf)
367}
368
369fn fail(reply: &mut HttpReply) -> Outcome {
370 reply.text(500, "the file could not be read");
371 Outcome::Done
372}
373
374#[must_use]
376pub fn mime_for(path: &Path) -> &'static str {
377 let ext = path
378 .extension()
379 .and_then(|e| e.to_str())
380 .map(str::to_ascii_lowercase)
381 .unwrap_or_default();
382 match ext.as_str() {
383 "html" | "htm" => "text/html; charset=utf-8",
384 "css" => "text/css; charset=utf-8",
385 "js" | "mjs" => "text/javascript; charset=utf-8",
386 "json" | "map" => "application/json",
387 "txt" => "text/plain; charset=utf-8",
388 "md" => "text/markdown; charset=utf-8",
389 "csv" => "text/csv; charset=utf-8",
390 "xml" => "application/xml",
391 "svg" => "image/svg+xml",
392 "png" => "image/png",
393 "jpg" | "jpeg" => "image/jpeg",
394 "gif" => "image/gif",
395 "webp" => "image/webp",
396 "avif" => "image/avif",
397 "ico" => "image/x-icon",
398 "bmp" => "image/bmp",
399 "woff" => "font/woff",
400 "woff2" => "font/woff2",
401 "ttf" => "font/ttf",
402 "otf" => "font/otf",
403 "pdf" => "application/pdf",
404 "wasm" => "application/wasm",
405 "zip" => "application/zip",
406 "gz" => "application/gzip",
407 "mp4" => "video/mp4",
408 "webm" => "video/webm",
409 "mp3" => "audio/mpeg",
410 "ogg" => "audio/ogg",
411 "wav" => "audio/wav",
412 "webmanifest" => "application/manifest+json",
413 "rss" => "application/rss+xml",
414 "atom" => "application/atom+xml",
415 _ => "application/octet-stream",
416 }
417}
418
419#[cfg(test)]
420mod tests {
421 use super::*;
422
423 #[test]
424 fn test_unsafe_segments() {
425 for bad in [
426 ".env",
427 ".git",
428 "a:b",
429 "file.txt::$DATA",
430 "c:",
431 "x.",
432 "x ",
433 "CON",
434 "con.txt",
435 "Nul",
436 "COM1",
437 "lpt9.log",
438 "a\\b",
439 "",
440 ] {
441 assert!(!safe_segment(bad, false), "{bad:?}");
442 }
443 for ok in [
444 "a.css",
445 "index.html",
446 "COM",
447 "COM10",
448 "console.js",
449 "a b.txt",
450 ] {
451 assert!(safe_segment(ok, false), "{ok:?}");
452 }
453 assert!(safe_segment(".well-known", true));
454 }
455
456 #[test]
457 fn test_mime() {
458 assert_eq!(mime_for(Path::new("a/B.CSS")), "text/css; charset=utf-8");
459 assert_eq!(mime_for(Path::new("x")), "application/octet-stream");
460 assert_eq!(mime_for(Path::new("x.woff2")), "font/woff2");
461 }
462
463 #[test]
464 fn test_strip() {
465 let a = Assets::new("/static/", ".");
466 assert_eq!(a.strip("/static/a.css"), Some("/a.css"));
467 assert_eq!(a.strip("/static"), Some(""));
468 assert_eq!(a.strip("/statics/a"), None);
469 }
470}