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 switch (protocol) {
143 case WMS, WMTS -> {
144 return doProxy(buildWMSUrl(service, request), service, request);
145 }
146 case LEGEND -> {
147 URI legendURI = buildLegendURI(service, layer, request);
148 if (legendURI == null) {
149 logger.warn("No legend URL found for layer {}", layer.getName());
150 return null;
151 }
152 return doProxy(legendURI, service, request);
153 }
154 case TILES3D ->
155 throw new ResponseStatusException(
156 HttpStatus.BAD_REQUEST, "Incorrect 3D Tiles proxy request: No path to capabilities or content");
157 default ->
158 throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported proxy protocol: " + protocol);
159 }
160 }
161
162 private void checkRequestValidity(
163 Application application,
164 GeoService service,
165 GeoServiceLayer layer,
166 GeoServiceProtocol protocol,
167 HttpServletRequest request) {
168 if (service == null || layer == null) {
169 throw new ResponseStatusException(HttpStatus.NOT_FOUND);
170 }
171
172 if (GeoServiceProtocol.XYZ.equals(protocol)) {
173 throw new ResponseStatusException(HttpStatus.NOT_IMPLEMENTED, "XYZ proxying not implemented");
174 }
175
176 if (!(service.getProtocol().equals(protocol) || GeoServiceProtocol.LEGEND.equals(protocol))) {
177 throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid proxy protocol: " + protocol);
178 }
179
180 if (!service.getSettings().getUseProxy()) {
181 throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Proxy not enabled for requested service");
182 }
183
184
185
186 long wmsLayerParamCount = request.getParameterMap().entrySet().stream()
187 .filter(entry -> "LAYERS".equalsIgnoreCase(entry.getKey()))
188 .count();
189 boolean hasMultipleLayerValues = request.getParameterMap().entrySet().stream()
190 .filter(entry -> "LAYERS".equalsIgnoreCase(entry.getKey()))
191 .anyMatch(entry -> entry.getValue().length > 1
192 || (entry.getValue().length == 1 && entry.getValue()[0].indexOf(',') >= 0));
193 if (wmsLayerParamCount > 1 || hasMultipleLayerValues) {
194 throw new ResponseStatusException(
195 HttpStatus.BAD_REQUEST, "Multiple layers in LAYERS parameter not supported");
196 }
197
198
199 String layerNameParamValue = request.getParameterMap().entrySet().stream()
200 .filter(entry -> "LAYERS".equalsIgnoreCase(entry.getKey()))
201 .findFirst()
202 .map(entry -> entry.getValue()[0])
203 .orElse(null);
204
205 if (layerNameParamValue != null && !layer.getName().equals(layerNameParamValue)) {
206
207 if (proxyLayerPassthroughPatterns.stream().noneMatch(pattern -> {
208 String regex = String.format(pattern, Pattern.quote(layer.getName()));
209 return Pattern.compile(regex).matcher(layerNameParamValue).matches();
210 })) {
211 throw new ResponseStatusException(
212 HttpStatus.BAD_REQUEST, "Requested layer name does not match expected layer");
213 }
214
215 if (!proxyPassthroughHostNames.isEmpty()) {
216
217 try {
218 String geoServiceHostName = new URI(service.getUrl()).getHost();
219 if (proxyPassthroughHostNames.stream()
220 .noneMatch(hostname -> hostname.equalsIgnoreCase(geoServiceHostName))) {
221 throw new ResponseStatusException(
222 HttpStatus.BAD_REQUEST,
223 "Requested service hostname does not match allowed hostnames for layer passthrough");
224 }
225 } catch (URISyntaxException e) {
226 logger.error(
227 "Invalid service URL \"{}\" for layer id {}: {}",
228 service.getUrl(),
229 layer.getId(),
230 e.getMessage());
231 throw new ResponseStatusException(
232 HttpStatus.INTERNAL_SERVER_ERROR, "Invalid service URL in configuration");
233 }
234 }
235 }
236
237 if (authorisationService.mustDenyAccessForSecuredProxy(service)) {
238 logger.debug(
239 "Denying proxy for layer \"{}\" in app #{} (\"{}\") from secured service #{} (URL {}): user is not authenticated",
240 layer.getName(),
241 application.getId(),
242 application.getName(),
243 service.getId(),
244 service.getUrl());
245 throw new ResponseStatusException(HttpStatus.FORBIDDEN);
246 }
247 }
248
249 private @Nullable URI buildLegendURI(GeoService service, GeoServiceLayer layer, HttpServletRequest request) {
250 URI legendURI = GeoServiceHelper.getLayerLegendUrlFromStyles(service, layer);
251 if (legendURI == null) {
252 return null;
253 }
254
255
256
257 String wmsRequest = getWmsRequest(legendURI);
258 if (!"getlegendgraphic".equalsIgnoreCase(wmsRequest)) {
259 return legendURI;
260 }
261
262
263
264 UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUri(legendURI);
265 if (request.getParameterMap() != null) {
266 request.getParameterMap().forEach((key, values) -> {
267 for (String value : values) {
268 uriComponentsBuilder.replaceQueryParam(key, UriUtils.encode(value, StandardCharsets.UTF_8));
269 }
270 });
271 }
272
273
274 uriComponentsBuilder.replaceQueryParam("REQUEST", "GetLegendGraphic");
275
276 return uriComponentsBuilder.build(true).toUri();
277 }
278
279 private URI buildWMSUrl(GeoService service, HttpServletRequest request) {
280 final UriComponentsBuilder originalServiceUrl = UriComponentsBuilder.fromUriString(service.getUrl());
281
282
283 final MultiValueMap<String, String> requestParams = request.getParameterMap().entrySet().stream()
284 .map(entry -> new AbstractMap.SimpleEntry<>(
285 entry.getKey(),
286 Arrays.stream(entry.getValue())
287 .map(value -> UriUtils.encode(value, StandardCharsets.UTF_8))
288 .collect(Collectors.toList())))
289 .collect(Collectors.toMap(
290 Map.Entry::getKey, Map.Entry::getValue, (x, y) -> y, LinkedMultiValueMap::new));
291 final MultiValueMap<String, String> params =
292 buildOgcProxyRequestParams(originalServiceUrl.build(true).getQueryParams(), requestParams);
293 originalServiceUrl.replaceQueryParams(params);
294 return originalServiceUrl.build(true).toUri();
295 }
296
297 public static MultiValueMap<String, String> buildOgcProxyRequestParams(
298 MultiValueMap<String, String> originalServiceParams, MultiValueMap<String, String> requestParams) {
299
300 final MultiValueMap<String, String> params = new LinkedMultiValueMap<>(requestParams);
301
302
303
304 final List<String> ogcParams = List.of(new String[] {"SERVICE", "REQUEST", "VERSION"});
305 for (Map.Entry<String, List<String>> serviceParam : originalServiceParams.entrySet()) {
306 if (!params.containsKey(serviceParam.getKey())
307 && !ogcParams.contains(serviceParam.getKey().toUpperCase(Locale.ROOT))) {
308 params.put(serviceParam.getKey(), serviceParam.getValue());
309 }
310 }
311 return params;
312 }
313
314 private URI build3DTilesUrl(GeoService service, HttpServletRequest request) {
315
316
317
318
319 final UriComponentsBuilder originalServiceUrl = UriComponentsBuilder.fromUriString(service.getUrl());
320 String baseUrl = originalServiceUrl.build(true).toUriString();
321 String[] parts = request.getRequestURI().split("/proxy/tiles3d/", 2);
322 if (parts.length < 2) {
323 throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid 3D tiles proxy path");
324 }
325 String pathToContent = parts[1];
326
327
328 if (pathToContent.contains("..") || pathToContent.contains("\\")) {
329 throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid 3D tiles content path");
330 }
331
332
333 if (Objects.equals(pathToContent, TILES3D_DESCRIPTION_PATH)) {
334 return UriComponentsBuilder.fromUriString(baseUrl).build(true).toUri();
335 }
336
337
338 int lastSlashIndex = baseUrl.lastIndexOf('/');
339 if (lastSlashIndex != -1) {
340 baseUrl = baseUrl.substring(0, lastSlashIndex + 1);
341 }
342
343
344 String finalUrl = baseUrl + pathToContent;
345 return UriComponentsBuilder.fromUriString(finalUrl).build(true).toUri();
346 }
347
348 private ResponseEntity<?> doProxy(URI uri, GeoService service, HttpServletRequest request) {
349 HttpRequest.Builder requestBuilder = HttpRequest.newBuilder();
350
351 configureProxyRequestBuilderForUri(requestBuilder, uri, request);
352
353 addForwardedForRequestHeaders(requestBuilder, request);
354
355 passthroughRequestHeaders(
356 requestBuilder,
357 request,
358 Set.of(
359 ACCEPT,
360 IF_MODIFIED_SINCE,
361 IF_UNMODIFIED_SINCE,
362 IF_MATCH,
363 IF_NONE_MATCH,
364 IF_RANGE,
365 RANGE,
366 REFERER,
367 USER_AGENT));
368
369 if (service.getAuthentication() != null
370 && service.getAuthentication().getMethod() == ServiceAuthentication.MethodEnum.PASSWORD) {
371 setHttpBasicAuthenticationHeader(
372 requestBuilder,
373 service.getAuthentication().getUsername(),
374 service.getAuthentication().getPassword());
375 }
376
377 try {
378
379 HttpResponse<InputStream> response =
380 this.httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofInputStream());
381
382
383
384
385
386
387
388 InputStreamResource body = new InputStreamResource(response.body());
389 HttpHeaders headers = passthroughResponseHeaders(
390 response.headers(),
391 Set.of(
392 CONTENT_TYPE,
393 CONTENT_LENGTH,
394 CONTENT_RANGE,
395 CONTENT_DISPOSITION,
396 CACHE_CONTROL,
397 EXPIRES,
398 LAST_MODIFIED,
399 ETAG,
400 PRAGMA));
401 return ResponseEntity.status(response.statusCode()).headers(headers).body(body);
402 } catch (Exception e) {
403 return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body("Bad Gateway");
404 }
405 }
406 }