1
2
3
4
5
6 package org.tailormap.api.controller;
7
8 import static org.springframework.http.HttpHeaders.ACCEPT;
9 import static org.springframework.http.HttpHeaders.CACHE_CONTROL;
10 import static org.springframework.http.HttpHeaders.CONTENT_DISPOSITION;
11 import static org.springframework.http.HttpHeaders.CONTENT_LENGTH;
12 import static org.springframework.http.HttpHeaders.CONTENT_RANGE;
13 import static org.springframework.http.HttpHeaders.CONTENT_TYPE;
14 import static org.springframework.http.HttpHeaders.ETAG;
15 import static org.springframework.http.HttpHeaders.EXPIRES;
16 import static org.springframework.http.HttpHeaders.IF_MATCH;
17 import static org.springframework.http.HttpHeaders.IF_MODIFIED_SINCE;
18 import static org.springframework.http.HttpHeaders.IF_NONE_MATCH;
19 import static org.springframework.http.HttpHeaders.IF_RANGE;
20 import static org.springframework.http.HttpHeaders.IF_UNMODIFIED_SINCE;
21 import static org.springframework.http.HttpHeaders.LAST_MODIFIED;
22 import static org.springframework.http.HttpHeaders.PRAGMA;
23 import static org.springframework.http.HttpHeaders.RANGE;
24 import static org.springframework.http.HttpHeaders.REFERER;
25 import static org.springframework.http.HttpHeaders.USER_AGENT;
26 import static org.springframework.web.bind.annotation.RequestMethod.GET;
27 import static org.springframework.web.bind.annotation.RequestMethod.POST;
28 import static org.tailormap.api.persistence.helper.GeoServiceHelper.getWmsRequest;
29 import static org.tailormap.api.util.HttpProxyUtil.addForwardedForRequestHeaders;
30 import static org.tailormap.api.util.HttpProxyUtil.configureProxyRequestBuilderForUri;
31 import static org.tailormap.api.util.HttpProxyUtil.passthroughRequestHeaders;
32 import static org.tailormap.api.util.HttpProxyUtil.passthroughResponseHeaders;
33 import static org.tailormap.api.util.HttpProxyUtil.setHttpBasicAuthenticationHeader;
34
35 import io.micrometer.core.annotation.Timed;
36 import jakarta.annotation.Nullable;
37 import jakarta.servlet.http.HttpServletRequest;
38 import java.io.InputStream;
39 import java.lang.invoke.MethodHandles;
40 import java.net.URI;
41 import java.net.URISyntaxException;
42 import java.net.http.HttpClient;
43 import java.net.http.HttpRequest;
44 import java.net.http.HttpResponse;
45 import java.nio.charset.StandardCharsets;
46 import java.util.AbstractMap;
47 import java.util.Arrays;
48 import java.util.List;
49 import java.util.Locale;
50 import java.util.Map;
51 import java.util.Objects;
52 import java.util.Set;
53 import java.util.regex.Pattern;
54 import java.util.stream.Collectors;
55 import org.slf4j.Logger;
56 import org.slf4j.LoggerFactory;
57 import org.springframework.beans.factory.annotation.Value;
58 import org.springframework.core.io.InputStreamResource;
59 import org.springframework.http.HttpHeaders;
60 import org.springframework.http.HttpStatus;
61 import org.springframework.http.ResponseEntity;
62 import org.springframework.util.LinkedMultiValueMap;
63 import org.springframework.util.MultiValueMap;
64 import org.springframework.validation.annotation.Validated;
65 import org.springframework.web.bind.annotation.ModelAttribute;
66 import org.springframework.web.bind.annotation.PathVariable;
67 import org.springframework.web.bind.annotation.RequestMapping;
68 import org.springframework.web.server.ResponseStatusException;
69 import org.springframework.web.util.UriComponentsBuilder;
70 import org.springframework.web.util.UriUtils;
71 import org.tailormap.api.annotation.AppRestController;
72 import org.tailormap.api.persistence.Application;
73 import org.tailormap.api.persistence.GeoService;
74 import org.tailormap.api.persistence.helper.GeoServiceHelper;
75 import org.tailormap.api.persistence.json.GeoServiceLayer;
76 import org.tailormap.api.persistence.json.GeoServiceProtocol;
77 import org.tailormap.api.persistence.json.ServiceAuthentication;
78 import org.tailormap.api.security.AuthorisationService;
79
80
81
82
83
84
85
86
87
88
89
90 @AppRestController
91 @Validated
92
93 @RequestMapping(path = "/api/{viewerKind}/{viewerName}/layer/{appLayerId}/proxy")
94 public class GeoServiceProxyController {
95 private static final Logger logger =
96 LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
97 private final AuthorisationService authorisationService;
98 private final HttpClient httpClient;
99
100 public static final String TILES3D_DESCRIPTION_PATH = "tiles3dDescription";
101
102 @Value("${tailormap-api.proxy.passthrough.layerpatterns:}")
103 private Set<String> proxyLayerPassthroughPatterns = Set.of();
104
105 @Value("${tailormap-api.proxy.passthrough.hostnames:}")
106 private Set<String> proxyPassthroughHostNames = Set.of();
107
108 public GeoServiceProxyController(AuthorisationService authorisationService) {
109 this.authorisationService = authorisationService;
110
111 final HttpClient.Builder builder = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL);
112 this.httpClient = builder.build();
113 }
114
115 @RequestMapping(
116 method = {GET, POST},
117 path = "/tiles3d/**")
118 public ResponseEntity<?> proxy3dtiles(
119 @ModelAttribute Application application,
120 @ModelAttribute GeoService service,
121 @ModelAttribute GeoServiceLayer layer,
122 HttpServletRequest request) {
123
124 checkRequestValidity(application, service, layer, GeoServiceProtocol.TILES3D, request);
125
126 return doProxy(build3DTilesUrl(service, request), service, request);
127 }
128
129 @RequestMapping(
130 method = {GET, POST},
131 path = "/{protocol}")
132 @Timed(value = "proxy", description = "Proxy OGC service calls")
133 public ResponseEntity<?> proxy(
134 @ModelAttribute Application application,
135 @ModelAttribute GeoService service,
136 @ModelAttribute GeoServiceLayer layer,
137 @PathVariable("protocol") GeoServiceProtocol protocol,
138 HttpServletRequest request) {
139
140 checkRequestValidity(application, service, layer, protocol, request);
141
142 return switch (protocol) {
143 case WMS, WMTS -> doProxy(buildWMSUrl(service, request), service, request);
144 case LEGEND -> {
145 URI legendURI = buildLegendURI(service, layer, request);
146 if (legendURI == null) {
147 logger.warn("No legend URL found for layer {}", layer.getName());
148 yield null;
149 }
150 yield doProxy(legendURI, service, request);
151 }
152 case TILES3D ->
153 throw new ResponseStatusException(
154 HttpStatus.BAD_REQUEST, "Incorrect 3D Tiles proxy request: No path to capabilities or content");
155 default ->
156 throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported proxy protocol: " + protocol);
157 };
158 }
159
160 private void checkRequestValidity(
161 Application application,
162 GeoService service,
163 GeoServiceLayer layer,
164 GeoServiceProtocol protocol,
165 HttpServletRequest request) {
166 if (service == null || layer == null) {
167 throw new ResponseStatusException(HttpStatus.NOT_FOUND);
168 }
169
170 if (GeoServiceProtocol.XYZ.equals(protocol)) {
171 throw new ResponseStatusException(HttpStatus.NOT_IMPLEMENTED, "XYZ proxying not implemented");
172 }
173
174 if (!(service.getProtocol().equals(protocol) || GeoServiceProtocol.LEGEND.equals(protocol))) {
175 throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid proxy protocol: " + protocol);
176 }
177
178 if (!service.getSettings().getUseProxy()) {
179 throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Proxy not enabled for requested service");
180 }
181
182
183
184 long wmsLayerParamCount = request.getParameterMap().entrySet().stream()
185 .filter(entry -> "LAYERS".equalsIgnoreCase(entry.getKey()))
186 .count();
187 boolean hasMultipleLayerValues = request.getParameterMap().entrySet().stream()
188 .filter(entry -> "LAYERS".equalsIgnoreCase(entry.getKey()))
189 .anyMatch(entry -> entry.getValue().length > 1
190 || (entry.getValue().length == 1 && entry.getValue()[0].indexOf(',') >= 0));
191 if (wmsLayerParamCount > 1 || hasMultipleLayerValues) {
192 throw new ResponseStatusException(
193 HttpStatus.BAD_REQUEST, "Multiple layers in LAYERS parameter not supported");
194 }
195
196
197 String layerNameParamValue = request.getParameterMap().entrySet().stream()
198 .filter(entry -> "LAYERS".equalsIgnoreCase(entry.getKey()))
199 .findFirst()
200 .map(entry -> entry.getValue()[0])
201 .orElse(null);
202
203 if (layerNameParamValue != null && !layer.getName().equals(layerNameParamValue)) {
204
205 if (proxyLayerPassthroughPatterns.stream().noneMatch(pattern -> {
206 return Pattern.compile(pattern.formatted(Pattern.quote(layer.getName())))
207 .matcher(layerNameParamValue)
208 .matches();
209 })) {
210 throw new ResponseStatusException(
211 HttpStatus.BAD_REQUEST, "Requested layer name does not match expected layer");
212 }
213
214 if (!proxyPassthroughHostNames.isEmpty()) {
215
216 try {
217 String geoServiceHostName = new URI(service.getUrl()).getHost();
218 if (proxyPassthroughHostNames.stream()
219 .noneMatch(hostname -> hostname.equalsIgnoreCase(geoServiceHostName))) {
220 throw new ResponseStatusException(
221 HttpStatus.BAD_REQUEST,
222 "Requested service hostname does not match allowed hostnames for layer passthrough");
223 }
224 } catch (URISyntaxException e) {
225 logger.error(
226 "Invalid service URL \"{}\" for layer id {}: {}",
227 service.getUrl(),
228 layer.getId(),
229 e.getMessage());
230 throw new ResponseStatusException(
231 HttpStatus.INTERNAL_SERVER_ERROR, "Invalid service URL in configuration");
232 }
233 }
234 }
235
236 if (authorisationService.mustDenyAccessForSecuredProxy(service)) {
237 logger.debug(
238 "Denying proxy for layer \"{}\" in app #{} (\"{}\") from secured service #{} (URL {}): user is not authenticated",
239 layer.getName(),
240 application.getId(),
241 application.getName(),
242 service.getId(),
243 service.getUrl());
244 throw new ResponseStatusException(HttpStatus.FORBIDDEN);
245 }
246 }
247
248 private @Nullable URI buildLegendURI(GeoService service, GeoServiceLayer layer, HttpServletRequest request) {
249 URI legendURI = GeoServiceHelper.getLayerLegendUrlFromStyles(service, layer);
250 if (legendURI == null) {
251 return null;
252 }
253
254
255
256 String wmsRequest = getWmsRequest(legendURI);
257 if (!"getlegendgraphic".equalsIgnoreCase(wmsRequest)) {
258 return legendURI;
259 }
260
261
262
263 UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUri(legendURI);
264 if (request.getParameterMap() != null) {
265 request.getParameterMap().forEach((key, values) -> {
266 for (String value : values) {
267 uriComponentsBuilder.replaceQueryParam(key, UriUtils.encode(value, StandardCharsets.UTF_8));
268 }
269 });
270 }
271
272
273 uriComponentsBuilder.replaceQueryParam("REQUEST", "GetLegendGraphic");
274
275 return uriComponentsBuilder.build(true).toUri();
276 }
277
278 private URI buildWMSUrl(GeoService service, HttpServletRequest request) {
279 final UriComponentsBuilder originalServiceUrl = UriComponentsBuilder.fromUriString(service.getUrl());
280
281
282 final MultiValueMap<String, String> requestParams = request.getParameterMap().entrySet().stream()
283 .map(entry -> new AbstractMap.SimpleEntry<>(
284 entry.getKey(),
285 Arrays.stream(entry.getValue())
286 .map(value -> UriUtils.encode(value, StandardCharsets.UTF_8))
287 .collect(Collectors.toList())))
288 .collect(Collectors.toMap(
289 Map.Entry::getKey, Map.Entry::getValue, (x, y) -> y, LinkedMultiValueMap::new));
290 final MultiValueMap<String, String> params =
291 buildOgcProxyRequestParams(originalServiceUrl.build(true).getQueryParams(), requestParams);
292 originalServiceUrl.replaceQueryParams(params);
293 return originalServiceUrl.build(true).toUri();
294 }
295
296 public static MultiValueMap<String, String> buildOgcProxyRequestParams(
297 MultiValueMap<String, String> originalServiceParams, MultiValueMap<String, String> requestParams) {
298
299 final MultiValueMap<String, String> params = new LinkedMultiValueMap<>(requestParams);
300
301
302
303 final List<String> ogcParams = List.of(new String[] {"SERVICE", "REQUEST", "VERSION"});
304 for (Map.Entry<String, List<String>> serviceParam : originalServiceParams.entrySet()) {
305 if (!params.containsKey(serviceParam.getKey())
306 && !ogcParams.contains(serviceParam.getKey().toUpperCase(Locale.ROOT))) {
307 params.put(serviceParam.getKey(), serviceParam.getValue());
308 }
309 }
310 return params;
311 }
312
313 private URI build3DTilesUrl(GeoService service, HttpServletRequest request) {
314
315
316
317
318 final UriComponentsBuilder originalServiceUrl = UriComponentsBuilder.fromUriString(service.getUrl());
319 String baseUrl = originalServiceUrl.build(true).toUriString();
320 String[] parts = request.getRequestURI().split("/proxy/tiles3d/", 2);
321 if (parts.length < 2) {
322 throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid 3D tiles proxy path");
323 }
324 String pathToContent = parts[1];
325
326
327 if (pathToContent.contains("..") || pathToContent.contains("\\")) {
328 throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid 3D tiles content path");
329 }
330
331
332 if (Objects.equals(pathToContent, TILES3D_DESCRIPTION_PATH)) {
333 return UriComponentsBuilder.fromUriString(baseUrl).build(true).toUri();
334 }
335
336
337 int lastSlashIndex = baseUrl.lastIndexOf('/');
338 if (lastSlashIndex != -1) {
339 baseUrl = baseUrl.substring(0, lastSlashIndex + 1);
340 }
341
342
343 String finalUrl = baseUrl + pathToContent;
344 return UriComponentsBuilder.fromUriString(finalUrl).build(true).toUri();
345 }
346
347 private ResponseEntity<?> doProxy(URI uri, GeoService service, HttpServletRequest request) {
348 HttpRequest.Builder requestBuilder = HttpRequest.newBuilder();
349
350 configureProxyRequestBuilderForUri(requestBuilder, uri, request);
351
352 addForwardedForRequestHeaders(requestBuilder, request);
353
354 passthroughRequestHeaders(
355 requestBuilder,
356 request,
357 Set.of(
358 ACCEPT,
359 IF_MODIFIED_SINCE,
360 IF_UNMODIFIED_SINCE,
361 IF_MATCH,
362 IF_NONE_MATCH,
363 IF_RANGE,
364 RANGE,
365 REFERER,
366 USER_AGENT));
367
368 if (service.getAuthentication() != null
369 && service.getAuthentication().getMethod() == ServiceAuthentication.MethodEnum.PASSWORD) {
370 setHttpBasicAuthenticationHeader(
371 requestBuilder,
372 service.getAuthentication().getUsername(),
373 service.getAuthentication().getPassword());
374 }
375
376 try {
377
378 HttpResponse<InputStream> response =
379 this.httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofInputStream());
380
381
382
383
384
385
386
387 InputStreamResource body = new InputStreamResource(response.body());
388 HttpHeaders headers = passthroughResponseHeaders(
389 response.headers(),
390 Set.of(
391 CONTENT_TYPE,
392 CONTENT_LENGTH,
393 CONTENT_RANGE,
394 CONTENT_DISPOSITION,
395 CACHE_CONTROL,
396 EXPIRES,
397 LAST_MODIFIED,
398 ETAG,
399 PRAGMA));
400 return ResponseEntity.status(response.statusCode()).headers(headers).body(body);
401 } catch (Exception e) {
402 return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body("Bad Gateway");
403 }
404 }
405 }