1
2
3
4
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
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
149
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
162 Set<String> nonHiddenAttributes =
163 getConfiguredAttributes(tmft, appLayerSettings).keySet();
164
165 if (!attributes.isEmpty()) {
166
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
174
175
176 attributes = new HashSet<>(nonHiddenAttributes);
177 }
178
179
180
181
182 if (!attributes.isEmpty() && tmft.getDefaultGeometryAttribute() != null) {
183 attributes.add(tmft.getDefaultGeometryAttribute());
184 }
185
186
187
188
189
190 try {
191 List<String> wfsAttributeNames = getWFSAttributeNames(wfsTypeNameDescriptor);
192 attributes.retainAll(wfsAttributeNames);
193
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
221 getFeatureParameters.add("typeNames", wfsTypeName.typeName());
222 getFeatureParameters.add("outputFormat", outputFormat);
223 if (filter != null) {
224
225
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
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
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
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
299
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
342
343
344
345
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 }