1
2
3
4
5
6 package org.tailormap.api.drawing;
7
8 import static org.tailormap.api.persistence.helper.AdminAdditionalPropertyHelper.KEY_DRAWINGS_ADMIN;
9 import static org.tailormap.api.persistence.helper.AdminAdditionalPropertyHelper.KEY_DRAWINGS_READ_ALL;
10
11 import com.fasterxml.jackson.core.JsonProcessingException;
12 import com.fasterxml.jackson.databind.JsonNode;
13 import com.fasterxml.jackson.databind.ObjectMapper;
14 import com.fasterxml.jackson.databind.node.ArrayNode;
15 import com.fasterxml.jackson.databind.node.ObjectNode;
16 import java.lang.invoke.MethodHandles;
17 import java.sql.ResultSet;
18 import java.sql.SQLException;
19 import java.time.OffsetDateTime;
20 import java.time.ZoneId;
21 import java.util.Comparator;
22 import java.util.LinkedHashSet;
23 import java.util.List;
24 import java.util.Map;
25 import java.util.Objects;
26 import java.util.Optional;
27 import java.util.Set;
28 import java.util.UUID;
29 import java.util.stream.Collectors;
30 import org.postgresql.util.PGobject;
31 import org.slf4j.Logger;
32 import org.slf4j.LoggerFactory;
33 import org.springframework.core.convert.converter.Converter;
34 import org.springframework.core.convert.support.DefaultConversionService;
35 import org.springframework.core.convert.support.GenericConversionService;
36 import org.springframework.http.HttpStatus;
37 import org.springframework.jdbc.core.RowMapper;
38 import org.springframework.jdbc.core.SimplePropertyRowMapper;
39 import org.springframework.jdbc.core.simple.JdbcClient;
40 import org.springframework.lang.NonNull;
41 import org.springframework.security.authentication.AnonymousAuthenticationToken;
42 import org.springframework.security.core.Authentication;
43 import org.springframework.stereotype.Service;
44 import org.springframework.transaction.annotation.Transactional;
45 import org.springframework.web.server.ResponseStatusException;
46 import org.tailormap.api.security.TailormapUserDetails;
47 import org.tailormap.api.viewer.model.Drawing;
48
49
50
51
52
53
54
55
56 @Service
57 public class DrawingService {
58 private static final Logger logger =
59 LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
60
61 private final JdbcClient jdbcClient;
62 private final RowMapper<Drawing> drawingRowMapper;
63 private final ObjectMapper objectMapper;
64
65 public DrawingService(JdbcClient jdbcClient, ObjectMapper objectMapper) {
66 this.jdbcClient = jdbcClient;
67 this.objectMapper = objectMapper;
68
69 final GenericConversionService conversionService = new GenericConversionService();
70 DefaultConversionService.addDefaultConverters(conversionService);
71
72 conversionService.addConverter(new Converter<String, Drawing.AccessEnum>() {
73 @Override
74 public Drawing.AccessEnum convert(@NonNull String source) {
75 return Drawing.AccessEnum.fromValue(source);
76 }
77 });
78
79 conversionService.addConverter(new Converter<PGobject, Map<String, Object>>() {
80 @Override
81 @SuppressWarnings("unchecked")
82 public Map<String, Object> convert(@NonNull PGobject source) {
83 try {
84 return objectMapper.readValue(source.getValue(), Map.class);
85 } catch (JsonProcessingException e) {
86 throw new IllegalArgumentException("Failed to convert PGobject to Map", e);
87 }
88 }
89 });
90
91 drawingRowMapper = new SimplePropertyRowMapper<>(Drawing.class, conversionService) {
92 @Override
93 @NonNull public Drawing mapRow(@NonNull ResultSet rs, int rowNum) throws SQLException {
94 return super.mapRow(rs, rowNum);
95 }
96 };
97 }
98
99
100
101
102
103
104
105
106 @Transactional
107 public Drawing createDrawing(@NonNull Drawing drawing, @NonNull Authentication authentication)
108 throws JsonProcessingException {
109
110 canCreateDrawing(authentication);
111
112 logger.trace(
113 "creating new drawing: {}, domainData {}, createdAt {}",
114 drawing,
115 objectMapper.writeValueAsString(drawing.getDomainData()),
116 OffsetDateTime.now(ZoneId.systemDefault()));
117
118 Drawing storedDrawing = jdbcClient
119 .sql("""
120 INSERT INTO data.drawing (name, description, domain_data, access, created_at, created_by,srid)
121 VALUES (?, ?, ?::jsonb, ?, ?, ?, ?) RETURNING *
122 """)
123 .param(drawing.getName())
124 .param(drawing.getDescription())
125 .param(objectMapper.writeValueAsString(drawing.getDomainData()))
126 .param(drawing.getAccess().getValue())
127 .param(OffsetDateTime.now(ZoneId.systemDefault()))
128 .param(authentication.getName())
129 .param(drawing.getSrid())
130 .query(drawingRowMapper)
131 .single();
132
133 if (drawing.getFeatureCollection() != null) {
134 ObjectNode featureCollection = insertGeoJsonFeatureCollection(
135 storedDrawing.getId(),
136 drawing.getSrid(),
137 objectMapper.writeValueAsString(drawing.getFeatureCollection()));
138 storedDrawing.setFeatureCollection(featureCollection);
139 }
140
141 logger.trace("stored new drawing: {}", storedDrawing);
142 return storedDrawing;
143 }
144
145 private ObjectNode insertGeoJsonFeatureCollection(UUID drawingId, int srid, String featureCollectionToStore)
146 throws JsonProcessingException {
147 List<JsonNode> storedFeatures = jdbcClient
148 .sql("""
149 WITH jsonData AS (SELECT :featureCollectionToStore::json AS featureCollection)
150 INSERT INTO data.drawing_feature (drawing_id, geometry, properties)
151 SELECT :drawingId::uuid AS drawing_id,
152 ST_SetSRID(ST_GeomFromGeoJSON(feature ->> 'geometry'), :srid) AS geometry,
153 feature -> 'properties' AS properties
154 FROM (SELECT json_array_elements(featureCollection -> 'features') AS feature
155 FROM jsonData)
156 AS f
157 RETURNING
158 -- since we cannot use aggregate functions in a returning clause, we will return a list of geojson
159 -- features and aggregate them into a featureCollection in the next step
160 ST_AsGeoJSON(data.drawing_feature.*, geom_column =>'geometry', id_column => 'id')::json;
161 """)
162 .param("featureCollectionToStore", featureCollectionToStore)
163 .param("drawingId", drawingId)
164 .param("srid", srid)
165 .query(new RowMapper<JsonNode>() {
166 @Override
167 public JsonNode mapRow(@NonNull ResultSet rs, int rowNum) throws SQLException {
168 try {
169 JsonNode jsonNode = objectMapper.readTree(rs.getString(1));
170
171
172
173
174 final ObjectNode properties = (ObjectNode) jsonNode.get("properties");
175 JsonNode nestedProperties = properties.get("properties");
176 if (nestedProperties != null) {
177 nestedProperties.properties().stream()
178 .iterator()
179 .forEachRemaining(
180 entry -> properties.putIfAbsent(entry.getKey(), entry.getValue()));
181 }
182 properties.remove("properties");
183 return jsonNode;
184 } catch (JsonProcessingException e) {
185 throw new RuntimeException(e);
186 }
187 }
188 })
189 .list();
190
191 return objectMapper
192 .createObjectNode()
193 .put("type", "FeatureCollection")
194 .set("features", objectMapper.createArrayNode().addAll(storedFeatures));
195 }
196
197
198
199
200
201
202
203
204 @Transactional
205 public Drawing updateDrawing(@NonNull Drawing drawing, @NonNull Authentication authentication)
206 throws JsonProcessingException {
207
208 canSaveOrDeleteDrawing(drawing, authentication);
209
210 logger.trace(
211 "updating drawing: {}, domainData {}, updatedAt {}",
212 drawing,
213 objectMapper.writeValueAsString(drawing.getDomainData()),
214 OffsetDateTime.now(ZoneId.systemDefault()));
215
216 final Drawing oldDrawing = getDrawing(drawing.getId(), authentication)
217 .orElseThrow(() ->
218 new ResponseStatusException(HttpStatus.NOT_FOUND, "Drawing has been deleted by another user"));
219
220 if (drawing.getVersion() < oldDrawing.getVersion()) {
221 throw new ResponseStatusException(HttpStatus.CONFLICT, "Drawing has been updated by another user");
222 }
223 drawing.setVersion(drawing.getVersion() + 1);
224
225 Drawing updatedDrawing = jdbcClient
226 .sql("""
227 UPDATE data.drawing SET
228 id=:id,
229 name=:name,
230 description=:description,
231 domain_data=:domainData::jsonb,
232 access=:access,
233 created_by=:createdBy,
234 created_at=:createdAt,
235 updated_by=:updatedBy,
236 updated_at=:updatedAt,
237 srid=:srid,
238 version=:version
239 WHERE id = :id RETURNING *""")
240 .param("id", drawing.getId())
241 .param("name", drawing.getName())
242 .param("description", drawing.getDescription())
243 .param("domainData", objectMapper.writeValueAsString(drawing.getDomainData()))
244 .param("access", drawing.getAccess().getValue())
245 .param("createdBy", drawing.getCreatedBy())
246 .param("createdAt", drawing.getCreatedAt())
247 .param("updatedBy", authentication.getName())
248 .param("updatedAt", OffsetDateTime.now(ZoneId.systemDefault()))
249 .param("srid", drawing.getSrid())
250 .param("version", drawing.getVersion())
251 .query(drawingRowMapper)
252 .single();
253
254
255
256 jdbcClient
257 .sql("DELETE FROM data.drawing_feature WHERE drawing_id = ?")
258 .param(drawing.getId())
259 .update();
260
261 if (drawing.getFeatureCollection() != null) {
262 ObjectNode featureCollection = insertGeoJsonFeatureCollection(
263 drawing.getId(),
264 drawing.getSrid(),
265 objectMapper.writeValueAsString(drawing.getFeatureCollection()));
266 updatedDrawing.setFeatureCollection(featureCollection);
267 }
268
269 logger.trace("stored updated drawing: {}", updatedDrawing);
270 return updatedDrawing;
271 }
272
273
274
275
276
277
278
279 public Set<Drawing> getDrawingsForUser(Authentication authentication) throws ResponseStatusException {
280 if (authentication == null || authentication instanceof AnonymousAuthenticationToken) {
281 return Set.of();
282 }
283 return jdbcClient.sql("SELECT * FROM data.drawing").query(drawingRowMapper).set().stream()
284 .filter(d -> {
285 try {
286 canReadDrawing(d, authentication);
287 return true;
288 } catch (ResponseStatusException e) {
289 return false;
290 }
291 })
292 .sorted(Comparator.comparing(Drawing::getCreatedAt))
293 .collect(Collectors.toCollection(LinkedHashSet::new));
294 }
295
296
297
298
299
300
301
302
303 @SuppressWarnings("SpringTransactionalMethodCallsInspection")
304 public Optional<Drawing> getDrawing(@NonNull UUID drawingId, @NonNull Authentication authentication) {
305 return this.getDrawing(drawingId, authentication, false, 0);
306 }
307
308
309
310
311
312
313
314
315
316
317 @Transactional
318 public Optional<Drawing> getDrawing(
319 @NonNull UUID drawingId,
320 @NonNull Authentication authentication,
321 boolean withGeometries,
322 int requestedSrid) {
323 Optional<Drawing> drawing =
324 jdbcClient
325 .sql("SELECT * FROM data.drawing WHERE id = ?")
326 .param(1, drawingId)
327 .query(drawingRowMapper)
328 .stream()
329 .findFirst();
330
331 drawing.ifPresent(d -> {
332
333 canReadDrawing(d, authentication);
334
335 d.setSrid(requestedSrid);
336 if (withGeometries) {
337 d.setFeatureCollection(getFeatureCollection(drawingId, requestedSrid));
338 }
339 });
340
341 return drawing;
342 }
343
344
345
346
347
348
349
350
351 private JsonNode getFeatureCollection(UUID drawingId, int srid) {
352 return jdbcClient
353 .sql("""
354 SELECT row_to_json(featureCollection) from (
355 SELECT
356 'FeatureCollection' AS type,
357 array_to_json(array_agg(feature)) AS features FROM (
358 SELECT
359 'Feature' AS type,
360 id as id,
361 ST_ASGeoJSON(ST_Transform(geomTable.geometry, :srid))::json AS geometry,
362 row_to_json((SELECT l from (SELECT id, drawing_id, properties) AS l)) AS properties
363 FROM data.drawing_feature AS geomTable WHERE drawing_id = :drawingId::uuid) AS feature) AS featureCollection
364 """)
365 .param("drawingId", drawingId)
366 .param("srid", srid)
367 .query(new RowMapper<JsonNode>() {
368 @Override
369 public JsonNode mapRow(@NonNull ResultSet rs, int rowNum) throws SQLException {
370 try {
371 JsonNode jsonNode = objectMapper.readTree(rs.getString(1));
372
373
374 ArrayNode features = (ArrayNode) jsonNode.get("features");
375 features.elements().forEachRemaining(feature -> {
376 ObjectNode properties = (ObjectNode) feature.get("properties");
377 JsonNode nestedProperties = properties.get("properties");
378 if (nestedProperties != null) {
379 nestedProperties
380 .properties()
381 .iterator()
382 .forEachRemaining(
383 entry -> properties.putIfAbsent(entry.getKey(), entry.getValue()));
384 }
385 properties.remove("properties");
386 });
387 return jsonNode;
388 } catch (JsonProcessingException e) {
389 throw new RuntimeException(e);
390 }
391 }
392 })
393 .single();
394 }
395
396
397
398
399
400
401
402 public void deleteDrawing(@NonNull UUID drawingId, @NonNull Authentication authentication) {
403 canSaveOrDeleteDrawing(
404 getDrawing(drawingId, authentication)
405 .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Drawing not found")),
406 authentication);
407
408 jdbcClient.sql("DELETE FROM data.drawing WHERE id = ?").param(drawingId).update();
409 }
410
411
412
413
414
415
416
417 private void canCreateDrawing(@NonNull Authentication authentication) throws ResponseStatusException {
418 if ((authentication instanceof AnonymousAuthenticationToken)) {
419 throw new ResponseStatusException(
420 HttpStatus.UNAUTHORIZED, "Insufficient permissions to create new drawing");
421 }
422
423
424 }
425
426
427
428
429
430
431
432
433 private void canReadDrawing(@NonNull Drawing drawing, @NonNull Authentication authentication)
434 throws ResponseStatusException {
435 boolean isAuthenticated = !(authentication instanceof AnonymousAuthenticationToken);
436 boolean canRead =
437 switch (drawing.getAccess()) {
438 case PRIVATE -> {
439 if (isAuthenticated) {
440 if (Objects.equals(authentication.getName(), drawing.getCreatedBy())) {
441
442 yield true;
443 }
444 if (authentication.getPrincipal() instanceof TailormapUserDetails userProperties) {
445 yield userProperties.hasTruePropertyForKey(KEY_DRAWINGS_ADMIN)
446 || userProperties.hasTruePropertyForKey(KEY_DRAWINGS_READ_ALL);
447 }
448 }
449 yield false;
450 }
451 case SHARED -> isAuthenticated;
452 case PUBLIC -> true;
453 };
454
455 if (!canRead) {
456 throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Insufficient permissions to access drawing");
457 }
458 }
459
460
461
462
463
464
465
466
467 private void canSaveOrDeleteDrawing(@NonNull Drawing drawing, @NonNull Authentication authentication)
468 throws ResponseStatusException {
469 if (authentication instanceof AnonymousAuthenticationToken) {
470
471 throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Insufficient permissions to save drawing");
472 }
473
474 boolean canSave =
475 switch (drawing.getAccess()) {
476 case PRIVATE -> {
477 if (Objects.equals(authentication.getName(), drawing.getCreatedBy())) {
478
479 yield true;
480 }
481 if (authentication.getPrincipal() instanceof TailormapUserDetails userDetails) {
482 yield userDetails.hasTruePropertyForKey(KEY_DRAWINGS_ADMIN);
483 }
484 yield false;
485 }
486 case SHARED, PUBLIC -> true;
487 };
488
489 if (!canSave) {
490 throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Insufficient permissions to save drawing");
491 }
492 }
493 }