actix_files/
named.rs

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, HeaderValue,
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/// A file with an associated name.
47///
48/// `NamedFile` can be registered as services:
49/// ```
50/// use actix_web::App;
51/// use actix_files::NamedFile;
52///
53/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
54/// let file = NamedFile::open_async("./static/index.html").await?;
55/// let app = App::new().service(file);
56/// # Ok(())
57/// # }
58/// ```
59///
60/// They can also be returned from handlers:
61/// ```
62/// use actix_web::{Responder, get};
63/// use actix_files::NamedFile;
64///
65/// #[get("/")]
66/// async fn index() -> impl Responder {
67///     NamedFile::open_async("./static/index.html").await
68/// }
69/// ```
70#[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
86#[cfg(not(feature = "experimental-io-uring"))]
87pub(crate) use std::fs::File;
88
89#[cfg(feature = "experimental-io-uring")]
90pub(crate) use tokio_uring::fs::File;
91
92use super::chunked;
93
94impl NamedFile {
95    /// Creates an instance from a previously opened file.
96    ///
97    /// The given `path` need not exist and is only used to determine the `ContentType` and
98    /// `ContentDisposition` headers.
99    ///
100    /// # Examples
101    /// ```ignore
102    /// use std::{
103    ///     io::{self, Write as _},
104    ///     env,
105    ///     fs::File
106    /// };
107    /// use actix_files::NamedFile;
108    ///
109    /// let mut file = File::create("foo.txt")?;
110    /// file.write_all(b"Hello, world!")?;
111    /// let named_file = NamedFile::from_file(file, "bar.txt")?;
112    /// # std::fs::remove_file("foo.txt");
113    /// Ok(())
114    /// ```
115    pub fn from_file<P: AsRef<Path>>(file: File, path: P) -> io::Result<NamedFile> {
116        let path = path.as_ref().to_path_buf();
117
118        // Get the name of the file and use it to construct default Content-Type
119        // and Content-Disposition values
120        let (content_type, content_disposition) = {
121            let filename = match path.file_name() {
122                Some(name) => name.to_string_lossy(),
123                None => {
124                    return Err(io::Error::new(
125                        io::ErrorKind::InvalidInput,
126                        "Provided path has no filename",
127                    ));
128                }
129            };
130
131            let ct = mime_guess::from_path(&path).first_or_octet_stream();
132
133            let disposition = match ct.type_() {
134                mime::IMAGE | mime::TEXT | mime::AUDIO | mime::VIDEO => DispositionType::Inline,
135                mime::APPLICATION => match ct.subtype() {
136                    mime::JAVASCRIPT | mime::JSON => DispositionType::Inline,
137                    name if name == "wasm" || name == "xhtml" => DispositionType::Inline,
138                    _ => DispositionType::Attachment,
139                },
140                _ => DispositionType::Attachment,
141            };
142
143            // replace special characters in filenames which could occur on some filesystems
144            let filename_s = filename
145                .replace('\n', "%0A") // \n line break
146                .replace('\x0B', "%0B") // \v vertical tab
147                .replace('\x0C', "%0C") // \f form feed
148                .replace('\r', "%0D"); // \r carriage return
149            let mut parameters = vec![DispositionParam::Filename(filename_s)];
150
151            if !filename.is_ascii() {
152                parameters.push(DispositionParam::FilenameExt(ExtendedValue {
153                    charset: Charset::Ext(String::from("UTF-8")),
154                    language_tag: None,
155                    value: filename.into_owned().into_bytes(),
156                }))
157            }
158
159            let cd = ContentDisposition {
160                disposition,
161                parameters,
162            };
163
164            (ct, cd)
165        };
166
167        let md = {
168            #[cfg(not(feature = "experimental-io-uring"))]
169            {
170                file.metadata()?
171            }
172
173            #[cfg(feature = "experimental-io-uring")]
174            {
175                use std::os::unix::prelude::{AsRawFd, FromRawFd};
176
177                let fd = file.as_raw_fd();
178
179                // SAFETY: fd is borrowed and lives longer than the unsafe block
180                unsafe {
181                    let file = std::fs::File::from_raw_fd(fd);
182                    let md = file.metadata();
183                    // SAFETY: forget the fd before exiting block in success or error case but don't
184                    // run destructor (that would close file handle)
185                    std::mem::forget(file);
186                    md?
187                }
188            }
189        };
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    /// Attempts to open a file in read-only mode.
209    ///
210    /// # Examples
211    /// ```
212    /// use actix_files::NamedFile;
213    /// let file = NamedFile::open("foo.txt");
214    /// ```
215    #[cfg(not(feature = "experimental-io-uring"))]
216    pub fn open<P: AsRef<Path>>(path: P) -> io::Result<NamedFile> {
217        let file = File::open(&path)?;
218        Self::from_file(file, path)
219    }
220
221    /// Attempts to open a file asynchronously in read-only mode.
222    ///
223    /// When the `experimental-io-uring` crate feature is enabled, this will be async. Otherwise, it
224    /// will behave just like `open`.
225    ///
226    /// # Examples
227    /// ```
228    /// use actix_files::NamedFile;
229    /// # async fn open() {
230    /// let file = NamedFile::open_async("foo.txt").await.unwrap();
231    /// # }
232    /// ```
233    pub async fn open_async<P: AsRef<Path>>(path: P) -> io::Result<NamedFile> {
234        let file = {
235            #[cfg(not(feature = "experimental-io-uring"))]
236            {
237                File::open(&path)?
238            }
239
240            #[cfg(feature = "experimental-io-uring")]
241            {
242                File::open(&path).await?
243            }
244        };
245
246        Self::from_file(file, path)
247    }
248
249    /// Returns reference to the underlying file object.
250    #[inline]
251    pub fn file(&self) -> &File {
252        &self.file
253    }
254
255    /// Returns the filesystem path to this file.
256    ///
257    /// # Examples
258    /// ```
259    /// # use std::io;
260    /// use actix_files::NamedFile;
261    ///
262    /// # async fn path() -> io::Result<()> {
263    /// let file = NamedFile::open_async("test.txt").await?;
264    /// assert_eq!(file.path().as_os_str(), "foo.txt");
265    /// # Ok(())
266    /// # }
267    /// ```
268    #[inline]
269    pub fn path(&self) -> &Path {
270        self.path.as_path()
271    }
272
273    /// Returns the time the file was last modified.
274    ///
275    /// Returns `None` only on unsupported platforms; see [`std::fs::Metadata::modified()`].
276    /// Therefore, it is usually safe to unwrap this.
277    #[inline]
278    pub fn modified(&self) -> Option<SystemTime> {
279        self.modified
280    }
281
282    /// Returns the filesystem metadata associated with this file.
283    #[inline]
284    pub fn metadata(&self) -> &Metadata {
285        &self.md
286    }
287
288    /// Returns the `Content-Type` header that will be used when serving this file.
289    #[inline]
290    pub fn content_type(&self) -> &Mime {
291        &self.content_type
292    }
293
294    /// Returns the `Content-Disposition` that will be used when serving this file.
295    #[inline]
296    pub fn content_disposition(&self) -> &ContentDisposition {
297        &self.content_disposition
298    }
299
300    /// Returns the `Content-Encoding` that will be used when serving this file.
301    ///
302    /// A return value of `None` indicates that the content is not already using a compressed
303    /// representation and may be subject to compression downstream.
304    #[inline]
305    pub fn content_encoding(&self) -> Option<ContentEncoding> {
306        self.encoding
307    }
308
309    /// Set response status code.
310    #[deprecated(since = "0.7.0", note = "Prefer `Responder::customize()`.")]
311    pub fn set_status_code(mut self, status: StatusCode) -> Self {
312        self.status_code = status;
313        self
314    }
315
316    /// Sets the `Content-Type` header that will be used when serving this file. By default the
317    /// `Content-Type` is inferred from the filename extension.
318    #[inline]
319    pub fn set_content_type(mut self, mime_type: Mime) -> Self {
320        self.content_type = mime_type;
321        self
322    }
323
324    /// Set the Content-Disposition for serving this file. This allows changing the
325    /// `inline/attachment` disposition as well as the filename sent to the peer.
326    ///
327    /// By default the disposition is `inline` for `text/*`, `image/*`, `video/*` and
328    /// `application/{javascript, json, wasm}` mime types, and `attachment` otherwise, and the
329    /// filename is taken from the path provided in the `open` method after converting it to UTF-8
330    /// (using `to_string_lossy`).
331    #[inline]
332    pub fn set_content_disposition(mut self, cd: ContentDisposition) -> Self {
333        self.content_disposition = cd;
334        self.flags.insert(Flags::CONTENT_DISPOSITION);
335        self
336    }
337
338    /// Disables `Content-Disposition` header.
339    ///
340    /// By default, the `Content-Disposition` header is sent.
341    #[inline]
342    pub fn disable_content_disposition(mut self) -> Self {
343        self.flags.remove(Flags::CONTENT_DISPOSITION);
344        self
345    }
346
347    /// Sets content encoding for this file.
348    ///
349    /// This prevents the `Compress` middleware from modifying the file contents and signals to
350    /// browsers/clients how to decode it. For example, if serving a compressed HTML file (e.g.,
351    /// `index.html.gz`) then use `.set_content_encoding(ContentEncoding::Gzip)`.
352    #[inline]
353    pub fn set_content_encoding(mut self, enc: ContentEncoding) -> Self {
354        self.encoding = Some(enc);
355        self
356    }
357
358    /// Sets the size threshold that determines file read mode (sync/async).
359    ///
360    /// When a file is smaller than the threshold (bytes), the reader will switch from synchronous
361    /// (blocking) file-reads to async reads to avoid blocking the main-thread when processing large
362    /// files.
363    ///
364    /// Tweaking this value according to your expected usage may lead to signifiant performance
365    /// gains (or losses in other handlers, if `size` is too high).
366    ///
367    /// When the `experimental-io-uring` crate feature is enabled, file reads are always async.
368    ///
369    /// Default is 0, meaning all files are read asynchronously.
370    pub fn read_mode_threshold(mut self, size: u64) -> Self {
371        self.read_mode_threshold = size;
372        self
373    }
374
375    /// Specifies whether to return `ETag` header in response.
376    ///
377    /// Default is true.
378    #[inline]
379    pub fn use_etag(mut self, value: bool) -> Self {
380        self.flags.set(Flags::ETAG, value);
381        self
382    }
383
384    /// Specifies whether to return `Last-Modified` header in response.
385    ///
386    /// Default is true.
387    #[inline]
388    pub fn use_last_modified(mut self, value: bool) -> Self {
389        self.flags.set(Flags::LAST_MD, value);
390        self
391    }
392
393    /// Specifies whether text responses should signal a UTF-8 encoding.
394    ///
395    /// Default is false (but will default to true in a future version).
396    #[inline]
397    pub fn prefer_utf8(mut self, value: bool) -> Self {
398        self.flags.set(Flags::PREFER_UTF8, value);
399        self
400    }
401
402    /// Creates an `ETag` in a format is similar to Apache's.
403    pub(crate) fn etag(&self) -> Option<header::EntityTag> {
404        self.modified.as_ref().map(|mtime| {
405            let ino = {
406                #[cfg(unix)]
407                {
408                    #[cfg(unix)]
409                    use std::os::unix::fs::MetadataExt as _;
410
411                    self.md.ino()
412                }
413
414                #[cfg(not(unix))]
415                {
416                    0
417                }
418            };
419
420            let dur = mtime
421                .duration_since(UNIX_EPOCH)
422                .expect("modification time must be after epoch");
423
424            header::EntityTag::new_strong(format!(
425                "{:x}:{:x}:{:x}:{:x}",
426                ino,
427                self.md.len(),
428                dur.as_secs(),
429                dur.subsec_nanos()
430            ))
431        })
432    }
433
434    pub(crate) fn last_modified(&self) -> Option<header::HttpDate> {
435        self.modified.map(|mtime| mtime.into())
436    }
437
438    /// Creates an `HttpResponse` with file as a streaming body.
439    pub fn into_response(self, req: &HttpRequest) -> HttpResponse<BoxBody> {
440        if self.status_code != StatusCode::OK {
441            let mut res = HttpResponse::build(self.status_code);
442
443            let ct = if self.flags.contains(Flags::PREFER_UTF8) {
444                equiv_utf8_text(self.content_type.clone())
445            } else {
446                self.content_type
447            };
448
449            res.insert_header((header::CONTENT_TYPE, ct.to_string()));
450
451            if self.flags.contains(Flags::CONTENT_DISPOSITION) {
452                res.insert_header((
453                    header::CONTENT_DISPOSITION,
454                    self.content_disposition.to_string(),
455                ));
456            }
457
458            if let Some(current_encoding) = self.encoding {
459                res.insert_header((header::CONTENT_ENCODING, current_encoding.as_str()));
460            }
461
462            let reader =
463                chunked::new_chunked_read(self.md.len(), 0, self.file, self.read_mode_threshold);
464
465            return res.streaming(reader);
466        }
467
468        let etag = if self.flags.contains(Flags::ETAG) {
469            self.etag()
470        } else {
471            None
472        };
473
474        let last_modified = if self.flags.contains(Flags::LAST_MD) {
475            self.last_modified()
476        } else {
477            None
478        };
479
480        // check preconditions
481        let precondition_failed = if !any_match(etag.as_ref(), req) {
482            true
483        } else if let (Some(ref m), Some(header::IfUnmodifiedSince(ref since))) =
484            (last_modified, req.get_header())
485        {
486            let t1: SystemTime = (*m).into();
487            let t2: SystemTime = (*since).into();
488
489            match (t1.duration_since(UNIX_EPOCH), t2.duration_since(UNIX_EPOCH)) {
490                (Ok(t1), Ok(t2)) => t1.as_secs() > t2.as_secs(),
491                _ => false,
492            }
493        } else {
494            false
495        };
496
497        // check last modified
498        let not_modified = if !none_match(etag.as_ref(), req) {
499            true
500        } else if req.headers().contains_key(header::IF_NONE_MATCH) {
501            false
502        } else if let (Some(ref m), Some(header::IfModifiedSince(ref since))) =
503            (last_modified, req.get_header())
504        {
505            let t1: SystemTime = (*m).into();
506            let t2: SystemTime = (*since).into();
507
508            match (t1.duration_since(UNIX_EPOCH), t2.duration_since(UNIX_EPOCH)) {
509                (Ok(t1), Ok(t2)) => t1.as_secs() <= t2.as_secs(),
510                _ => false,
511            }
512        } else {
513            false
514        };
515
516        let mut res = HttpResponse::build(self.status_code);
517
518        let ct = if self.flags.contains(Flags::PREFER_UTF8) {
519            equiv_utf8_text(self.content_type.clone())
520        } else {
521            self.content_type
522        };
523
524        res.insert_header((header::CONTENT_TYPE, ct.to_string()));
525
526        if self.flags.contains(Flags::CONTENT_DISPOSITION) {
527            res.insert_header((
528                header::CONTENT_DISPOSITION,
529                self.content_disposition.to_string(),
530            ));
531        }
532
533        if let Some(current_encoding) = self.encoding {
534            res.insert_header((header::CONTENT_ENCODING, current_encoding.as_str()));
535        }
536
537        if let Some(lm) = last_modified {
538            res.insert_header((header::LAST_MODIFIED, lm.to_string()));
539        }
540
541        if let Some(etag) = etag {
542            res.insert_header((header::ETAG, etag.to_string()));
543        }
544
545        res.insert_header((header::ACCEPT_RANGES, "bytes"));
546
547        let mut length = self.md.len();
548        let mut offset = 0;
549
550        // check for range header
551        if let Some(ranges) = req.headers().get(header::RANGE) {
552            if let Ok(ranges_header) = ranges.to_str() {
553                if let Ok(ranges) = HttpRange::parse(ranges_header, length) {
554                    length = ranges[0].length;
555                    offset = ranges[0].start;
556
557                    // When a Content-Encoding header is present in a 206 partial content response
558                    // for video content, it prevents browser video players from starting playback
559                    // before loading the whole video and also prevents seeking.
560                    //
561                    // See: https://github.com/actix/actix-web/issues/2815
562                    //
563                    // The assumption of this fix is that the video player knows to not send an
564                    // Accept-Encoding header for this request and that downstream middleware will
565                    // not attempt compression for requests without it.
566                    //
567                    // TODO: Solve question around what to do if self.encoding is set and partial
568                    // range is requested. Reject request? Ignoring self.encoding seems wrong, too.
569                    // In practice, it should not come up.
570                    if req.headers().contains_key(&header::ACCEPT_ENCODING) {
571                        // don't allow compression middleware to modify partial content
572                        res.insert_header((
573                            header::CONTENT_ENCODING,
574                            HeaderValue::from_static("identity"),
575                        ));
576                    }
577
578                    res.insert_header((
579                        header::CONTENT_RANGE,
580                        format!("bytes {}-{}/{}", offset, offset + length - 1, self.md.len()),
581                    ));
582                } else {
583                    res.insert_header((header::CONTENT_RANGE, format!("bytes */{}", length)));
584                    return res.status(StatusCode::RANGE_NOT_SATISFIABLE).finish();
585                };
586            } else {
587                return res.status(StatusCode::BAD_REQUEST).finish();
588            };
589        };
590
591        if precondition_failed {
592            return res.status(StatusCode::PRECONDITION_FAILED).finish();
593        } else if not_modified {
594            return res
595                .status(StatusCode::NOT_MODIFIED)
596                .body(body::None::new())
597                .map_into_boxed_body();
598        }
599
600        let reader = chunked::new_chunked_read(length, offset, self.file, self.read_mode_threshold);
601
602        if offset != 0 || length != self.md.len() {
603            res.status(StatusCode::PARTIAL_CONTENT);
604        }
605
606        res.body(SizedStream::new(length, reader))
607    }
608}
609
610/// Returns true if `req` has no `If-Match` header or one which matches `etag`.
611fn any_match(etag: Option<&header::EntityTag>, req: &HttpRequest) -> bool {
612    match req.get_header::<header::IfMatch>() {
613        None | Some(header::IfMatch::Any) => true,
614
615        Some(header::IfMatch::Items(ref items)) => {
616            if let Some(some_etag) = etag {
617                for item in items {
618                    if item.strong_eq(some_etag) {
619                        return true;
620                    }
621                }
622            }
623
624            false
625        }
626    }
627}
628
629/// Returns true if `req` doesn't have an `If-None-Match` header matching `req`.
630fn none_match(etag: Option<&header::EntityTag>, req: &HttpRequest) -> bool {
631    match req.get_header::<header::IfNoneMatch>() {
632        Some(header::IfNoneMatch::Any) => false,
633
634        Some(header::IfNoneMatch::Items(ref items)) => {
635            if let Some(some_etag) = etag {
636                for item in items {
637                    if item.weak_eq(some_etag) {
638                        return false;
639                    }
640                }
641            }
642
643            true
644        }
645
646        None => true,
647    }
648}
649
650impl Responder for NamedFile {
651    type Body = BoxBody;
652
653    fn respond_to(self, req: &HttpRequest) -> HttpResponse<Self::Body> {
654        self.into_response(req)
655    }
656}
657
658impl ServiceFactory<ServiceRequest> for NamedFile {
659    type Response = ServiceResponse;
660    type Error = Error;
661    type Config = ();
662    type Service = NamedFileService;
663    type InitError = ();
664    type Future = LocalBoxFuture<'static, Result<Self::Service, Self::InitError>>;
665
666    fn new_service(&self, _: ()) -> Self::Future {
667        let service = NamedFileService {
668            path: self.path.clone(),
669        };
670
671        Box::pin(async move { Ok(service) })
672    }
673}
674
675#[doc(hidden)]
676#[derive(Debug)]
677pub struct NamedFileService {
678    path: PathBuf,
679}
680
681impl Service<ServiceRequest> for NamedFileService {
682    type Response = ServiceResponse;
683    type Error = Error;
684    type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
685
686    dev::always_ready!();
687
688    fn call(&self, req: ServiceRequest) -> Self::Future {
689        let (req, _) = req.into_parts();
690
691        let path = self.path.clone();
692        Box::pin(async move {
693            let file = NamedFile::open_async(path).await?;
694            let res = file.into_response(&req);
695            Ok(ServiceResponse::new(req, res))
696        })
697    }
698}
699
700impl HttpServiceFactory for NamedFile {
701    fn register(self, config: &mut AppService) {
702        config.register_service(
703            ResourceDef::root_prefix(self.path.to_string_lossy().as_ref()),
704            None,
705            self,
706            None,
707        )
708    }
709}