From ed950266c2391432934129184d60d793b0a3ee12 Mon Sep 17 00:00:00 2001 From: Fernando Mino Date: Tue, 7 Jul 2026 16:18:43 -0500 Subject: [PATCH 1/2] [GEOS-12149] GeoServer WMTS capabilities put query parameters in the wrong place in generated URLs --- .../java/org/geowebcache/util/URLMangler.java | 32 +++ .../geowebcache/util/NullURLManglerTest.java | 15 ++ .../service/wmts/WMTSGetCapabilities.java | 91 +++++--- .../geowebcache/service/wmts/WMTSService.java | 15 +- .../service/wmts/WMTSTileJSON.java | 23 +- .../geowebcache/service/wmts/WMTSUrls.java | 46 ++++ .../geowebcache/service/wmts/WMTSUtils.java | 51 +++++ .../service/wmts/WMTSRestTest.java | 72 +++++- .../service/wmts/WMTSServiceTest.java | 210 +++++++++++++++++- .../service/wmts/WMTSUtilsTest.java | 62 ++++++ 10 files changed, 568 insertions(+), 49 deletions(-) create mode 100644 geowebcache/wmts/src/main/java/org/geowebcache/service/wmts/WMTSUrls.java diff --git a/geowebcache/core/src/main/java/org/geowebcache/util/URLMangler.java b/geowebcache/core/src/main/java/org/geowebcache/util/URLMangler.java index 8739b83921..9d8f8d9218 100644 --- a/geowebcache/core/src/main/java/org/geowebcache/util/URLMangler.java +++ b/geowebcache/core/src/main/java/org/geowebcache/util/URLMangler.java @@ -13,6 +13,9 @@ */ package org.geowebcache.util; +import java.util.Collections; +import java.util.Map; + /** * subset copied from org.geoserver.ows.URLMangler * @@ -29,4 +32,33 @@ public interface URLMangler { * @return the full generated url from the pieces */ public String buildURL(String baseURL, String contextPath, String path); + + /** + * Allows for a custom url generation strategy while also carrying query parameters separately from the path. + * + *

The default implementation preserves the legacy behavior and ignores the query parameter map. New callers + * should prefer overriding this method so propagated parameters can remain separated until the final URL is + * emitted. + * + * @param baseURL the base url, contains the url up to the domain and port + * @param contextPath the servlet context path, like /geoserver/gwc + * @param path the remaining path after the context path + * @param queryParameters propagated query parameters to preserve separately from the path + * @return the generated url (without serialized query parameters) together with the resulting query parameter map, + * so implementations return their result instead of mutating the {@code queryParameters} argument + */ + default UrlAndParams buildURL( + String baseURL, String contextPath, String path, Map queryParameters) { + return new UrlAndParams(buildURL(baseURL, contextPath, path), queryParameters); + } + + /** + * Result of {@link #buildURL(String, String, String, Map)}: the generated URL (without a serialized query string) + * and the query parameters that should eventually be appended to it. + */ + record UrlAndParams(String url, Map queryParameters) { + public UrlAndParams { + queryParameters = queryParameters == null ? Collections.emptyMap() : queryParameters; + } + } } diff --git a/geowebcache/core/src/test/java/org/geowebcache/util/NullURLManglerTest.java b/geowebcache/core/src/test/java/org/geowebcache/util/NullURLManglerTest.java index 6cf3f957ce..06601b093e 100644 --- a/geowebcache/core/src/test/java/org/geowebcache/util/NullURLManglerTest.java +++ b/geowebcache/core/src/test/java/org/geowebcache/util/NullURLManglerTest.java @@ -1,5 +1,7 @@ package org.geowebcache.util; +import java.util.LinkedHashMap; +import java.util.Map; import org.junit.Assert; import org.junit.Before; import org.junit.Test; @@ -48,4 +50,17 @@ public void testBuildEmptyContext() throws Exception { String url = urlMangler.buildURL("http://foo.example.com/", "", "/bar"); Assert.assertEquals("http://foo.example.com/bar", url); } + + @Test + public void testBuildWithQueryParametersLeavesMapUntouched() throws Exception { + Map queryParameters = new LinkedHashMap<>(); + queryParameters.put("projecttoken", "abc123"); + + URLMangler.UrlAndParams result = + urlMangler.buildURL("http://foo.example.com/", "/foo", "/bar", queryParameters); + + Assert.assertEquals("http://foo.example.com/foo/bar", result.url()); + Assert.assertEquals(1, result.queryParameters().size()); + Assert.assertEquals("abc123", result.queryParameters().get("projecttoken")); + } } diff --git a/geowebcache/wmts/src/main/java/org/geowebcache/service/wmts/WMTSGetCapabilities.java b/geowebcache/wmts/src/main/java/org/geowebcache/service/wmts/WMTSGetCapabilities.java index 536510f591..7ffaa7056c 100644 --- a/geowebcache/wmts/src/main/java/org/geowebcache/service/wmts/WMTSGetCapabilities.java +++ b/geowebcache/wmts/src/main/java/org/geowebcache/service/wmts/WMTSGetCapabilities.java @@ -66,9 +66,7 @@ public class WMTSGetCapabilities { private GridSetBroker gsb; - private String baseUrl; - - private String restBaseUrl; + private final WMTSUrls urls; private final Collection extensions; @@ -90,21 +88,31 @@ protected WMTSGetCapabilities( String contextPath, URLMangler urlMangler, Collection extensions) { + this(tld, gsb, servReq, buildUrls(servReq, baseUrl, contextPath, urlMangler), extensions); + } + + protected WMTSGetCapabilities( + TileLayerDispatcher tld, + GridSetBroker gsb, + HttpServletRequest servReq, + WMTSUrls urls, + Collection extensions) { this.tld = tld; this.gsb = gsb; + this.urls = urls; + this.extensions = extensions; + } + private static WMTSUrls buildUrls( + HttpServletRequest servReq, String baseUrl, String contextPath, URLMangler urlMangler) { String forcedBaseUrl = ServletUtils.stringFromMap(servReq.getParameterMap(), servReq.getCharacterEncoding(), "base_url"); - - if (forcedBaseUrl != null) { - this.baseUrl = forcedBaseUrl; - } else { - this.baseUrl = urlMangler.buildURL(baseUrl, contextPath, WMTSService.SERVICE_PATH); - } - - this.restBaseUrl = urlMangler.buildURL(baseUrl, contextPath, WMTSService.REST_PATH); - - this.extensions = extensions; + String effectiveBaseUrl = forcedBaseUrl != null ? forcedBaseUrl : baseUrl; + URLMangler.UrlAndParams service = + urlMangler.buildURL(effectiveBaseUrl, contextPath, WMTSService.SERVICE_PATH, Collections.emptyMap()); + URLMangler.UrlAndParams rest = + urlMangler.buildURL(effectiveBaseUrl, contextPath, WMTSService.REST_PATH, Collections.emptyMap()); + return new WMTSUrls(service.url(), service.queryParameters(), rest.url(), rest.queryParameters()); } protected void writeResponse(HttpServletResponse response, RuntimeStats stats) { @@ -169,11 +177,18 @@ private String generateGetCapabilities(Charset encoding) { contents(xml); xml.indentElement("ServiceMetadataURL") - .attribute("xlink:href", WMTSUtils.getKvpServiceMetadataURL(baseUrl)) + .attribute( + "xlink:href", + WMTSUtils.appendQueryParameters( + WMTSUtils.getKvpServiceMetadataURL(urls.serviceBaseUrl()), + urls.serviceQueryParameters())) .endElement(); xml.indentElement("ServiceMetadataURL") - .attribute("xlink:href", restBaseUrl + "/WMTSCapabilities.xml") + .attribute( + "xlink:href", + WMTSUtils.appendQueryParameters( + urls.restBaseUrl() + "/WMTSCapabilities.xml", urls.restQueryParameters())) .endElement(); xml.endElement("Capabilities"); @@ -365,9 +380,9 @@ private void serviceProvider(XMLBuilder xml, ServiceInformation servInfo) throws xml.endElement(); } } else { - appendTag(xml, "ows:ProviderName", baseUrl, null); + appendTag(xml, "ows:ProviderName", urls.serviceBaseUrl(), null); xml.indentElement("ows:ProviderSite") - .attribute("xlink:href", baseUrl) + .attribute("xlink:href", urls.serviceBaseUrl()) .endElement(); xml.indentElement("ows:ServiceContact"); appendTag(xml, "ows:IndividualName", "GeoWebCache User", null); @@ -379,9 +394,9 @@ private void serviceProvider(XMLBuilder xml, ServiceInformation servInfo) throws private void operationsMetadata(XMLBuilder xml) throws IOException { xml.indentElement("ows:OperationsMetadata"); - operation(xml, "GetCapabilities", baseUrl); - operation(xml, "GetTile", baseUrl); - operation(xml, "GetFeatureInfo", baseUrl); + operation(xml, "GetCapabilities", urls.serviceBaseUrl(), urls.serviceQueryParameters()); + operation(xml, "GetTile", urls.serviceBaseUrl(), urls.serviceQueryParameters()); + operation(xml, "GetFeatureInfo", urls.serviceBaseUrl(), urls.serviceQueryParameters()); // allow extension to inject their own metadata for (WMTSExtension extension : extensions) { List operationsMetaData = extension.getExtraOperationsMetadata(); @@ -390,7 +405,10 @@ private void operationsMetadata(XMLBuilder xml) throws IOException { operation( xml, operationMetadata.getName(), - operationMetadata.getBaseUrl() == null ? baseUrl : operationMetadata.getBaseUrl()); + operationMetadata.getBaseUrl() == null + ? urls.serviceBaseUrl() + : operationMetadata.getBaseUrl(), + urls.serviceQueryParameters()); } } extension.encodedOperationsMetadata(xml); @@ -398,14 +416,16 @@ private void operationsMetadata(XMLBuilder xml) throws IOException { xml.endElement("ows:OperationsMetadata"); } - private void operation(XMLBuilder xml, String operationName, String baseUrl) throws IOException { + private void operation(XMLBuilder xml, String operationName, String baseUrl, Map queryParameters) + throws IOException { xml.indentElement("ows:Operation").attribute("name", operationName); xml.indentElement("ows:DCP"); xml.indentElement("ows:HTTP"); - if (baseUrl.contains("?")) { - xml.indentElement("ows:Get").attribute("xlink:href", baseUrl + "&"); + String href = WMTSUtils.appendQueryParameters(baseUrl, queryParameters); + if (href.contains("?")) { + xml.indentElement("ows:Get").attribute("xlink:href", href + "&"); } else { - xml.indentElement("ows:Get").attribute("xlink:href", baseUrl + "?"); + xml.indentElement("ows:Get").attribute("xlink:href", href + "?"); } xml.indentElement("ows:Constraint").attribute("name", "GetEncoding"); xml.indentElement("ows:AllowedValues"); @@ -426,7 +446,7 @@ private void contents(XMLBuilder xml) throws IOException { if (!layer.isEnabled() || !layer.isAdvertised()) { continue; } - layer(xml, layer, baseUrl, usedGridsets); + layer(xml, layer, urls.serviceBaseUrl(), usedGridsets); } // only dump the gridsets actually used, as the OGC TMS spec introduced many default ones @@ -488,7 +508,7 @@ private void layer(XMLBuilder xml, TileLayer layer, String baseurl, Set layerGridSubSets(xml, layer, usedGridsets); - layerResourceUrls(xml, layer, filters, restBaseUrl); + layerResourceUrls(xml, layer, filters, urls.restBaseUrl(), urls.restQueryParameters()); // allow extensions to contribute extra metadata to this layer for (WMTSExtension extension : extensions) { @@ -712,7 +732,12 @@ private void layerGridSubSets(XMLBuilder xml, TileLayer layer, Set used * For each layer discovers the available image formats, feature info formats and dimensions and produce the * necessary elements. */ - private void layerResourceUrls(XMLBuilder xml, TileLayer layer, List filters, String baseurl) + private void layerResourceUrls( + XMLBuilder xml, + TileLayer layer, + List filters, + String baseurl, + Map queryParameters) throws IOException { String baseTemplate = baseurl + "/" + layer.getName(); String commonTemplate = baseTemplate + "/{style}/{TileMatrixSet}/{TileMatrix}/{TileRow}/{TileCol}"; @@ -728,13 +753,15 @@ private void layerResourceUrls(XMLBuilder xml, TileLayer layer, List mimeFormats = WMTSUtils.getLayerFormats(layer); for (String format : mimeFormats) { - String template = commonTemplate + "?format=" + format + commonDimensions; + String template = WMTSUtils.appendQueryParameters( + commonTemplate + "?format=" + format + commonDimensions, queryParameters); layerResourceUrlsGen(xml, format, "tile", template); } // Extracts feature info formats List infoFormats = WMTSUtils.getInfoFormats(layer); for (String format : infoFormats) { - String template = commonTemplate + "/{J}/{I}?format=" + format + commonDimensions; + String template = WMTSUtils.appendQueryParameters( + commonTemplate + "/{J}/{I}?format=" + format + commonDimensions, queryParameters); layerResourceUrlsGen(xml, format, "FeatureInfo", template); } if (layer instanceof TileJSONProvider provider) { @@ -742,7 +769,9 @@ private void layerResourceUrls(XMLBuilder xml, TileLayer layer, List urls = new ArrayList<>(); + List tileUrls = new ArrayList<>(); MimeType mimeType = convTile.getMimeType(); Set gridSubSets = layer.getGridSubsets(); for (String gridSubSet : gridSubSets) { - addTileUrl(layer, gridSubSet, mimeType, urls); + addTileUrl(layer, gridSubSet, mimeType, tileUrls); } List vectorLayers = json.getLayers(); if (vectorLayers != null && !vectorLayers.isEmpty() && !mimeType.isVector()) { @@ -68,8 +66,8 @@ public void writeResponse(TileLayer layer) { json.setLayers(null); } - String[] tileUrls = urls.toArray(new String[urls.size()]); - json.setTiles(tileUrls); + String[] serializedTileUrls = tileUrls.toArray(new String[tileUrls.size()]); + json.setTiles(serializedTileUrls); convTile.servletResp.setStatus(HttpServletResponse.SC_OK); convTile.servletResp.setContentType(ApplicationMime.json.getMimeType()); @@ -87,7 +85,7 @@ public void writeResponse(TileLayer layer) { } } - private void addTileUrl(TileLayer layer, String gridSubSet, MimeType mimeType, List urls) { + private void addTileUrl(TileLayer layer, String gridSubSet, MimeType mimeType, List tileUrls) { GridSubset grid = layer.getGridSubset(gridSubSet); int zoomLevelStart = -1; int start = -1; @@ -108,7 +106,7 @@ private void addTileUrl(TileLayer layer, String gridSubSet, MimeType mimeType, L zoomLevelStart = start; } - String tileUrl = restBaseUrl + String tileUrl = this.urls.restBaseUrl() + "/" + layer.getName() + "/" @@ -120,6 +118,7 @@ private void addTileUrl(TileLayer layer, String gridSubSet, MimeType mimeType, L + "/{y}/{x}" + "?format=" + mimeType; - urls.add(tileUrl); + tileUrl = WMTSUtils.appendQueryParameters(tileUrl, this.urls.restQueryParameters()); + tileUrls.add(tileUrl); } } diff --git a/geowebcache/wmts/src/main/java/org/geowebcache/service/wmts/WMTSUrls.java b/geowebcache/wmts/src/main/java/org/geowebcache/service/wmts/WMTSUrls.java new file mode 100644 index 0000000000..fa5e654f4d --- /dev/null +++ b/geowebcache/wmts/src/main/java/org/geowebcache/service/wmts/WMTSUrls.java @@ -0,0 +1,46 @@ +/** + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General + * Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any + * later version. + * + *

This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied + * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + *

You should have received a copy of the GNU Lesser General Public License along with this program. If not, see + * . + * + * @author Fernando Mino, GeoSolutions S.A.S., Copyright 2026 + */ +package org.geowebcache.service.wmts; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Holds the WMTS URLs needed while generating capabilities and related backlinks. + * + *

The service and REST endpoints are carried separately so their query parameters can remain mutable until the final + * URL serialization step. This avoids injecting propagated parameters into the path segment of template URLs and lets + * the callers append them only once the full path has already been resolved. + * + * @param serviceBaseUrl base URL for the KVP service endpoint + * @param serviceQueryParameters propagated query parameters to append to the service endpoint + * @param restBaseUrl base URL for the REST endpoint + * @param restQueryParameters propagated query parameters to append to the REST endpoint + */ +public record WMTSUrls( + String serviceBaseUrl, + Map serviceQueryParameters, + String restBaseUrl, + Map restQueryParameters) { + + public WMTSUrls { + serviceQueryParameters = serviceQueryParameters == null + ? Collections.emptyMap() + : Collections.unmodifiableMap(new LinkedHashMap<>(serviceQueryParameters)); + restQueryParameters = restQueryParameters == null + ? Collections.emptyMap() + : Collections.unmodifiableMap(new LinkedHashMap<>(restQueryParameters)); + } +} diff --git a/geowebcache/wmts/src/main/java/org/geowebcache/service/wmts/WMTSUtils.java b/geowebcache/wmts/src/main/java/org/geowebcache/service/wmts/WMTSUtils.java index 8b130791d8..d170b98ac4 100644 --- a/geowebcache/wmts/src/main/java/org/geowebcache/service/wmts/WMTSUtils.java +++ b/geowebcache/wmts/src/main/java/org/geowebcache/service/wmts/WMTSUtils.java @@ -16,10 +16,12 @@ import java.io.IOException; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; import org.geowebcache.filter.parameters.ParameterFilter; import org.geowebcache.layer.TileLayer; import org.geowebcache.mime.MimeType; +import org.geowebcache.util.ServletUtils; final class WMTSUtils { @@ -73,4 +75,53 @@ public static String getKvpServiceMetadataURL(String baseUrl) { return base + "SERVICE=wmts&REQUEST=getcapabilities&VERSION=1.0.0" + anchor; } + + /** + * Appends propagated query parameters to a URL while preserving the existing query string order and any anchor. + * + *

This helper stays local to WMTS because capability templates can still contain unresolved path placeholders + * such as {@code {TileRow}} or {@code {TileMatrixSet}} at the point where the final backlink URL is serialized. + * General-purpose URI builders such as Apache HttpComponents {@code URIBuilder} reject those template URLs as + * invalid URIs, so WMTS needs a lightweight string-based helper that keeps the path intact and appends the + * propagated query map at the end of the URL. + * + *

The method preserves the original path, keeps any fragment suffix at the end, and serializes the provided map + * in iteration order. It does not normalize or deduplicate template path components, but it does percent-encode + * each appended key and value so that propagated parameters (such as security tokens) cannot corrupt the query + * string or inject additional parameters. + */ + public static String appendQueryParameters(String url, Map queryParameters) { + if (queryParameters == null || queryParameters.isEmpty()) { + return url; + } + + String base = url; + String anchor = ""; + int anchorIndex = base.indexOf('#'); + if (anchorIndex != -1) { + anchor = base.substring(anchorIndex); + base = base.substring(0, anchorIndex); + } + + StringBuilder query = new StringBuilder(); + for (Map.Entry entry : queryParameters.entrySet()) { + if (!query.isEmpty()) { + query.append("&"); + } + query.append(ServletUtils.URLEncode(entry.getKey())).append("="); + if (entry.getValue() != null) { + query.append(ServletUtils.URLEncode(entry.getValue())); + } + } + + if (base.endsWith("?") || base.endsWith("&")) { + return base + query + anchor; + } + + if (base.contains("?")) { + return base + "&" + query + anchor; + } + + return base + "?" + query + anchor; + } } diff --git a/geowebcache/wmts/src/test/java/org/geowebcache/service/wmts/WMTSRestTest.java b/geowebcache/wmts/src/test/java/org/geowebcache/service/wmts/WMTSRestTest.java index d7f54c83fe..4735260d20 100644 --- a/geowebcache/wmts/src/test/java/org/geowebcache/service/wmts/WMTSRestTest.java +++ b/geowebcache/wmts/src/test/java/org/geowebcache/service/wmts/WMTSRestTest.java @@ -70,6 +70,7 @@ import org.geowebcache.stats.RuntimeStats; import org.geowebcache.storage.StorageBroker; import org.geowebcache.util.ResponseUtils; +import org.geowebcache.util.URLMangler; import org.junit.Before; import org.junit.BeforeClass; import org.junit.Test; @@ -207,6 +208,47 @@ public void testGetTileJSONWithoutStyle() throws Exception { assertTrue(content.contains("vector_layers")); } + /** + * Verifies that WMTS REST capabilities and TileJSON links append the propagated project token after the existing + * endpoint query parameters, while leaving the template path portion untouched. + */ + @Test + public void testProjectTokenPlacement() throws Exception { + WMTSService tokenService = wmtsServiceWithProjectToken(); + + MockHttpServletRequest capReq = new MockHttpServletRequest(); + capReq.setPathInfo("geowebcache/service/wmts/rest/WMTSCapabilities.xml"); + MockHttpServletResponse capResp = dispatch(capReq, tokenService); + + assertEquals(200, capResp.getStatus()); + Document capDoc = XMLUnit.buildTestDocument(capResp.getContentAsString()); + assertXpathExists( + "//wmts:Contents/wmts:Layer/wmts:ResourceURL[@resourceType='tile']" + + "[@format='image/jpeg']" + + "[@template='http://localhost/service/wmts/rest/mockLayer/{style}/{TileMatrixSet}/{TileMatrix}/{TileRow}/{TileCol}?format=image/jpeg&time={time}&elevation={elevation}&projecttoken=abc123']", + capDoc); + assertXpathExists( + "//wmts:Contents/wmts:Layer/wmts:ResourceURL[@resourceType='tile']" + + "[@format='image/png']" + + "[@template='http://localhost/service/wmts/rest/mockLayer/{style}/{TileMatrixSet}/{TileMatrix}/{TileRow}/{TileCol}?format=image/png&time={time}&elevation={elevation}&projecttoken=abc123']", + capDoc); + assertXpathExists( + "//wmts:ServiceMetadataURL[@xlink:href='http://localhost/service/wmts/rest/WMTSCapabilities.xml?projecttoken=abc123']", + capDoc); + + addTileLayerJsonMock("image/png"); + MockHttpServletRequest tileJsonReq = new MockHttpServletRequest(); + tileJsonReq.setPathInfo("geowebcache/service/wmts/rest/mockLayerTileJSON/style-a/tilejson/png"); + tileJsonReq.addParameter("format", "application/json"); + MockHttpServletResponse tileJsonResp = dispatch(tileJsonReq, tokenService); + + assertEquals(200, tileJsonResp.getStatus()); + String tileJsonContent = tileJsonResp.getContentAsString(); + assertTrue( + tileJsonContent.contains( + "\"tiles\":[\"http://localhost/service/wmts/rest/mockLayerTileJSON/style-a/EPSG:900913/EPSG:900913:{z}/{y}/{x}?format=image/png&projecttoken=abc123\"]")); + } + private void addTileLayerJsonMock(String mimeType) throws GeoWebCacheException { final MimeType mimeType1 = MimeType.createFromFormat(mimeType); @@ -236,9 +278,13 @@ private void addTileLayerJsonMock(String mimeType) throws GeoWebCacheException { } public MockHttpServletResponse dispatch(MockHttpServletRequest req) throws Exception { + return dispatch(req, wmtsService); + } + + private MockHttpServletResponse dispatch(MockHttpServletRequest req, WMTSService service) throws Exception { MockHttpServletResponse resp = new MockHttpServletResponse(); try { - final Conveyor conveyor = wmtsService.getConveyor(req, resp); + final Conveyor conveyor = service.getConveyor(req, resp); if (conveyor.reqHandler == Conveyor.RequestHandler.SERVICE) { final String layerName = conveyor.getLayerId(); @@ -254,7 +300,7 @@ public MockHttpServletResponse dispatch(MockHttpServletRequest req) throws Excep tile.setTileLayer(layer); } } - wmtsService.handleRequest(conveyor); + service.handleRequest(conveyor); } else { ResponseUtils.writeTile( securityDispatcher(), @@ -277,6 +323,28 @@ public MockHttpServletResponse dispatch(MockHttpServletRequest req) throws Excep return resp; } + private WMTSService wmtsServiceWithProjectToken() { + URLMangler tokenMangler = new URLMangler() { + @Override + public String buildURL(String baseURL, String contextPath, String path) { + String context = contextPath == null ? "" : contextPath; + return baseURL + context + path; + } + + @Override + public UrlAndParams buildURL( + String baseURL, String contextPath, String path, Map queryParameters) { + Map resultParameters = new HashMap<>(queryParameters); + resultParameters.put("projecttoken", "abc123"); + return new UrlAndParams(buildURL(baseURL, contextPath, path), resultParameters); + } + }; + WMTSService service = + new WMTSService(storageBroker, tileLayerDispatcher, broker, runtimeStats, tokenMangler, null); + service.setSecurityDispatcher(securityDispatcher); + return service; + } + @Test public void testGetTileWithoutStyle() throws Exception { MockHttpServletRequest req = new MockHttpServletRequest(); diff --git a/geowebcache/wmts/src/test/java/org/geowebcache/service/wmts/WMTSServiceTest.java b/geowebcache/wmts/src/test/java/org/geowebcache/service/wmts/WMTSServiceTest.java index affc2d431d..ba4a7988f3 100644 --- a/geowebcache/wmts/src/test/java/org/geowebcache/service/wmts/WMTSServiceTest.java +++ b/geowebcache/wmts/src/test/java/org/geowebcache/service/wmts/WMTSServiceTest.java @@ -4,6 +4,7 @@ import static org.hamcrest.CoreMatchers.notNullValue; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.arrayContaining; +import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.equalToIgnoringCase; @@ -14,6 +15,7 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNotSame; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; @@ -41,6 +43,7 @@ import java.util.Map; import org.apache.commons.collections4.map.CaseInsensitiveMap; import org.apache.commons.io.FileUtils; +import org.apache.commons.lang3.StringUtils; import org.custommonkey.xmlunit.SimpleNamespaceContext; import org.custommonkey.xmlunit.XMLUnit; import org.custommonkey.xmlunit.XpathEngine; @@ -85,6 +88,7 @@ import org.geowebcache.stats.RuntimeStats; import org.geowebcache.storage.StorageBroker; import org.geowebcache.util.NullURLMangler; +import org.geowebcache.util.URLMangler; import org.geowebcache.util.URLs; import org.junit.Before; import org.junit.Rule; @@ -1346,6 +1350,203 @@ public void testGetFeatureInfoSecure() throws Exception { } } + /** + * Verifies that GetCapabilities builds distinct query parameter maps for the service and REST URLs so each one can + * be mutated independently before final serialization. + */ + @Test + public void testGetCapabilitiesQueryMaps() throws Exception { + ProjectTokenCapabilitiesResult result = runProjectTokenCapabilitiesScenario(); + + // Expect two separate invocations: one for the service backlink and one for + // the REST capabilities backlink. + assertEquals(2, result.capturedQueryMaps.size()); + assertEquals(2, result.capturedPaths.size()); + assertThat(result.capturedPaths, contains(WMTSService.SERVICE_PATH, WMTSService.REST_PATH)); + + // The two query maps must not be shared, and both must carry the propagated + // project token before serialization. + assertNotSame(result.capturedQueryMaps.get(0), result.capturedQueryMaps.get(1)); + assertEquals("abc123", result.capturedQueryMaps.get(0).get("projecttoken")); + assertEquals("abc123", result.capturedQueryMaps.get(1).get("projecttoken")); + } + + /** + * Verifies that GetCapabilities serializes WMTS backlinks with propagated security parameters appended after the + * endpoint-specific query parameters. + */ + @Test + public void testGetCapabilitiesQueryParamsPlacement() throws Exception { + ProjectTokenCapabilitiesResult result = runProjectTokenCapabilitiesScenario(); + Document doc = result.document; + XpathEngine xpath = buildWMTSXPath(); + + assertEquals( + "1", + xpath.evaluate( + "count(//wmts:Contents/wmts:Layer/wmts:ResourceURL[@resourceType='tile']" + + "[@format='image/png']" + + "[contains(@template,'/mockLayer/{style}/{TileMatrixSet}/{TileMatrix}/{TileRow}/{TileCol}?format=image/png&time={time}&elevation={elevation}&projecttoken=abc123')])", + doc)); + assertEquals( + "1", + xpath.evaluate( + "count(//wmts:Contents/wmts:Layer/wmts:ResourceURL[@resourceType='FeatureInfo']" + + "[@format='text/plain']" + + "[contains(@template,'/mockLayer/{style}/{TileMatrixSet}/{TileMatrix}/{TileRow}/{TileCol}/{J}/{I}?format=text/plain&time={time}&elevation={elevation}&projecttoken=abc123')])", + doc)); + assertEquals( + "1", + xpath.evaluate( + "count(//wmts:Contents/wmts:Layer/wmts:ResourceURL[@resourceType='TileJSON']" + + "[@format='application/json']" + + "[contains(@template,'/mockLayer/{style}/tilejson/png?format=application/json&projecttoken=abc123')])", + doc)); + assertEquals( + "1", + xpath.evaluate( + "count(//wmts:ServiceMetadataURL[contains(@xlink:href,'SERVICE=wmts&REQUEST=getcapabilities&VERSION=1.0.0&projecttoken=abc123')])", + doc)); + assertEquals( + "1", + xpath.evaluate( + "count(//wmts:ServiceMetadataURL[contains(@xlink:href,'/WMTSCapabilities.xml?projecttoken=abc123')])", + doc)); + assertEquals( + "1", + xpath.evaluate( + "count(//ows:OperationsMetadata/ows:Operation[@name='GetCapabilities']" + + "/ows:DCP/ows:HTTP/ows:Get[contains(@xlink:href,'?projecttoken=abc123&')])", + doc)); + assertEquals( + "1", + xpath.evaluate( + "count(//ows:OperationsMetadata/ows:Operation[@name='GetTile']" + + "/ows:DCP/ows:HTTP/ows:Get[contains(@xlink:href,'?projecttoken=abc123&')])", + doc)); + assertEquals( + "1", + xpath.evaluate( + "count(//ows:OperationsMetadata/ows:Operation[@name='GetFeatureInfo']" + + "/ows:DCP/ows:HTTP/ows:Get[contains(@xlink:href,'?projecttoken=abc123&')])", + doc)); + } + + /** + * Verifies that {@code operationsMetadata} propagates the service query parameters (e.g. the security token added + * by a registered {@link URLMangler}) to a {@code WMTSExtension}-supplied {@code OperationMetadata} even when that + * extension provides its own custom base URL instead of {@code urls.serviceBaseUrl()}. + */ + @Test + public void testGetCapabilitiesQueryParamPropagatesToExtensionCustomBaseUrl() throws Exception { + WMTSExtension extension = new WMTSExtensionImpl() { + @Override + public List getExtraOperationsMetadata() throws IOException { + return Collections.singletonList( + new OperationMetadata("ExtraOperation", "http://extension.example.com/custom")); + } + }; + + ProjectTokenCapabilitiesResult result = + runProjectTokenCapabilitiesScenario(Collections.singletonList(extension)); + XpathEngine xpath = buildWMTSXPath(); + + assertEquals( + "1", + xpath.evaluate( + "count(//ows:OperationsMetadata/ows:Operation[@name='ExtraOperation']" + + "/ows:DCP/ows:HTTP/ows:Get[@xlink:href='http://extension.example.com/custom?projecttoken=abc123&'])", + result.document)); + } + + private ProjectTokenCapabilitiesResult runProjectTokenCapabilitiesScenario() throws Exception { + return runProjectTokenCapabilitiesScenario(Collections.emptyList()); + } + + private ProjectTokenCapabilitiesResult runProjectTokenCapabilitiesScenario(List extraExtensions) + throws Exception { + // Capture the paths and query maps passed to the URL mangler so we can + // verify that service and REST backlinks are built from separate inputs. + List> capturedQueryMaps = new ArrayList<>(); + List capturedPaths = new ArrayList<>(); + // Record the WMTS URL-building calls while still returning a plausible URL + // string so the GetCapabilities flow can complete normally. + URLMangler recordingUrlMangler = new URLMangler() { + @Override + public String buildURL(String baseURL, String contextPath, String path) { + return StringUtils.strip(baseURL, "/") + + "/" + + StringUtils.strip(contextPath, "/") + + "/" + + StringUtils.strip(path, "/"); + } + + @Override + public UrlAndParams buildURL( + String baseURL, String contextPath, String path, Map queryParameters) { + capturedPaths.add(path); + Map resultParameters = new HashMap<>(queryParameters); + resultParameters.put("projecttoken", "abc123"); + capturedQueryMaps.add(resultParameters); + return new UrlAndParams(buildURL(baseURL, contextPath, path), resultParameters); + } + }; + // Wire the service with the recording mangler and a minimal dispatcher so we + // can run the GetCapabilities request end to end. + GeoWebCacheDispatcher gwcd = mock(GeoWebCacheDispatcher.class); + when(gwcd.getServletPrefix()).thenReturn(null); + service = new WMTSService(sb, tld, gridsetBroker, mock(RuntimeStats.class), recordingUrlMangler, gwcd); + extraExtensions.forEach(service::addExtension); + + // Provide one layer and one gridset so the capabilities document includes the + // backlinks that exercise this code path. + List gridSetNames = Arrays.asList("EPSG:900913"); + TileLayer tileLayer = mockTileLayerWithJSONSupport("mockLayer", gridSetNames); + when(tileLayer.getInfoMimeTypes()) + .thenReturn(Collections.singletonList(MimeType.createFromFormat("text/plain"))); + ParameterFilter time = mock(ParameterFilter.class); + when(time.isUserVisible()).thenReturn(true); + when(time.getKey()).thenReturn("time"); + when(time.getDefaultValue()).thenReturn("2024-01-01"); + when(time.getLegalValues()).thenReturn(Collections.singletonList("2024-01-01")); + ParameterFilter elevation = mock(ParameterFilter.class); + when(elevation.isUserVisible()).thenReturn(true); + when(elevation.getKey()).thenReturn("elevation"); + when(elevation.getDefaultValue()).thenReturn("0"); + when(elevation.getLegalValues()).thenReturn(Collections.singletonList("0")); + when(tileLayer.getParameterFilters()).thenReturn(Arrays.asList(time, elevation)); + when(tld.getLayerList()).thenReturn(Collections.singletonList(tileLayer)); + when(tld.getLayerListFiltered()).thenReturn(Collections.singletonList(tileLayer)); + + // Build a GetCapabilities request and let the WMTS service handle it. + MockHttpServletRequest req = new MockHttpServletRequest(); + req.setPathInfo("geowebcache/service/wmts"); + req.addParameter("service", "WMTS"); + req.addParameter("request", "GetCapabilities"); + MockHttpServletResponse resp = new MockHttpServletResponse(); + + Conveyor conv = service.getConveyor(req, resp); + assertNotNull(conv); + + service.handleRequest(conv); + + return new ProjectTokenCapabilitiesResult( + capturedQueryMaps, capturedPaths, XMLUnit.buildTestDocument(resp.getContentAsString())); + } + + private static final class ProjectTokenCapabilitiesResult { + private final List> capturedQueryMaps; + private final List capturedPaths; + private final Document document; + + private ProjectTokenCapabilitiesResult( + List> capturedQueryMaps, List capturedPaths, Document document) { + this.capturedQueryMaps = capturedQueryMaps; + this.capturedPaths = capturedPaths; + this.document = document; + } + } + @Test public void testGetCapWithTileJSONDifferentUrls() throws Exception { @@ -1431,7 +1632,14 @@ public void testGetCapWithTileJSONDifferentUrls() throws Exception { private String writeTileJsonResponse(ConveyorTile conv, TileLayer tileLayer, MockHttpServletResponse resp) throws UnsupportedEncodingException { - WMTSTileJSON tileJSON = new WMTSTileJSON(conv, "http://localhost", "", null, new NullURLMangler()); + WMTSTileJSON tileJSON = new WMTSTileJSON( + conv, + new WMTSUrls( + "http://localhost/service/wmts", + Collections.emptyMap(), + "http://localhost/service/wmts/rest", + Collections.emptyMap()), + null); tileJSON.writeResponse(tileLayer); return resp.getContentAsString(); } diff --git a/geowebcache/wmts/src/test/java/org/geowebcache/service/wmts/WMTSUtilsTest.java b/geowebcache/wmts/src/test/java/org/geowebcache/service/wmts/WMTSUtilsTest.java index caec299b45..41285599aa 100644 --- a/geowebcache/wmts/src/test/java/org/geowebcache/service/wmts/WMTSUtilsTest.java +++ b/geowebcache/wmts/src/test/java/org/geowebcache/service/wmts/WMTSUtilsTest.java @@ -2,6 +2,8 @@ import static org.junit.Assert.assertEquals; +import java.util.LinkedHashMap; +import java.util.Map; import org.junit.Test; public class WMTSUtilsTest { @@ -55,4 +57,64 @@ public void testKvpServiceMetadataURLMultipleParameters_manyAmpersands_withAncho "https://www.foo.com/?bar=doo&dii=daa&SERVICE=wmts&REQUEST=getcapabilities&VERSION=1.0.0#hello", result); } + + /** + * Verifies that query parameters are appended to a bare WMTS URL and that the first parameter starts with a + * question mark. + */ + @Test + public void testAppendQueryParametersNoQuery() { + Map params = new LinkedHashMap<>(); + params.put("projecttoken", "abc123"); + + String result = WMTSUtils.appendQueryParameters("https://www.foo.com/wmts/rest/WMTSCapabilities.xml", params); + + assertEquals("https://www.foo.com/wmts/rest/WMTSCapabilities.xml?projecttoken=abc123", result); + } + + /** Verifies that query parameters are appended after an existing query string using an ampersand separator. */ + @Test + public void testAppendQueryParametersWithQuery() { + Map params = new LinkedHashMap<>(); + params.put("projecttoken", "abc123"); + + String result = WMTSUtils.appendQueryParameters( + "https://www.foo.com/wmts/rest/WMTSCapabilities.xml?format=image/png", params); + + assertEquals("https://www.foo.com/wmts/rest/WMTSCapabilities.xml?format=image/png&projecttoken=abc123", result); + } + + /** + * Verifies that propagated WMTS query parameters are appended in the same order they were provided and that the + * existing URL path remains unchanged. Values are percent-encoded, so reserved characters such as {@code /}, + * {@code {} and {@code }} show up encoded in the expected result. + */ + @Test + public void testAppendQueryParametersOrder() { + Map params = new LinkedHashMap<>(); + params.put("format", "image/png"); + params.put("time", "{time}"); + params.put("elevation", "{elevation}"); + params.put("projecttoken", "abc123"); + + String result = WMTSUtils.appendQueryParameters("https://www.foo.com/wmts/rest/layer", params); + + assertEquals( + "https://www.foo.com/wmts/rest/layer?format=image%2Fpng&time=%7Btime%7D&elevation=%7Belevation%7D&projecttoken=abc123", + result); + } + + /** + * Verifies that a propagated parameter value containing reserved URL characters (such as {@code &} and {@code =}) + * is percent-encoded rather than being allowed to inject additional query parameters. + */ + @Test + public void testAppendQueryParametersEncodesReservedCharacters() { + Map params = new LinkedHashMap<>(); + params.put("projecttoken", "abc&evil=1"); + + String result = WMTSUtils.appendQueryParameters("https://www.foo.com/wmts/rest/layer", params); + + assertEquals("https://www.foo.com/wmts/rest/layer?projecttoken=abc%26evil%3D1", result); + } } From b21224cf088e41fd64db1e25638b1e11801a2554 Mon Sep 17 00:00:00 2001 From: Fernando Mino Date: Mon, 13 Jul 2026 15:16:06 -0500 Subject: [PATCH 2/2] Review fixes --- .../org/geowebcache/util/NullURLMangler.java | 14 +- .../java/org/geowebcache/util/URLMangler.java | 52 ++------ .../org/geowebcache/util/URLManglerUtils.java | 82 ++++++++++++ .../geowebcache/util/NullURLManglerTest.java | 68 +++++----- .../geowebcache/util/URLManglerUtilsTest.java | 81 ++++++++++++ .../service/tms/TMSDocumentFactory.java | 13 +- .../service/tms/TMSServiceTest.java | 27 ++-- .../service/wms/WMSGetCapabilities.java | 9 +- .../service/wmts/WMTSGetCapabilities.java | 75 ++++------- .../geowebcache/service/wmts/WMTSService.java | 15 +-- .../service/wmts/WMTSTileJSON.java | 24 ++-- .../service/wmts/WMTSUrlBuilder.java | 70 ++++++++++ .../geowebcache/service/wmts/WMTSUrls.java | 46 ------- .../geowebcache/service/wmts/WMTSUtils.java | 78 ------------ .../service/wmts/WMTSRestTest.java | 17 +-- .../service/wmts/WMTSServiceTest.java | 90 ++++++++----- .../service/wmts/WMTSUtilsTest.java | 120 ------------------ 17 files changed, 422 insertions(+), 459 deletions(-) create mode 100644 geowebcache/core/src/main/java/org/geowebcache/util/URLManglerUtils.java create mode 100644 geowebcache/core/src/test/java/org/geowebcache/util/URLManglerUtilsTest.java create mode 100644 geowebcache/wmts/src/main/java/org/geowebcache/service/wmts/WMTSUrlBuilder.java delete mode 100644 geowebcache/wmts/src/main/java/org/geowebcache/service/wmts/WMTSUrls.java delete mode 100644 geowebcache/wmts/src/test/java/org/geowebcache/service/wmts/WMTSUtilsTest.java diff --git a/geowebcache/core/src/main/java/org/geowebcache/util/NullURLMangler.java b/geowebcache/core/src/main/java/org/geowebcache/util/NullURLMangler.java index 6d6b98eba7..ae87cf5241 100644 --- a/geowebcache/core/src/main/java/org/geowebcache/util/NullURLMangler.java +++ b/geowebcache/core/src/main/java/org/geowebcache/util/NullURLMangler.java @@ -13,20 +13,12 @@ */ package org.geowebcache.util; -import org.apache.commons.lang3.StringUtils; +import java.util.Map; public class NullURLMangler implements URLMangler { @Override - public String buildURL(String baseURL, String contextPath, String path) { - final String context = StringUtils.strip(contextPath, "/"); - - // if context is root ("/") then don't append it to prevent double slashes ("//") in return - // URLs - if (context == null || context.isEmpty()) { - return StringUtils.strip(baseURL, "/") + "/" + StringUtils.strip(path, "/"); - } else { - return StringUtils.strip(baseURL, "/") + "/" + context + "/" + StringUtils.strip(path, "/"); - } + public void mangleURL(StringBuilder baseURL, StringBuilder path, Map kvp, URLType type) { + // Default URL assembly is handled by URLManglerUtils. } } diff --git a/geowebcache/core/src/main/java/org/geowebcache/util/URLMangler.java b/geowebcache/core/src/main/java/org/geowebcache/util/URLMangler.java index 9d8f8d9218..29410c7583 100644 --- a/geowebcache/core/src/main/java/org/geowebcache/util/URLMangler.java +++ b/geowebcache/core/src/main/java/org/geowebcache/util/URLMangler.java @@ -13,52 +13,24 @@ */ package org.geowebcache.util; -import java.util.Collections; import java.util.Map; -/** - * subset copied from org.geoserver.ows.URLMangler - * - *

This hook allows others to plug in custom url generation. - */ +/** Hook allowing custom URL generation and mangling. */ public interface URLMangler { - /** - * Allows for a custom url generation strategy - * - * @param baseURL the base url - contains the url up to the domain and port - * @param contextPath the servlet context path, like /geoserver/gwc - * @param path the remaining path after the context path - * @return the full generated url from the pieces - */ - public String buildURL(String baseURL, String contextPath, String path); - - /** - * Allows for a custom url generation strategy while also carrying query parameters separately from the path. - * - *

The default implementation preserves the legacy behavior and ignores the query parameter map. New callers - * should prefer overriding this method so propagated parameters can remain separated until the final URL is - * emitted. - * - * @param baseURL the base url, contains the url up to the domain and port - * @param contextPath the servlet context path, like /geoserver/gwc - * @param path the remaining path after the context path - * @param queryParameters propagated query parameters to preserve separately from the path - * @return the generated url (without serialized query parameters) together with the resulting query parameter map, - * so implementations return their result instead of mutating the {@code queryParameters} argument - */ - default UrlAndParams buildURL( - String baseURL, String contextPath, String path, Map queryParameters) { - return new UrlAndParams(buildURL(baseURL, contextPath, path), queryParameters); + enum URLType { + EXTERNAL, + RESOURCE, + SERVICE } /** - * Result of {@link #buildURL(String, String, String, Map)}: the generated URL (without a serialized query string) - * and the query parameters that should eventually be appended to it. + * Callback that can change the base URL, path, or query parameter map before URL serialization. + * + * @param baseURL mutable base URL buffer containing host, port, and application base + * @param path mutable path buffer after the application name + * @param kvp mutable GET request parameters, which may be enriched or modified + * @param type URL type for consideration during mangling */ - record UrlAndParams(String url, Map queryParameters) { - public UrlAndParams { - queryParameters = queryParameters == null ? Collections.emptyMap() : queryParameters; - } - } + void mangleURL(StringBuilder baseURL, StringBuilder path, Map kvp, URLType type); } diff --git a/geowebcache/core/src/main/java/org/geowebcache/util/URLManglerUtils.java b/geowebcache/core/src/main/java/org/geowebcache/util/URLManglerUtils.java new file mode 100644 index 0000000000..9bfb4d44b2 --- /dev/null +++ b/geowebcache/core/src/main/java/org/geowebcache/util/URLManglerUtils.java @@ -0,0 +1,82 @@ +/** + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General + * Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any + * later version. + * + *

This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied + * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + *

You should have received a copy of the GNU Lesser General Public License along with this program. If not, see + * . + * + * @author Fernando Mino, GeoSolutions, Copyright 2026 + */ +package org.geowebcache.util; + +import java.util.LinkedHashMap; +import java.util.Map; +import org.apache.commons.lang3.StringUtils; + +/** Shared URL assembly for GeoWebCache URL manglers. */ +public final class URLManglerUtils { + + private URLManglerUtils() {} + + /** + * Builds a URL after allowing one mangler to mutate its base, path, and query parameters. + * + *

Query parameters embedded in {@code path} remain in place; parameters from {@code kvp} are appended after them + * and before any fragment. The input map is copied so callers retain ownership of their map. + */ + public static String buildURL( + String baseURL, + String contextPath, + String path, + Map kvp, + URLMangler urlMangler, + URLMangler.URLType type) { + StringBuilder base = new StringBuilder(StringUtils.strip(baseURL, "/")); + String context = StringUtils.strip(contextPath, "/"); + String remainingPath = StringUtils.stripStart(path, "/"); + boolean trailingSlash = path != null && (path.isEmpty() || path.endsWith("/")); + StringBuilder pathBuffer = new StringBuilder(); + if (StringUtils.isNotBlank(context)) pathBuffer.append(context); + if (StringUtils.isNotBlank(remainingPath)) { + if (!pathBuffer.isEmpty()) pathBuffer.append('/'); + pathBuffer.append(remainingPath); + } + Map parameters = kvp == null ? new LinkedHashMap<>() : new LinkedHashMap<>(kvp); + + urlMangler.mangleURL(base, pathBuffer, parameters, type); + + String result = base.toString(); + if (!pathBuffer.isEmpty()) { + result = appendPath(result, pathBuffer.toString()); + } + if (trailingSlash && !pathBuffer.isEmpty() && !result.endsWith("/")) result += "/"; + return appendQueryParameters(result, parameters); + } + + private static String appendPath(String base, String path) { + if (base.endsWith("/") || path.isEmpty()) return base + path; + return base + "/" + path; + } + + private static String appendQueryParameters(String url, Map parameters) { + if (parameters.isEmpty()) return url; + String fragment = ""; + int fragmentIndex = url.indexOf('#'); + if (fragmentIndex >= 0) { + fragment = url.substring(fragmentIndex); + url = url.substring(0, fragmentIndex); + } + StringBuilder query = new StringBuilder(); + for (Map.Entry entry : parameters.entrySet()) { + if (!query.isEmpty()) query.append('&'); + query.append(ServletUtils.URLEncode(entry.getKey())).append('='); + if (entry.getValue() != null) query.append(ServletUtils.URLEncode(entry.getValue())); + } + if (url.endsWith("?") || url.endsWith("&")) return url + query + fragment; + return url + (url.indexOf('?') >= 0 ? '&' : '?') + query + fragment; + } +} diff --git a/geowebcache/core/src/test/java/org/geowebcache/util/NullURLManglerTest.java b/geowebcache/core/src/test/java/org/geowebcache/util/NullURLManglerTest.java index 06601b093e..479fe04ab3 100644 --- a/geowebcache/core/src/test/java/org/geowebcache/util/NullURLManglerTest.java +++ b/geowebcache/core/src/test/java/org/geowebcache/util/NullURLManglerTest.java @@ -11,56 +11,54 @@ public class NullURLManglerTest { private URLMangler urlMangler; @Before - public void setUp() throws Exception { + public void setUp() { urlMangler = new NullURLMangler(); } @Test public void testBuildURL() { - String url = urlMangler.buildURL("http://foo.example.com", "/foo", "/bar"); - Assert.assertEquals("http://foo.example.com/foo/bar", url); + Assert.assertEquals( + "http://foo.example.com/foo/bar", + URLManglerUtils.buildURL( + "http://foo.example.com", "/foo", "/bar", null, urlMangler, URLMangler.URLType.SERVICE)); } @Test - public void testBuildTrailingSlashes() throws Exception { - String url = urlMangler.buildURL("http://foo.example.com/", "/foo/", "/bar"); - Assert.assertEquals("http://foo.example.com/foo/bar", url); + public void testBuildURLNormalizesSlashes() { + Assert.assertEquals( + "http://foo.example.com/foo/bar", + URLManglerUtils.buildURL( + "http://foo.example.com/", "/foo/", "/bar", null, urlMangler, URLMangler.URLType.SERVICE)); + Assert.assertEquals( + "http://foo.example.com/foo/bar", + URLManglerUtils.buildURL( + "http://foo.example.com/", "foo/", "bar", null, urlMangler, URLMangler.URLType.SERVICE)); } @Test - public void testBuildNoLeadingSlashes() throws Exception { - String url = urlMangler.buildURL("http://foo.example.com/", "foo/", "bar"); - Assert.assertEquals("http://foo.example.com/foo/bar", url); + public void testBuildURLWithEmptyContext() { + Assert.assertEquals( + "http://foo.example.com/bar", + URLManglerUtils.buildURL( + "http://foo.example.com/", "/", "/bar", null, urlMangler, URLMangler.URLType.SERVICE)); + Assert.assertEquals( + "http://foo.example.com/bar", + URLManglerUtils.buildURL( + "http://foo.example.com/", null, "/bar", null, urlMangler, URLMangler.URLType.SERVICE)); } @Test - public void testBuildRootContext() throws Exception { - String url = urlMangler.buildURL("http://foo.example.com/", "/", "/bar"); - Assert.assertEquals("http://foo.example.com/bar", url); - } - - @Test - public void testBuildNullContext() throws Exception { - String url = urlMangler.buildURL("http://foo.example.com/", null, "/bar"); - Assert.assertEquals("http://foo.example.com/bar", url); - } - - @Test - public void testBuildEmptyContext() throws Exception { - String url = urlMangler.buildURL("http://foo.example.com/", "", "/bar"); - Assert.assertEquals("http://foo.example.com/bar", url); - } - - @Test - public void testBuildWithQueryParametersLeavesMapUntouched() throws Exception { + public void testBuildURLWithQueryParameters() { Map queryParameters = new LinkedHashMap<>(); queryParameters.put("projecttoken", "abc123"); - - URLMangler.UrlAndParams result = - urlMangler.buildURL("http://foo.example.com/", "/foo", "/bar", queryParameters); - - Assert.assertEquals("http://foo.example.com/foo/bar", result.url()); - Assert.assertEquals(1, result.queryParameters().size()); - Assert.assertEquals("abc123", result.queryParameters().get("projecttoken")); + Assert.assertEquals( + "http://foo.example.com/foo/bar?projecttoken=abc123", + URLManglerUtils.buildURL( + "http://foo.example.com/", + "/foo", + "/bar", + queryParameters, + urlMangler, + URLMangler.URLType.SERVICE)); } } diff --git a/geowebcache/core/src/test/java/org/geowebcache/util/URLManglerUtilsTest.java b/geowebcache/core/src/test/java/org/geowebcache/util/URLManglerUtilsTest.java new file mode 100644 index 0000000000..094501974c --- /dev/null +++ b/geowebcache/core/src/test/java/org/geowebcache/util/URLManglerUtilsTest.java @@ -0,0 +1,81 @@ +/** + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General + * Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any + * later version. + * + *

This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied + * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + *

You should have received a copy of the GNU Lesser General Public License along with this program. If not, see + * . + * + * @author Fernando Mino, GeoSolutions, Copyright 2026 + */ +package org.geowebcache.util; + +import static org.junit.Assert.assertEquals; + +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.Test; + +public class URLManglerUtilsTest { + + @Test + public void testBuildURLAppendsMutatedParametersAfterPathQuery() { + URLMangler mangler = (base, path, kvp, type) -> kvp.put("projecttoken", "abc 123"); + assertEquals( + "https://host/app/wmts/{TileRow}?format=image/png&projecttoken=abc+123", + URLManglerUtils.buildURL( + "https://host/", + "/app", + "/wmts/{TileRow}?format=image/png", + new LinkedHashMap<>(), + mangler, + URLMangler.URLType.SERVICE)); + } + + @Test + public void testBuildURLPreservesOrderAndFragment() { + Map parameters = new LinkedHashMap<>(); + parameters.put("first", "one"); + parameters.put("second", "two"); + assertEquals( + "https://host/path?existing=yes&first=one&second=two#fragment", + URLManglerUtils.buildURL( + "https://host", + null, + "/path?existing=yes#fragment", + parameters, + new NullURLMangler(), + URLMangler.URLType.RESOURCE)); + } + + @Test + public void testBuildURLUsesCompleteURLCreatedByMangler() { + URLMangler mangler = (base, path, kvp, type) -> { + base.setLength(0); + base.append("https://proxy.example.com/complete"); + path.setLength(0); + kvp.clear(); + }; + assertEquals( + "https://proxy.example.com/complete", + URLManglerUtils.buildURL( + "https://host", "/context", "/path", null, mangler, URLMangler.URLType.SERVICE)); + } + + @Test + public void testBuildURLPassesTypeAndAllowsBaseAndPathChanges() { + URLMangler mangler = (base, path, kvp, type) -> { + assertEquals(URLMangler.URLType.RESOURCE, type); + base.append("/proxy"); + path.append("/resource"); + kvp.put("token", "value"); + }; + assertEquals( + "https://host/proxy/context/path/resource?token=value", + URLManglerUtils.buildURL( + "https://host", "/context", "/path", null, mangler, URLMangler.URLType.RESOURCE)); + } +} diff --git a/geowebcache/tms/src/main/java/org/geowebcache/service/tms/TMSDocumentFactory.java b/geowebcache/tms/src/main/java/org/geowebcache/service/tms/TMSDocumentFactory.java index 508b62cb98..7c05fcd2f1 100644 --- a/geowebcache/tms/src/main/java/org/geowebcache/service/tms/TMSDocumentFactory.java +++ b/geowebcache/tms/src/main/java/org/geowebcache/service/tms/TMSDocumentFactory.java @@ -27,6 +27,7 @@ import org.geowebcache.layer.meta.LayerMetaInformation; import org.geowebcache.mime.MimeType; import org.geowebcache.util.URLMangler; +import org.geowebcache.util.URLManglerUtils; /** * Basic implementation of the TMS documents. Not all of GWCs more advanced features can easily be accomodated by this @@ -95,7 +96,7 @@ protected String getTileMapServiceDoc(String baseUrl, String contextPath) { xml.header("1.0", encoding); xml.indentElement("TileMapService") .attribute("version", "1.0.0") - .attribute("services", urlMangler.buildURL(baseUrl, contextPath, "")); + .attribute("services", buildURL(baseUrl, contextPath, "")); // TODO can have these set through Spring xml.simpleElement("Title", "Tile Map Service", true); xml.simpleElement("Abstract", "A Tile Map Service served by GeoWebCache", true); @@ -178,7 +179,7 @@ protected String getTileMapDoc( xml.header("1.0", encoding); xml.indentElement("TileMap") .attribute("version", "1.0.0") - .attribute("tilemapservice", urlMangler.buildURL(baseUrl, contextPath, SERVICE_PATH)); + .attribute("tilemapservice", buildURL(baseUrl, contextPath, SERVICE_PATH)); xml.simpleElement("Title", tileMapTitle(layer), true); xml.simpleElement("Abstract", tileMapDescription(layer), true); @@ -243,12 +244,16 @@ protected String profileForGridSet(GridSet gridSet) { protected String tileMapUrl( TileLayer tl, GridSubset gridSub, MimeType mimeType, String baseUrl, String contextPath) { - return urlMangler.buildURL(baseUrl, contextPath, SERVICE_PATH + "/" + tileMapName(tl, gridSub, mimeType)); + return buildURL(baseUrl, contextPath, SERVICE_PATH + "/" + tileMapName(tl, gridSub, mimeType)); + } + + private String buildURL(String baseUrl, String contextPath, String path) { + return URLManglerUtils.buildURL(baseUrl, contextPath, path, null, urlMangler, URLMangler.URLType.SERVICE); } protected String tileMapUrl( TileLayer tl, GridSubset gridSub, MimeType mimeType, int z, String baseUrl, String contextPath) { - return tileMapUrl(tl, gridSub, mimeType, baseUrl, contextPath) + "/" + z; + return buildURL(baseUrl, contextPath, SERVICE_PATH + "/" + tileMapName(tl, gridSub, mimeType) + "/" + z); } protected String tileMapName(TileLayer tl, GridSubset gridSub, MimeType mimeType) { diff --git a/geowebcache/tms/src/test/java/org/geowebcache/service/tms/TMSServiceTest.java b/geowebcache/tms/src/test/java/org/geowebcache/service/tms/TMSServiceTest.java index ab649393d1..0639bc4e4e 100644 --- a/geowebcache/tms/src/test/java/org/geowebcache/service/tms/TMSServiceTest.java +++ b/geowebcache/tms/src/test/java/org/geowebcache/service/tms/TMSServiceTest.java @@ -16,7 +16,6 @@ import java.util.List; import java.util.Map; import java.util.regex.Pattern; -import org.apache.commons.lang3.StringUtils; import org.custommonkey.xmlunit.XMLUnit; import org.custommonkey.xmlunit.XpathEngine; import org.geowebcache.GeoWebCacheDispatcher; @@ -35,6 +34,7 @@ import org.geowebcache.stats.RuntimeStats; import org.geowebcache.storage.StorageBroker; import org.geowebcache.util.URLMangler; +import org.geowebcache.util.URLManglerUtils; import org.junit.Assert; import org.junit.Before; import org.junit.Test; @@ -67,15 +67,12 @@ public void setUp() throws Exception { final Pattern PATTERN = Pattern.compile("http"); @Override - public String buildURL(String baseURL, String contextPath, String path) { - String url = StringUtils.strip(baseURL, "/") - + "/" - + StringUtils.strip(contextPath, "/") - + "/" - + StringUtils.stripStart(path, "/"); - url = url.startsWith("https") ? url : PATTERN.matcher(url).replaceFirst("https"); - - return url; + public void mangleURL( + StringBuilder baseURL, StringBuilder path, Map kvp, URLMangler.URLType type) { + if (!baseURL.toString().startsWith("https")) { + baseURL.replace( + 0, baseURL.length(), PATTERN.matcher(baseURL).replaceFirst("https")); + } } }; customFactory = new TMSCustomFactoryTest( @@ -143,7 +140,7 @@ protected String getTileMapServiceDoc(String baseUrl, String contextPath) { StringBuilder str = new StringBuilder(); str.append("\n"); str.append("\n"); str.append(" Custom Tile Map Service\n"); str.append(" A Custom Tile Map Service served by GeoWebCache\n"); @@ -173,7 +170,13 @@ protected void tileMapsForLayer( str.append(" href=\""); String tileMapName = layer.name + "@EPSG:4326" + "@" + format; - String url = urlMangler.buildURL(baseUrl, contextPath, TMSDocumentFactory.SERVICE_PATH + "/" + tileMapName); + String url = URLManglerUtils.buildURL( + baseUrl, + contextPath, + TMSDocumentFactory.SERVICE_PATH + "/" + tileMapName, + null, + urlMangler, + URLMangler.URLType.SERVICE); str.append(url).append("\" />\n"); } } diff --git a/geowebcache/wms/src/main/java/org/geowebcache/service/wms/WMSGetCapabilities.java b/geowebcache/wms/src/main/java/org/geowebcache/service/wms/WMSGetCapabilities.java index 5a21977cb2..7d1d3b6ee1 100644 --- a/geowebcache/wms/src/main/java/org/geowebcache/service/wms/WMSGetCapabilities.java +++ b/geowebcache/wms/src/main/java/org/geowebcache/service/wms/WMSGetCapabilities.java @@ -53,6 +53,7 @@ import org.geowebcache.mime.MimeType; import org.geowebcache.util.ServletUtils; import org.geowebcache.util.URLMangler; +import org.geowebcache.util.URLManglerUtils; public class WMSGetCapabilities { @@ -72,7 +73,13 @@ protected WMSGetCapabilities( URLMangler urlMangler) { this.tld = tld; - urlStr = urlMangler.buildURL(baseUrl, contextPath, WMSService.SERVICE_PATH) + "?SERVICE=WMS&"; + urlStr = URLManglerUtils.buildURL( + baseUrl, + contextPath, + WMSService.SERVICE_PATH + "?SERVICE=WMS&", + null, + urlMangler, + URLMangler.URLType.SERVICE); String[] tiledKey = {"TILED"}; Map tiledValue = ServletUtils.selectedStringsFromMap( diff --git a/geowebcache/wmts/src/main/java/org/geowebcache/service/wmts/WMTSGetCapabilities.java b/geowebcache/wmts/src/main/java/org/geowebcache/service/wmts/WMTSGetCapabilities.java index 7ffaa7056c..18a2f83fe0 100644 --- a/geowebcache/wmts/src/main/java/org/geowebcache/service/wmts/WMTSGetCapabilities.java +++ b/geowebcache/wmts/src/main/java/org/geowebcache/service/wmts/WMTSGetCapabilities.java @@ -66,7 +66,7 @@ public class WMTSGetCapabilities { private GridSetBroker gsb; - private final WMTSUrls urls; + private final WMTSUrlBuilder urls; private final Collection extensions; @@ -95,7 +95,7 @@ protected WMTSGetCapabilities( TileLayerDispatcher tld, GridSetBroker gsb, HttpServletRequest servReq, - WMTSUrls urls, + WMTSUrlBuilder urls, Collection extensions) { this.tld = tld; this.gsb = gsb; @@ -103,16 +103,11 @@ protected WMTSGetCapabilities( this.extensions = extensions; } - private static WMTSUrls buildUrls( + private static WMTSUrlBuilder buildUrls( HttpServletRequest servReq, String baseUrl, String contextPath, URLMangler urlMangler) { String forcedBaseUrl = ServletUtils.stringFromMap(servReq.getParameterMap(), servReq.getCharacterEncoding(), "base_url"); - String effectiveBaseUrl = forcedBaseUrl != null ? forcedBaseUrl : baseUrl; - URLMangler.UrlAndParams service = - urlMangler.buildURL(effectiveBaseUrl, contextPath, WMTSService.SERVICE_PATH, Collections.emptyMap()); - URLMangler.UrlAndParams rest = - urlMangler.buildURL(effectiveBaseUrl, contextPath, WMTSService.REST_PATH, Collections.emptyMap()); - return new WMTSUrls(service.url(), service.queryParameters(), rest.url(), rest.queryParameters()); + return new WMTSUrlBuilder(forcedBaseUrl == null ? baseUrl : forcedBaseUrl, baseUrl, contextPath, urlMangler); } protected void writeResponse(HttpServletResponse response, RuntimeStats stats) { @@ -179,16 +174,12 @@ private String generateGetCapabilities(Charset encoding) { xml.indentElement("ServiceMetadataURL") .attribute( "xlink:href", - WMTSUtils.appendQueryParameters( - WMTSUtils.getKvpServiceMetadataURL(urls.serviceBaseUrl()), - urls.serviceQueryParameters())) + urls.serviceUrl( + WMTSService.SERVICE_PATH + "?SERVICE=wmts&REQUEST=getcapabilities&VERSION=1.0.0")) .endElement(); xml.indentElement("ServiceMetadataURL") - .attribute( - "xlink:href", - WMTSUtils.appendQueryParameters( - urls.restBaseUrl() + "/WMTSCapabilities.xml", urls.restQueryParameters())) + .attribute("xlink:href", urls.restUrl(WMTSService.REST_PATH + "/WMTSCapabilities.xml")) .endElement(); xml.endElement("Capabilities"); @@ -394,21 +385,15 @@ private void serviceProvider(XMLBuilder xml, ServiceInformation servInfo) throws private void operationsMetadata(XMLBuilder xml) throws IOException { xml.indentElement("ows:OperationsMetadata"); - operation(xml, "GetCapabilities", urls.serviceBaseUrl(), urls.serviceQueryParameters()); - operation(xml, "GetTile", urls.serviceBaseUrl(), urls.serviceQueryParameters()); - operation(xml, "GetFeatureInfo", urls.serviceBaseUrl(), urls.serviceQueryParameters()); + operation(xml, "GetCapabilities"); + operation(xml, "GetTile"); + operation(xml, "GetFeatureInfo"); // allow extension to inject their own metadata for (WMTSExtension extension : extensions) { List operationsMetaData = extension.getExtraOperationsMetadata(); if (operationsMetaData != null) { for (WMTSExtension.OperationMetadata operationMetadata : operationsMetaData) { - operation( - xml, - operationMetadata.getName(), - operationMetadata.getBaseUrl() == null - ? urls.serviceBaseUrl() - : operationMetadata.getBaseUrl(), - urls.serviceQueryParameters()); + operation(xml, operationMetadata.getName(), operationMetadata.getBaseUrl()); } } extension.encodedOperationsMetadata(xml); @@ -416,12 +401,17 @@ private void operationsMetadata(XMLBuilder xml) throws IOException { xml.endElement("ows:OperationsMetadata"); } - private void operation(XMLBuilder xml, String operationName, String baseUrl, Map queryParameters) - throws IOException { + /** Writes an operation that uses the service endpoint generated for this capabilities response. */ + private void operation(XMLBuilder xml, String operationName) throws IOException { + operation(xml, operationName, null); + } + + /** Writes an operation using either the generated service endpoint or an extension-provided custom URL. */ + private void operation(XMLBuilder xml, String operationName, String customBaseUrl) throws IOException { xml.indentElement("ows:Operation").attribute("name", operationName); xml.indentElement("ows:DCP"); xml.indentElement("ows:HTTP"); - String href = WMTSUtils.appendQueryParameters(baseUrl, queryParameters); + String href = customBaseUrl == null ? urls.serviceUrl(WMTSService.SERVICE_PATH) : urls.customUrl(customBaseUrl); if (href.contains("?")) { xml.indentElement("ows:Get").attribute("xlink:href", href + "&"); } else { @@ -446,7 +436,7 @@ private void contents(XMLBuilder xml) throws IOException { if (!layer.isEnabled() || !layer.isAdvertised()) { continue; } - layer(xml, layer, urls.serviceBaseUrl(), usedGridsets); + layer(xml, layer, usedGridsets); } // only dump the gridsets actually used, as the OGC TMS spec introduced many default ones @@ -461,7 +451,7 @@ private void contents(XMLBuilder xml) throws IOException { xml.endElement("Contents"); } - private void layer(XMLBuilder xml, TileLayer layer, String baseurl, Set usedGridsets) throws IOException { + private void layer(XMLBuilder xml, TileLayer layer, Set usedGridsets) throws IOException { xml.indentElement("Layer"); LayerMetaInformation layerMeta = layer.getMetaInformation(); @@ -508,7 +498,7 @@ private void layer(XMLBuilder xml, TileLayer layer, String baseurl, Set layerGridSubSets(xml, layer, usedGridsets); - layerResourceUrls(xml, layer, filters, urls.restBaseUrl(), urls.restQueryParameters()); + layerResourceUrls(xml, layer, filters); // allow extensions to contribute extra metadata to this layer for (WMTSExtension extension : extensions) { @@ -732,14 +722,8 @@ private void layerGridSubSets(XMLBuilder xml, TileLayer layer, Set used * For each layer discovers the available image formats, feature info formats and dimensions and produce the * necessary elements. */ - private void layerResourceUrls( - XMLBuilder xml, - TileLayer layer, - List filters, - String baseurl, - Map queryParameters) - throws IOException { - String baseTemplate = baseurl + "/" + layer.getName(); + private void layerResourceUrls(XMLBuilder xml, TileLayer layer, List filters) throws IOException { + String baseTemplate = WMTSService.REST_PATH + "/" + layer.getName(); String commonTemplate = baseTemplate + "/{style}/{TileMatrixSet}/{TileMatrix}/{TileRow}/{TileCol}"; String commonDimensions = ""; // Extracts layer dimension @@ -753,15 +737,13 @@ private void layerResourceUrls( // Extracts image formats List mimeFormats = WMTSUtils.getLayerFormats(layer); for (String format : mimeFormats) { - String template = WMTSUtils.appendQueryParameters( - commonTemplate + "?format=" + format + commonDimensions, queryParameters); + String template = urls.restUrl(commonTemplate + "?format=" + format + commonDimensions); layerResourceUrlsGen(xml, format, "tile", template); } // Extracts feature info formats List infoFormats = WMTSUtils.getInfoFormats(layer); for (String format : infoFormats) { - String template = WMTSUtils.appendQueryParameters( - commonTemplate + "/{J}/{I}?format=" + format + commonDimensions, queryParameters); + String template = urls.restUrl(commonTemplate + "/{J}/{I}?format=" + format + commonDimensions); layerResourceUrlsGen(xml, format, "FeatureInfo", template); } if (layer instanceof TileJSONProvider provider) { @@ -769,9 +751,8 @@ private void layerResourceUrls( String outputFormat = ApplicationMime.json.getFormat(); if (provider.supportsTileJSON()) { for (String tileJsonFormat : formatExtensions) { - String template = WMTSUtils.appendQueryParameters( - baseTemplate + "/{style}/tilejson/" + tileJsonFormat + "?format=" + outputFormat, - queryParameters); + String template = urls.restUrl( + baseTemplate + "/{style}/tilejson/" + tileJsonFormat + "?format=" + outputFormat); layerResourceUrlsGen(xml, outputFormat, "TileJSON", template); } } diff --git a/geowebcache/wmts/src/main/java/org/geowebcache/service/wmts/WMTSService.java b/geowebcache/wmts/src/main/java/org/geowebcache/service/wmts/WMTSService.java index 0fcf479ffa..da1788aa80 100644 --- a/geowebcache/wmts/src/main/java/org/geowebcache/service/wmts/WMTSService.java +++ b/geowebcache/wmts/src/main/java/org/geowebcache/service/wmts/WMTSService.java @@ -572,7 +572,7 @@ public void handleRequest(Conveyor conv) throws OWSException, GeoWebCacheExcepti if (tile.getHint() != null) { if (tile.getHint().equals(GET_CAPABILITIES)) { - WMTSUrls urls = buildUrls(servletBase, context); + WMTSUrlBuilder urls = buildUrls(tile.servletReq, servletBase, context); WMTSGetCapabilities wmsGC = new WMTSGetCapabilities(tld, gsb, tile.servletReq, urls, extensions); wmsGC.writeResponse(tile.servletResp, stats); @@ -600,7 +600,7 @@ public void handleRequest(Conveyor conv) throws OWSException, GeoWebCacheExcepti if (!provider.supportsTileJSON()) { throw new HttpErrorCodeException(404, "TileJSON Not supported"); } - WMTSUrls urls = buildUrls(servletBase, context); + WMTSUrlBuilder urls = buildUrls(tile.servletReq, servletBase, context); WMTSTileJSON wmtsTileJSON = new WMTSTileJSON(convTile, urls, style); wmtsTileJSON.writeResponse(layer); } @@ -608,12 +608,11 @@ public void handleRequest(Conveyor conv) throws OWSException, GeoWebCacheExcepti } } - private WMTSUrls buildUrls(String servletBase, String context) { - URLMangler.UrlAndParams service = - urlMangler.buildURL(servletBase, context, SERVICE_PATH, Collections.emptyMap()); - URLMangler.UrlAndParams rest = urlMangler.buildURL(servletBase, context, REST_PATH, Collections.emptyMap()); - - return new WMTSUrls(service.url(), service.queryParameters(), rest.url(), rest.queryParameters()); + private WMTSUrlBuilder buildUrls(HttpServletRequest request, String servletBase, String context) { + String forcedBaseUrl = + ServletUtils.stringFromMap(request.getParameterMap(), request.getCharacterEncoding(), "base_url"); + String serviceBase = forcedBaseUrl == null ? servletBase : forcedBaseUrl; + return new WMTSUrlBuilder(serviceBase, servletBase, context, urlMangler); } void addExtension(WMTSExtension extension) { diff --git a/geowebcache/wmts/src/main/java/org/geowebcache/service/wmts/WMTSTileJSON.java b/geowebcache/wmts/src/main/java/org/geowebcache/service/wmts/WMTSTileJSON.java index ca922c8928..a017557b86 100644 --- a/geowebcache/wmts/src/main/java/org/geowebcache/service/wmts/WMTSTileJSON.java +++ b/geowebcache/wmts/src/main/java/org/geowebcache/service/wmts/WMTSTileJSON.java @@ -38,27 +38,27 @@ public class WMTSTileJSON { private static Logger log = Logging.getLogger(WMTSTileJSON.class.getName()); - private final WMTSUrls urls; + private final WMTSUrlBuilder urlBuilder; private String style; private ConveyorTile convTile; private static final String ENDING_DIGITS_REGEX = "([0-9]+)$"; private static final Pattern PATTERN_DIGITS = Pattern.compile(ENDING_DIGITS_REGEX); - public WMTSTileJSON(ConveyorTile convTile, WMTSUrls urls, String style) { + public WMTSTileJSON(ConveyorTile convTile, WMTSUrlBuilder urls, String style) { this.convTile = convTile; this.style = style; - this.urls = urls; + this.urlBuilder = urls; } public void writeResponse(TileLayer layer) { TileJSONProvider provider = (TileJSONProvider) layer; TileJSON json = provider.getTileJSON(); - List tileUrls = new ArrayList<>(); + List urls = new ArrayList<>(); MimeType mimeType = convTile.getMimeType(); Set gridSubSets = layer.getGridSubsets(); for (String gridSubSet : gridSubSets) { - addTileUrl(layer, gridSubSet, mimeType, tileUrls); + addTileUrl(layer, gridSubSet, mimeType, urls); } List vectorLayers = json.getLayers(); if (vectorLayers != null && !vectorLayers.isEmpty() && !mimeType.isVector()) { @@ -66,8 +66,8 @@ public void writeResponse(TileLayer layer) { json.setLayers(null); } - String[] serializedTileUrls = tileUrls.toArray(new String[tileUrls.size()]); - json.setTiles(serializedTileUrls); + String[] tileUrls = urls.toArray(new String[urls.size()]); + json.setTiles(tileUrls); convTile.servletResp.setStatus(HttpServletResponse.SC_OK); convTile.servletResp.setContentType(ApplicationMime.json.getMimeType()); @@ -85,7 +85,7 @@ public void writeResponse(TileLayer layer) { } } - private void addTileUrl(TileLayer layer, String gridSubSet, MimeType mimeType, List tileUrls) { + private void addTileUrl(TileLayer layer, String gridSubSet, MimeType mimeType, List urls) { GridSubset grid = layer.getGridSubset(gridSubSet); int zoomLevelStart = -1; int start = -1; @@ -106,8 +106,7 @@ private void addTileUrl(TileLayer layer, String gridSubSet, MimeType mimeType, L zoomLevelStart = start; } - String tileUrl = this.urls.restBaseUrl() - + "/" + String tileUrl = urlBuilder.restUrl(WMTSService.REST_PATH + "/" + layer.getName() + "/" + (style != null ? (style + "/") : "") @@ -117,8 +116,7 @@ private void addTileUrl(TileLayer layer, String gridSubSet, MimeType mimeType, L + "{z}" + "/{y}/{x}" + "?format=" - + mimeType; - tileUrl = WMTSUtils.appendQueryParameters(tileUrl, this.urls.restQueryParameters()); - tileUrls.add(tileUrl); + + mimeType); + urls.add(tileUrl); } } diff --git a/geowebcache/wmts/src/main/java/org/geowebcache/service/wmts/WMTSUrlBuilder.java b/geowebcache/wmts/src/main/java/org/geowebcache/service/wmts/WMTSUrlBuilder.java new file mode 100644 index 0000000000..f6925738ef --- /dev/null +++ b/geowebcache/wmts/src/main/java/org/geowebcache/service/wmts/WMTSUrlBuilder.java @@ -0,0 +1,70 @@ +/** + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General + * Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any + * later version. + * + *

This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied + * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + *

You should have received a copy of the GNU Lesser General Public License along with this program. If not, see + * . + * + * @author Fernando Mino, GeoSolutions, Copyright 2026 + */ +package org.geowebcache.service.wmts; + +import org.geowebcache.util.URLMangler; +import org.geowebcache.util.URLManglerUtils; + +/** + * Holds the base components used to generate WMTS service and REST URLs. + * + *

The service and REST bases are deliberately kept separate. A request-level {@code base_url} override may apply to + * service and KVP links while REST resource templates must continue to use the regular servlet base URL. This builder + * does not store serialized query parameters; each complete URL is assembled through {@link URLManglerUtils} only after + * its full path template has been supplied. + * + * @param serviceBaseUrl base URL used for WMTS service and KVP links, including any request-level override + * @param restBaseUrl base URL used for WMTS REST resource links + * @param contextPath servlet context path shared by the service and REST endpoints + * @param urlMangler callback used to apply proxy, security, or other URL transformations + */ +public record WMTSUrlBuilder(String serviceBaseUrl, String restBaseUrl, String contextPath, URLMangler urlMangler) { + + /** + * Builds a URL for a WMTS service or KVP endpoint. + * + *

The supplied path may contain an existing query string or unresolved WMTS template placeholders. Parameters + * added by the mangler are serialized after that path query string. + * + * @param path endpoint path, including any endpoint-specific query string + * @return the fully assembled and mangled service URL + */ + public String serviceUrl(String path) { + return URLManglerUtils.buildURL( + serviceBaseUrl, contextPath, path, null, urlMangler, URLMangler.URLType.SERVICE); + } + + /** + * Builds a URL for a WMTS REST resource or REST capabilities endpoint. + * + * @param path REST resource path, including any existing query string or unresolved WMTS placeholders + * @return the fully assembled and mangled REST URL + */ + public String restUrl(String path) { + return URLManglerUtils.buildURL(restBaseUrl, contextPath, path, null, urlMangler, URLMangler.URLType.SERVICE); + } + + /** + * Applies URL mangling to an extension-provided absolute operation URL. + * + *

The custom URL is treated as the complete base URL, so the standard WMTS context path is not added. This + * preserves extension-specific paths while still allowing registered manglers to add or modify query parameters. + * + * @param url extension-provided operation URL + * @return the fully mangled custom operation URL + */ + public String customUrl(String url) { + return URLManglerUtils.buildURL(url, null, null, null, urlMangler, URLMangler.URLType.SERVICE); + } +} diff --git a/geowebcache/wmts/src/main/java/org/geowebcache/service/wmts/WMTSUrls.java b/geowebcache/wmts/src/main/java/org/geowebcache/service/wmts/WMTSUrls.java deleted file mode 100644 index fa5e654f4d..0000000000 --- a/geowebcache/wmts/src/main/java/org/geowebcache/service/wmts/WMTSUrls.java +++ /dev/null @@ -1,46 +0,0 @@ -/** - * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General - * Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any - * later version. - * - *

This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied - * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. - * - *

You should have received a copy of the GNU Lesser General Public License along with this program. If not, see - * . - * - * @author Fernando Mino, GeoSolutions S.A.S., Copyright 2026 - */ -package org.geowebcache.service.wmts; - -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.Map; - -/** - * Holds the WMTS URLs needed while generating capabilities and related backlinks. - * - *

The service and REST endpoints are carried separately so their query parameters can remain mutable until the final - * URL serialization step. This avoids injecting propagated parameters into the path segment of template URLs and lets - * the callers append them only once the full path has already been resolved. - * - * @param serviceBaseUrl base URL for the KVP service endpoint - * @param serviceQueryParameters propagated query parameters to append to the service endpoint - * @param restBaseUrl base URL for the REST endpoint - * @param restQueryParameters propagated query parameters to append to the REST endpoint - */ -public record WMTSUrls( - String serviceBaseUrl, - Map serviceQueryParameters, - String restBaseUrl, - Map restQueryParameters) { - - public WMTSUrls { - serviceQueryParameters = serviceQueryParameters == null - ? Collections.emptyMap() - : Collections.unmodifiableMap(new LinkedHashMap<>(serviceQueryParameters)); - restQueryParameters = restQueryParameters == null - ? Collections.emptyMap() - : Collections.unmodifiableMap(new LinkedHashMap<>(restQueryParameters)); - } -} diff --git a/geowebcache/wmts/src/main/java/org/geowebcache/service/wmts/WMTSUtils.java b/geowebcache/wmts/src/main/java/org/geowebcache/service/wmts/WMTSUtils.java index d170b98ac4..3a102aacef 100644 --- a/geowebcache/wmts/src/main/java/org/geowebcache/service/wmts/WMTSUtils.java +++ b/geowebcache/wmts/src/main/java/org/geowebcache/service/wmts/WMTSUtils.java @@ -16,12 +16,10 @@ import java.io.IOException; import java.util.ArrayList; import java.util.List; -import java.util.Map; import java.util.stream.Collectors; import org.geowebcache.filter.parameters.ParameterFilter; import org.geowebcache.layer.TileLayer; import org.geowebcache.mime.MimeType; -import org.geowebcache.util.ServletUtils; final class WMTSUtils { @@ -48,80 +46,4 @@ public static List getLayerDimensions(List fil } return dimensions; } - - public static String getKvpServiceMetadataURL(String baseUrl) { - String base = baseUrl; - String anchor = ""; - - // Split anchor - if (base.indexOf('#') != -1) { - anchor = base.substring(base.indexOf('#')); - base = base.substring(0, base.indexOf('#')); - } - - // Remove stray ? and &'s at the end of the URL - int l = base.length(); - while (l > 0 && (base.charAt(l - 1) == '?' || base.charAt(l - 1) == '&')) { - base = base.substring(0, l - 1); - l--; - } - - // Append the correct delimiter - if (base.indexOf('?') == -1) { - base += "?"; - } else { - base += "&"; - } - - return base + "SERVICE=wmts&REQUEST=getcapabilities&VERSION=1.0.0" + anchor; - } - - /** - * Appends propagated query parameters to a URL while preserving the existing query string order and any anchor. - * - *

This helper stays local to WMTS because capability templates can still contain unresolved path placeholders - * such as {@code {TileRow}} or {@code {TileMatrixSet}} at the point where the final backlink URL is serialized. - * General-purpose URI builders such as Apache HttpComponents {@code URIBuilder} reject those template URLs as - * invalid URIs, so WMTS needs a lightweight string-based helper that keeps the path intact and appends the - * propagated query map at the end of the URL. - * - *

The method preserves the original path, keeps any fragment suffix at the end, and serializes the provided map - * in iteration order. It does not normalize or deduplicate template path components, but it does percent-encode - * each appended key and value so that propagated parameters (such as security tokens) cannot corrupt the query - * string or inject additional parameters. - */ - public static String appendQueryParameters(String url, Map queryParameters) { - if (queryParameters == null || queryParameters.isEmpty()) { - return url; - } - - String base = url; - String anchor = ""; - int anchorIndex = base.indexOf('#'); - if (anchorIndex != -1) { - anchor = base.substring(anchorIndex); - base = base.substring(0, anchorIndex); - } - - StringBuilder query = new StringBuilder(); - for (Map.Entry entry : queryParameters.entrySet()) { - if (!query.isEmpty()) { - query.append("&"); - } - query.append(ServletUtils.URLEncode(entry.getKey())).append("="); - if (entry.getValue() != null) { - query.append(ServletUtils.URLEncode(entry.getValue())); - } - } - - if (base.endsWith("?") || base.endsWith("&")) { - return base + query + anchor; - } - - if (base.contains("?")) { - return base + "&" + query + anchor; - } - - return base + "?" + query + anchor; - } } diff --git a/geowebcache/wmts/src/test/java/org/geowebcache/service/wmts/WMTSRestTest.java b/geowebcache/wmts/src/test/java/org/geowebcache/service/wmts/WMTSRestTest.java index 4735260d20..9eb3582af3 100644 --- a/geowebcache/wmts/src/test/java/org/geowebcache/service/wmts/WMTSRestTest.java +++ b/geowebcache/wmts/src/test/java/org/geowebcache/service/wmts/WMTSRestTest.java @@ -326,17 +326,12 @@ private MockHttpServletResponse dispatch(MockHttpServletRequest req, WMTSService private WMTSService wmtsServiceWithProjectToken() { URLMangler tokenMangler = new URLMangler() { @Override - public String buildURL(String baseURL, String contextPath, String path) { - String context = contextPath == null ? "" : contextPath; - return baseURL + context + path; - } - - @Override - public UrlAndParams buildURL( - String baseURL, String contextPath, String path, Map queryParameters) { - Map resultParameters = new HashMap<>(queryParameters); - resultParameters.put("projecttoken", "abc123"); - return new UrlAndParams(buildURL(baseURL, contextPath, path), resultParameters); + public void mangleURL( + StringBuilder baseURL, + StringBuilder path, + Map queryParameters, + URLMangler.URLType type) { + queryParameters.put("projecttoken", "abc123"); } }; WMTSService service = diff --git a/geowebcache/wmts/src/test/java/org/geowebcache/service/wmts/WMTSServiceTest.java b/geowebcache/wmts/src/test/java/org/geowebcache/service/wmts/WMTSServiceTest.java index ba4a7988f3..ad6355085f 100644 --- a/geowebcache/wmts/src/test/java/org/geowebcache/service/wmts/WMTSServiceTest.java +++ b/geowebcache/wmts/src/test/java/org/geowebcache/service/wmts/WMTSServiceTest.java @@ -4,7 +4,6 @@ import static org.hamcrest.CoreMatchers.notNullValue; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.arrayContaining; -import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.equalToIgnoringCase; @@ -43,7 +42,6 @@ import java.util.Map; import org.apache.commons.collections4.map.CaseInsensitiveMap; import org.apache.commons.io.FileUtils; -import org.apache.commons.lang3.StringUtils; import org.custommonkey.xmlunit.SimpleNamespaceContext; import org.custommonkey.xmlunit.XMLUnit; import org.custommonkey.xmlunit.XpathEngine; @@ -1360,15 +1358,15 @@ public void testGetCapabilitiesQueryMaps() throws Exception { // Expect two separate invocations: one for the service backlink and one for // the REST capabilities backlink. - assertEquals(2, result.capturedQueryMaps.size()); - assertEquals(2, result.capturedPaths.size()); - assertThat(result.capturedPaths, contains(WMTSService.SERVICE_PATH, WMTSService.REST_PATH)); + assertTrue(result.capturedQueryMaps.size() >= 2); + assertTrue( + result.capturedPaths.stream().anyMatch(path -> path.contains(WMTSService.SERVICE_PATH.substring(1)))); + assertTrue(result.capturedPaths.stream().anyMatch(path -> path.contains(WMTSService.REST_PATH.substring(1)))); // The two query maps must not be shared, and both must carry the propagated // project token before serialization. assertNotSame(result.capturedQueryMaps.get(0), result.capturedQueryMaps.get(1)); - assertEquals("abc123", result.capturedQueryMaps.get(0).get("projecttoken")); - assertEquals("abc123", result.capturedQueryMaps.get(1).get("projecttoken")); + assertTrue(result.capturedQueryMaps.stream().allMatch(map -> "abc123".equals(map.get("projecttoken")))); } /** @@ -1432,6 +1430,24 @@ public void testGetCapabilitiesQueryParamsPlacement() throws Exception { doc)); } + /** + * Verifies that the GetCapabilities {@code base_url} parameter overrides the service backlinks while REST resource + * URLs continue to use the regular servlet base URL. + */ + @Test + public void testGetCapabilitiesHonorsForcedBaseUrl() throws Exception { + ProjectTokenCapabilitiesResult result = runProjectTokenCapabilitiesScenario("https://proxy.example.com"); + XpathEngine xpath = buildWMTSXPath(); + + assertEquals( + "1", + xpath.evaluate( + "count(//wmts:ServiceMetadataURL[contains(@xlink:href,'https://proxy.example.com')])", + result.document)); + assertTrue(result.capturedBaseUrls.contains("https://proxy.example.com")); + assertTrue(result.capturedBaseUrls.contains("http://localhost")); + } + /** * Verifies that {@code operationsMetadata} propagates the service query parameters (e.g. the security token added * by a registered {@link URLMangler}) to a {@code WMTSExtension}-supplied {@code OperationMetadata} even when that @@ -1460,35 +1476,38 @@ public List getExtraOperationsMetadata() throws IOException { } private ProjectTokenCapabilitiesResult runProjectTokenCapabilitiesScenario() throws Exception { - return runProjectTokenCapabilitiesScenario(Collections.emptyList()); + return runProjectTokenCapabilitiesScenario(Collections.emptyList(), null); } private ProjectTokenCapabilitiesResult runProjectTokenCapabilitiesScenario(List extraExtensions) throws Exception { + return runProjectTokenCapabilitiesScenario(extraExtensions, null); + } + + private ProjectTokenCapabilitiesResult runProjectTokenCapabilitiesScenario(String forcedBaseUrl) throws Exception { + return runProjectTokenCapabilitiesScenario(Collections.emptyList(), forcedBaseUrl); + } + + private ProjectTokenCapabilitiesResult runProjectTokenCapabilitiesScenario( + List extraExtensions, String forcedBaseUrl) throws Exception { // Capture the paths and query maps passed to the URL mangler so we can // verify that service and REST backlinks are built from separate inputs. List> capturedQueryMaps = new ArrayList<>(); List capturedPaths = new ArrayList<>(); + List capturedBaseUrls = new ArrayList<>(); // Record the WMTS URL-building calls while still returning a plausible URL // string so the GetCapabilities flow can complete normally. URLMangler recordingUrlMangler = new URLMangler() { @Override - public String buildURL(String baseURL, String contextPath, String path) { - return StringUtils.strip(baseURL, "/") - + "/" - + StringUtils.strip(contextPath, "/") - + "/" - + StringUtils.strip(path, "/"); - } - - @Override - public UrlAndParams buildURL( - String baseURL, String contextPath, String path, Map queryParameters) { - capturedPaths.add(path); - Map resultParameters = new HashMap<>(queryParameters); - resultParameters.put("projecttoken", "abc123"); - capturedQueryMaps.add(resultParameters); - return new UrlAndParams(buildURL(baseURL, contextPath, path), resultParameters); + public void mangleURL( + StringBuilder baseURL, + StringBuilder path, + Map queryParameters, + URLMangler.URLType type) { + capturedBaseUrls.add(baseURL.toString()); + capturedPaths.add(path.toString()); + queryParameters.put("projecttoken", "abc123"); + capturedQueryMaps.add(new HashMap<>(queryParameters)); } }; // Wire the service with the recording mangler and a minimal dispatcher so we @@ -1523,6 +1542,9 @@ public UrlAndParams buildURL( req.setPathInfo("geowebcache/service/wmts"); req.addParameter("service", "WMTS"); req.addParameter("request", "GetCapabilities"); + if (forcedBaseUrl != null) { + req.addParameter("base_url", forcedBaseUrl); + } MockHttpServletResponse resp = new MockHttpServletResponse(); Conveyor conv = service.getConveyor(req, resp); @@ -1531,16 +1553,24 @@ public UrlAndParams buildURL( service.handleRequest(conv); return new ProjectTokenCapabilitiesResult( - capturedQueryMaps, capturedPaths, XMLUnit.buildTestDocument(resp.getContentAsString())); + capturedBaseUrls, + capturedQueryMaps, + capturedPaths, + XMLUnit.buildTestDocument(resp.getContentAsString())); } private static final class ProjectTokenCapabilitiesResult { + private final List capturedBaseUrls; private final List> capturedQueryMaps; private final List capturedPaths; private final Document document; private ProjectTokenCapabilitiesResult( - List> capturedQueryMaps, List capturedPaths, Document document) { + List capturedBaseUrls, + List> capturedQueryMaps, + List capturedPaths, + Document document) { + this.capturedBaseUrls = capturedBaseUrls; this.capturedQueryMaps = capturedQueryMaps; this.capturedPaths = capturedPaths; this.document = document; @@ -1633,13 +1663,7 @@ public void testGetCapWithTileJSONDifferentUrls() throws Exception { private String writeTileJsonResponse(ConveyorTile conv, TileLayer tileLayer, MockHttpServletResponse resp) throws UnsupportedEncodingException { WMTSTileJSON tileJSON = new WMTSTileJSON( - conv, - new WMTSUrls( - "http://localhost/service/wmts", - Collections.emptyMap(), - "http://localhost/service/wmts/rest", - Collections.emptyMap()), - null); + conv, new WMTSUrlBuilder("http://localhost", "http://localhost", "", new NullURLMangler()), null); tileJSON.writeResponse(tileLayer); return resp.getContentAsString(); } diff --git a/geowebcache/wmts/src/test/java/org/geowebcache/service/wmts/WMTSUtilsTest.java b/geowebcache/wmts/src/test/java/org/geowebcache/service/wmts/WMTSUtilsTest.java deleted file mode 100644 index 41285599aa..0000000000 --- a/geowebcache/wmts/src/test/java/org/geowebcache/service/wmts/WMTSUtilsTest.java +++ /dev/null @@ -1,120 +0,0 @@ -package org.geowebcache.service.wmts; - -import static org.junit.Assert.assertEquals; - -import java.util.LinkedHashMap; -import java.util.Map; -import org.junit.Test; - -public class WMTSUtilsTest { - - @Test - public void testKvpServiceMetadataURLNoParameters() throws Exception { - String result = WMTSUtils.getKvpServiceMetadataURL("https://www.foo.com/"); - assertEquals("https://www.foo.com/?SERVICE=wmts&REQUEST=getcapabilities&VERSION=1.0.0", result); - } - - @Test - public void testKvpServiceMetadataURLNoParametersWithAnchor() throws Exception { - String result = WMTSUtils.getKvpServiceMetadataURL("https://www.foo.com/#anchor"); - assertEquals("https://www.foo.com/?SERVICE=wmts&REQUEST=getcapabilities&VERSION=1.0.0#anchor", result); - } - - @Test - public void testKvpServiceMetadataURLNoParameters_questionMark() throws Exception { - String result = WMTSUtils.getKvpServiceMetadataURL("https://www.foo.com/?"); - assertEquals("https://www.foo.com/?SERVICE=wmts&REQUEST=getcapabilities&VERSION=1.0.0", result); - } - - @Test - public void testKvpServiceMetadataURLSingleParameter() throws Exception { - String result = WMTSUtils.getKvpServiceMetadataURL("https://www.foo.com/?bar=doo"); - assertEquals("https://www.foo.com/?bar=doo&SERVICE=wmts&REQUEST=getcapabilities&VERSION=1.0.0", result); - } - - @Test - public void testKvpServiceMetadataURLSingleParameter_ampersand() throws Exception { - String result = WMTSUtils.getKvpServiceMetadataURL("https://www.foo.com/?bar=doo&"); - assertEquals("https://www.foo.com/?bar=doo&SERVICE=wmts&REQUEST=getcapabilities&VERSION=1.0.0", result); - } - - @Test - public void testKvpServiceMetadataURLMultipleParameters() throws Exception { - String result = WMTSUtils.getKvpServiceMetadataURL("https://www.foo.com/?bar=doo&dii=daa"); - assertEquals("https://www.foo.com/?bar=doo&dii=daa&SERVICE=wmts&REQUEST=getcapabilities&VERSION=1.0.0", result); - } - - @Test - public void testKvpServiceMetadataURLMultipleParameters_manyAmpersands() throws Exception { - String result = WMTSUtils.getKvpServiceMetadataURL("https://www.foo.com/?bar=doo&dii=daa&&&"); - assertEquals("https://www.foo.com/?bar=doo&dii=daa&SERVICE=wmts&REQUEST=getcapabilities&VERSION=1.0.0", result); - } - - @Test - public void testKvpServiceMetadataURLMultipleParameters_manyAmpersands_withAnchor() throws Exception { - String result = WMTSUtils.getKvpServiceMetadataURL("https://www.foo.com/?bar=doo&dii=daa&&&#hello"); - assertEquals( - "https://www.foo.com/?bar=doo&dii=daa&SERVICE=wmts&REQUEST=getcapabilities&VERSION=1.0.0#hello", - result); - } - - /** - * Verifies that query parameters are appended to a bare WMTS URL and that the first parameter starts with a - * question mark. - */ - @Test - public void testAppendQueryParametersNoQuery() { - Map params = new LinkedHashMap<>(); - params.put("projecttoken", "abc123"); - - String result = WMTSUtils.appendQueryParameters("https://www.foo.com/wmts/rest/WMTSCapabilities.xml", params); - - assertEquals("https://www.foo.com/wmts/rest/WMTSCapabilities.xml?projecttoken=abc123", result); - } - - /** Verifies that query parameters are appended after an existing query string using an ampersand separator. */ - @Test - public void testAppendQueryParametersWithQuery() { - Map params = new LinkedHashMap<>(); - params.put("projecttoken", "abc123"); - - String result = WMTSUtils.appendQueryParameters( - "https://www.foo.com/wmts/rest/WMTSCapabilities.xml?format=image/png", params); - - assertEquals("https://www.foo.com/wmts/rest/WMTSCapabilities.xml?format=image/png&projecttoken=abc123", result); - } - - /** - * Verifies that propagated WMTS query parameters are appended in the same order they were provided and that the - * existing URL path remains unchanged. Values are percent-encoded, so reserved characters such as {@code /}, - * {@code {} and {@code }} show up encoded in the expected result. - */ - @Test - public void testAppendQueryParametersOrder() { - Map params = new LinkedHashMap<>(); - params.put("format", "image/png"); - params.put("time", "{time}"); - params.put("elevation", "{elevation}"); - params.put("projecttoken", "abc123"); - - String result = WMTSUtils.appendQueryParameters("https://www.foo.com/wmts/rest/layer", params); - - assertEquals( - "https://www.foo.com/wmts/rest/layer?format=image%2Fpng&time=%7Btime%7D&elevation=%7Belevation%7D&projecttoken=abc123", - result); - } - - /** - * Verifies that a propagated parameter value containing reserved URL characters (such as {@code &} and {@code =}) - * is percent-encoded rather than being allowed to inject additional query parameters. - */ - @Test - public void testAppendQueryParametersEncodesReservedCharacters() { - Map params = new LinkedHashMap<>(); - params.put("projecttoken", "abc&evil=1"); - - String result = WMTSUtils.appendQueryParameters("https://www.foo.com/wmts/rest/layer", params); - - assertEquals("https://www.foo.com/wmts/rest/layer?projecttoken=abc%26evil%3D1", result); - } -}