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