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