1use std::{
2 fs::Metadata,
3 io,
4 path::{Path, PathBuf},
5 time::{SystemTime, UNIX_EPOCH},
6};
7
8use actix_web::{
9 body::{self, BoxBody, SizedStream},
10 dev::{
11 self, AppService, HttpServiceFactory, ResourceDef, Service, ServiceFactory, ServiceRequest,
12 ServiceResponse,
13 },
14 http::{
15 header::{
16 self, Charset, ContentDisposition, ContentEncoding, DispositionParam, DispositionType,
17 ExtendedValue,
18 },
19 StatusCode,
20 },
21 Error, HttpMessage, HttpRequest, HttpResponse, Responder,
22};
23use bitflags::bitflags;
24use derive_more::{Deref, DerefMut};
25use futures_core::future::LocalBoxFuture;
26use mime::Mime;
27
28use crate::{encoding::equiv_utf8_text, range::HttpRange};
29
30bitflags! {
31 #[derive(Debug, Clone, Copy)]
32 pub(crate) struct Flags: u8 {
33 const ETAG = 0b0000_0001;
34 const LAST_MD = 0b0000_0010;
35 const CONTENT_DISPOSITION = 0b0000_0100;
36 const PREFER_UTF8 = 0b0000_1000;
37 }
38}
39
40impl Default for Flags {
41 fn default() -> Self {
42 Flags::from_bits_truncate(0b0000_1111)
43 }
44}
45
46#[derive(Debug, Deref, DerefMut)]
71pub struct NamedFile {
72 #[deref]
73 #[deref_mut]
74 file: File,
75 path: PathBuf,
76 modified: Option<SystemTime>,
77 pub(crate) md: Metadata,
78 pub(crate) flags: Flags,
79 pub(crate) status_code: StatusCode,
80 pub(crate) content_type: Mime,
81 pub(crate) content_disposition: ContentDisposition,
82 pub(crate) encoding: Option<ContentEncoding>,
83 pub(crate) read_mode_threshold: u64,
84}
85
86pub(crate) use std::fs::File;
87
88use super::chunked;
89
90pub(crate) fn get_content_type_and_disposition(
91 path: &Path,
92) -> Result<(mime::Mime, ContentDisposition), io::Error> {
93 let filename = match path.file_name() {
94 Some(name) => name.to_string_lossy(),
95 None => {
96 return Err(io::Error::new(
97 io::ErrorKind::InvalidInput,
98 "Provided path has no filename",
99 ));
100 }
101 };
102
103 let ct = mime_guess::from_path(path).first_or_octet_stream();
104
105 let disposition = match ct.type_() {
106 mime::IMAGE | mime::TEXT | mime::AUDIO | mime::VIDEO => DispositionType::Inline,
107 mime::APPLICATION => match ct.subtype() {
108 mime::JAVASCRIPT | mime::JSON => DispositionType::Inline,
109 name if name == "wasm" || name == "xhtml" => DispositionType::Inline,
110 _ => DispositionType::Attachment,
111 },
112 _ => DispositionType::Attachment,
113 };
114
115 let mut escaped_len = filename.len();
117 for byte in filename.bytes() {
118 if matches!(byte, b'\n' | b'\x0B' | b'\x0C' | b'\r') {
119 escaped_len += 2;
120 }
121 }
122
123 let filename_s = if escaped_len == filename.len() {
124 filename.to_string()
125 } else {
126 let mut escaped = String::with_capacity(escaped_len);
127 for ch in filename.chars() {
128 match ch {
129 '\n' => escaped.push_str("%0A"), '\x0B' => escaped.push_str("%0B"), '\x0C' => escaped.push_str("%0C"), '\r' => escaped.push_str("%0D"), ch => escaped.push(ch),
134 }
135 }
136 escaped
137 };
138
139 let is_ascii = filename.is_ascii();
140
141 let mut parameters = Vec::with_capacity(if is_ascii { 1 } else { 2 });
142 parameters.push(DispositionParam::Filename(filename_s));
143
144 if !is_ascii {
145 parameters.push(DispositionParam::FilenameExt(ExtendedValue {
146 charset: Charset::Ext(String::from("UTF-8")),
147 language_tag: None,
148 value: filename.into_owned().into_bytes(),
149 }))
150 }
151
152 let cd = ContentDisposition {
153 disposition,
154 parameters,
155 };
156
157 Ok((ct, cd))
158}
159
160impl NamedFile {
161 pub fn from_file<P: AsRef<Path>>(file: File, path: P) -> io::Result<NamedFile> {
182 let path = path.as_ref().to_path_buf();
183
184 let (content_type, content_disposition) = get_content_type_and_disposition(&path)?;
187
188 let md = file.metadata()?;
189
190 let modified = md.modified().ok();
191 let encoding = None;
192
193 Ok(NamedFile {
194 path,
195 file,
196 content_type,
197 content_disposition,
198 md,
199 modified,
200 encoding,
201 status_code: StatusCode::OK,
202 flags: Flags::default(),
203 read_mode_threshold: 0,
204 })
205 }
206
207 pub fn open<P: AsRef<Path>>(path: P) -> io::Result<NamedFile> {
215 let file = File::open(&path)?;
216 Self::from_file(file, path)
217 }
218
219 #[inline]
221 pub fn file(&self) -> &File {
222 &self.file
223 }
224
225 #[inline]
239 pub fn path(&self) -> &Path {
240 self.path.as_path()
241 }
242
243 #[inline]
248 pub fn modified(&self) -> Option<SystemTime> {
249 self.modified
250 }
251
252 #[inline]
254 pub fn metadata(&self) -> &Metadata {
255 &self.md
256 }
257
258 #[inline]
260 pub fn content_type(&self) -> &Mime {
261 &self.content_type
262 }
263
264 #[inline]
266 pub fn content_disposition(&self) -> &ContentDisposition {
267 &self.content_disposition
268 }
269
270 #[inline]
275 pub fn content_encoding(&self) -> Option<ContentEncoding> {
276 self.encoding
277 }
278
279 #[deprecated(since = "0.7.0", note = "Prefer `Responder::customize()`.")]
281 pub fn set_status_code(mut self, status: StatusCode) -> Self {
282 self.status_code = status;
283 self
284 }
285
286 #[inline]
289 pub fn set_content_type(mut self, mime_type: Mime) -> Self {
290 self.content_type = mime_type;
291 self
292 }
293
294 #[inline]
302 pub fn set_content_disposition(mut self, cd: ContentDisposition) -> Self {
303 self.content_disposition = cd;
304 self.flags.insert(Flags::CONTENT_DISPOSITION);
305 self
306 }
307
308 #[inline]
312 pub fn disable_content_disposition(mut self) -> Self {
313 self.flags.remove(Flags::CONTENT_DISPOSITION);
314 self
315 }
316
317 #[inline]
323 pub fn set_content_encoding(mut self, enc: ContentEncoding) -> Self {
324 self.encoding = Some(enc);
325 self
326 }
327
328 pub fn read_mode_threshold(mut self, size: u64) -> Self {
339 self.read_mode_threshold = size;
340 self
341 }
342
343 #[inline]
347 pub fn use_etag(mut self, value: bool) -> Self {
348 self.flags.set(Flags::ETAG, value);
349 self
350 }
351
352 #[inline]
356 pub fn use_last_modified(mut self, value: bool) -> Self {
357 self.flags.set(Flags::LAST_MD, value);
358 self
359 }
360
361 #[inline]
365 pub fn prefer_utf8(mut self, value: bool) -> Self {
366 self.flags.set(Flags::PREFER_UTF8, value);
367 self
368 }
369
370 pub(crate) fn etag(&self) -> Option<header::EntityTag> {
377 let mtime = self.modified?;
378
379 Some({
380 let ino = {
381 #[cfg(unix)]
382 {
383 use std::os::unix::fs::MetadataExt as _;
384
385 self.md.ino()
386 }
387
388 #[cfg(windows)]
389 {
390 winapi_util::file::information(&self.file).map_or(0, |info| info.file_index())
392 }
393
394 #[cfg(not(any(unix, windows)))]
395 {
396 0
397 }
398 };
399
400 let (secs, nanos) = match mtime.duration_since(UNIX_EPOCH) {
403 Ok(dur) => (dur.as_secs() as i64, dur.subsec_nanos()),
404 Err(err) => {
405 let dur = err.duration();
406
407 if dur.subsec_nanos() == 0 {
410 (-(dur.as_secs() as i64), 0)
411 } else {
412 (
413 -(dur.as_secs() as i64) - 1,
414 1_000_000_000 - dur.subsec_nanos(),
415 )
416 }
417 }
418 };
419
420 header::EntityTag::new_strong(format!(
421 "{:x}:{:x}:{:x}:{:x}",
422 ino,
423 self.md.len(),
424 secs as u64,
425 nanos
426 ))
427 })
428 }
429
430 pub(crate) fn last_modified(&self) -> Option<header::HttpDate> {
431 let mtime = self.modified?;
432
433 let dur = mtime.duration_since(UNIX_EPOCH).ok()?;
439 if dur.as_secs() >= 253_402_300_800 {
440 return None;
441 }
442
443 Some(mtime.into())
444 }
445
446 pub fn into_response(self, req: &HttpRequest) -> HttpResponse<BoxBody> {
448 if self.status_code != StatusCode::OK {
449 let mut res = HttpResponse::build(self.status_code);
450
451 let ct = if self.flags.contains(Flags::PREFER_UTF8) {
452 equiv_utf8_text(self.content_type.clone())
453 } else {
454 self.content_type
455 };
456
457 res.insert_header((header::CONTENT_TYPE, ct.to_string()));
458
459 if self.flags.contains(Flags::CONTENT_DISPOSITION) {
460 res.insert_header((
461 header::CONTENT_DISPOSITION,
462 self.content_disposition.to_string(),
463 ));
464 }
465
466 if let Some(current_encoding) = self.encoding {
467 res.insert_header((header::CONTENT_ENCODING, current_encoding.as_str()));
468 }
469
470 let reader =
471 chunked::new_chunked_read(self.md.len(), 0, self.file, self.read_mode_threshold);
472
473 return res.streaming(reader);
474 }
475
476 let etag = if self.flags.contains(Flags::ETAG) {
477 self.etag()
478 } else {
479 None
480 };
481
482 let last_modified = if self.flags.contains(Flags::LAST_MD) {
483 self.last_modified()
484 } else {
485 None
486 };
487
488 let precondition_failed = if !any_match(etag.as_ref(), req) {
490 true
491 } else if let (Some(ref m), Some(header::IfUnmodifiedSince(ref since))) =
492 (last_modified, req.get_header())
493 {
494 let t1: SystemTime = (*m).into();
495 let t2: SystemTime = (*since).into();
496
497 match (t1.duration_since(UNIX_EPOCH), t2.duration_since(UNIX_EPOCH)) {
498 (Ok(t1), Ok(t2)) => t1.as_secs() > t2.as_secs(),
499 _ => false,
500 }
501 } else {
502 false
503 };
504
505 let not_modified = if !none_match(etag.as_ref(), req) {
507 true
508 } else if req.headers().contains_key(header::IF_NONE_MATCH) {
509 false
510 } else if let (Some(ref m), Some(header::IfModifiedSince(ref since))) =
511 (last_modified, req.get_header())
512 {
513 let t1: SystemTime = (*m).into();
514 let t2: SystemTime = (*since).into();
515
516 match (t1.duration_since(UNIX_EPOCH), t2.duration_since(UNIX_EPOCH)) {
517 (Ok(t1), Ok(t2)) => t1.as_secs() <= t2.as_secs(),
518 _ => false,
519 }
520 } else {
521 false
522 };
523
524 let mut res = HttpResponse::build(self.status_code);
525
526 let ct = if self.flags.contains(Flags::PREFER_UTF8) {
527 equiv_utf8_text(self.content_type.clone())
528 } else {
529 self.content_type
530 };
531
532 res.insert_header((header::CONTENT_TYPE, ct.to_string()));
533
534 if self.flags.contains(Flags::CONTENT_DISPOSITION) {
535 res.insert_header((
536 header::CONTENT_DISPOSITION,
537 self.content_disposition.to_string(),
538 ));
539 }
540
541 if let Some(current_encoding) = self.encoding {
542 res.insert_header((header::CONTENT_ENCODING, current_encoding.as_str()));
543 }
544
545 if let Some(lm) = last_modified {
546 res.insert_header((header::LAST_MODIFIED, lm.to_string()));
547 }
548
549 if let Some(etag) = &etag {
550 res.insert_header((header::ETAG, etag.to_string()));
551 }
552
553 res.insert_header((header::ACCEPT_RANGES, "bytes"));
554
555 let mut length = self.md.len();
556 let mut offset = 0;
557 let mut ranged_req = false;
558
559 if let Some(ranges) = req
561 .headers()
562 .get(header::RANGE)
563 .filter(|_| if_range_matches(etag.as_ref(), last_modified, req))
564 {
565 if let Ok(ranges_header) = ranges.to_str() {
566 if let Some(range) = HttpRange::parse(ranges_header, length)
567 .ok()
568 .and_then(|ranges| ranges.first().copied())
569 {
570 ranged_req = true;
571 length = range.length;
572 offset = range.start;
573
574 res.insert_header((
575 header::CONTENT_RANGE,
576 format!("bytes {}-{}/{}", offset, offset + length - 1, self.md.len()),
577 ));
578 } else {
579 res.insert_header((header::CONTENT_RANGE, format!("bytes */{}", length)));
580 return res.status(StatusCode::RANGE_NOT_SATISFIABLE).finish();
581 };
582 } else {
583 return res.status(StatusCode::BAD_REQUEST).finish();
584 };
585 };
586
587 if precondition_failed {
588 return res.status(StatusCode::PRECONDITION_FAILED).finish();
589 } else if not_modified {
590 return res
591 .status(StatusCode::NOT_MODIFIED)
592 .body(body::None::new())
593 .map_into_boxed_body();
594 }
595
596 let reader = chunked::new_chunked_read(length, offset, self.file, self.read_mode_threshold);
597
598 if ranged_req {
599 res.status(StatusCode::PARTIAL_CONTENT);
600 }
601
602 res.body(SizedStream::new(length, reader))
603 }
604}
605
606fn any_match(etag: Option<&header::EntityTag>, req: &HttpRequest) -> bool {
608 match req.get_header::<header::IfMatch>() {
609 None | Some(header::IfMatch::Any) => true,
610
611 Some(header::IfMatch::Items(ref items)) => {
612 if let Some(some_etag) = etag {
613 for item in items {
614 if item.strong_eq(some_etag) {
615 return true;
616 }
617 }
618 }
619
620 false
621 }
622 }
623}
624
625fn if_range_matches(
627 etag: Option<&header::EntityTag>,
628 last_modified: Option<header::HttpDate>,
629 req: &HttpRequest,
630) -> bool {
631 if !req.headers().contains_key(header::IF_RANGE) {
632 return true;
633 }
634
635 match req.get_header::<header::IfRange>() {
636 Some(header::IfRange::EntityTag(ref tag)) => etag.is_some_and(|etag| etag.strong_eq(tag)),
637 Some(header::IfRange::Date(date)) => last_modified.is_some_and(|m| {
638 let t1: SystemTime = m.into();
639 let t2: SystemTime = date.into();
640
641 match (t1.duration_since(UNIX_EPOCH), t2.duration_since(UNIX_EPOCH)) {
642 (Ok(t1), Ok(t2)) => t1.as_secs() == t2.as_secs(),
643 _ => false,
644 }
645 }),
646 None => false,
647 }
648}
649
650fn none_match(etag: Option<&header::EntityTag>, req: &HttpRequest) -> bool {
652 match req.get_header::<header::IfNoneMatch>() {
653 Some(header::IfNoneMatch::Any) => false,
654
655 Some(header::IfNoneMatch::Items(ref items)) => {
656 if let Some(some_etag) = etag {
657 for item in items {
658 if item.weak_eq(some_etag) {
659 return false;
660 }
661 }
662 }
663
664 true
665 }
666
667 None => true,
668 }
669}
670
671impl Responder for NamedFile {
672 type Body = BoxBody;
673
674 fn respond_to(self, req: &HttpRequest) -> HttpResponse<Self::Body> {
675 self.into_response(req)
676 }
677}
678
679impl ServiceFactory<ServiceRequest> for NamedFile {
680 type Response = ServiceResponse;
681 type Error = Error;
682 type Config = ();
683 type Service = NamedFileService;
684 type InitError = ();
685 type Future = LocalBoxFuture<'static, Result<Self::Service, Self::InitError>>;
686
687 fn new_service(&self, _: ()) -> Self::Future {
688 let service = NamedFileService {
689 path: self.path.clone(),
690 };
691
692 Box::pin(async move { Ok(service) })
693 }
694}
695
696#[doc(hidden)]
697#[derive(Debug)]
698pub struct NamedFileService {
699 path: PathBuf,
700}
701
702impl Service<ServiceRequest> for NamedFileService {
703 type Response = ServiceResponse;
704 type Error = Error;
705 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
706
707 dev::always_ready!();
708
709 fn call(&self, req: ServiceRequest) -> Self::Future {
710 let (req, _) = req.into_parts();
711
712 let path = self.path.clone();
713 Box::pin(async move {
714 let file = NamedFile::open(path)?;
715 let res = file.into_response(&req);
716 Ok(ServiceResponse::new(req, res))
717 })
718 }
719}
720
721impl HttpServiceFactory for NamedFile {
722 fn register(self, config: &mut AppService) {
723 config.register_service(
724 ResourceDef::root_prefix(self.path.to_string_lossy().as_ref()),
725 None,
726 self,
727 None,
728 )
729 }
730}
731
732#[cfg(test)]
733mod tests {
734 use super::*;
735
736 #[test]
737 fn audio_files_use_inline_content_disposition() {
738 let (_ct, cd) = get_content_type_and_disposition(Path::new("sound.mp3")).unwrap();
739 assert_eq!(cd.disposition, DispositionType::Inline);
740 }
741
742 #[test]
743 fn special_chars_are_escaped_in_content_disposition_filename() {
744 let (_ct, cd) =
745 get_content_type_and_disposition(Path::new("test\n\x0B\x0C\rnewline.text")).unwrap();
746
747 assert_eq!(
748 cd.to_string(),
749 "inline; filename=\"test%0A%0B%0C%0Dnewline.text\"",
750 );
751 }
752
753 #[cfg(any(unix, windows))]
754 #[actix_web::test]
755 async fn if_none_match_does_not_match_a_different_file_with_same_size_and_mtime() {
756 use std::fs;
757
758 use actix_web::{body::to_bytes, test::TestRequest};
759 use filetime::{set_file_mtime, FileTime};
760
761 let dir = tempfile::tempdir().unwrap();
762 let first_path = dir.path().join("first.txt");
763 let second_path = dir.path().join("second.txt");
764
765 fs::write(&first_path, b"first").unwrap();
766 fs::write(&second_path, b"other").unwrap();
767
768 let mtime = FileTime::from_unix_time(1_600_000_000, 0);
769
770 set_file_mtime(&first_path, mtime).unwrap();
771 set_file_mtime(&second_path, mtime).unwrap();
772
773 let first_md = fs::metadata(&first_path).unwrap();
774 let second_md = fs::metadata(&second_path).unwrap();
775
776 assert_eq!(first_md.len(), second_md.len());
777 assert_eq!(first_md.modified().unwrap(), second_md.modified().unwrap());
778
779 let req = TestRequest::default().to_http_request();
780 let first = NamedFile::from_file(File::open(&first_path).unwrap(), "file.txt").unwrap();
781 let res = first.into_response(&req);
782 let etag = res.headers().get(header::ETAG).unwrap().clone();
783
784 let req = TestRequest::default()
785 .insert_header((header::IF_NONE_MATCH, etag.clone()))
786 .to_http_request();
787 let second = NamedFile::from_file(File::open(&second_path).unwrap(), "file.txt").unwrap();
788 let res = second.into_response(&req);
789
790 assert_eq!(res.status(), StatusCode::OK);
791 assert_ne!(res.headers().get(header::ETAG).unwrap(), &etag);
792 assert_eq!(
793 to_bytes(res.into_body()).await.unwrap(),
794 b"other".as_slice()
795 );
796 }
797}