View Javadoc
1   /*
2    * Copyright (C) 2025 B3Partners B.V.
3    *
4    * SPDX-License-Identifier: MIT
5    */
6   package org.tailormap.api.controller;
7   
8   import com.google.common.base.Splitter;
9   import jakarta.validation.Valid;
10  import java.io.IOException;
11  import java.io.Serializable;
12  import java.lang.invoke.MethodHandles;
13  import java.nio.ByteBuffer;
14  import java.sql.SQLException;
15  import java.util.List;
16  import java.util.Locale;
17  import java.util.Set;
18  import java.util.UUID;
19  import java.util.regex.Pattern;
20  import org.geotools.api.data.Query;
21  import org.geotools.api.data.SimpleFeatureSource;
22  import org.geotools.api.filter.Filter;
23  import org.geotools.api.filter.FilterFactory;
24  import org.geotools.data.simple.SimpleFeatureIterator;
25  import org.geotools.factory.CommonFactoryFinder;
26  import org.geotools.util.factory.GeoTools;
27  import org.slf4j.Logger;
28  import org.slf4j.LoggerFactory;
29  import org.springframework.http.HttpStatus;
30  import org.springframework.http.MediaType;
31  import org.springframework.http.ResponseEntity;
32  import org.springframework.transaction.annotation.Transactional;
33  import org.springframework.validation.annotation.Validated;
34  import org.springframework.web.bind.annotation.DeleteMapping;
35  import org.springframework.web.bind.annotation.GetMapping;
36  import org.springframework.web.bind.annotation.ModelAttribute;
37  import org.springframework.web.bind.annotation.PathVariable;
38  import org.springframework.web.bind.annotation.PostMapping;
39  import org.springframework.web.bind.annotation.RequestPart;
40  import org.springframework.web.server.ResponseStatusException;
41  import org.tailormap.api.annotation.AppRestController;
42  import org.tailormap.api.geotools.featuresources.AttachmentsHelper;
43  import org.tailormap.api.geotools.featuresources.FeatureSourceFactoryHelper;
44  import org.tailormap.api.persistence.Application;
45  import org.tailormap.api.persistence.GeoService;
46  import org.tailormap.api.persistence.TMFeatureType;
47  import org.tailormap.api.persistence.json.AppTreeLayerNode;
48  import org.tailormap.api.persistence.json.AttachmentAttributeType;
49  import org.tailormap.api.persistence.json.GeoServiceLayer;
50  import org.tailormap.api.util.EditUtil;
51  import org.tailormap.api.viewer.model.AttachmentMetadata;
52  
53  @AppRestController
54  @Validated
55  public class AttachmentsController {
56  
57    private static final Logger logger =
58        LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
59  
60    private final EditUtil editUtil;
61    private final FeatureSourceFactoryHelper featureSourceFactoryHelper;
62    private final FilterFactory ff = CommonFactoryFinder.getFilterFactory(GeoTools.getDefaultHints());
63  
64    public AttachmentsController(EditUtil editUtil, FeatureSourceFactoryHelper featureSourceFactoryHelper) {
65      this.editUtil = editUtil;
66      this.featureSourceFactoryHelper = featureSourceFactoryHelper;
67    }
68  
69    /**
70     * Add an attachment to a feature
71     *
72     * @param appTreeLayerNode the application tree layer node
73     * @param service the geo service
74     * @param layer the geo service layer
75     * @param application the application
76     * @param featureId the feature id
77     * @param attachment the attachment metadata
78     * @param fileData the attachment file data
79     * @return the response entity
80     */
81    @PostMapping(
82        path = {
83          "${tailormap-api.base-path}/{viewerKind}/{viewerName}/layer/{appLayerId}/feature/{featureId}/attachments"
84        },
85        consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
86        produces = MediaType.APPLICATION_JSON_VALUE)
87    @Transactional
88    public ResponseEntity<Serializable> addAttachment(
89        @ModelAttribute AppTreeLayerNode appTreeLayerNode,
90        @ModelAttribute GeoService service,
91        @ModelAttribute GeoServiceLayer layer,
92        @ModelAttribute Application application,
93        @PathVariable String featureId,
94        @RequestPart("attachmentMetadata") AttachmentMetadata attachment,
95        @RequestPart("attachment") byte[] fileData) {
96  
97      editUtil.checkEditAuthorisation();
98  
99      TMFeatureType tmFeatureType = editUtil.getEditableFeatureType(application, appTreeLayerNode, service, layer);
100 
101     Object primaryKey = getFeaturePrimaryKeyByFid(tmFeatureType, featureId);
102 
103     Set<@Valid AttachmentAttributeType> attachmentAttrSet =
104         tmFeatureType.getSettings().getAttachmentAttributes();
105     if (attachmentAttrSet == null || attachmentAttrSet.isEmpty()) {
106       throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Layer does not support attachments");
107     }
108 
109     AttachmentAttributeType attachmentAttribute = attachmentAttrSet.stream()
110         .filter(attr -> attr.getAttributeName().equals(attachment.getAttributeName()))
111         .findFirst()
112         .orElseThrow(() -> new ResponseStatusException(
113             HttpStatus.BAD_REQUEST,
114             "Layer does not support attachments for attribute " + attachment.getAttributeName()));
115 
116     if (attachmentAttribute.getMaxAttachmentSize() != null
117         && attachmentAttribute.getMaxAttachmentSize() < fileData.length) {
118       throw new ResponseStatusException(
119           HttpStatus.BAD_REQUEST,
120           "Attachment size %d exceeds maximum of %d"
121               .formatted(fileData.length, attachmentAttribute.getMaxAttachmentSize()));
122     }
123 
124     if (attachmentAttribute.getMimeType() != null) {
125       if (!validateMimeTypeAccept(
126           attachmentAttribute.getMimeType(), attachment.getFileName(), attachment.getMimeType())) {
127         throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "File type or extension not allowed");
128       }
129     }
130 
131     logger.debug("Using attachment attribute {}", attachmentAttribute);
132 
133     AttachmentMetadata response;
134     try {
135       response = AttachmentsHelper.insertAttachment(tmFeatureType, attachment, primaryKey, fileData);
136     } catch (IOException | SQLException e) {
137       throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage());
138     }
139 
140     return new ResponseEntity<>(response, HttpStatus.CREATED);
141   }
142 
143   /**
144    * Validate as <a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/file#accept">file
145    * input "accept" attribute</a>.
146    *
147    * @param acceptList comma-separated list of MIME types and file extensions to validate against
148    * @param fileName name of the file to validate
149    * @param mimeType MIME type of the file to validate
150    * @return true if the file's extension or MIME type matches one of the accepted types, false otherwise
151    */
152   private static boolean validateMimeTypeAccept(String acceptList, String fileName, String mimeType) {
153     Iterable<String> allowedMimeTypes =
154         Splitter.on(Pattern.compile(",\\s*")).split(acceptList);
155     final Locale locale = Locale.ENGLISH;
156     for (String allowedType : allowedMimeTypes) {
157       if (allowedType.startsWith(".")) {
158         // Check file extension
159         if (fileName.toLowerCase(locale).endsWith(allowedType.toLowerCase(locale))) {
160           return true;
161         }
162       } else if (allowedType.endsWith("/*")) {
163         // Check mime type category (e.g. image/*)
164         String category = allowedType.substring(0, allowedType.length() - 1);
165         if (mimeType.startsWith(category)) {
166           return true;
167         }
168       } else {
169         // Check exact mime type match
170         if (mimeType.equals(allowedType)) {
171           return true;
172         }
173       }
174     }
175     return false;
176   }
177 
178   /**
179    * List attachments for a feature.
180    *
181    * @param appTreeLayerNode the application tree layer node
182    * @param service the geo service
183    * @param layer the geo service layer
184    * @param application the application
185    * @param featureId the feature id
186    * @return the response entity containing a list of attachment metadata
187    */
188   @GetMapping(
189       path = {
190         "${tailormap-api.base-path}/{viewerKind}/{viewerName}/layer/{appLayerId}/feature/{featureId}/attachments"
191       },
192       produces = MediaType.APPLICATION_JSON_VALUE)
193   @Transactional
194   public ResponseEntity<List<AttachmentMetadata>> listAttachments(
195       @ModelAttribute AppTreeLayerNode appTreeLayerNode,
196       @ModelAttribute GeoService service,
197       @ModelAttribute GeoServiceLayer layer,
198       @ModelAttribute Application application,
199       @PathVariable String featureId) {
200 
201     TMFeatureType tmFeatureType = editUtil.getEditableFeatureType(application, appTreeLayerNode, service, layer);
202 
203     checkFeatureTypeSupportsAttachments(tmFeatureType);
204     Object primaryKey = getFeaturePrimaryKeyByFid(tmFeatureType, featureId);
205 
206     List<AttachmentMetadata> response;
207     try {
208       response = AttachmentsHelper.listAttachmentsForFeature(tmFeatureType, primaryKey);
209     } catch (IOException | SQLException e) {
210       throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage());
211     }
212 
213     return new ResponseEntity<>(response, HttpStatus.OK);
214   }
215 
216   @DeleteMapping(
217       path = "${tailormap-api.base-path}/{viewerKind}/{viewerName}/layer/{appLayerId}/attachment/{attachmentId}")
218   @Transactional
219   public ResponseEntity<Serializable> deleteAttachment(
220       @ModelAttribute AppTreeLayerNode appTreeLayerNode,
221       @ModelAttribute GeoService service,
222       @ModelAttribute GeoServiceLayer layer,
223       @ModelAttribute Application application,
224       @PathVariable UUID attachmentId) {
225     editUtil.checkEditAuthorisation();
226 
227     TMFeatureType tmFeatureType = editUtil.getEditableFeatureType(application, appTreeLayerNode, service, layer);
228 
229     checkFeatureTypeSupportsAttachments(tmFeatureType);
230 
231     try {
232       AttachmentsHelper.deleteAttachment(attachmentId, tmFeatureType);
233     } catch (IOException | SQLException e) {
234       throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage());
235     }
236 
237     return new ResponseEntity<>(HttpStatus.NO_CONTENT);
238   }
239 
240   @Transactional
241   @GetMapping(
242       path = "${tailormap-api.base-path}/{viewerKind}/{viewerName}/layer/{appLayerId}/attachment/{attachmentId}",
243       produces = {"application/octet-stream"})
244   public ResponseEntity<byte[]> getAttachment(
245       @ModelAttribute AppTreeLayerNode appTreeLayerNode,
246       @ModelAttribute GeoService service,
247       @ModelAttribute GeoServiceLayer layer,
248       @ModelAttribute Application application,
249       @PathVariable UUID attachmentId) {
250 
251     TMFeatureType tmFeatureType = editUtil.getEditableFeatureType(application, appTreeLayerNode, service, layer);
252 
253     try {
254       final AttachmentsHelper.AttachmentWithBinary attachmentWithBinary =
255           AttachmentsHelper.getAttachment(tmFeatureType, attachmentId);
256 
257       if (attachmentWithBinary == null) {
258         throw new ResponseStatusException(
259             HttpStatus.NOT_FOUND, "Attachment %s not found".formatted(attachmentId.toString()));
260       }
261 
262       // the binary attachment() is a read-only ByteBuffer, so we cant use .array()
263       final ByteBuffer bb = attachmentWithBinary.attachment().asReadOnlyBuffer();
264       bb.rewind();
265       byte[] attachmentData = new byte[bb.remaining()];
266       bb.get(attachmentData);
267 
268       return ResponseEntity.ok()
269           .header(
270               "Content-Disposition",
271               "inline; filename=\""
272                   + attachmentWithBinary.attachmentMetadata().getFileName() + "\"")
273           .contentType(MediaType.parseMediaType(
274               attachmentWithBinary.attachmentMetadata().getMimeType()))
275           .body(attachmentData);
276     } catch (SQLException | IOException e) {
277       throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage());
278     }
279   }
280 
281   private Object getFeaturePrimaryKeyByFid(TMFeatureType tmFeatureType, String featureId)
282       throws ResponseStatusException {
283     final Filter fidFilter = ff.id(ff.featureId(featureId));
284     SimpleFeatureSource fs = null;
285     try {
286       fs = featureSourceFactoryHelper.openGeoToolsFeatureSource(tmFeatureType);
287       Query query = new Query();
288       query.setFilter(fidFilter);
289       query.setPropertyNames(tmFeatureType.getPrimaryKeyAttribute());
290       try (SimpleFeatureIterator sfi = fs.getFeatures(query).features()) {
291         if (!sfi.hasNext()) {
292           throw new ResponseStatusException(
293               HttpStatus.NOT_FOUND, "Feature with id %s does not exist".formatted(featureId));
294         }
295         return sfi.next().getAttribute(tmFeatureType.getPrimaryKeyAttribute());
296       }
297     } catch (IOException e) {
298       throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage(), e);
299     } finally {
300       if (fs != null) {
301         fs.getDataStore().dispose();
302       }
303     }
304   }
305 
306   private void checkFeatureTypeSupportsAttachments(TMFeatureType tmFeatureType) throws ResponseStatusException {
307     Set<@Valid AttachmentAttributeType> attachmentAttrSet =
308         tmFeatureType.getSettings().getAttachmentAttributes();
309     if (attachmentAttrSet == null || attachmentAttrSet.isEmpty()) {
310       throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Layer does not support attachments");
311     }
312   }
313 }