1use crate::error::{Error, Result};
23use crate::request::Request;
24use crate::response::Response;
25use std::path::Path;
26use std::time::{SystemTime, UNIX_EPOCH};
27use tokio::io::{AsyncReadExt, AsyncSeekExt};
28
29const BUFFER_LIMIT: u64 = 64 * 1024;
31const CHUNK: usize = 64 * 1024;
32
33#[derive(Debug, Clone, Default)]
35pub struct FileOptions {
36 cache_control: Option<String>,
37 content_type: Option<String>,
38 attachment: Option<String>,
39 headers: Vec<(String, String)>,
40}
41
42impl FileOptions {
43 pub fn new() -> Self {
44 Self::default()
45 }
46
47 pub fn cache_control(mut self, value: impl Into<String>) -> Self {
49 self.cache_control = Some(value.into());
50 self
51 }
52
53 pub fn content_type(mut self, value: impl Into<String>) -> Self {
55 self.content_type = Some(value.into());
56 self
57 }
58
59 pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
61 self.headers.push((name.into(), value.into()));
62 self
63 }
64
65 pub fn attachment(mut self, filename: impl Into<String>) -> Self {
67 self.attachment = Some(filename.into());
68 self
69 }
70}
71
72pub async fn serve_file(
76 req: &Request,
77 path: impl AsRef<Path>,
78 options: &FileOptions,
79) -> Result<Response> {
80 let path = path.as_ref();
81 let mut file = tokio::fs::File::open(path)
82 .await
83 .map_err(|_| Error::not_found("File not found"))?;
84 let meta = file
85 .metadata()
86 .await
87 .map_err(|_| Error::not_found("File not found"))?;
88 if !meta.is_file() {
89 return Err(Error::not_found("File not found"));
90 }
91 let size = meta.len();
92 let mtime = meta
93 .modified()
94 .ok()
95 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
96 .map(|d| d.as_secs())
97 .unwrap_or(0);
98 let etag = format!("W/\"{:x}-{:x}\"", size, mtime);
99
100 let content_type = options
101 .content_type
102 .clone()
103 .unwrap_or_else(|| content_type_for(path).to_string());
104
105 let mut base = Response::ok()
106 .with_header("content-type", content_type)
107 .with_header("accept-ranges", "bytes")
108 .with_header("etag", etag.clone())
109 .with_header("last-modified", http_date(mtime));
110 if let Some(cc) = &options.cache_control {
111 base = base.with_header("cache-control", cc.clone());
112 }
113 if let Some(name) = &options.attachment {
114 base = base.with_header("content-disposition", content_disposition(name));
115 }
116 for (name, value) in &options.headers {
117 base = base.with_header(name.clone(), value.clone());
118 }
119
120 let header = |name: &str| req.headers.get(name).and_then(|v| v.to_str().ok());
122 let not_modified = match header("if-none-match") {
123 Some(inm) => crate::conditional::if_none_match_hits(inm, &etag),
124 None => header("if-modified-since")
125 .and_then(parse_http_date)
126 .is_some_and(|since| mtime <= since),
127 };
128 if not_modified {
129 return Ok(base.with_status(http::StatusCode::NOT_MODIFIED));
130 }
131
132 let range_applies = header("if-range").is_none_or(|ir| {
134 if ir.starts_with('"') || ir.starts_with("W/") {
135 false
137 } else {
138 parse_http_date(ir) == Some(mtime)
139 }
140 });
141 let range = match header("range").filter(|_| range_applies) {
142 Some(r) => parse_range(r, size),
143 None => RangeOutcome::Full,
144 };
145
146 let (start, len, partial) = match range {
147 RangeOutcome::Full => (0, size, false),
148 RangeOutcome::Partial(start, end) => (start, end - start + 1, true),
149 RangeOutcome::Unsatisfiable => {
150 return Ok(
151 Response::from_error(Error::custom(416, "Range not satisfiable"))
152 .with_header("content-range", format!("bytes */{}", size))
153 .with_header("accept-ranges", "bytes"),
154 );
155 }
156 };
157
158 let mut res = base;
159 if partial {
160 res = res
161 .with_status(http::StatusCode::PARTIAL_CONTENT)
162 .with_header(
163 "content-range",
164 format!("bytes {}-{}/{}", start, start + len - 1, size),
165 );
166 }
167
168 if req.method == http::Method::HEAD {
169 return Ok(res.with_header("content-length", len.to_string()));
171 }
172
173 if len <= BUFFER_LIMIT {
174 let mut buf = vec![0u8; len as usize];
175 if start > 0 {
176 file.seek(std::io::SeekFrom::Start(start))
177 .await
178 .map_err(|_| Error::internal("Failed to read file"))?;
179 }
180 file.read_exact(&mut buf)
181 .await
182 .map_err(|_| Error::internal("Failed to read file"))?;
183 return Ok(res.with_body(buf));
184 }
185
186 let (tx, mut streamed) = Response::stream();
187 streamed.status = res.status;
188 streamed.headers = res.headers;
189 streamed
190 .headers
191 .insert(http::header::CONTENT_LENGTH, http::HeaderValue::from(len));
192 tokio::spawn(async move {
193 if start > 0 && file.seek(std::io::SeekFrom::Start(start)).await.is_err() {
194 return;
195 }
196 let mut remaining = len;
197 let mut buf = vec![0u8; CHUNK];
198 while remaining > 0 {
199 let want = (remaining as usize).min(CHUNK);
200 match file.read(&mut buf[..want]).await {
201 Ok(0) | Err(_) => return,
203 Ok(n) => {
204 remaining -= n as u64;
205 if tx
206 .send(bytes::Bytes::copy_from_slice(&buf[..n]))
207 .await
208 .is_err()
209 {
210 return; }
212 }
213 }
214 }
215 });
216 Ok(streamed)
217}
218
219enum RangeOutcome {
220 Full,
221 Partial(u64, u64),
223 Unsatisfiable,
224}
225
226fn parse_range(header: &str, size: u64) -> RangeOutcome {
229 let Some(spec) = header.trim().strip_prefix("bytes=") else {
230 return RangeOutcome::Full;
231 };
232 if spec.contains(',') {
233 return RangeOutcome::Full;
234 }
235 let Some((first, last)) = spec.trim().split_once('-') else {
236 return RangeOutcome::Full;
237 };
238 let (first, last) = (first.trim(), last.trim());
239 match (first.is_empty(), last.is_empty()) {
240 (true, false) => match last.parse::<u64>() {
242 Ok(0) => RangeOutcome::Unsatisfiable,
243 Ok(_) if size == 0 => RangeOutcome::Unsatisfiable,
244 Ok(n) => RangeOutcome::Partial(size.saturating_sub(n), size - 1),
245 Err(_) => RangeOutcome::Full,
246 },
247 (false, empty_last) => {
248 let Ok(start) = first.parse::<u64>() else {
249 return RangeOutcome::Full;
250 };
251 let end = if empty_last {
252 u64::MAX
253 } else {
254 match last.parse::<u64>() {
255 Ok(e) if e >= start => e,
256 _ => return RangeOutcome::Full, }
258 };
259 if start >= size {
260 RangeOutcome::Unsatisfiable
261 } else {
262 RangeOutcome::Partial(start, end.min(size - 1))
263 }
264 }
265 (true, true) => RangeOutcome::Full,
266 }
267}
268
269fn content_disposition(name: &str) -> String {
271 let fallback: String = name
272 .chars()
273 .map(|c| {
274 if c.is_ascii() && !c.is_ascii_control() && c != '"' && c != '\\' {
275 c
276 } else {
277 '_'
278 }
279 })
280 .collect();
281 format!(
282 "attachment; filename=\"{}\"; filename*=UTF-8''{}",
283 fallback,
284 urlencoding::encode(name)
285 )
286}
287
288pub fn content_type_for(path: &Path) -> &'static str {
290 let ext = path
291 .extension()
292 .and_then(|e| e.to_str())
293 .map(|e| e.to_ascii_lowercase());
294 match ext.as_deref() {
295 Some("html" | "htm") => "text/html; charset=utf-8",
296 Some("css") => "text/css; charset=utf-8",
297 Some("js" | "mjs") => "application/javascript; charset=utf-8",
298 Some("json" | "map") => "application/json",
299 Some("xml") => "application/xml",
300 Some("txt" | "log") => "text/plain; charset=utf-8",
301 Some("md") => "text/markdown; charset=utf-8",
302 Some("csv") => "text/csv; charset=utf-8",
303 Some("png") => "image/png",
304 Some("jpg" | "jpeg") => "image/jpeg",
305 Some("gif") => "image/gif",
306 Some("webp") => "image/webp",
307 Some("avif") => "image/avif",
308 Some("svg") => "image/svg+xml",
309 Some("ico") => "image/x-icon",
310 Some("woff") => "font/woff",
311 Some("woff2") => "font/woff2",
312 Some("ttf") => "font/ttf",
313 Some("otf") => "font/otf",
314 Some("pdf") => "application/pdf",
315 Some("zip") => "application/zip",
316 Some("gz") => "application/gzip",
317 Some("tar") => "application/x-tar",
318 Some("wasm") => "application/wasm",
319 Some("mp3") => "audio/mpeg",
320 Some("ogg" | "oga") => "audio/ogg",
321 Some("wav") => "audio/wav",
322 Some("mp4" | "m4v") => "video/mp4",
323 Some("webm") => "video/webm",
324 _ => "application/octet-stream",
325 }
326}
327
328const DAYS: [&str; 7] = ["Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed"];
329const MONTHS: [&str; 12] = [
330 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
331];
332
333fn civil(unix_secs: u64) -> (i64, i64, i64, u64, u64, u64, usize) {
336 let days = (unix_secs / 86_400) as i64;
337 let rem = unix_secs % 86_400;
338 let z = days + 719_468;
340 let era = z.div_euclid(146_097);
341 let doe = z.rem_euclid(146_097);
342 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
343 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
344 let mp = (5 * doy + 2) / 153;
345 let day = doy - (153 * mp + 2) / 5 + 1;
346 let month = if mp < 10 { mp + 3 } else { mp - 9 };
347 let year = yoe + era * 400 + i64::from(month <= 2);
348 (
349 year,
350 month,
351 day,
352 rem / 3600,
353 (rem % 3600) / 60,
354 rem % 60,
355 days.rem_euclid(7) as usize,
356 )
357}
358
359pub fn http_date(unix_secs: u64) -> String {
361 let (year, month, day, h, m, s, weekday) = civil(unix_secs);
362 format!(
363 "{}, {:02} {} {:04} {:02}:{:02}:{:02} GMT",
364 DAYS[weekday],
365 day,
366 MONTHS[(month - 1) as usize],
367 year,
368 h,
369 m,
370 s
371 )
372}
373
374pub fn iso8601(unix_secs: u64) -> String {
376 let (year, month, day, h, m, s, _) = civil(unix_secs);
377 format!(
378 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
379 year, month, day, h, m, s
380 )
381}
382
383pub fn parse_http_date(s: &str) -> Option<u64> {
385 let mut parts = s.trim().split(' ');
386 let day_name = parts.next()?;
387 if !day_name.ends_with(',') {
388 return None;
389 }
390 let day: i64 = parts.next()?.parse().ok()?;
391 let month_name = parts.next()?;
392 let month = MONTHS.iter().position(|m| *m == month_name)? as i64 + 1;
393 let year: i64 = parts.next()?.parse().ok()?;
394 let mut hms = parts.next()?.split(':');
395 let (h, m, sec): (u64, u64, u64) = (
396 hms.next()?.parse().ok()?,
397 hms.next()?.parse().ok()?,
398 hms.next()?.parse().ok()?,
399 );
400 if parts.next()? != "GMT" || parts.next().is_some() || hms.next().is_some() {
401 return None;
402 }
403 if !(1..=31).contains(&day) || h > 23 || m > 59 || sec > 60 || !(1970..=9999).contains(&year) {
404 return None;
405 }
406 let y = year - i64::from(month <= 2);
408 let era = y.div_euclid(400);
409 let yoe = y.rem_euclid(400);
410 let mp = if month > 2 { month - 3 } else { month + 9 };
411 let doy = (153 * mp + 2) / 5 + day - 1;
412 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
413 let days = era * 146_097 + doe - 719_468;
414 u64::try_from(days)
415 .ok()
416 .map(|d| d * 86_400 + h * 3600 + m * 60 + sec)
417}
418
419pub fn http_date_now() -> String {
421 http_date(
422 SystemTime::now()
423 .duration_since(UNIX_EPOCH)
424 .map(|d| d.as_secs())
425 .unwrap_or(0),
426 )
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432
433 #[test]
434 fn http_dates_roundtrip_and_match_the_rfc_example() {
435 assert_eq!(http_date(784_111_777), "Sun, 06 Nov 1994 08:49:37 GMT");
437 assert_eq!(
438 parse_http_date("Sun, 06 Nov 1994 08:49:37 GMT"),
439 Some(784_111_777)
440 );
441 assert_eq!(http_date(0), "Thu, 01 Jan 1970 00:00:00 GMT");
442 assert_eq!(http_date(951_782_400), "Tue, 29 Feb 2000 00:00:00 GMT"); for secs in [
444 0u64,
445 1,
446 86_399,
447 86_400,
448 951_782_400,
449 1_700_000_000,
450 4_102_444_799,
451 253_402_300_799,
452 ] {
453 assert_eq!(parse_http_date(&http_date(secs)), Some(secs), "{secs}");
454 }
455 }
456
457 #[test]
458 fn iso_timestamps() {
459 assert_eq!(iso8601(784_111_777), "1994-11-06T08:49:37Z");
460 assert_eq!(iso8601(0), "1970-01-01T00:00:00Z");
461 assert_eq!(iso8601(951_782_400), "2000-02-29T00:00:00Z");
462 assert_eq!(iso8601(253_402_300_799), "9999-12-31T23:59:59Z");
463 }
464
465 #[test]
466 fn bad_http_dates_are_rejected() {
467 for bad in [
468 "",
469 "yesterday",
470 "Sun, 06 Nov 1994 08:49:37",
471 "Sun, 06 Nov 1994 08:49:37 UTC",
472 "Sunday, 06-Nov-94 08:49:37 GMT",
473 "Sun, 32 Nov 1994 08:49:37 GMT",
474 "Sun, 06 Foo 1994 08:49:37 GMT",
475 "Sun, 06 Nov 1994 25:49:37 GMT",
476 "Sun, 06 Nov 1969 00:00:00 GMT",
477 "Sun, 06 Nov 1994 08:49:37 GMT extra",
478 ] {
479 assert_eq!(parse_http_date(bad), None, "{bad:?}");
480 }
481 }
482
483 fn range(h: &str, size: u64) -> Option<(u64, u64)> {
484 match parse_range(h, size) {
485 RangeOutcome::Partial(a, b) => Some((a, b)),
486 _ => None,
487 }
488 }
489
490 #[test]
491 fn ranges() {
492 assert_eq!(range("bytes=0-99", 1000), Some((0, 99)));
493 assert_eq!(range("bytes=900-", 1000), Some((900, 999)));
494 assert_eq!(range("bytes=-100", 1000), Some((900, 999)));
495 assert_eq!(range("bytes=-5000", 1000), Some((0, 999)));
496 assert_eq!(range("bytes=990-5000", 1000), Some((990, 999)));
497 assert_eq!(range("bytes=0-0", 1000), Some((0, 0)));
498 assert_eq!(range(" bytes= 5 - 9 ", 1000), Some((5, 9)));
499 assert!(matches!(
501 parse_range("bytes=1000-", 1000),
502 RangeOutcome::Unsatisfiable
503 ));
504 assert!(matches!(
505 parse_range("bytes=2000-3000", 1000),
506 RangeOutcome::Unsatisfiable
507 ));
508 assert!(matches!(
509 parse_range("bytes=-0", 1000),
510 RangeOutcome::Unsatisfiable
511 ));
512 assert!(matches!(
513 parse_range("bytes=0-0", 0),
514 RangeOutcome::Unsatisfiable
515 ));
516 assert!(matches!(
517 parse_range("bytes=-5", 0),
518 RangeOutcome::Unsatisfiable
519 ));
520 for ignored in [
522 "bytes=0-1,5-6",
523 "bytes=abc",
524 "bytes=5-2",
525 "items=0-1",
526 "0-1",
527 "bytes=",
528 "bytes=-",
529 "bytes=a-b",
530 ] {
531 assert!(
532 matches!(parse_range(ignored, 1000), RangeOutcome::Full),
533 "{ignored}"
534 );
535 }
536 assert!(range("bytes=0-99999999999999999999", 10).is_none());
538 assert_eq!(range(&format!("bytes=0-{}", u64::MAX), 10), Some((0, 9)));
539 }
540
541 #[test]
542 fn attachment_header_has_ascii_fallback_and_utf8_name() {
543 assert_eq!(
544 content_disposition("report.pdf"),
545 "attachment; filename=\"report.pdf\"; filename*=UTF-8''report.pdf"
546 );
547 let h = content_disposition("Übersicht \"Q1\".pdf");
548 assert!(h.contains("filename=\"_bersicht _Q1_.pdf\""), "{h}");
549 assert!(
550 h.contains("filename*=UTF-8''%C3%9Cbersicht%20%22Q1%22.pdf"),
551 "{h}"
552 );
553 assert!(!content_disposition("a\r\nb").contains('\r'));
554 }
555
556 #[test]
557 fn content_types() {
558 let t = |p: &str| content_type_for(Path::new(p));
559 assert_eq!(t("a.HTML"), "text/html; charset=utf-8");
560 assert_eq!(t("photo.JPeG"), "image/jpeg");
561 assert_eq!(t("video.mp4"), "video/mp4");
562 assert_eq!(t("noext"), "application/octet-stream");
563 assert_eq!(t("archive.tar.gz"), "application/gzip");
564 }
565}