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