actix_web_static_files/
resource_files.rs

1use actix_web::{
2    dev::{
3        always_ready, AppService, HttpServiceFactory, ResourceDef, Service, ServiceFactory,
4        ServiceRequest, ServiceResponse,
5    },
6    error::Error,
7    guard::{Guard, GuardContext},
8    http::{
9        header::{self, ContentType},
10        Method, StatusCode,
11    },
12    HttpMessage, HttpRequest, HttpResponse, ResponseError,
13};
14use derive_more::{Deref, Display, Error};
15use futures_util::future::{ok, FutureExt, LocalBoxFuture, Ready};
16use static_files::Resource;
17use std::{collections::HashMap, ops::Deref, rc::Rc, sync::Arc};
18
19pub type DefaultResourceFiles = HashMap<&'static str, Resource>;
20
21/// Resource file with static data and metadata.
22pub trait ResourceFile {
23    fn data(&self) -> &'static [u8];
24    fn modified(&self) -> u64;
25    fn mime_type(&self) -> &str;
26}
27
28/// Basic abstraction for a dictionary of resources.
29pub trait ResourceFilesCollection {
30    type Resource: ResourceFile;
31    /// Get a resource by path
32    fn get_resource(&self, path: &str) -> Option<&Self::Resource>;
33    /// Check if a resource exists by path
34    fn contains_key(&self, path: &str) -> bool;
35}
36
37impl<R> ResourceFilesCollection for Rc<R>
38where
39    R: ResourceFilesCollection,
40{
41    type Resource = R::Resource;
42    fn get_resource(&self, path: &str) -> Option<&Self::Resource> {
43        let r: &R = self;
44        r.get_resource(path)
45    }
46
47    fn contains_key(&self, path: &str) -> bool {
48        let r: &R = self;
49        r.contains_key(path)
50    }
51}
52
53impl<R> ResourceFilesCollection for Arc<R>
54where
55    R: ResourceFilesCollection,
56{
57    type Resource = R::Resource;
58    fn get_resource(&self, path: &str) -> Option<&Self::Resource> {
59        let r: &R = self;
60        r.get_resource(path)
61    }
62
63    fn contains_key(&self, path: &str) -> bool {
64        let r: &R = self;
65        r.contains_key(path)
66    }
67}
68
69mod legacy_static_files {
70    use super::*;
71
72    impl ResourceFile for Resource {
73        fn data(&self) -> &'static [u8] {
74            self.data
75        }
76
77        fn modified(&self) -> u64 {
78            self.modified
79        }
80
81        fn mime_type(&self) -> &str {
82            self.mime_type
83        }
84    }
85
86    impl ResourceFilesCollection for DefaultResourceFiles {
87        type Resource = Resource;
88        fn get_resource(&self, path: &str) -> Option<&Self::Resource> {
89            self.get(path)
90        }
91
92        fn contains_key(&self, path: &str) -> bool {
93            self.contains_key(path)
94        }
95    }
96}
97
98/// Static resource files handling
99///
100/// `ResourceFiles` service must be registered with `App::service` method.
101///
102/// ```rust
103/// use std::collections::HashMap;
104///
105/// use actix_web::App;
106///
107/// fn main() {
108///     // serve root directory with default options:
109///     // - resolve index.html
110///     let files: HashMap<&'static str, static_files::Resource> = HashMap::new();
111///     let app = App::new()
112///         .service(actix_web_static_files::ResourceFiles::new("/", files));
113///     // or subpath with additional option to not resolve index.html
114///     let files: HashMap<&'static str, static_files::Resource> = HashMap::new();
115///     let app = App::new()
116///         .service(actix_web_static_files::ResourceFiles::new("/imgs", files)
117///             .do_not_resolve_defaults());
118/// }
119/// ```
120#[allow(clippy::needless_doctest_main)]
121pub struct ResourceFiles<C = DefaultResourceFiles> {
122    not_resolve_defaults: bool,
123    use_guard: bool,
124    not_found_resolves_to: Option<String>,
125    inner: Rc<ResourceFilesInner<C>>,
126}
127
128pub struct ResourceFilesInner<C> {
129    path: String,
130    files: C,
131}
132
133const INDEX_HTML: &str = "index.html";
134
135impl<F> ResourceFiles<F>
136where
137    F: ResourceFilesCollection + 'static,
138{
139    #[must_use]
140    pub fn new(path: &str, files: F) -> Self {
141        let inner = ResourceFilesInner {
142            path: path.into(),
143            files,
144        };
145        Self {
146            inner: Rc::new(inner),
147            not_resolve_defaults: false,
148            not_found_resolves_to: None,
149            use_guard: false,
150        }
151    }
152
153    /// By default trying to resolve '.../' to '.../index.html' if it exists.
154    /// Turn off this resolution by calling this function.
155    #[must_use]
156    pub fn do_not_resolve_defaults(mut self) -> Self {
157        self.not_resolve_defaults = true;
158        self
159    }
160
161    /// Resolves not found references to this path.
162    ///
163    /// This can be useful for angular-like applications.
164    #[must_use]
165    pub fn resolve_not_found_to<S: ToString>(mut self, path: S) -> Self {
166        self.not_found_resolves_to = Some(path.to_string());
167        self
168    }
169
170    /// Resolves not found references to root path.
171    ///
172    /// This can be useful for angular-like applications.
173    #[must_use]
174    pub fn resolve_not_found_to_root(self) -> Self {
175        self.resolve_not_found_to(INDEX_HTML)
176    }
177
178    /// If this is called, we will use a [`Guard`] to check if this request should be handled.
179    /// If set to true, we skip using the handler for files that haven't been found, instead of sending 404s.
180    /// Would be ignored, if `resolve_not_found_to` or `resolve_not_found_to_root` is used.
181    ///
182    /// Can be useful if you want to share files on a (sub)path that's also used by a different route handler.
183    #[must_use]
184    pub fn skip_handler_when_not_found(mut self) -> Self {
185        self.use_guard = true;
186        self
187    }
188
189    fn select_guard(&self) -> Box<dyn Guard> {
190        if self.not_resolve_defaults {
191            Box::new(NotResolveDefaultsGuard::from(self))
192        } else {
193            Box::new(ResolveDefaultsGuard::from(self))
194        }
195    }
196}
197
198impl<C> Deref for ResourceFiles<C> {
199    type Target = ResourceFilesInner<C>;
200
201    fn deref(&self) -> &Self::Target {
202        &self.inner
203    }
204}
205
206struct NotResolveDefaultsGuard<C> {
207    inner: Rc<ResourceFilesInner<C>>,
208}
209
210impl<C> Guard for NotResolveDefaultsGuard<C>
211where
212    C: ResourceFilesCollection,
213{
214    fn check(&self, ctx: &GuardContext<'_>) -> bool {
215        self.inner
216            .files
217            .contains_key(ctx.head().uri.path().trim_start_matches('/'))
218    }
219}
220
221impl<C> From<&ResourceFiles<C>> for NotResolveDefaultsGuard<C> {
222    fn from(files: &ResourceFiles<C>) -> Self {
223        Self {
224            inner: files.inner.clone(),
225        }
226    }
227}
228
229struct ResolveDefaultsGuard<C> {
230    inner: Rc<ResourceFilesInner<C>>,
231}
232
233impl<C> Guard for ResolveDefaultsGuard<C>
234where
235    C: ResourceFilesCollection,
236{
237    fn check(&self, ctx: &GuardContext<'_>) -> bool {
238        let path = ctx.head().uri.path().trim_start_matches('/');
239        self.inner.files.contains_key(path)
240            || ((path.is_empty() || path.ends_with('/'))
241                && self
242                    .inner
243                    .files
244                    .contains_key((path.to_string() + INDEX_HTML).as_str()))
245    }
246}
247
248impl<C> From<&ResourceFiles<C>> for ResolveDefaultsGuard<C> {
249    fn from(files: &ResourceFiles<C>) -> Self {
250        Self {
251            inner: files.inner.clone(),
252        }
253    }
254}
255
256impl<C> HttpServiceFactory for ResourceFiles<C>
257where
258    C: ResourceFilesCollection + 'static,
259{
260    fn register(self, config: &mut AppService) {
261        let prefix = self.path.trim_start_matches('/');
262        let rdef = if config.is_root() {
263            ResourceDef::root_prefix(prefix)
264        } else {
265            ResourceDef::prefix(prefix)
266        };
267        let guards = if self.use_guard && self.not_found_resolves_to.is_none() {
268            Some(vec![self.select_guard()])
269        } else {
270            None
271        };
272        config.register_service(rdef, guards, self, None);
273    }
274}
275
276impl<C> ServiceFactory<ServiceRequest> for ResourceFiles<C>
277where
278    C: ResourceFilesCollection + 'static,
279{
280    type Response = ServiceResponse;
281    type Error = Error;
282    type Config = ();
283    type Service = ResourceFilesService<C>;
284    type InitError = ();
285    type Future = LocalBoxFuture<'static, Result<Self::Service, Self::InitError>>;
286
287    fn new_service(&self, _: ()) -> Self::Future {
288        ok(ResourceFilesService {
289            resolve_defaults: !self.not_resolve_defaults,
290            not_found_resolves_to: self.not_found_resolves_to.clone(),
291            inner: self.inner.clone(),
292        })
293        .boxed_local()
294    }
295}
296
297#[derive(Deref)]
298pub struct ResourceFilesService<C> {
299    resolve_defaults: bool,
300    not_found_resolves_to: Option<String>,
301    #[deref]
302    inner: Rc<ResourceFilesInner<C>>,
303}
304
305impl<C> Service<ServiceRequest> for ResourceFilesService<C>
306where
307    C: ResourceFilesCollection,
308{
309    type Response = ServiceResponse;
310    type Error = Error;
311    type Future = Ready<Result<Self::Response, Self::Error>>;
312
313    always_ready!();
314
315    fn call(&self, req: ServiceRequest) -> Self::Future {
316        match *req.method() {
317            Method::HEAD | Method::GET => (),
318            _ => {
319                return ok(ServiceResponse::new(
320                    req.into_parts().0,
321                    HttpResponse::MethodNotAllowed()
322                        .insert_header(ContentType::plaintext())
323                        .insert_header((header::ALLOW, "GET, HEAD"))
324                        .body("This resource only supports GET and HEAD."),
325                ));
326            }
327        }
328
329        let req_path = req.match_info().unprocessed();
330        let mut item = self.files.get_resource(req_path);
331
332        if item.is_none()
333            && self.resolve_defaults
334            && (req_path.is_empty() || req_path.ends_with('/'))
335        {
336            let index_req_path = req_path.to_string() + INDEX_HTML;
337            item = self
338                .files
339                .get_resource(index_req_path.trim_start_matches('/'));
340        }
341
342        let (req, response) = if item.is_some() {
343            let (req, _) = req.into_parts();
344            let response = respond_to(&req, item);
345            (req, response)
346        } else {
347            let real_path = match get_pathbuf(req_path) {
348                Ok(item) => item,
349                Err(e) => return ok(req.error_response(e)),
350            };
351
352            let (req, _) = req.into_parts();
353
354            let mut item = self.files.get_resource(real_path.as_str());
355
356            if item.is_none() && self.not_found_resolves_to.is_some() {
357                let not_found_path = self.not_found_resolves_to.as_ref().unwrap();
358                item = self.files.get_resource(not_found_path.as_str());
359            }
360
361            let response = respond_to(&req, item);
362            (req, response)
363        };
364
365        ok(ServiceResponse::new(req, response))
366    }
367}
368
369fn respond_to<Resource: ResourceFile>(req: &HttpRequest, item: Option<&Resource>) -> HttpResponse {
370    if let Some(file) = item {
371        let etag = Some(header::EntityTag::new_strong(format!(
372            "{:x}:{:x}",
373            file.data().len(),
374            file.modified()
375        )));
376
377        let precondition_failed = !any_match(etag.as_ref(), req);
378
379        let not_modified = !none_match(etag.as_ref(), req);
380
381        let mut resp = HttpResponse::build(StatusCode::OK);
382        resp.insert_header((header::CONTENT_TYPE, file.mime_type()));
383
384        if let Some(etag) = etag {
385            resp.insert_header(header::ETag(etag));
386        }
387
388        if precondition_failed {
389            return resp.status(StatusCode::PRECONDITION_FAILED).finish();
390        } else if not_modified {
391            return resp.status(StatusCode::NOT_MODIFIED).finish();
392        }
393
394        resp.body(file.data())
395    } else {
396        HttpResponse::NotFound().body("Not found")
397    }
398}
399
400/// Returns true if `req` has no `If-Match` header or one which matches `etag`.
401fn any_match(etag: Option<&header::EntityTag>, req: &HttpRequest) -> bool {
402    match req.get_header::<header::IfMatch>() {
403        None | Some(header::IfMatch::Any) => true,
404        Some(header::IfMatch::Items(ref items)) => {
405            if let Some(some_etag) = etag {
406                for item in items {
407                    if item.strong_eq(some_etag) {
408                        return true;
409                    }
410                }
411            }
412            false
413        }
414    }
415}
416
417/// Returns true if `req` doesn't have an `If-None-Match` header matching `req`.
418fn none_match(etag: Option<&header::EntityTag>, req: &HttpRequest) -> bool {
419    match req.get_header::<header::IfNoneMatch>() {
420        Some(header::IfNoneMatch::Any) => false,
421        Some(header::IfNoneMatch::Items(ref items)) => {
422            if let Some(some_etag) = etag {
423                for item in items {
424                    if item.weak_eq(some_etag) {
425                        return false;
426                    }
427                }
428            }
429            true
430        }
431        None => true,
432    }
433}
434
435#[derive(Debug, PartialEq, Display, Error)]
436pub enum UriSegmentError {
437    /// The segment started with the wrapped invalid character.
438    #[display(fmt = "The segment started with the wrapped invalid character")]
439    BadStart(#[error(not(source))] char),
440
441    /// The segment contained the wrapped invalid character.
442    #[display(fmt = "The segment contained the wrapped invalid character")]
443    BadChar(#[error(not(source))] char),
444
445    /// The segment ended with the wrapped invalid character.
446    #[display(fmt = "The segment ended with the wrapped invalid character")]
447    BadEnd(#[error(not(source))] char),
448}
449
450/// Return `BadRequest` for `UriSegmentError`
451impl ResponseError for UriSegmentError {
452    fn error_response(&self) -> HttpResponse {
453        HttpResponse::new(StatusCode::BAD_REQUEST)
454    }
455}
456
457fn get_pathbuf(path: &str) -> Result<String, UriSegmentError> {
458    let mut buf = Vec::new();
459    for segment in path.split('/') {
460        if segment == ".." {
461            buf.pop();
462        } else if segment.starts_with('.') {
463            return Err(UriSegmentError::BadStart('.'));
464        } else if segment.starts_with('*') {
465            return Err(UriSegmentError::BadStart('*'));
466        } else if segment.ends_with(':') {
467            return Err(UriSegmentError::BadEnd(':'));
468        } else if segment.ends_with('>') {
469            return Err(UriSegmentError::BadEnd('>'));
470        } else if segment.ends_with('<') {
471            return Err(UriSegmentError::BadEnd('<'));
472        } else if segment.is_empty() {
473            continue;
474        } else if cfg!(windows) && segment.contains('\\') {
475            return Err(UriSegmentError::BadChar('\\'));
476        } else {
477            buf.push(segment);
478        }
479    }
480
481    Ok(buf.join("/"))
482}
483
484#[cfg(test)]
485mod tests_error_impl {
486    use super::*;
487
488    fn assert_send_and_sync<T: Send + Sync + 'static>() {}
489
490    #[test]
491    fn test_error_impl() {
492        // ensure backwards compatibility when migrating away from failure
493        assert_send_and_sync::<UriSegmentError>();
494    }
495}