diff --git a/helpers/pagetop-statics/src/lib.rs b/helpers/pagetop-statics/src/lib.rs index f0135695..d2f147e3 100644 --- a/helpers/pagetop-statics/src/lib.rs +++ b/helpers/pagetop-statics/src/lib.rs @@ -47,7 +47,10 @@ pub mod resource; pub use resource::Resource as StaticResource; mod resource_dir; -pub use resource_dir::{ResourceDir, resource_dir}; +pub use resource_dir::{resource_dir, ResourceDir}; + +mod resource_files; +pub use resource_files::{ResourceFiles, UriSegmentError}; /// Support for module based generations. Use it for large data sets (more than 128 Mb). pub mod sets; diff --git a/helpers/pagetop-statics/src/resource_files.rs b/helpers/pagetop-statics/src/resource_files.rs new file mode 100644 index 00000000..b487bca9 --- /dev/null +++ b/helpers/pagetop-statics/src/resource_files.rs @@ -0,0 +1,396 @@ +use super::resource::Resource; +use actix_web::{ + dev::{ + always_ready, AppService, HttpServiceFactory, ResourceDef, Service, ServiceFactory, + ServiceRequest, ServiceResponse, + }, + error::Error, + guard::{Guard, GuardContext}, + http::{ + header::{self, ContentType}, + Method, StatusCode, + }, + HttpMessage, HttpRequest, HttpResponse, ResponseError, +}; +use derive_more::{Deref, Display, Error}; +use futures_util::future::{ok, FutureExt, LocalBoxFuture, Ready}; +use std::{collections::HashMap, ops::Deref, rc::Rc}; + +/// Static resource files handling +/// +/// `ResourceFiles` service must be registered with `App::service` method. +/// +/// ```rust +/// use std::collections::HashMap; +/// +/// use actix_web::App; +/// +/// fn main() { +/// // serve root directory with default options: +/// // - resolve index.html +/// let files: HashMap<&'static str, pagetop_statics::StaticResource> = HashMap::new(); +/// let app = App::new() +/// .service(pagetop_statics::ResourceFiles::new("/", files)); +/// // or subpath with additional option to not resolve index.html +/// let files: HashMap<&'static str, pagetop_statics::StaticResource> = HashMap::new(); +/// let app = App::new() +/// .service(pagetop_statics::ResourceFiles::new("/imgs", files) +/// .do_not_resolve_defaults()); +/// } +/// ``` +#[allow(clippy::needless_doctest_main)] +pub struct ResourceFiles { + not_resolve_defaults: bool, + use_guard: bool, + not_found_resolves_to: Option, + inner: Rc, +} + +pub struct ResourceFilesInner { + path: String, + files: HashMap<&'static str, Resource>, +} + +const INDEX_HTML: &str = "index.html"; + +impl ResourceFiles { + pub fn new(path: &str, files: HashMap<&'static str, Resource>) -> Self { + let inner = ResourceFilesInner { + path: path.into(), + files, + }; + Self { + inner: Rc::new(inner), + not_resolve_defaults: false, + not_found_resolves_to: None, + use_guard: false, + } + } + + /// By default trying to resolve '.../' to '.../index.html' if it exists. + /// Turn off this resolution by calling this function. + pub fn do_not_resolve_defaults(mut self) -> Self { + self.not_resolve_defaults = true; + self + } + + /// Resolves not found references to this path. + /// + /// This can be useful for angular-like applications. + pub fn resolve_not_found_to(mut self, path: S) -> Self { + self.not_found_resolves_to = Some(path.to_string()); + self + } + + /// Resolves not found references to root path. + /// + /// This can be useful for angular-like applications. + pub fn resolve_not_found_to_root(self) -> Self { + self.resolve_not_found_to(INDEX_HTML) + } + + /// If this is called, we will use an [actix_web::guard::Guard] to check if this request should be handled. + /// If set to true, we skip using the handler for files that haven't been found, instead of sending 404s. + /// Would be ignored, if `resolve_not_found_to` or `resolve_not_found_to_root` is used. + /// + /// Can be useful if you want to share files on a (sub)path that's also used by a different route handler. + pub fn skip_handler_when_not_found(mut self) -> Self { + self.use_guard = true; + self + } + + fn select_guard(&self) -> Box { + if self.not_resolve_defaults { + Box::new(NotResolveDefaultsGuard::from(self)) + } else { + Box::new(ResolveDefaultsGuard::from(self)) + } + } +} + +impl Deref for ResourceFiles { + type Target = ResourceFilesInner; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +struct NotResolveDefaultsGuard { + inner: Rc, +} + +impl Guard for NotResolveDefaultsGuard { + fn check(&self, ctx: &GuardContext<'_>) -> bool { + self.inner + .files + .contains_key(ctx.head().uri.path().trim_start_matches('/')) + } +} + +impl From<&ResourceFiles> for NotResolveDefaultsGuard { + fn from(files: &ResourceFiles) -> Self { + Self { + inner: files.inner.clone(), + } + } +} + +struct ResolveDefaultsGuard { + inner: Rc, +} + +impl Guard for ResolveDefaultsGuard { + fn check(&self, ctx: &GuardContext<'_>) -> bool { + let path = ctx.head().uri.path().trim_start_matches('/'); + self.inner.files.contains_key(path) + || ((path.is_empty() || path.ends_with('/')) + && self + .inner + .files + .contains_key((path.to_string() + INDEX_HTML).as_str())) + } +} + +impl From<&ResourceFiles> for ResolveDefaultsGuard { + fn from(files: &ResourceFiles) -> Self { + Self { + inner: files.inner.clone(), + } + } +} + +impl HttpServiceFactory for ResourceFiles { + fn register(self, config: &mut AppService) { + let prefix = self.path.trim_start_matches('/'); + let rdef = if config.is_root() { + ResourceDef::root_prefix(prefix) + } else { + ResourceDef::prefix(prefix) + }; + let guards = if self.use_guard && self.not_found_resolves_to.is_none() { + Some(vec![self.select_guard()]) + } else { + None + }; + config.register_service(rdef, guards, self, None); + } +} + +impl ServiceFactory for ResourceFiles { + type Config = (); + type Response = ServiceResponse; + type Error = Error; + type Service = ResourceFilesService; + type InitError = (); + type Future = LocalBoxFuture<'static, Result>; + + fn new_service(&self, _: ()) -> Self::Future { + ok(ResourceFilesService { + resolve_defaults: !self.not_resolve_defaults, + not_found_resolves_to: self.not_found_resolves_to.clone(), + inner: self.inner.clone(), + }) + .boxed_local() + } +} + +#[derive(Deref)] +pub struct ResourceFilesService { + resolve_defaults: bool, + not_found_resolves_to: Option, + #[deref] + inner: Rc, +} + +impl Service for ResourceFilesService { + type Response = ServiceResponse; + type Error = Error; + type Future = Ready>; + + always_ready!(); + + fn call(&self, req: ServiceRequest) -> Self::Future { + match *req.method() { + Method::HEAD | Method::GET => (), + _ => { + return ok(ServiceResponse::new( + req.into_parts().0, + HttpResponse::MethodNotAllowed() + .insert_header(ContentType::plaintext()) + .insert_header((header::ALLOW, "GET, HEAD")) + .body("This resource only supports GET and HEAD."), + )); + } + } + + let req_path = req.match_info().unprocessed(); + let mut item = self.files.get(req_path); + + if item.is_none() + && self.resolve_defaults + && (req_path.is_empty() || req_path.ends_with('/')) + { + let index_req_path = req_path.to_string() + INDEX_HTML; + item = self.files.get(index_req_path.trim_start_matches('/')); + } + + let (req, response) = if item.is_some() { + let (req, _) = req.into_parts(); + let response = respond_to(&req, item); + (req, response) + } else { + let real_path = match get_pathbuf(req_path) { + Ok(item) => item, + Err(e) => return ok(req.error_response(e)), + }; + + let (req, _) = req.into_parts(); + + let mut item = self.files.get(real_path.as_str()); + + if item.is_none() && self.not_found_resolves_to.is_some() { + let not_found_path = self.not_found_resolves_to.as_ref().unwrap(); + item = self.files.get(not_found_path.as_str()); + } + + let response = respond_to(&req, item); + (req, response) + }; + + ok(ServiceResponse::new(req, response)) + } +} + +fn respond_to(req: &HttpRequest, item: Option<&Resource>) -> HttpResponse { + if let Some(file) = item { + let etag = Some(header::EntityTag::new_strong(format!( + "{:x}:{:x}", + file.data.len(), + file.modified + ))); + + let precondition_failed = !any_match(etag.as_ref(), req); + + let not_modified = !none_match(etag.as_ref(), req); + + let mut resp = HttpResponse::build(StatusCode::OK); + resp.insert_header((header::CONTENT_TYPE, file.mime_type)); + + if let Some(etag) = etag { + resp.insert_header(header::ETag(etag)); + } + + if precondition_failed { + return resp.status(StatusCode::PRECONDITION_FAILED).finish(); + } else if not_modified { + return resp.status(StatusCode::NOT_MODIFIED).finish(); + } + + resp.body(file.data) + } else { + HttpResponse::NotFound().body("Not found") + } +} + +/// Returns true if `req` has no `If-Match` header or one which matches `etag`. +fn any_match(etag: Option<&header::EntityTag>, req: &HttpRequest) -> bool { + match req.get_header::() { + None | Some(header::IfMatch::Any) => true, + Some(header::IfMatch::Items(ref items)) => { + if let Some(some_etag) = etag { + for item in items { + if item.strong_eq(some_etag) { + return true; + } + } + } + false + } + } +} + +/// Returns true if `req` doesn't have an `If-None-Match` header matching `req`. +fn none_match(etag: Option<&header::EntityTag>, req: &HttpRequest) -> bool { + match req.get_header::() { + Some(header::IfNoneMatch::Any) => false, + Some(header::IfNoneMatch::Items(ref items)) => { + if let Some(some_etag) = etag { + for item in items { + if item.weak_eq(some_etag) { + return false; + } + } + } + true + } + None => true, + } +} + +/// Error type representing invalid characters in a URI path segment. +/// +/// This enum is used to report specific formatting errors in individual segments of a URI path, +/// such as starting, ending, or containing disallowed characters. Each variant wraps the offending +/// character that caused the error. +#[derive(Debug, PartialEq, Display, Error)] +pub enum UriSegmentError { + /// The segment started with the wrapped invalid character. + #[display(fmt = "The segment started with the wrapped invalid character")] + BadStart(#[error(not(source))] char), + + /// The segment contained the wrapped invalid character. + #[display(fmt = "The segment contained the wrapped invalid character")] + BadChar(#[error(not(source))] char), + + /// The segment ended with the wrapped invalid character. + #[display(fmt = "The segment ended with the wrapped invalid character")] + BadEnd(#[error(not(source))] char), +} + +#[cfg(test)] +mod tests_error_impl { + use super::*; + + fn assert_send_and_sync() {} + + #[test] + fn test_error_impl() { + // ensure backwards compatibility when migrating away from failure + assert_send_and_sync::(); + } +} + +/// Return `BadRequest` for `UriSegmentError` +impl ResponseError for UriSegmentError { + fn error_response(&self) -> HttpResponse { + HttpResponse::new(StatusCode::BAD_REQUEST) + } +} + +fn get_pathbuf(path: &str) -> Result { + let mut buf = Vec::new(); + for segment in path.split('/') { + if segment == ".." { + buf.pop(); + } else if segment.starts_with('.') { + return Err(UriSegmentError::BadStart('.')); + } else if segment.starts_with('*') { + return Err(UriSegmentError::BadStart('*')); + } else if segment.ends_with(':') { + return Err(UriSegmentError::BadEnd(':')); + } else if segment.ends_with('>') { + return Err(UriSegmentError::BadEnd('>')); + } else if segment.ends_with('<') { + return Err(UriSegmentError::BadEnd('<')); + } else if segment.is_empty() { + continue; + } else if cfg!(windows) && segment.contains('\\') { + return Err(UriSegmentError::BadChar('\\')); + } else { + buf.push(segment) + } + } + + Ok(buf.join("/")) +} diff --git a/tests/component_html.rs b/tests/component_html.rs index d805d079..06d77ec9 100644 --- a/tests/component_html.rs +++ b/tests/component_html.rs @@ -46,20 +46,18 @@ async fn component_html_default_renders_empty_markup() { } #[pagetop::test] -async fn component_html_can_access_request_path() { - let req = web::test::TestRequest::get() - .uri("/hello/world") - .to_http_request(); +async fn component_html_can_access_http_method() { + let req = service::test::TestRequest::with_uri("/").to_http_request(); let mut cx = Context::new(Some(req)); let mut component = Html::with(|cx| { - let path = cx + let method = cx .request() - .map(|r| r.path().to_string()) + .map(|r| r.method().to_string()) .unwrap_or_default(); - html! { span { (path) } } + html! { span { (method) } } }); let markup = component.render(&mut cx); - assert_eq!(markup.0, "/hello/world"); + assert_eq!(markup.0, "GET"); } diff --git a/tests/component_poweredby.rs b/tests/component_poweredby.rs index 1cd689cb..7dca895d 100644 --- a/tests/component_poweredby.rs +++ b/tests/component_poweredby.rs @@ -1,16 +1,8 @@ use pagetop::prelude::*; -/// Inicializa PageTop (locale, extensiones…) una sola vez para toda la suite. -/// -/// Los tests de este módulo renderizan componentes directamente con `Context::default()`, por lo -/// que sólo necesitan el subsistema de localización y las extensiones registradas, no un router. -fn setup() { - let _ = Application::new(); -} - #[pagetop::test] async fn poweredby_default_shows_only_pagetop_recognition() { - setup(); + let _app = service::test::init_service(Application::new().test()).await; let mut p = PoweredBy::default(); let html = p.render(&mut Context::default()); @@ -24,7 +16,7 @@ async fn poweredby_default_shows_only_pagetop_recognition() { #[pagetop::test] async fn poweredby_new_includes_current_year_and_app_name() { - setup(); + let _app = service::test::init_service(Application::new().test()).await; let mut p = PoweredBy::new(); let html = p.render(&mut Context::default()); @@ -48,7 +40,7 @@ async fn poweredby_new_includes_current_year_and_app_name() { #[pagetop::test] async fn poweredby_with_copyright_overrides_text() { - setup(); + let _app = service::test::init_service(Application::new().test()).await; let custom = "2001 © FooBar Inc."; let mut p = PoweredBy::default().with_copyright(Some(custom)); @@ -60,7 +52,7 @@ async fn poweredby_with_copyright_overrides_text() { #[pagetop::test] async fn poweredby_with_copyright_none_hides_text() { - setup(); + let _app = service::test::init_service(Application::new().test()).await; let mut p = PoweredBy::new().with_copyright(None::); let html = p.render(&mut Context::default()); @@ -72,7 +64,7 @@ async fn poweredby_with_copyright_none_hides_text() { #[pagetop::test] async fn poweredby_link_points_to_crates_io() { - setup(); + let _app = service::test::init_service(Application::new().test()).await; let mut p = PoweredBy::default(); let html = p.render(&mut Context::default()); @@ -85,7 +77,7 @@ async fn poweredby_link_points_to_crates_io() { #[pagetop::test] async fn poweredby_getter_reflects_internal_state() { - setup(); + let _app = service::test::init_service(Application::new().test()).await; // Por defecto no hay copyright. let p0 = PoweredBy::default(); diff --git a/tests/config.rs b/tests/config.rs index 91fc8419..ff7135c0 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -2,6 +2,8 @@ use pagetop::prelude::*; use serde::Deserialize; +use std::env; + include_config!(SETTINGS: Settings => [ "test.string_value" => "Test String", "test.int_value" => -321, @@ -20,12 +22,10 @@ pub struct Test { pub float_value: f32, } -// La *feature* `testing` (activo con `cargo ts` / `cargo tw`) fija el modo "test" en tiempo de -// compilación dentro de `config::CONFIG_VALUES`, de forma que `global::SETTINGS` y cualquier -// `include_config!` local cargan automáticamente la configuración del modo "test". - #[pagetop::test] async fn check_global_config() { + env::set_var("PAGETOP_RUN_MODE", "test"); + assert_eq!(global::SETTINGS.app.run_mode, "test"); assert_eq!(global::SETTINGS.app.name, "Testing"); assert_eq!(global::SETTINGS.server.bind_port, 9000); @@ -33,6 +33,8 @@ async fn check_global_config() { #[pagetop::test] async fn check_local_config() { + env::set_var("PAGETOP_RUN_MODE", "test"); + assert_eq!(SETTINGS.test.string_value, "Modified value"); assert_eq!(SETTINGS.test.int_value, -321); assert_eq!(SETTINGS.test.float_value, 8.7654); diff --git a/tests/locale.rs b/tests/locale.rs index 51ff1863..e15d4f75 100644 --- a/tests/locale.rs +++ b/tests/locale.rs @@ -2,7 +2,7 @@ use pagetop::prelude::*; #[pagetop::test] async fn literal_text() { - let _app = web::test::init_router(Application::new().test()); + let _app = service::test::init_service(Application::new().test()).await; let l10n = L10n::n("© 2025 PageTop"); assert_eq!(l10n.get(), Some("© 2025 PageTop".to_string())); @@ -10,7 +10,7 @@ async fn literal_text() { #[pagetop::test] async fn translation_without_args() { - let _app = web::test::init_router(Application::new().test()); + let _app = service::test::init_service(Application::new().test()).await; let l10n = L10n::l("test_hello_world"); let translation = l10n.lookup(&Locale::resolve("es-ES")); @@ -19,7 +19,7 @@ async fn translation_without_args() { #[pagetop::test] async fn translation_with_args() { - let _app = web::test::init_router(Application::new().test()); + let _app = service::test::init_service(Application::new().test()).await; let l10n = L10n::l("test_hello_user").with_arg("userName", "Manuel"); let translation = l10n.lookup(&Locale::resolve("es-ES")); @@ -28,7 +28,7 @@ async fn translation_with_args() { #[pagetop::test] async fn translation_with_plural_and_select() { - let _app = web::test::init_router(Application::new().test()); + let _app = service::test::init_service(Application::new().test()).await; let l10n = L10n::l("test_shared_photos").with_args(vec![ ("userName", "Roberto"), @@ -41,7 +41,7 @@ async fn translation_with_plural_and_select() { #[pagetop::test] async fn check_fallback_language() { - let _app = web::test::init_router(Application::new().test()); + let _app = service::test::init_service(Application::new().test()).await; let l10n = L10n::l("test_hello_world"); let translation = l10n.lookup(&Locale::resolve("xx-YY")); // Retrocede a "en-US". @@ -50,7 +50,7 @@ async fn check_fallback_language() { #[pagetop::test] async fn check_unknown_key() { - let _app = web::test::init_router(Application::new().test()); + let _app = service::test::init_service(Application::new().test()).await; let l10n = L10n::l("non-existent-key"); let translation = l10n.lookup(&Locale::resolve("en-US")); diff --git a/tests/service.rs b/tests/service.rs index 5f2ca87b..5aec398e 100644 --- a/tests/service.rs +++ b/tests/service.rs @@ -2,11 +2,11 @@ use pagetop::prelude::*; #[pagetop::test] async fn homepage_returns_404() { - let app = web::test::init_router(Application::new().test()); + let app = service::test::init_service(Application::new().test()).await; - let req = web::test::TestRequest::get().uri("/").to_request(); - let resp = web::test::send_request(&app, req).await; + let req = service::test::TestRequest::get().uri("/").to_request(); + let resp = service::test::call_service(&app, req).await; // Comprueba el acceso a la ruta de inicio. - assert_eq!(resp.status(), web::http::StatusCode::OK); + assert_eq!(resp.status(), service::http::StatusCode::OK); } diff --git a/tests/util.rs b/tests/util.rs index 6dafce8a..d7d8dd65 100644 --- a/tests/util.rs +++ b/tests/util.rs @@ -1,6 +1,6 @@ use pagetop::prelude::*; -use std::{borrow::Cow, fs, io}; +use std::{borrow::Cow, env, fs, io}; use tempfile::TempDir; // **< Testing normalize_ascii() >****************************************************************** @@ -265,7 +265,7 @@ mod unix { #[pagetop::test] async fn ok_absolute_dir() -> io::Result<()> { - let _app = web::test::init_router(Application::new().test()); + let _app = service::test::init_service(Application::new().test()).await; // /tmp//sub let td = TempDir::new()?; @@ -279,13 +279,21 @@ mod unix { #[pagetop::test] async fn ok_relative_dir_with_manifest() -> io::Result<()> { - let _app = web::test::init_router(Application::new().test()); + let _app = service::test::init_service(Application::new().test()).await; let td = TempDir::new()?; let sub = td.path().join("sub"); fs::create_dir(&sub)?; - let res = util::resolve_absolute_dir_with_base("sub", Some(td.path().to_path_buf())); + // Fija CARGO_MANIFEST_DIR para que "sub" se resuelva contra td.path() + let prev_manifest_dir = env::var_os("CARGO_MANIFEST_DIR"); + env::set_var("CARGO_MANIFEST_DIR", td.path()); + let res = util::resolve_absolute_dir("sub"); + // Restaura entorno. + match prev_manifest_dir { + Some(v) => env::set_var("CARGO_MANIFEST_DIR", v), + None => env::remove_var("CARGO_MANIFEST_DIR"), + } assert_eq!(res?, std::fs::canonicalize(&sub)?); Ok(()) @@ -293,7 +301,7 @@ mod unix { #[pagetop::test] async fn error_not_a_directory() -> io::Result<()> { - let _app = web::test::init_router(Application::new().test()); + let _app = service::test::init_service(Application::new().test()).await; let td = TempDir::new()?; let file = td.path().join("foo.txt"); @@ -311,7 +319,7 @@ mod windows { #[pagetop::test] async fn ok_absolute_dir() -> io::Result<()> { - let _app = web::test::init_router(Application::new().test()); + let _app = service::test::init_service(Application::new().test()).await; // C:\Users\...\Temp\... let td = TempDir::new()?; @@ -325,13 +333,21 @@ mod windows { #[pagetop::test] async fn ok_relative_dir_with_manifest() -> io::Result<()> { - let _app = web::test::init_router(Application::new().test()); + let _app = service::test::init_service(Application::new().test()).await; let td = TempDir::new()?; let sub = td.path().join("sub"); fs::create_dir(&sub)?; - let res = resolve_absolute_dir_with_base("sub", Some(td.path().to_path_buf())); + // Fija CARGO_MANIFEST_DIR para que "sub" se resuelva contra td.path() + let prev_manifest_dir = env::var_os("CARGO_MANIFEST_DIR"); + env::set_var("CARGO_MANIFEST_DIR", td.path()); + let res = util::resolve_absolute_dir("sub"); + // Restaura entorno. + match prev_manifest_dir { + Some(v) => env::set_var("CARGO_MANIFEST_DIR", v), + None => env::remove_var("CARGO_MANIFEST_DIR"), + } assert_eq!(res?, std::fs::canonicalize(&sub)?); Ok(()) @@ -339,7 +355,7 @@ mod windows { #[pagetop::test] async fn error_not_a_directory() -> io::Result<()> { - let _app = web::test::init_router(Application::new().test()); + let _app = service::test::init_service(Application::new().test()).await; let td = TempDir::new()?; let file = td.path().join("foo.txt");