Skip to main content

actix_files/
service.rs

1use std::{
2    fmt, io,
3    ops::Deref,
4    path::{Path, PathBuf},
5    rc::Rc,
6};
7
8use actix_web::{
9    body::BoxBody,
10    dev::{self, Service, ServiceRequest, ServiceResponse},
11    error::Error,
12    guard::Guard,
13    http::{header, Method},
14    HttpResponse,
15};
16use futures_core::future::LocalBoxFuture;
17
18use crate::{
19    named, Directory, DirectoryRenderer, FilesError, HttpService, MimeOverride, NamedFile,
20    PathBufWrap, PathFilter,
21};
22
23/// Assembled file serving service.
24#[derive(Clone)]
25pub struct FilesService(pub(crate) Rc<FilesServiceInner>);
26
27impl Deref for FilesService {
28    type Target = FilesServiceInner;
29
30    fn deref(&self) -> &Self::Target {
31        &self.0
32    }
33}
34
35pub struct FilesServiceInner {
36    pub(crate) directories: Vec<PathBuf>,
37    pub(crate) index: Option<String>,
38    pub(crate) show_index: bool,
39    pub(crate) redirect_to_slash: bool,
40    pub(crate) default: Option<HttpService>,
41    pub(crate) renderer: Rc<DirectoryRenderer>,
42    pub(crate) mime_override: Option<Rc<MimeOverride>>,
43    pub(crate) path_filter: Option<Rc<PathFilter>>,
44    pub(crate) file_flags: named::Flags,
45    pub(crate) guards: Option<Rc<dyn Guard>>,
46    pub(crate) hidden_files: bool,
47    pub(crate) try_compressed: bool,
48    pub(crate) size_threshold: u64,
49    pub(crate) with_permanent_redirect: bool,
50}
51
52impl fmt::Debug for FilesServiceInner {
53    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54        f.write_str("FilesServiceInner")
55    }
56}
57
58impl FilesService {
59    async fn handle_err(
60        &self,
61        err: io::Error,
62        req: ServiceRequest,
63    ) -> Result<ServiceResponse, Error> {
64        log::debug!("error handling {}: {}", req.path(), err);
65
66        if let Some(ref default) = self.default {
67            default.call(req).await
68        } else {
69            Ok(req.error_response(err))
70        }
71    }
72
73    fn serve_named_file_with_encoding(
74        &self,
75        req: ServiceRequest,
76        mut named_file: NamedFile,
77        encoding: header::ContentEncoding,
78    ) -> ServiceResponse {
79        if let Some(ref mime_override) = self.mime_override {
80            let new_disposition = mime_override(&named_file.content_type.type_());
81            named_file.content_disposition.disposition = new_disposition;
82        }
83        named_file.flags = self.file_flags;
84
85        let (req, _) = req.into_parts();
86        let mut res = named_file
87            .read_mode_threshold(self.size_threshold)
88            .into_response(&req);
89
90        let header_value = match encoding {
91            header::ContentEncoding::Brotli => Some("br"),
92            header::ContentEncoding::Gzip => Some("gzip"),
93            header::ContentEncoding::Zstd => Some("zstd"),
94            header::ContentEncoding::Identity => None,
95            // Only variants in SUPPORTED_PRECOMPRESSION_ENCODINGS can occur here
96            _ => unreachable!(),
97        };
98        if let Some(header_value) = header_value {
99            res.headers_mut().insert(
100                header::CONTENT_ENCODING,
101                header::HeaderValue::from_static(header_value),
102            );
103            // Response representation varies by Accept-Encoding when serving pre-compressed assets.
104            res.headers_mut().append(
105                header::VARY,
106                header::HeaderValue::from_static("accept-encoding"),
107            );
108        }
109        ServiceResponse::new(req, res)
110    }
111
112    fn serve_named_file(&self, req: ServiceRequest, named_file: NamedFile) -> ServiceResponse {
113        self.serve_named_file_with_encoding(req, named_file, header::ContentEncoding::Identity)
114    }
115
116    fn show_index(&self, req: ServiceRequest, base: PathBuf, path: PathBuf) -> ServiceResponse {
117        let dir = Directory::new(base, path);
118
119        let (req, _) = req.into_parts();
120
121        (self.renderer)(&dir, &req).unwrap_or_else(|err| ServiceResponse::from_err(err, req))
122    }
123}
124
125impl fmt::Debug for FilesService {
126    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127        f.write_str("FilesService")
128    }
129}
130
131impl Service<ServiceRequest> for FilesService {
132    type Response = ServiceResponse<BoxBody>;
133    type Error = Error;
134    type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
135
136    dev::always_ready!();
137
138    fn call(&self, req: ServiceRequest) -> Self::Future {
139        let is_method_valid = if let Some(guard) = &self.guards {
140            // execute user defined guards
141            (**guard).check(&req.guard_ctx())
142        } else {
143            // default behavior
144            matches!(*req.method(), Method::HEAD | Method::GET)
145        };
146
147        let this = self.clone();
148
149        Box::pin(async move {
150            if !is_method_valid {
151                return Ok(req.into_response(
152                    HttpResponse::MethodNotAllowed()
153                        .insert_header(header::ContentType(mime::TEXT_PLAIN_UTF_8))
154                        .body("Request did not meet this resource's requirements."),
155                ));
156            }
157
158            let path_on_disk =
159                match PathBufWrap::parse_path(req.match_info().unprocessed(), this.hidden_files) {
160                    Ok(item) => item,
161                    Err(err) => return Ok(req.error_response(err)),
162                };
163
164            if let Some(filter) = &this.path_filter {
165                if !filter(path_on_disk.as_ref(), req.head()) {
166                    if let Some(ref default) = this.default {
167                        return default.call(req).await;
168                    } else {
169                        return Ok(req.into_response(HttpResponse::NotFound().finish()));
170                    }
171                }
172            }
173
174            let mut last_miss = None;
175            let mut first_index_listing = None;
176            let mut found_unrenderable_dir = false;
177
178            for directory in &this.directories {
179                // full file path
180                let path = directory.join(&path_on_disk);
181
182                // Try serving pre-compressed file even if the uncompressed file doesn't exist yet.
183                // Still handle directories (index/listing) through the normal branch below.
184                if this.try_compressed && !path.is_dir() {
185                    if let Some((named_file, encoding)) = find_compressed(&req, &path).await {
186                        return Ok(this.serve_named_file_with_encoding(req, named_file, encoding));
187                    }
188                }
189
190                if let Err(err) = path.canonicalize() {
191                    if matches!(
192                        err.kind(),
193                        io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
194                    ) {
195                        last_miss = Some(err);
196                        continue;
197                    }
198
199                    return this.handle_err(err, req).await;
200                }
201
202                if path.is_dir() {
203                    if this.redirect_to_slash
204                        && !req.path().ends_with('/')
205                        && (this.index.is_some() || this.show_index)
206                    {
207                        let redirect_to = format!("{}/", req.path());
208
209                        let response = if this.with_permanent_redirect {
210                            HttpResponse::PermanentRedirect()
211                        } else {
212                            HttpResponse::TemporaryRedirect()
213                        }
214                        .insert_header((header::LOCATION, redirect_to))
215                        .finish();
216
217                        return Ok(req.into_response(response));
218                    }
219
220                    match &this.index {
221                        Some(index) => {
222                            let named_path = path.join(index);
223                            if this.try_compressed {
224                                if let Some((named_file, encoding)) =
225                                    find_compressed(&req, &named_path).await
226                                {
227                                    return Ok(this.serve_named_file_with_encoding(
228                                        req, named_file, encoding,
229                                    ));
230                                }
231                            }
232                            // fallback to the uncompressed version
233                            match NamedFile::open(named_path) {
234                                Ok(named_file) => return Ok(this.serve_named_file(req, named_file)),
235                                Err(err)
236                                    if matches!(
237                                        err.kind(),
238                                        io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
239                                    ) =>
240                                {
241                                    if this.show_index && first_index_listing.is_none() {
242                                        first_index_listing =
243                                            Some((directory.to_path_buf(), path.clone()));
244                                    }
245                                    last_miss = Some(err);
246                                }
247                                Err(_) if this.show_index => {
248                                    if first_index_listing.is_none() {
249                                        first_index_listing =
250                                            Some((directory.to_path_buf(), path.clone()));
251                                    }
252                                    break;
253                                }
254                                Err(err) => return this.handle_err(err, req).await,
255                            }
256                        }
257                        None if this.show_index => {
258                            return Ok(this.show_index(req, directory.to_path_buf(), path));
259                        }
260                        None => found_unrenderable_dir = true,
261                    }
262                } else {
263                    match NamedFile::open(&path) {
264                        Ok(named_file) => return Ok(this.serve_named_file(req, named_file)),
265                        Err(err)
266                            if matches!(
267                                err.kind(),
268                                io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
269                            ) =>
270                        {
271                            last_miss = Some(err);
272                        }
273                        Err(err) => return this.handle_err(err, req).await,
274                    }
275                }
276            }
277
278            if let Some((base, path)) = first_index_listing {
279                return Ok(this.show_index(req, base, path));
280            }
281
282            if found_unrenderable_dir {
283                return Ok(ServiceResponse::from_err(
284                    FilesError::IsDirectory,
285                    req.into_parts().0,
286                ));
287            }
288
289            let err = last_miss
290                .unwrap_or_else(|| io::Error::new(io::ErrorKind::NotFound, "No such file"));
291            this.handle_err(err, req).await
292        })
293    }
294}
295
296/// Flate doesn't have an accepted file extension, so it is not included here.
297const SUPPORTED_PRECOMPRESSION_ENCODINGS: &[header::ContentEncoding] = &[
298    header::ContentEncoding::Brotli,
299    header::ContentEncoding::Gzip,
300    header::ContentEncoding::Zstd,
301    header::ContentEncoding::Identity,
302];
303
304/// Searches disk for an acceptable alternate encoding of the content at the given path, as
305/// preferred by the request's `Accept-Encoding` header. Returns the corresponding `NamedFile` with
306/// the most appropriate supported encoding, if any exist.
307async fn find_compressed(
308    req: &ServiceRequest,
309    original_path: &Path,
310) -> Option<(NamedFile, header::ContentEncoding)> {
311    use actix_web::HttpMessage;
312    use header::{AcceptEncoding, ContentEncoding, Encoding};
313
314    // Retrieve the content type and content disposition based on the original filename. If we
315    // can't get these successfully, don't even try to find a compressed file.
316    let (content_type, content_disposition) =
317        match crate::named::get_content_type_and_disposition(original_path) {
318            Ok(values) => values,
319            Err(_) => return None,
320        };
321
322    let accept_encoding = req.get_header::<AcceptEncoding>()?;
323
324    let mut supported = SUPPORTED_PRECOMPRESSION_ENCODINGS
325        .iter()
326        .copied()
327        .map(Encoding::Known)
328        .collect::<Vec<_>>();
329
330    // Only move the original content-type/disposition into the chosen compressed file once.
331    let mut content_type = Some(content_type);
332    let mut content_disposition = Some(content_disposition);
333
334    loop {
335        // Select next acceptable encoding (honouring q=0 rejections) from remaining supported set.
336        let chosen = accept_encoding.negotiate(supported.iter())?;
337
338        let encoding = match chosen {
339            Encoding::Known(enc) => enc,
340            // No supported encoding should ever be unknown here.
341            Encoding::Unknown(_) => return None,
342        };
343
344        // Identity indicates there is no acceptable pre-compressed representation.
345        if encoding == ContentEncoding::Identity {
346            return None;
347        }
348
349        let extension = match encoding {
350            ContentEncoding::Brotli => ".br",
351            ContentEncoding::Gzip => ".gz",
352            ContentEncoding::Zstd => ".zst",
353            ContentEncoding::Identity => unreachable!(),
354            // Only variants in SUPPORTED_PRECOMPRESSION_ENCODINGS can occur here.
355            _ => unreachable!(),
356        };
357
358        let mut compressed_path = original_path.to_owned();
359        let mut filename = compressed_path.file_name()?.to_owned();
360        filename.push(extension);
361        compressed_path.set_file_name(filename);
362
363        match NamedFile::open(&compressed_path) {
364            Ok(mut named_file) => {
365                named_file.content_type = content_type.take().unwrap();
366                named_file.content_disposition = content_disposition.take().unwrap();
367                return Some((named_file, encoding));
368            }
369            // Ignore errors while searching disk for a suitable encoding.
370            Err(_) => {
371                supported.retain(|enc| enc != &chosen);
372            }
373        }
374    }
375}