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