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.http.HttpClient;
42 import java.net.http.HttpRequest;
43 import java.net.http.HttpResponse;
44 import java.nio.charset.StandardCharsets;
45 import java.util.AbstractMap;
46 import java.util.Arrays;
47 import java.util.List;
48 import java.util.Locale;
49 import java.util.Map;
50 import java.util.Objects;
51 import java.util.Set;
52 import java.util.stream.Collectors;
53 import org.slf4j.Logger;
54 import org.slf4j.LoggerFactory;
55 import org.springframework.core.io.InputStreamResource;
56 import org.springframework.http.HttpHeaders;
57 import org.springframework.http.HttpStatus;
58 import org.springframework.http.ResponseEntity;
59 import org.springframework.util.LinkedMultiValueMap;
60 import org.springframework.util.MultiValueMap;
61 import org.springframework.validation.annotation.Validated;
62 import org.springframework.web.bind.annotation.ModelAttribute;
63 import org.springframework.web.bind.annotation.PathVariable;
64 import org.springframework.web.bind.annotation.RequestMapping;
65 import org.springframework.web.server.ResponseStatusException;
66 import org.springframework.web.util.UriComponentsBuilder;
67 import org.springframework.web.util.UriUtils;
68 import org.tailormap.api.annotation.AppRestController;
69 import org.tailormap.api.persistence.Application;
70 import org.tailormap.api.persistence.GeoService;
71 import org.tailormap.api.persistence.helper.GeoServiceHelper;
72 import org.tailormap.api.persistence.json.GeoServiceLayer;
73 import org.tailormap.api.persistence.json.GeoServiceProtocol;
74 import org.tailormap.api.persistence.json.ServiceAuthentication;
75 import org.tailormap.api.security.AuthorisationService;
76
77
78
79
80
81
82
83
84
85
86
87 @AppRestController
88 @Validated
89
90 @RequestMapping(path = "/api/{viewerKind}/{viewerName}/layer/{appLayerId}/proxy")
91 public class GeoServiceProxyController {
92 private static final Logger logger =
93 LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
94 private final AuthorisationService authorisationService;
95 private final HttpClient httpClient;
96
97 public static final String TILES3D_DESCRIPTION_PATH = "tiles3dDescription";
98
99 public GeoServiceProxyController(AuthorisationService authorisationService) {
100 this.authorisationService = authorisationService;
101
102 final HttpClient.Builder builder = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL);
103 this.httpClient = builder.build();
104 }
105
106 @RequestMapping(
107 method = {GET, POST},
108 path = "/tiles3d/**")
109 public ResponseEntity<?> proxy3dtiles(
110 @ModelAttribute Application application,
111 @ModelAttribute GeoService service,
112 @ModelAttribute GeoServiceLayer layer,
113 HttpServletRequest request) {
114
115 checkRequestValidity(application, service, layer, GeoServiceProtocol.TILES3D);
116
117 return doProxy(build3DTilesUrl(service, request), service, request);
118 }
119
120 @RequestMapping(
121 method = {GET, POST},
122 path = "/{protocol}")
123 @Timed(value = "proxy", description = "Proxy OGC service calls")
124 public ResponseEntity<?> proxy(
125 @ModelAttribute Application application,
126 @ModelAttribute GeoService service,
127 @ModelAttribute GeoServiceLayer layer,
128 @PathVariable("protocol") GeoServiceProtocol protocol,
129 HttpServletRequest request) {
130
131 checkRequestValidity(application, service, layer, protocol);
132
133 switch (protocol) {
134 case WMS, WMTS -> {
135 return doProxy(buildWMSUrl(service, request), service, request);
136 }
137 case LEGEND -> {
138 URI legendURI = buildLegendURI(service, layer, request);
139 if (legendURI == null) {
140 logger.warn("No legend URL found for layer {}", layer.getName());
141 return null;
142 }
143 return doProxy(legendURI, service, request);
144 }
145 case TILES3D ->
146 throw new ResponseStatusException(
147 HttpStatus.BAD_REQUEST, "Incorrect 3D Tiles proxy request: No path to capabilities or content");
148 default ->
149 throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported proxy protocol: " + protocol);
150 }
151 }
152
153 private void checkRequestValidity(
154 Application application, GeoService service, GeoServiceLayer layer, GeoServiceProtocol protocol) {
155 if (service == null || layer == null) {
156 throw new ResponseStatusException(HttpStatus.NOT_FOUND);
157 }
158
159 if (GeoServiceProtocol.XYZ.equals(protocol)) {
160 throw new ResponseStatusException(HttpStatus.NOT_IMPLEMENTED, "XYZ proxying not implemented");
161 }
162
163 if (!(service.getProtocol().equals(protocol) || GeoServiceProtocol.LEGEND.equals(protocol))) {
164 throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid proxy protocol: " + protocol);
165 }
166
167 if (!service.getSettings().getUseProxy()) {
168 throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Proxy not enabled for requested service");
169 }
170
171 if (authorisationService.mustDenyAccessForSecuredProxy(service)) {
172 logger.debug(
173 "Denying proxy for layer \"{}\" in app #{} (\"{}\") from secured service #{} (URL {}): user is not authenticated",
174 layer.getName(),
175 application.getId(),
176 application.getName(),
177 service.getId(),
178 service.getUrl());
179 throw new ResponseStatusException(HttpStatus.FORBIDDEN);
180 }
181 }
182
183 private @Nullable URI buildLegendURI(GeoService service, GeoServiceLayer layer, HttpServletRequest request) {
184 URI legendURI = GeoServiceHelper.getLayerLegendUrlFromStyles(service, layer);
185 if (legendURI == null) {
186 return null;
187 }
188
189
190
191 String wmsRequest = getWmsRequest(legendURI);
192 if (!"getlegendgraphic".equalsIgnoreCase(wmsRequest)) {
193 return legendURI;
194 }
195
196
197
198 UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUri(legendURI);
199 if (request.getParameterMap() != null) {
200 request.getParameterMap().forEach((key, values) -> {
201 for (String value : values) {
202 uriComponentsBuilder.replaceQueryParam(key, UriUtils.encode(value, StandardCharsets.UTF_8));
203 }
204 });
205 }
206
207
208 uriComponentsBuilder.replaceQueryParam("REQUEST", "GetLegendGraphic");
209
210 return uriComponentsBuilder.build(true).toUri();
211 }
212
213 private URI buildWMSUrl(GeoService service, HttpServletRequest request) {
214 final UriComponentsBuilder originalServiceUrl = UriComponentsBuilder.fromUriString(service.getUrl());
215
216
217 final MultiValueMap<String, String> requestParams = request.getParameterMap().entrySet().stream()
218 .map(entry -> new AbstractMap.SimpleEntry<>(
219 entry.getKey(),
220 Arrays.stream(entry.getValue())
221 .map(value -> UriUtils.encode(value, StandardCharsets.UTF_8))
222 .collect(Collectors.toList())))
223 .collect(Collectors.toMap(
224 Map.Entry::getKey, Map.Entry::getValue, (x, y) -> y, LinkedMultiValueMap::new));
225 final MultiValueMap<String, String> params =
226 buildOgcProxyRequestParams(originalServiceUrl.build(true).getQueryParams(), requestParams);
227 originalServiceUrl.replaceQueryParams(params);
228 return originalServiceUrl.build(true).toUri();
229 }
230
231 public static MultiValueMap<String, String> buildOgcProxyRequestParams(
232 MultiValueMap<String, String> originalServiceParams, MultiValueMap<String, String> requestParams) {
233
234 final MultiValueMap<String, String> params = new LinkedMultiValueMap<>(requestParams);
235
236
237
238 final List<String> ogcParams = List.of(new String[] {"SERVICE", "REQUEST", "VERSION"});
239 for (Map.Entry<String, List<String>> serviceParam : originalServiceParams.entrySet()) {
240 if (!params.containsKey(serviceParam.getKey())
241 && !ogcParams.contains(serviceParam.getKey().toUpperCase(Locale.ROOT))) {
242 params.put(serviceParam.getKey(), serviceParam.getValue());
243 }
244 }
245 return params;
246 }
247
248 private URI build3DTilesUrl(GeoService service, HttpServletRequest request) {
249
250
251
252
253 final UriComponentsBuilder originalServiceUrl = UriComponentsBuilder.fromUriString(service.getUrl());
254 String baseUrl = originalServiceUrl.build(true).toUriString();
255 String[] parts = request.getRequestURI().split("/proxy/tiles3d/", 2);
256 if (parts.length < 2) {
257 throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid 3D tiles proxy path");
258 }
259 String pathToContent = parts[1];
260
261
262 if (pathToContent.contains("..") || pathToContent.contains("\\")) {
263 throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid 3D tiles content path");
264 }
265
266
267 if (Objects.equals(pathToContent, TILES3D_DESCRIPTION_PATH)) {
268 return UriComponentsBuilder.fromUriString(baseUrl).build(true).toUri();
269 }
270
271
272 int lastSlashIndex = baseUrl.lastIndexOf('/');
273 if (lastSlashIndex != -1) {
274 baseUrl = baseUrl.substring(0, lastSlashIndex + 1);
275 }
276
277
278 String finalUrl = baseUrl + pathToContent;
279 return UriComponentsBuilder.fromUriString(finalUrl).build(true).toUri();
280 }
281
282 private ResponseEntity<?> doProxy(URI uri, GeoService service, HttpServletRequest request) {
283 HttpRequest.Builder requestBuilder = HttpRequest.newBuilder();
284
285 configureProxyRequestBuilderForUri(requestBuilder, uri, request);
286
287 addForwardedForRequestHeaders(requestBuilder, request);
288
289 passthroughRequestHeaders(
290 requestBuilder,
291 request,
292 Set.of(
293 ACCEPT,
294 IF_MODIFIED_SINCE,
295 IF_UNMODIFIED_SINCE,
296 IF_MATCH,
297 IF_NONE_MATCH,
298 IF_RANGE,
299 RANGE,
300 REFERER,
301 USER_AGENT));
302
303 if (service.getAuthentication() != null
304 && service.getAuthentication().getMethod() == ServiceAuthentication.MethodEnum.PASSWORD) {
305 setHttpBasicAuthenticationHeader(
306 requestBuilder,
307 service.getAuthentication().getUsername(),
308 service.getAuthentication().getPassword());
309 }
310
311 try {
312
313 HttpResponse<InputStream> response =
314 this.httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofInputStream());
315
316
317
318
319
320
321
322 InputStreamResource body = new InputStreamResource(response.body());
323 HttpHeaders headers = passthroughResponseHeaders(
324 response.headers(),
325 Set.of(
326 CONTENT_TYPE,
327 CONTENT_LENGTH,
328 CONTENT_RANGE,
329 CONTENT_DISPOSITION,
330 CACHE_CONTROL,
331 EXPIRES,
332 LAST_MODIFIED,
333 ETAG,
334 PRAGMA));
335 return ResponseEntity.status(response.statusCode()).headers(headers).body(body);
336 } catch (Exception e) {
337 return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body("Bad Gateway");
338 }
339 }
340 }