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