View Javadoc
1   /*
2    * Copyright (C) 2026 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   
10  import io.micrometer.core.annotation.Counted;
11  import io.micrometer.core.annotation.Timed;
12  import jakarta.validation.Valid;
13  import java.io.IOException;
14  import java.lang.invoke.MethodHandles;
15  import java.net.MalformedURLException;
16  import java.nio.file.Files;
17  import java.nio.file.Path;
18  import java.util.HashSet;
19  import java.util.List;
20  import java.util.Locale;
21  import java.util.Map;
22  import java.util.Set;
23  import java.util.regex.Pattern;
24  import org.apache.commons.lang3.StringUtils;
25  import org.geotools.api.data.Query;
26  import org.geotools.api.data.SimpleFeatureSource;
27  import org.geotools.api.filter.Filter;
28  import org.geotools.api.filter.sort.SortOrder;
29  import org.geotools.api.referencing.FactoryException;
30  import org.geotools.filter.text.cql2.CQLException;
31  import org.jspecify.annotations.Nullable;
32  import org.slf4j.Logger;
33  import org.slf4j.LoggerFactory;
34  import org.springframework.beans.factory.annotation.Value;
35  import org.springframework.core.io.Resource;
36  import org.springframework.core.io.UrlResource;
37  import org.springframework.http.HttpHeaders;
38  import org.springframework.http.HttpStatus;
39  import org.springframework.http.MediaType;
40  import org.springframework.http.ResponseEntity;
41  import org.springframework.transaction.annotation.Transactional;
42  import org.springframework.web.bind.annotation.GetMapping;
43  import org.springframework.web.bind.annotation.ModelAttribute;
44  import org.springframework.web.bind.annotation.PathVariable;
45  import org.springframework.web.bind.annotation.PostMapping;
46  import org.springframework.web.bind.annotation.RequestMapping;
47  import org.springframework.web.bind.annotation.RequestParam;
48  import org.springframework.web.server.ResponseStatusException;
49  import org.tailormap.api.annotation.AppRestController;
50  import org.tailormap.api.geotools.FilterUtil;
51  import org.tailormap.api.geotools.data.excel.ExcelDataStore;
52  import org.tailormap.api.geotools.featuresources.FeatureSourceFactoryHelper;
53  import org.tailormap.api.persistence.Application;
54  import org.tailormap.api.persistence.GeoService;
55  import org.tailormap.api.persistence.TMFeatureType;
56  import org.tailormap.api.persistence.json.AppLayerSettings;
57  import org.tailormap.api.persistence.json.AppTreeLayerNode;
58  import org.tailormap.api.persistence.json.GeoServiceLayer;
59  import org.tailormap.api.repository.FeatureSourceRepository;
60  import org.tailormap.api.service.CreateLayerExtractService;
61  
62  @AppRestController
63  @RequestMapping(path = "${tailormap-api.base-path}/{viewerKind}/{viewerName}/layer/{appLayerId}/extract")
64  public class LayerExtractController {
65    private static final Logger logger =
66        LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
67    private static final Pattern SAFE_DOWNLOAD_ID = Pattern.compile("^[A-Za-z0-9._-]+$");
68    private final FeatureSourceRepository featureSourceRepository;
69    private final CreateLayerExtractService createLayerExtractService;
70    private final FeatureSourceFactoryHelper featureSourceFactoryHelper;
71  
72    @Value("#{'${tailormap-api.extract.allowed-outputformats}'.split(',')}")
73    private List<ExtractOutputFormat> allowedExtractOutputFormats;
74  
75    public LayerExtractController(
76        FeatureSourceRepository featureSourceRepository,
77        CreateLayerExtractService createLayerExtractService,
78        FeatureSourceFactoryHelper featureSourceFactoryHelper) {
79      this.featureSourceRepository = featureSourceRepository;
80      this.createLayerExtractService = createLayerExtractService;
81      this.featureSourceFactoryHelper = featureSourceFactoryHelper;
82    }
83  
84    /**
85     * Download the result of an extract request. The extract generation should be initiated first by a POST to
86     * {@code /{viewerKind}/{viewerName}/layer/{appLayerId}/extract/{clientId}}.
87     */
88    @GetMapping(path = "/download/{downloadId}")
89    @Counted(value = "tailormap_api_extract_download", description = "Count of layer extract downloads")
90    public ResponseEntity<?> download(
91        @ModelAttribute GeoService service,
92        @ModelAttribute GeoServiceLayer layer,
93        @ModelAttribute Application application,
94        @ModelAttribute AppTreeLayerNode appTreeLayerNode,
95        @PathVariable String downloadId)
96        throws MalformedURLException {
97  
98      if (downloadId == null || !SAFE_DOWNLOAD_ID.matcher(downloadId).matches()) {
99        throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid downloadId");
100     }
101     Path exportRoot = Path.of(createLayerExtractService.getExportFilesLocation())
102         .toAbsolutePath()
103         .normalize();
104     Path filePath = exportRoot.resolve(downloadId).normalize();
105     if (!filePath.startsWith(exportRoot)) {
106       throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid downloadId");
107     }
108 
109     Resource resource = new UrlResource(filePath.toUri());
110     if (!resource.exists() || !resource.isReadable() || !resource.isFile()) {
111       throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Download file not found");
112     }
113 
114     String contentType = MediaType.APPLICATION_OCTET_STREAM_VALUE;
115     try {
116       String detectedContentType = Files.probeContentType(filePath);
117       if (detectedContentType != null) {
118         contentType = detectedContentType;
119       }
120     } catch (IOException e) {
121       logger.debug("Could not determine content type for {}", filePath, e);
122     }
123 
124     return ResponseEntity.ok()
125         .contentType(MediaType.parseMediaType(contentType))
126         .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filePath.getFileName() + "\"")
127         .body(resource);
128   }
129 
130   @GetMapping("/formats")
131   public ResponseEntity<?> formats(
132       @Valid @ModelAttribute GeoServiceLayer layer,
133       @ModelAttribute GeoService service,
134       @ModelAttribute Application application,
135       @ModelAttribute AppTreeLayerNode appTreeLayerNode) {
136     return ResponseEntity.ok(allowedExtractOutputFormats.stream()
137         .map(ExtractOutputFormat::getValue)
138         .toList());
139   }
140 
141   @Transactional
142   @PostMapping("/{clientId}")
143   @Timed(value = "tailormap_api_extract", description = "Time taken to process a layer extract request")
144   public ResponseEntity<?> extract(
145       @Valid @ModelAttribute GeoServiceLayer layer,
146       @ModelAttribute GeoService service,
147       @ModelAttribute Application application,
148       @ModelAttribute AppTreeLayerNode appTreeLayerNode,
149       @PathVariable String clientId,
150       @RequestParam ExtractOutputFormat outputFormat,
151       @RequestParam(required = false) Set<String> attributes,
152       @RequestParam(required = false) String filter,
153       @RequestParam(required = false) String sortBy,
154       @RequestParam(required = false, defaultValue = "asc") String sortOrder) {
155 
156     try {
157       createLayerExtractService.validateClientId(clientId);
158     } catch (IllegalArgumentException e) {
159       logger.warn("Invalid clientId for extract request: {}", clientId);
160       throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
161     }
162 
163     if (!allowedExtractOutputFormats.contains(outputFormat)) {
164       logger.debug("Invalid output format requested: {}", outputFormat);
165       throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid output format");
166     }
167 
168     TMFeatureType sourceFT = service.findFeatureTypeForLayer(layer, featureSourceRepository);
169     if (sourceFT == null) {
170       logger.debug("Layer export requested for layer without feature type");
171       throw new ResponseStatusException(HttpStatus.NOT_FOUND);
172     }
173     if (attributes == null) {
174       attributes = new HashSet<>();
175     }
176 
177     AppLayerSettings appLayerSettings = application.getAppLayerSettings(appTreeLayerNode);
178     // Get attributes in configured or original order
179     Set<String> nonHiddenAttributes =
180         getConfiguredAttributes(sourceFT, appLayerSettings).keySet();
181 
182     if (!attributes.isEmpty()) {
183       // Only export non-hidden property names
184       if (!nonHiddenAttributes.containsAll(attributes)) {
185         throw new ResponseStatusException(
186             HttpStatus.BAD_REQUEST,
187             "One or more requested attributes are not available on the feature type");
188       }
189     } else if (!sourceFT.getSettings().getHideAttributes().isEmpty()) {
190       // Only specify specific propNames if there are hidden attributes. Having no propNames
191       // request parameter to request all propNames is less error-prone than specifying the ones
192       // we have saved in the feature type
193       attributes = new HashSet<>(nonHiddenAttributes);
194     }
195 
196     // Empty attributes means we won't specify propNames in the GetFeature request. However, if we do select only
197     // some property names, we need the geometry attribute which is not in the 'attributes' request param so spatial
198     // export formats don't have the geometry missing.
199     if (!attributes.isEmpty() && sourceFT.getDefaultGeometryAttribute() != null) {
200       attributes.add(sourceFT.getDefaultGeometryAttribute());
201     }
202 
203     // check if filter has valid syntax (it could still be invalid wrt feature type)
204     Filter parsedCQL = null;
205     SimpleFeatureSource simpleFeatureSource = null;
206     try {
207       if (!StringUtils.isBlank(filter)) {
208         simpleFeatureSource = featureSourceFactoryHelper.openGeoToolsFeatureSource(sourceFT);
209         parsedCQL = FilterUtil.parseFilter(filter, application, simpleFeatureSource);
210       }
211     } catch (CQLException | FactoryException | UnsupportedOperationException e) {
212       throw new ResponseStatusException(
213           HttpStatus.BAD_REQUEST, "Could not parse requested filter: " + e.getMessage(), e);
214     } catch (IOException e) {
215       throw new ResponseStatusException(
216           HttpStatus.INTERNAL_SERVER_ERROR, "Failed to connect to datasource: " + e.getMessage(), e);
217     } finally {
218       if (simpleFeatureSource != null) {
219         try {
220           simpleFeatureSource.getDataStore().dispose();
221         } catch (Exception e) {
222           logger.warn("Failed to dispose feature source", e);
223         }
224       }
225     }
226     if (ExtractOutputFormat.XLSX.equals(outputFormat)) {
227       validateExcelLimits(sourceFT, attributes, parsedCQL);
228     }
229 
230     SortOrder sortingOrder = SortOrder.ASCENDING;
231     if (null != sortOrder && (sortOrder.equalsIgnoreCase("desc") || sortOrder.equalsIgnoreCase("asc"))) {
232       sortingOrder = SortOrder.valueOf(sortOrder.toUpperCase(Locale.ROOT));
233     }
234 
235     final String outputFileName =
236         this.createLayerExtractService.createExtractFilename(clientId, sourceFT, outputFormat);
237     this.createLayerExtractService.emitProgress(clientId, outputFileName, 0, false, "Extract task received");
238 
239     //noinspection JvmTaintAnalysis Not a Path Traversal Sink because the clientId is validated
240     this.createLayerExtractService.createLayerExtract(
241         clientId, sourceFT, attributes, parsedCQL, sortBy, sortingOrder, outputFormat, outputFileName);
242 
243     //noinspection JvmTaintAnalysis Not an XSS sink because the response is a json message
244     return ResponseEntity.accepted()
245         .body(Map.of("message", "Extract request accepted", "downloadId", outputFileName));
246   }
247 
248   /**
249    * Check that neither the number of columns nor the number of rows requested for the extract exceed the limits of
250    * Excel format. This is required to block extract requests that would fail later on in the ExcelFeatureWriter when
251    * the limits are exceeded. NOTE: cell size limits are handled in the ExcelFeatureWriter.
252    *
253    * @param featureType requested FT
254    * @param attributes requested attributes
255    * @param filter requested filter
256    */
257   private void validateExcelLimits(TMFeatureType featureType, Set<String> attributes, @Nullable Filter filter) {
258     if (attributes.size() > ExcelDataStore.getMaxColumns()) {
259       throw new ResponseStatusException(
260           HttpStatus.BAD_REQUEST,
261           "Excel format does not support more than " + ExcelDataStore.getMaxColumns() + " columns");
262     }
263     SimpleFeatureSource inputFeatureSource = null;
264     try {
265       // count all the features; this is expensive but required to block extract when the Excel limits for
266       // row/columns are exceeded
267       inputFeatureSource = featureSourceFactoryHelper.openGeoToolsFeatureSource(featureType);
268       Query q = new Query(inputFeatureSource.getName().toString());
269       if (!attributes.isEmpty()) {
270         q.setPropertyNames(attributes.toArray(new String[0]));
271       }
272 
273       if (filter != null) {
274         q.setFilter(filter);
275       }
276       final int featCount = inputFeatureSource.getCount(q);
277       if (featCount >= ExcelDataStore.getMaxRows()) {
278         throw new ResponseStatusException(
279             HttpStatus.BAD_REQUEST,
280             "Excel format does not support more than " + ExcelDataStore.getMaxRows() + " rows");
281       }
282     } catch (IOException e) {
283       throw new ResponseStatusException(
284           HttpStatus.INTERNAL_SERVER_ERROR,
285           "Failed to count all features for Excel extract: " + e.getMessage());
286     } catch (IllegalArgumentException e) {
287       throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid filter");
288     } finally {
289       if (inputFeatureSource != null) {
290         inputFeatureSource.getDataStore().dispose();
291       }
292     }
293   }
294 
295   public enum ExtractOutputFormat {
296     GEOPACKAGE("geopackage", ".gpkg"),
297     CSV("csv", ".csv"),
298     GEOJSON("geojson", ".geojson"),
299     XLSX("xlsx", ".xlsx"),
300     SHAPE("shape", ".zip");
301 
302     private final String value;
303     private final String extension;
304 
305     ExtractOutputFormat(String value, String extension) {
306       this.value = value;
307       this.extension = extension;
308     }
309 
310     public static ExtractOutputFormat fromValue(String value) {
311       for (ExtractOutputFormat format : ExtractOutputFormat.values()) {
312         if (format.value.equalsIgnoreCase(value)) {
313           return format;
314         }
315       }
316       throw new IllegalArgumentException("Invalid output format: " + value);
317     }
318 
319     public String getValue() {
320       return this.value;
321     }
322 
323     public String getExtension() {
324       return this.extension;
325     }
326 
327     @Override
328     public String toString() {
329       return String.valueOf(this.value);
330     }
331   }
332 }