1
2
3
4
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
71
72
73
74
75
76
77
78
79
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
145
146
147
148
149
150
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
159 if (fileName.toLowerCase(locale).endsWith(allowedType.toLowerCase(locale))) {
160 return true;
161 }
162 } else if (allowedType.endsWith("/*")) {
163
164 String category = allowedType.substring(0, allowedType.length() - 1);
165 if (mimeType.startsWith(category)) {
166 return true;
167 }
168 } else {
169
170 if (mimeType.equals(allowedType)) {
171 return true;
172 }
173 }
174 }
175 return false;
176 }
177
178
179
180
181
182
183
184
185
186
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
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 }