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> {
372 let mtime = self.modified?;
373
374 Some({
375 let ino = {
376 #[cfg(unix)]
377 {
378 #[cfg(unix)]
379 use std::os::unix::fs::MetadataExt as _;
380
381 self.md.ino()
382 }
383
384 #[cfg(not(unix))]
385 {
386 0
387 }
388 };
389
390 let (secs, nanos) = match mtime.duration_since(UNIX_EPOCH) {
393 Ok(dur) => (dur.as_secs() as i64, dur.subsec_nanos()),
394 Err(err) => {
395 let dur = err.duration();
396
397 if dur.subsec_nanos() == 0 {
400 (-(dur.as_secs() as i64), 0)
401 } else {
402 (
403 -(dur.as_secs() as i64) - 1,
404 1_000_000_000 - dur.subsec_nanos(),
405 )
406 }
407 }
408 };
409
410 header::EntityTag::new_strong(format!(
411 "{:x}:{:x}:{:x}:{:x}",
412 ino,
413 self.md.len(),
414 secs as u64,
415 nanos
416 ))
417 })
418 }
419
420 pub(crate) fn last_modified(&self) -> Option<header::HttpDate> {
421 let mtime = self.modified?;
422
423 let dur = mtime.duration_since(UNIX_EPOCH).ok()?;
429 if dur.as_secs() >= 253_402_300_800 {
430 return None;
431 }
432
433 Some(mtime.into())
434 }
435
436 pub fn into_response(self, req: &HttpRequest) -> HttpResponse<BoxBody> {
438 if self.status_code != StatusCode::OK {
439 let mut res = HttpResponse::build(self.status_code);
440
441 let ct = if self.flags.contains(Flags::PREFER_UTF8) {
442 equiv_utf8_text(self.content_type.clone())
443 } else {
444 self.content_type
445 };
446
447 res.insert_header((header::CONTENT_TYPE, ct.to_string()));
448
449 if self.flags.contains(Flags::CONTENT_DISPOSITION) {
450 res.insert_header((
451 header::CONTENT_DISPOSITION,
452 self.content_disposition.to_string(),
453 ));
454 }
455
456 if let Some(current_encoding) = self.encoding {
457 res.insert_header((header::CONTENT_ENCODING, current_encoding.as_str()));
458 }
459
460 let reader =
461 chunked::new_chunked_read(self.md.len(), 0, self.file, self.read_mode_threshold);
462
463 return res.streaming(reader);
464 }
465
466 let etag = if self.flags.contains(Flags::ETAG) {
467 self.etag()
468 } else {
469 None
470 };
471
472 let last_modified = if self.flags.contains(Flags::LAST_MD) {
473 self.last_modified()
474 } else {
475 None
476 };
477
478 let precondition_failed = if !any_match(etag.as_ref(), req) {
480 true
481 } else if let (Some(ref m), Some(header::IfUnmodifiedSince(ref since))) =
482 (last_modified, req.get_header())
483 {
484 let t1: SystemTime = (*m).into();
485 let t2: SystemTime = (*since).into();
486
487 match (t1.duration_since(UNIX_EPOCH), t2.duration_since(UNIX_EPOCH)) {
488 (Ok(t1), Ok(t2)) => t1.as_secs() > t2.as_secs(),
489 _ => false,
490 }
491 } else {
492 false
493 };
494
495 let not_modified = if !none_match(etag.as_ref(), req) {
497 true
498 } else if req.headers().contains_key(header::IF_NONE_MATCH) {
499 false
500 } else if let (Some(ref m), Some(header::IfModifiedSince(ref since))) =
501 (last_modified, req.get_header())
502 {
503 let t1: SystemTime = (*m).into();
504 let t2: SystemTime = (*since).into();
505
506 match (t1.duration_since(UNIX_EPOCH), t2.duration_since(UNIX_EPOCH)) {
507 (Ok(t1), Ok(t2)) => t1.as_secs() <= t2.as_secs(),
508 _ => false,
509 }
510 } else {
511 false
512 };
513
514 let mut res = HttpResponse::build(self.status_code);
515
516 let ct = if self.flags.contains(Flags::PREFER_UTF8) {
517 equiv_utf8_text(self.content_type.clone())
518 } else {
519 self.content_type
520 };
521
522 res.insert_header((header::CONTENT_TYPE, ct.to_string()));
523
524 if self.flags.contains(Flags::CONTENT_DISPOSITION) {
525 res.insert_header((
526 header::CONTENT_DISPOSITION,
527 self.content_disposition.to_string(),
528 ));
529 }
530
531 if let Some(current_encoding) = self.encoding {
532 res.insert_header((header::CONTENT_ENCODING, current_encoding.as_str()));
533 }
534
535 if let Some(lm) = last_modified {
536 res.insert_header((header::LAST_MODIFIED, lm.to_string()));
537 }
538
539 if let Some(etag) = etag {
540 res.insert_header((header::ETAG, etag.to_string()));
541 }
542
543 res.insert_header((header::ACCEPT_RANGES, "bytes"));
544
545 let mut length = self.md.len();
546 let mut offset = 0;
547 let mut ranged_req = false;
548
549 if let Some(ranges) = req.headers().get(header::RANGE) {
551 if let Ok(ranges_header) = ranges.to_str() {
552 if let Some(range) = HttpRange::parse(ranges_header, length)
553 .ok()
554 .and_then(|ranges| ranges.first().copied())
555 {
556 ranged_req = true;
557 length = range.length;
558 offset = range.start;
559
560 res.insert_header((
561 header::CONTENT_RANGE,
562 format!("bytes {}-{}/{}", offset, offset + length - 1, self.md.len()),
563 ));
564 } else {
565 res.insert_header((header::CONTENT_RANGE, format!("bytes */{}", length)));
566 return res.status(StatusCode::RANGE_NOT_SATISFIABLE).finish();
567 };
568 } else {
569 return res.status(StatusCode::BAD_REQUEST).finish();
570 };
571 };
572
573 if precondition_failed {
574 return res.status(StatusCode::PRECONDITION_FAILED).finish();
575 } else if not_modified {
576 return res
577 .status(StatusCode::NOT_MODIFIED)
578 .body(body::None::new())
579 .map_into_boxed_body();
580 }
581
582 let reader = chunked::new_chunked_read(length, offset, self.file, self.read_mode_threshold);
583
584 if ranged_req {
585 res.status(StatusCode::PARTIAL_CONTENT);
586 }
587
588 res.body(SizedStream::new(length, reader))
589 }
590}
591
592fn any_match(etag: Option<&header::EntityTag>, req: &HttpRequest) -> bool {
594 match req.get_header::<header::IfMatch>() {
595 None | Some(header::IfMatch::Any) => true,
596
597 Some(header::IfMatch::Items(ref items)) => {
598 if let Some(some_etag) = etag {
599 for item in items {
600 if item.strong_eq(some_etag) {
601 return true;
602 }
603 }
604 }
605
606 false
607 }
608 }
609}
610
611fn none_match(etag: Option<&header::EntityTag>, req: &HttpRequest) -> bool {
613 match req.get_header::<header::IfNoneMatch>() {
614 Some(header::IfNoneMatch::Any) => false,
615
616 Some(header::IfNoneMatch::Items(ref items)) => {
617 if let Some(some_etag) = etag {
618 for item in items {
619 if item.weak_eq(some_etag) {
620 return false;
621 }
622 }
623 }
624
625 true
626 }
627
628 None => true,
629 }
630}
631
632impl Responder for NamedFile {
633 type Body = BoxBody;
634
635 fn respond_to(self, req: &HttpRequest) -> HttpResponse<Self::Body> {
636 self.into_response(req)
637 }
638}
639
640impl ServiceFactory<ServiceRequest> for NamedFile {
641 type Response = ServiceResponse;
642 type Error = Error;
643 type Config = ();
644 type Service = NamedFileService;
645 type InitError = ();
646 type Future = LocalBoxFuture<'static, Result<Self::Service, Self::InitError>>;
647
648 fn new_service(&self, _: ()) -> Self::Future {
649 let service = NamedFileService {
650 path: self.path.clone(),
651 };
652
653 Box::pin(async move { Ok(service) })
654 }
655}
656
657#[doc(hidden)]
658#[derive(Debug)]
659pub struct NamedFileService {
660 path: PathBuf,
661}
662
663impl Service<ServiceRequest> for NamedFileService {
664 type Response = ServiceResponse;
665 type Error = Error;
666 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
667
668 dev::always_ready!();
669
670 fn call(&self, req: ServiceRequest) -> Self::Future {
671 let (req, _) = req.into_parts();
672
673 let path = self.path.clone();
674 Box::pin(async move {
675 let file = NamedFile::open(path)?;
676 let res = file.into_response(&req);
677 Ok(ServiceResponse::new(req, res))
678 })
679 }
680}
681
682impl HttpServiceFactory for NamedFile {
683 fn register(self, config: &mut AppService) {
684 config.register_service(
685 ResourceDef::root_prefix(self.path.to_string_lossy().as_ref()),
686 None,
687 self,
688 None,
689 )
690 }
691}
692
693#[cfg(test)]
694mod tests {
695 use super::*;
696
697 #[test]
698 fn audio_files_use_inline_content_disposition() {
699 let (_ct, cd) = get_content_type_and_disposition(Path::new("sound.mp3")).unwrap();
700 assert_eq!(cd.disposition, DispositionType::Inline);
701 }
702
703 #[test]
704 fn special_chars_are_escaped_in_content_disposition_filename() {
705 let (_ct, cd) =
706 get_content_type_and_disposition(Path::new("test\n\x0B\x0C\rnewline.text")).unwrap();
707
708 assert_eq!(
709 cd.to_string(),
710 "inline; filename=\"test%0A%0B%0C%0Dnewline.text\"",
711 );
712 }
713}