diff --git a/docs/src/config-file.md b/docs/src/config-file.md index 3efc08bd..cc2670ac 100644 --- a/docs/src/config-file.md +++ b/docs/src/config-file.md @@ -21,6 +21,10 @@ keep_alive: 75 # The socket address to bind [default: 0.0.0.0:3000] listen_addresses: '0.0.0.0:3000' + +# Set TileJSON URL path prefix, ignoring X-Rewrite-URL header. Must begin with a `/` +base_path: /tiles + # Number of web server workers worker_processes: 8 diff --git a/docs/src/run-with-cli.md b/docs/src/run-with-cli.md index b5293558..da3241c1 100644 --- a/docs/src/run-with-cli.md +++ b/docs/src/run-with-cli.md @@ -27,7 +27,9 @@ Options: -l, --listen-addresses The socket address to bind. [DEFAULT: 0.0.0.0:3000] - + --base-path + Set TileJSON URL path prefix, ignoring X-Rewrite-URL header. Must begin with a `/`. Examples: `/`, `/tiles` + -W, --workers Number of web server workers diff --git a/martin/src/args/mod.rs b/martin/src/args/mod.rs index 0e58bd7a..a563833a 100644 --- a/martin/src/args/mod.rs +++ b/martin/src/args/mod.rs @@ -13,5 +13,4 @@ mod root; pub use root::{Args, ExtraArgs, MetaArgs}; mod srv; -pub use srv::PreferredEncoding; -pub use srv::SrvArgs; +pub use srv::{PreferredEncoding, SrvArgs}; diff --git a/martin/src/args/srv.rs b/martin/src/args/srv.rs index e7b74358..a3eddb72 100644 --- a/martin/src/args/srv.rs +++ b/martin/src/args/srv.rs @@ -1,7 +1,8 @@ -use crate::srv::{SrvConfig, KEEP_ALIVE_DEFAULT, LISTEN_ADDRESSES_DEFAULT}; use clap::ValueEnum; use serde::{Deserialize, Serialize}; +use crate::srv::{SrvConfig, KEEP_ALIVE_DEFAULT, LISTEN_ADDRESSES_DEFAULT}; + #[derive(clap::Args, Debug, PartialEq, Default)] #[command(about, version)] pub struct SrvArgs { @@ -9,6 +10,9 @@ pub struct SrvArgs { pub keep_alive: Option, #[arg(help = format!("The socket address to bind. [DEFAULT: {}]", LISTEN_ADDRESSES_DEFAULT), short, long)] pub listen_addresses: Option, + /// Set TileJSON URL path prefix, ignoring X-Rewrite-URL header. Must begin with a `/`. Examples: `/`, `/tiles` + #[arg(long)] + pub base_path: Option, /// Number of web server workers #[arg(short = 'W', long)] pub workers: Option, @@ -42,5 +46,8 @@ impl SrvArgs { if self.preferred_encoding.is_some() { srv_config.preferred_encoding = self.preferred_encoding; } + if self.base_path.is_some() { + srv_config.base_path = self.base_path; + } } } diff --git a/martin/src/config.rs b/martin/src/config.rs index 1377c096..8be723a9 100644 --- a/martin/src/config.rs +++ b/martin/src/config.rs @@ -19,7 +19,7 @@ use crate::source::{TileInfoSources, TileSources}; #[cfg(feature = "sprites")] use crate::sprites::{SpriteConfig, SpriteSources}; use crate::srv::{SrvConfig, RESERVED_KEYWORDS}; -use crate::utils::{CacheValue, MainCache, OptMainCache}; +use crate::utils::{parse_base_path, CacheValue, MainCache, OptMainCache}; use crate::MartinError::{ConfigLoadError, ConfigParseError, ConfigWriteError, NoSources}; use crate::{IdResolver, MartinResult, OptOneMany}; @@ -71,6 +71,10 @@ impl Config { let mut res = UnrecognizedValues::new(); copy_unrecognized_config(&mut res, "", &self.unrecognized); + if let Some(path) = &self.srv.base_path { + self.srv.base_path = Some(parse_base_path(path)?); + } + #[cfg(feature = "postgres")] for pg in self.postgres.iter_mut() { res.extend(pg.finalize()?); diff --git a/martin/src/srv/config.rs b/martin/src/srv/config.rs index bef28816..f40fbbc3 100644 --- a/martin/src/srv/config.rs +++ b/martin/src/srv/config.rs @@ -10,6 +10,7 @@ pub const LISTEN_ADDRESSES_DEFAULT: &str = "0.0.0.0:3000"; pub struct SrvConfig { pub keep_alive: Option, pub listen_addresses: Option, + pub base_path: Option, pub worker_processes: Option, pub preferred_encoding: Option, } @@ -35,6 +36,7 @@ mod tests { listen_addresses: some("0.0.0.0:3000"), worker_processes: Some(8), preferred_encoding: None, + base_path: None, } ); assert_eq!( @@ -50,6 +52,7 @@ mod tests { listen_addresses: some("0.0.0.0:3000"), worker_processes: Some(8), preferred_encoding: Some(PreferredEncoding::Brotli), + base_path: None } ); assert_eq!( @@ -65,6 +68,7 @@ mod tests { listen_addresses: some("0.0.0.0:3000"), worker_processes: Some(8), preferred_encoding: Some(PreferredEncoding::Brotli), + base_path: None, } ); } diff --git a/martin/src/srv/tiles_info.rs b/martin/src/srv/tiles_info.rs index e8445f82..049a27c4 100755 --- a/martin/src/srv/tiles_info.rs +++ b/martin/src/srv/tiles_info.rs @@ -9,6 +9,7 @@ use serde::Deserialize; use tilejson::{tilejson, TileJSON}; use crate::source::{Source, TileSources}; +use crate::srv::SrvConfig; #[derive(Deserialize)] pub struct SourceIDsRequest { @@ -26,17 +27,19 @@ async fn get_source_info( req: HttpRequest, path: Path, sources: Data, + srv_config: Data, ) -> ActixResult { let sources = sources.get_sources(&path.source_ids, None)?.0; - // Get `X-REWRITE-URL` header value, and extract its `path` component. - // If the header is not present or cannot be parsed as a URL, return the request path. - let tiles_path = req - .headers() - .get("x-rewrite-url") - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.parse::().ok()) - .map_or_else(|| req.path().to_owned(), |v| v.path().to_owned()); + let tiles_path = if let Some(base_path) = &srv_config.base_path { + format!("{base_path}/{}", path.source_ids) + } else { + req.headers() + .get("x-rewrite-url") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()) + .map_or_else(|| req.path().to_owned(), |v| v.path().to_owned()) + }; let query_string = req.query_string(); let path_and_query = if query_string.is_empty() { @@ -155,7 +158,7 @@ pub fn merge_tilejson(sources: &[&dyn Source], tiles_url: String) -> TileJSON { pub mod tests { use std::collections::BTreeMap; - use tilejson::{tilejson, Bounds, VectorLayer}; + use tilejson::{Bounds, VectorLayer}; use super::*; use crate::srv::server::tests::TestSource; diff --git a/martin/src/utils/error.rs b/martin/src/utils/error.rs index 2843eb45..0687356c 100644 --- a/martin/src/utils/error.rs +++ b/martin/src/utils/error.rs @@ -43,6 +43,9 @@ pub enum MartinError { #[error("Unable to bind to {1}: {0}")] BindingError(io::Error, String), + #[error("Base path must be a valid URL path, and must begin with a '/' symbol, but is '{0}'")] + BasePathError(String), + #[error("Unable to load config file {}: {0}", .1.display())] ConfigLoadError(io::Error, PathBuf), diff --git a/martin/src/utils/utilities.rs b/martin/src/utils/utilities.rs index 1bdb177b..7cc1156b 100644 --- a/martin/src/utils/utilities.rs +++ b/martin/src/utils/utilities.rs @@ -1,8 +1,12 @@ use std::io::{Read as _, Write as _}; +use actix_web::http::Uri; use flate2::read::GzDecoder; use flate2::write::GzEncoder; +use crate::MartinError::BasePathError; +use crate::MartinResult; + pub fn decode_gzip(data: &[u8]) -> Result, std::io::Error> { let mut decoder = GzDecoder::new(data); let mut decompressed = Vec::new(); @@ -28,3 +32,34 @@ pub fn encode_brotli(data: &[u8]) -> Result, std::io::Error> { encoder.write_all(data)?; Ok(encoder.into_inner()) } + +pub fn parse_base_path(path: &str) -> MartinResult { + if !path.starts_with('/') { + return Err(BasePathError(path.to_string())); + } + if let Ok(uri) = path.parse::() { + return Ok(uri.path().trim_end_matches('/').to_string()); + } + Err(BasePathError(path.to_string())) +} + +#[cfg(test)] +pub mod tests { + use crate::utils::parse_base_path; + #[test] + fn test_parse_base_path() { + for (path, expected) in [ + ("/", Some("")), + ("//", Some("")), + ("/foo/bar", Some("/foo/bar")), + ("/foo/bar/", Some("/foo/bar")), + ("", None), + ("foo/bar", None), + ] { + match expected { + Some(v) => assert_eq!(v, parse_base_path(path).unwrap()), + None => assert!(parse_base_path(path).is_err()), + } + } + } +}