Skip to main content

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,
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/// # fn run() -> Result<(), Box<dyn std::error::Error>> {
54/// let file = NamedFile::open("./static/index.html")?;
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("./static/index.html")
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
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    // replace special characters in filenames which could occur on some filesystems
116    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"),   // \n line break
130                '\x0B' => escaped.push_str("%0B"), // \v vertical tab
131                '\x0C' => escaped.push_str("%0C"), // \f form feed
132                '\r' => escaped.push_str("%0D"),   // \r carriage return
133                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    /// Creates an instance from a previously opened file.
162    ///
163    /// The given `path` need not exist and is only used to determine the `ContentType` and
164    /// `ContentDisposition` headers.
165    ///
166    /// # Examples
167    /// ```ignore
168    /// use std::{
169    ///     io::{self, Write as _},
170    ///     env,
171    ///     fs::File
172    /// };
173    /// use actix_files::NamedFile;
174    ///
175    /// let mut file = File::create("foo.txt")?;
176    /// file.write_all(b"Hello, world!")?;
177    /// let named_file = NamedFile::from_file(file, "bar.txt")?;
178    /// # std::fs::remove_file("foo.txt");
179    /// Ok(())
180    /// ```
181    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        // Get the name of the file and use it to construct default Content-Type
185        // and Content-Disposition values
186        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    /// Attempts to open a file in read-only mode.
208    ///
209    /// # Examples
210    /// ```
211    /// use actix_files::NamedFile;
212    /// let file = NamedFile::open("foo.txt");
213    /// ```
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    /// Returns reference to the underlying file object.
220    #[inline]
221    pub fn file(&self) -> &File {
222        &self.file
223    }
224
225    /// Returns the filesystem path to this file.
226    ///
227    /// # Examples
228    /// ```
229    /// # use std::io;
230    /// use actix_files::NamedFile;
231    ///
232    /// # fn path() -> io::Result<()> {
233    /// let file = NamedFile::open("test.txt")?;
234    /// assert_eq!(file.path().as_os_str(), "foo.txt");
235    /// # Ok(())
236    /// # }
237    /// ```
238    #[inline]
239    pub fn path(&self) -> &Path {
240        self.path.as_path()
241    }
242
243    /// Returns the time the file was last modified.
244    ///
245    /// Returns `None` only on unsupported platforms; see [`std::fs::Metadata::modified()`].
246    /// Therefore, it is usually safe to unwrap this.
247    #[inline]
248    pub fn modified(&self) -> Option<SystemTime> {
249        self.modified
250    }
251
252    /// Returns the filesystem metadata associated with this file.
253    #[inline]
254    pub fn metadata(&self) -> &Metadata {
255        &self.md
256    }
257
258    /// Returns the `Content-Type` header that will be used when serving this file.
259    #[inline]
260    pub fn content_type(&self) -> &Mime {
261        &self.content_type
262    }
263
264    /// Returns the `Content-Disposition` that will be used when serving this file.
265    #[inline]
266    pub fn content_disposition(&self) -> &ContentDisposition {
267        &self.content_disposition
268    }
269
270    /// Returns the `Content-Encoding` that will be used when serving this file.
271    ///
272    /// A return value of `None` indicates that the content is not already using a compressed
273    /// representation and may be subject to compression downstream.
274    #[inline]
275    pub fn content_encoding(&self) -> Option<ContentEncoding> {
276        self.encoding
277    }
278
279    /// Set response status code.
280    #[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    /// Sets the `Content-Type` header that will be used when serving this file. By default the
287    /// `Content-Type` is inferred from the filename extension.
288    #[inline]
289    pub fn set_content_type(mut self, mime_type: Mime) -> Self {
290        self.content_type = mime_type;
291        self
292    }
293
294    /// Set the Content-Disposition for serving this file. This allows changing the
295    /// `inline/attachment` disposition as well as the filename sent to the peer.
296    ///
297    /// By default the disposition is `inline` for `text/*`, `image/*`, `video/*` and
298    /// `application/{javascript, json, wasm}` mime types, and `attachment` otherwise, and the
299    /// filename is taken from the path provided in the `open` method after converting it to UTF-8
300    /// (using `to_string_lossy`).
301    #[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    /// Disables `Content-Disposition` header.
309    ///
310    /// By default, the `Content-Disposition` header is sent.
311    #[inline]
312    pub fn disable_content_disposition(mut self) -> Self {
313        self.flags.remove(Flags::CONTENT_DISPOSITION);
314        self
315    }
316
317    /// Sets content encoding for this file.
318    ///
319    /// This prevents the `Compress` middleware from modifying the file contents and signals to
320    /// browsers/clients how to decode it. For example, if serving a compressed HTML file (e.g.,
321    /// `index.html.gz`) then use `.set_content_encoding(ContentEncoding::Gzip)`.
322    #[inline]
323    pub fn set_content_encoding(mut self, enc: ContentEncoding) -> Self {
324        self.encoding = Some(enc);
325        self
326    }
327
328    /// Sets the size threshold that determines file read mode (sync/async).
329    ///
330    /// When a file is smaller than the threshold (bytes), the reader will use synchronous
331    /// (blocking) file reads. For larger files, it switches to async reads to avoid blocking the
332    /// main thread.
333    ///
334    /// Tweaking this value according to your expected usage may lead to significant performance
335    /// gains (or losses in other handlers, if `size` is too high).
336    ///
337    /// Default is 0, meaning all files are read asynchronously.
338    pub fn read_mode_threshold(mut self, size: u64) -> Self {
339        self.read_mode_threshold = size;
340        self
341    }
342
343    /// Specifies whether to return `ETag` header in response.
344    ///
345    /// Default is true.
346    #[inline]
347    pub fn use_etag(mut self, value: bool) -> Self {
348        self.flags.set(Flags::ETAG, value);
349        self
350    }
351
352    /// Specifies whether to return `Last-Modified` header in response.
353    ///
354    /// Default is true.
355    #[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    /// Specifies whether text responses should signal a UTF-8 encoding.
362    ///
363    /// Default is false (but will default to true in a future version).
364    #[inline]
365    pub fn prefer_utf8(mut self, value: bool) -> Self {
366        self.flags.set(Flags::PREFER_UTF8, value);
367        self
368    }
369
370    /// Creates an `ETag` in a format is similar to Apache's.
371    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            // Don't panic for pre-epoch modification times. Encode the timestamp as seconds and
391            // sub-second nanoseconds relative to the UNIX epoch, allowing negative values.
392            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                    // For timestamps before the epoch, represent the time as a negative seconds
398                    // offset with positive nanoseconds (like POSIX timespec).
399                    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        // avoid panic in `httpdate` crate when formatting as an HTTP date
424        // see: https://github.com/actix/actix-web/issues/2748
425        //
426        // httpdate supports dates in range [1970, 9999); see:
427        // https://github.com/seanmonstar/httpdate/blob/v1.0.3/src/date.rs
428        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    /// Creates an `HttpResponse` with file as a streaming body.
437    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        // check preconditions
479        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        // check last modified
496        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        // check for range header
550        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
592/// Returns true if `req` has no `If-Match` header or one which matches `etag`.
593fn 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
611/// Returns true if `req` doesn't have an `If-None-Match` header matching `req`.
612fn 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}