View Javadoc
1   /*
2    * Copyright (C) 2023 B3Partners B.V.
3    *
4    * SPDX-License-Identifier: MIT
5    */
6   package org.tailormap.api.controller;
7   
8   import static org.springframework.web.bind.annotation.RequestMethod.GET;
9   import static org.springframework.web.bind.annotation.RequestMethod.POST;
10  import static org.tailormap.api.persistence.helper.TMFeatureTypeHelper.getConfiguredAttributes;
11  import static org.tailormap.api.util.HttpProxyUtil.passthroughResponseHeaders;
12  
13  import io.micrometer.core.annotation.Counted;
14  import io.micrometer.core.annotation.Timed;
15  import jakarta.servlet.http.HttpServletRequest;
16  import java.io.IOException;
17  import java.io.InputStream;
18  import java.io.Serializable;
19  import java.lang.invoke.MethodHandles;
20  import java.net.URI;
21  import java.net.http.HttpResponse;
22  import java.util.HashMap;
23  import java.util.HashSet;
24  import java.util.List;
25  import java.util.Map;
26  import java.util.Set;
27  import org.geotools.api.feature.type.AttributeDescriptor;
28  import org.geotools.data.wfs.WFSDataStore;
29  import org.geotools.data.wfs.WFSDataStoreFactory;
30  import org.slf4j.Logger;
31  import org.slf4j.LoggerFactory;
32  import org.springframework.beans.factory.annotation.Value;
33  import org.springframework.core.io.InputStreamResource;
34  import org.springframework.http.HttpStatus;
35  import org.springframework.http.ResponseEntity;
36  import org.springframework.transaction.annotation.Transactional;
37  import org.springframework.util.CollectionUtils;
38  import org.springframework.util.LinkedMultiValueMap;
39  import org.springframework.util.MultiValueMap;
40  import org.springframework.web.bind.annotation.GetMapping;
41  import org.springframework.web.bind.annotation.ModelAttribute;
42  import org.springframework.web.bind.annotation.RequestMapping;
43  import org.springframework.web.bind.annotation.RequestParam;
44  import org.springframework.web.server.ResponseStatusException;
45  import org.tailormap.api.annotation.AppRestController;
46  import org.tailormap.api.geotools.PreventLocalAllowNestedJarEntityResolver;
47  import org.tailormap.api.geotools.wfs.SimpleWFSHelper;
48  import org.tailormap.api.geotools.wfs.SimpleWFSLayerDescription;
49  import org.tailormap.api.geotools.wfs.WFSProxy;
50  import org.tailormap.api.persistence.Application;
51  import org.tailormap.api.persistence.GeoService;
52  import org.tailormap.api.persistence.TMFeatureSource;
53  import org.tailormap.api.persistence.TMFeatureType;
54  import org.tailormap.api.persistence.json.AppLayerSettings;
55  import org.tailormap.api.persistence.json.AppTreeLayerNode;
56  import org.tailormap.api.persistence.json.GeoServiceLayer;
57  import org.tailormap.api.persistence.json.GeoServiceProtocol;
58  import org.tailormap.api.persistence.json.ServiceAuthentication;
59  import org.tailormap.api.repository.FeatureSourceRepository;
60  import org.tailormap.api.viewer.model.LayerExportCapabilities;
61  
62  @AppRestController
63  @RequestMapping(path = "${tailormap-api.base-path}/{viewerKind}/{viewerName}/layer/{appLayerId}/export/")
64  public class LayerExportController {
65    private static final Logger logger =
66        LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
67  
68    @Value("#{'${tailormap-api.export.allowed-outputformats}'.split(',')}")
69    private List<String> allowedOutputFormats;
70  
71    private final FeatureSourceRepository featureSourceRepository;
72    private final WFSProxy wfsProxy = new WFSProxy();
73  
74    public LayerExportController(FeatureSourceRepository featureSourceRepository) {
75      this.featureSourceRepository = featureSourceRepository;
76    }
77  
78    @Transactional
79    @GetMapping(path = "capabilities")
80    @Timed(value = "export_get_capabilities", description = "Get layer export capabilities")
81    public ResponseEntity<Serializable> capabilities(
82        @ModelAttribute GeoService service, @ModelAttribute GeoServiceLayer layer) {
83  
84      final LayerExportCapabilities capabilities = new LayerExportCapabilities().exportable(false);
85  
86      TMFeatureType tmft = service.findFeatureTypeForLayer(layer, featureSourceRepository);
87  
88      if (tmft != null) {
89        WFSTypeNameDescriptor wfsTypeNameDescriptor = findWFSFeatureType(service, layer, tmft);
90  
91        if (wfsTypeNameDescriptor != null) {
92          try {
93            List<String> outputFormats = SimpleWFSHelper.getOutputFormats(
94                wfsTypeNameDescriptor.wfsUrl(),
95                wfsTypeNameDescriptor.typeName(),
96                wfsTypeNameDescriptor.username(),
97                wfsTypeNameDescriptor.password());
98            capabilities.setOutputFormats(outputFormats);
99          } catch (Exception e) {
100           String msg = "Error getting capabilities for WFS \"%s\"".formatted(wfsTypeNameDescriptor.wfsUrl());
101           if (logger.isTraceEnabled()) {
102             logger.trace(msg, e);
103           } else {
104             logger.warn("{}: {}: {}", msg, e.getClass(), e.getMessage());
105           }
106           capabilities.setOutputFormats(null);
107         }
108       }
109       capabilities.setExportable(capabilities.getOutputFormats() != null
110           && !capabilities.getOutputFormats().isEmpty());
111     }
112 
113     return ResponseEntity.status(HttpStatus.OK).body(capabilities);
114   }
115 
116   @Transactional
117   @RequestMapping(
118       path = "download",
119       method = {GET, POST})
120   @Counted(value = "export_download", description = "Count of layer downloads")
121   public ResponseEntity<?> download(
122       @ModelAttribute GeoService service,
123       @ModelAttribute GeoServiceLayer layer,
124       @ModelAttribute Application application,
125       @ModelAttribute AppTreeLayerNode appTreeLayerNode,
126       @RequestParam String outputFormat,
127       @RequestParam(required = false) Set<String> attributes,
128       @RequestParam(required = false) String filter,
129       @RequestParam(required = false) String sortBy,
130       @RequestParam(required = false) String sortOrder,
131       @RequestParam(required = false) String crs,
132       HttpServletRequest request) {
133 
134     // Validate outputFormat
135     if (!allowedOutputFormats.contains(outputFormat)) {
136       logger.warn("Invalid output format requested: {}", outputFormat);
137       return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Invalid output format");
138     }
139 
140     TMFeatureType tmft = service.findFeatureTypeForLayer(layer, featureSourceRepository);
141     AppLayerSettings appLayerSettings = application.getAppLayerSettings(appTreeLayerNode);
142 
143     if (tmft == null) {
144       logger.debug("Layer export requested for layer without feature type");
145       throw new ResponseStatusException(HttpStatus.NOT_FOUND);
146     }
147 
148     // Find a WFS feature type either because it is configured in Tailormap or by a SLD
149     // DescribeLayer request
150     WFSTypeNameDescriptor wfsTypeNameDescriptor = findWFSFeatureType(service, layer, tmft);
151 
152     if (wfsTypeNameDescriptor == null) {
153       throw new ResponseStatusException(
154           HttpStatus.SERVICE_UNAVAILABLE, "No suitable WFS available for layer export");
155     }
156 
157     if (attributes == null) {
158       attributes = new HashSet<>();
159     }
160 
161     // Get attributes in configured or original order
162     Set<String> nonHiddenAttributes =
163         getConfiguredAttributes(tmft, appLayerSettings).keySet();
164 
165     if (!attributes.isEmpty()) {
166       // Only export non-hidden property names
167       if (!nonHiddenAttributes.containsAll(attributes)) {
168         throw new ResponseStatusException(
169             HttpStatus.BAD_REQUEST,
170             "One or more requested attributes are not available on the feature type");
171       }
172     } else if (!tmft.getSettings().getHideAttributes().isEmpty()) {
173       // Only specify specific propNames if there are hidden attributes. Having no propNames
174       // request parameter to request all propNames is less error-prone than specifying the ones
175       // we have saved in the feature type
176       attributes = new HashSet<>(nonHiddenAttributes);
177     }
178 
179     // Empty attributes means we won't specify propNames in the GetFeature request. However, if we do select only
180     // some property names, we need the geometry attribute which is not in the 'attributes' request param so spatial
181     // export formats don't have the geometry missing.
182     if (!attributes.isEmpty() && tmft.getDefaultGeometryAttribute() != null) {
183       attributes.add(tmft.getDefaultGeometryAttribute());
184     }
185 
186     // Remove attributes which the WFS does not expose. This can be the case when using the
187     // 'customize attributes' feature in GeoServer but when TM has been configured with a JDBC
188     // feature type with all the attributes. Requesting a non-existing attribute will return an
189     // error.
190     try {
191       List<String> wfsAttributeNames = getWFSAttributeNames(wfsTypeNameDescriptor);
192       attributes.retainAll(wfsAttributeNames);
193       // SSRF prevention: only allow known-safe attribute names
194       attributes.removeIf(attr -> !attr.matches("^[A-Za-z0-9_]+$"));
195       if (!CollectionUtils.isEmpty(attributes)
196           && attributes.stream().anyMatch(attr -> !wfsAttributeNames.contains(attr))) {
197         logger.warn("Download request contained illegal attribute(s)");
198         return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Invalid attribute selection");
199       }
200     } catch (IOException e) {
201       logger.error("Error getting WFS feature type", e);
202       return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Error getting WFS feature type");
203     }
204 
205     return downloadFromWFS(
206         wfsTypeNameDescriptor, outputFormat, attributes, filter, sortBy, sortOrder, crs, request);
207   }
208 
209   private ResponseEntity<?> downloadFromWFS(
210       WFSTypeNameDescriptor wfsTypeName,
211       String outputFormat,
212       Set<String> attributes,
213       String filter,
214       String sortBy,
215       String sortOrder,
216       String crs,
217       HttpServletRequest request) {
218 
219     MultiValueMap<String, String> getFeatureParameters = new LinkedMultiValueMap<>();
220     // A layer could have more than one featureType as source, currently we assume it's just one
221     getFeatureParameters.add("typeNames", wfsTypeName.typeName());
222     getFeatureParameters.add("outputFormat", outputFormat);
223     if (filter != null) {
224       // GeoServer vendor-specific
225       // https://docs.geoserver.org/latest/en/user/services/wfs/vendor.html#cql-filters
226       getFeatureParameters.add("cql_filter", filter);
227     }
228     if (crs != null) {
229       getFeatureParameters.add("srsName", crs);
230     }
231     if (!CollectionUtils.isEmpty(attributes)) {
232       getFeatureParameters.add("propertyName", String.join(",", attributes));
233     }
234     if (sortBy != null) {
235       getFeatureParameters.add("sortBy", sortBy + ("asc".equals(sortOrder) ? " A" : " D"));
236     }
237     URI wfsGetFeature = SimpleWFSHelper.getWFSRequestURL(wfsTypeName.wfsUrl(), "GetFeature", getFeatureParameters);
238 
239     logger.info("Layer download, proxying WFS GetFeature request {}", wfsGetFeature);
240     try {
241       // TODO: close JPA connection before proxying
242       HttpResponse<InputStream> response =
243           wfsProxy.proxyWfsRequest(wfsGetFeature, wfsTypeName.username(), wfsTypeName.password(), request);
244 
245       logger.info(
246           "Layer download response code: {}, content type: {}, disposition: {}",
247           response.statusCode(),
248           response.headers()
249               .firstValue("Content-Type")
250               .map(Object::toString)
251               .orElse("<none>"),
252           response.headers()
253               .firstValue("Content-Disposition")
254               .map(Object::toString)
255               .orElse("<none>"));
256 
257       InputStreamResource body = new InputStreamResource(response.body());
258 
259       org.springframework.http.HttpHeaders headers =
260           passthroughResponseHeaders(response.headers(), Set.of("Content-Type", "Content-Disposition"));
261 
262       // TODO: record response size and time with micrometer
263       return ResponseEntity.status(response.statusCode()).headers(headers).body(body);
264     } catch (Exception e) {
265       return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body("Bad Gateway");
266     }
267   }
268 
269   private record WFSTypeNameDescriptor(String wfsUrl, String typeName, String username, String password) {}
270 
271   private WFSTypeNameDescriptor findWFSFeatureType(GeoService service, GeoServiceLayer layer, TMFeatureType tmft) {
272 
273     String wfsUrl = null;
274     String typeName = null;
275     String username = null;
276     String password = null;
277     ServiceAuthentication auth = null;
278 
279     if (tmft != null) {
280       TMFeatureSource featureSource = tmft.getFeatureSource();
281 
282       if (featureSource.getProtocol() == TMFeatureSource.Protocol.WFS) {
283         wfsUrl = featureSource.getUrl();
284         typeName = tmft.getName();
285         auth = featureSource.getAuthentication();
286       }
287     }
288 
289     if ((wfsUrl == null || typeName == null) && service.getProtocol() == GeoServiceProtocol.WMS) {
290       // Try to find out the WFS by doing a DescribeLayer request (from OGC SLD spec)
291       auth = service.getAuthentication();
292 
293       SimpleWFSLayerDescription wfsLayerDescription = getWFSLayerDescriptionForWMS(service, layer.getName());
294       if (wfsLayerDescription != null
295           && wfsLayerDescription.wfsUrl() != null
296           && wfsLayerDescription.getFirstTypeName() != null) {
297         wfsUrl = wfsLayerDescription.wfsUrl();
298         // Ignores possibly multiple feature types associated with the layer (a group layer for
299         // instance)
300         typeName = wfsLayerDescription.getFirstTypeName();
301         auth = service.getAuthentication();
302       }
303     }
304 
305     if (auth != null && auth.getMethod() == ServiceAuthentication.MethodEnum.PASSWORD) {
306       username = auth.getUsername();
307       password = auth.getPassword();
308     }
309 
310     if (wfsUrl != null && typeName != null) {
311       return new WFSTypeNameDescriptor(wfsUrl, typeName, username, password);
312     } else {
313       return null;
314     }
315   }
316 
317   private SimpleWFSLayerDescription getWFSLayerDescriptionForWMS(GeoService wmsService, String layerName) {
318     String username = null;
319     String password = null;
320     if (wmsService.getAuthentication() != null
321         && wmsService.getAuthentication().getMethod() == ServiceAuthentication.MethodEnum.PASSWORD) {
322       username = wmsService.getAuthentication().getUsername();
323       password = wmsService.getAuthentication().getPassword();
324     }
325     SimpleWFSLayerDescription wfsLayerDescription =
326         SimpleWFSHelper.describeWMSLayer(wmsService.getUrl(), username, password, layerName);
327     if (wfsLayerDescription != null && !wfsLayerDescription.typeNames().isEmpty()) {
328       logger.info(
329           "WMS described layer \"{}\" with typeNames \"{}\" of WFS \"{}\" for WMS \"{}\"",
330           layerName,
331           wfsLayerDescription.typeNames(),
332           wfsLayerDescription.wfsUrl(),
333           wmsService.getUrl());
334 
335       return wfsLayerDescription;
336     }
337     return null;
338   }
339 
340   /**
341    * Get the (exposed) attribute names of the WFS feature type.
342    *
343    * @param wfsTypeNameDescriptor provides the WFS feature type to get the attribute names for
344    * @return a list of attribute names for the WFS feature type
345    * @throws IOException if there were any problems setting up (creating or connecting) the datasource.
346    */
347   private static List<String> getWFSAttributeNames(WFSTypeNameDescriptor wfsTypeNameDescriptor) throws IOException {
348     Map<String, Object> connectionParameters = new HashMap<>();
349     connectionParameters.put(
350         WFSDataStoreFactory.ENTITY_RESOLVER.key, PreventLocalAllowNestedJarEntityResolver.INSTANCE);
351     connectionParameters.put(
352         WFSDataStoreFactory.URL.key,
353         SimpleWFSHelper.getWFSRequestURL(wfsTypeNameDescriptor.wfsUrl(), "GetCapabilities")
354             .toURL());
355     connectionParameters.put(WFSDataStoreFactory.PROTOCOL.key, false);
356     connectionParameters.put(WFSDataStoreFactory.WFS_STRATEGY.key, "geoserver");
357     connectionParameters.put(WFSDataStoreFactory.LENIENT.key, true);
358     connectionParameters.put(WFSDataStoreFactory.TIMEOUT.key, SimpleWFSHelper.TIMEOUT);
359     if (wfsTypeNameDescriptor.username() != null) {
360       connectionParameters.put(WFSDataStoreFactory.USERNAME.key, wfsTypeNameDescriptor.username());
361       connectionParameters.put(WFSDataStoreFactory.PASSWORD.key, wfsTypeNameDescriptor.password());
362     }
363 
364     WFSDataStore wfs = new WFSDataStoreFactory().createDataStore(connectionParameters);
365     List<String> attributeNames =
366         wfs.getFeatureSource(wfsTypeNameDescriptor.typeName()).getSchema().getAttributeDescriptors().stream()
367             .map(AttributeDescriptor::getLocalName)
368             .toList();
369 
370     wfs.dispose();
371     return attributeNames;
372   }
373 }