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 """
121 INSERT INTO data.drawing (name, description, domain_data, access, created_at, created_by,srid)
122 VALUES (?, ?, ?::jsonb, ?, ?, ?, ?) RETURNING *
123 """)
124 .param(drawing.getName())
125 .param(drawing.getDescription())
126 .param(objectMapper.writeValueAsString(drawing.getDomainData()))
127 .param(drawing.getAccess().getValue())
128 .param(OffsetDateTime.now(ZoneId.systemDefault()))
129 .param(authentication.getName())
130 .param(drawing.getSrid())
131 .query(drawingRowMapper)
132 .single();
133
134 if (drawing.getFeatureCollection() != null) {
135 ObjectNode featureCollection = insertGeoJsonFeatureCollection(
136 storedDrawing.getId(),
137 drawing.getSrid(),
138 objectMapper.writeValueAsString(drawing.getFeatureCollection()));
139 storedDrawing.setFeatureCollection(featureCollection);
140 }
141
142 logger.trace("stored new drawing: {}", storedDrawing);
143 return storedDrawing;
144 }
145
146 private ObjectNode insertGeoJsonFeatureCollection(UUID drawingId, int srid, String featureCollectionToStore)
147 throws JsonProcessingException {
148 List<JsonNode> storedFeatures = jdbcClient
149 .sql(
150 """
151 WITH jsonData AS (SELECT :featureCollectionToStore::json AS featureCollection)
152 INSERT INTO data.drawing_feature (drawing_id, geometry, properties)
153 SELECT :drawingId::uuid AS drawing_id,
154 ST_SetSRID(ST_GeomFromGeoJSON(feature ->> 'geometry'), :srid) AS geometry,
155 feature -> 'properties' AS properties
156 FROM (SELECT json_array_elements(featureCollection -> 'features') AS feature
157 FROM jsonData)
158 AS f
159 RETURNING
160 -- since we cannot use aggregate functions in a returning clause, we will return a list of geojson
161 -- features and aggregate them into a featureCollection in the next step
162 ST_AsGeoJSON(data.drawing_feature.*, geom_column =>'geometry', id_column => 'id')::json;
163 """)
164 .param("featureCollectionToStore", featureCollectionToStore)
165 .param("drawingId", drawingId)
166 .param("srid", srid)
167 .query(new RowMapper<JsonNode>() {
168 @Override
169 public JsonNode mapRow(@NonNull ResultSet rs, int rowNum) throws SQLException {
170 try {
171 JsonNode jsonNode = objectMapper.readTree(rs.getString(1));
172
173
174
175
176 final ObjectNode properties = (ObjectNode) jsonNode.get("properties");
177 JsonNode nestedProperties = properties.get("properties");
178 if (nestedProperties != null) {
179 nestedProperties.properties().stream()
180 .iterator()
181 .forEachRemaining(
182 entry -> properties.putIfAbsent(entry.getKey(), entry.getValue()));
183 }
184 properties.remove("properties");
185 return jsonNode;
186 } catch (JsonProcessingException e) {
187 throw new RuntimeException(e);
188 }
189 }
190 })
191 .list();
192
193 return objectMapper
194 .createObjectNode()
195 .put("type", "FeatureCollection")
196 .set("features", objectMapper.createArrayNode().addAll(storedFeatures));
197 }
198
199
200
201
202
203
204
205
206 @Transactional
207 public Drawing updateDrawing(@NonNull Drawing drawing, @NonNull Authentication authentication)
208 throws JsonProcessingException {
209
210 canSaveOrDeleteDrawing(drawing, authentication);
211
212 logger.trace(
213 "updating drawing: {}, domainData {}, updatedAt {}",
214 drawing,
215 objectMapper.writeValueAsString(drawing.getDomainData()),
216 OffsetDateTime.now(ZoneId.systemDefault()));
217
218 final Drawing oldDrawing = getDrawing(drawing.getId(), authentication)
219 .orElseThrow(() ->
220 new ResponseStatusException(HttpStatus.NOT_FOUND, "Drawing has been deleted by another user"));
221
222 if (drawing.getVersion() < oldDrawing.getVersion()) {
223 throw new ResponseStatusException(HttpStatus.CONFLICT, "Drawing has been updated by another user");
224 }
225 drawing.setVersion(drawing.getVersion() + 1);
226
227 Drawing updatedDrawing = jdbcClient
228 .sql(
229 """
230 UPDATE data.drawing SET
231 id=:id,
232 name=:name,
233 description=:description,
234 domain_data=:domainData::jsonb,
235 access=:access,
236 created_by=:createdBy,
237 created_at=:createdAt,
238 updated_by=:updatedBy,
239 updated_at=:updatedAt,
240 srid=:srid,
241 version=:version
242 WHERE id = :id RETURNING *""")
243 .param("id", drawing.getId())
244 .param("name", drawing.getName())
245 .param("description", drawing.getDescription())
246 .param("domainData", objectMapper.writeValueAsString(drawing.getDomainData()))
247 .param("access", drawing.getAccess().getValue())
248 .param("createdBy", drawing.getCreatedBy())
249 .param("createdAt", drawing.getCreatedAt())
250 .param("updatedBy", authentication.getName())
251 .param("updatedAt", OffsetDateTime.now(ZoneId.systemDefault()))
252 .param("srid", drawing.getSrid())
253 .param("version", drawing.getVersion())
254 .query(drawingRowMapper)
255 .single();
256
257
258
259 jdbcClient
260 .sql("DELETE FROM data.drawing_feature WHERE drawing_id = ?")
261 .param(drawing.getId())
262 .update();
263
264 if (drawing.getFeatureCollection() != null) {
265 ObjectNode featureCollection = insertGeoJsonFeatureCollection(
266 drawing.getId(),
267 drawing.getSrid(),
268 objectMapper.writeValueAsString(drawing.getFeatureCollection()));
269 updatedDrawing.setFeatureCollection(featureCollection);
270 }
271
272 logger.trace("stored updated drawing: {}", updatedDrawing);
273 return updatedDrawing;
274 }
275
276
277
278
279
280
281
282 public Set<Drawing> getDrawingsForUser(Authentication authentication) throws ResponseStatusException {
283 if (authentication == null || authentication instanceof AnonymousAuthenticationToken) {
284 return Set.of();
285 }
286 return jdbcClient.sql("SELECT * FROM data.drawing").query(drawingRowMapper).set().stream()
287 .filter(d -> {
288 try {
289 canReadDrawing(d, authentication);
290 return true;
291 } catch (ResponseStatusException e) {
292 return false;
293 }
294 })
295 .sorted(Comparator.comparing(Drawing::getCreatedAt))
296 .collect(Collectors.toCollection(LinkedHashSet::new));
297 }
298
299
300
301
302
303
304
305
306 @SuppressWarnings("SpringTransactionalMethodCallsInspection")
307 public Optional<Drawing> getDrawing(@NonNull UUID drawingId, @NonNull Authentication authentication) {
308 return this.getDrawing(drawingId, authentication, false, 0);
309 }
310
311
312
313
314
315
316
317
318
319
320 @Transactional
321 public Optional<Drawing> getDrawing(
322 @NonNull UUID drawingId,
323 @NonNull Authentication authentication,
324 boolean withGeometries,
325 int requestedSrid) {
326 Optional<Drawing> drawing =
327 jdbcClient
328 .sql("SELECT * FROM data.drawing WHERE id = ?")
329 .param(1, drawingId)
330 .query(drawingRowMapper)
331 .stream()
332 .findFirst();
333
334 drawing.ifPresent(d -> {
335
336 canReadDrawing(d, authentication);
337
338 d.setSrid(requestedSrid);
339 if (withGeometries) {
340 d.setFeatureCollection(getFeatureCollection(drawingId, requestedSrid));
341 }
342 });
343
344 return drawing;
345 }
346
347
348
349
350
351
352
353
354 private JsonNode getFeatureCollection(UUID drawingId, int srid) {
355 return jdbcClient
356 .sql(
357 """
358 SELECT row_to_json(featureCollection) from (
359 SELECT
360 'FeatureCollection' AS type,
361 array_to_json(array_agg(feature)) AS features FROM (
362 SELECT
363 'Feature' AS type,
364 id as id,
365 ST_ASGeoJSON(ST_Transform(geomTable.geometry, :srid))::json AS geometry,
366 row_to_json((SELECT l from (SELECT id, drawing_id, properties) AS l)) AS properties
367 FROM data.drawing_feature AS geomTable WHERE drawing_id = :drawingId::uuid) AS feature) AS featureCollection
368 """)
369 .param("drawingId", drawingId)
370 .param("srid", srid)
371 .query(new RowMapper<JsonNode>() {
372 @Override
373 public JsonNode mapRow(@NonNull ResultSet rs, int rowNum) throws SQLException {
374 try {
375 JsonNode jsonNode = objectMapper.readTree(rs.getString(1));
376
377
378 ArrayNode features = (ArrayNode) jsonNode.get("features");
379 features.elements().forEachRemaining(feature -> {
380 ObjectNode properties = (ObjectNode) feature.get("properties");
381 JsonNode nestedProperties = properties.get("properties");
382 if (nestedProperties != null) {
383 nestedProperties
384 .properties()
385 .iterator()
386 .forEachRemaining(
387 entry -> properties.putIfAbsent(entry.getKey(), entry.getValue()));
388 }
389 properties.remove("properties");
390 });
391 return jsonNode;
392 } catch (JsonProcessingException e) {
393 throw new RuntimeException(e);
394 }
395 }
396 })
397 .single();
398 }
399
400
401
402
403
404
405
406 public void deleteDrawing(@NonNull UUID drawingId, @NonNull Authentication authentication) {
407 canSaveOrDeleteDrawing(
408 getDrawing(drawingId, authentication)
409 .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Drawing not found")),
410 authentication);
411
412 jdbcClient.sql("DELETE FROM data.drawing WHERE id = ?").param(drawingId).update();
413 }
414
415
416
417
418
419
420
421 private void canCreateDrawing(@NonNull Authentication authentication) throws ResponseStatusException {
422 if ((authentication instanceof AnonymousAuthenticationToken)) {
423 throw new ResponseStatusException(
424 HttpStatus.UNAUTHORIZED, "Insufficient permissions to create new drawing");
425 }
426
427
428 }
429
430
431
432
433
434
435
436
437 private void canReadDrawing(@NonNull Drawing drawing, @NonNull Authentication authentication)
438 throws ResponseStatusException {
439 boolean isAuthenticated = !(authentication instanceof AnonymousAuthenticationToken);
440 boolean canRead =
441 switch (drawing.getAccess()) {
442 case PRIVATE -> {
443 if (isAuthenticated) {
444 if (Objects.equals(authentication.getName(), drawing.getCreatedBy())) {
445
446 yield true;
447 }
448 if (authentication.getPrincipal() instanceof TailormapUserDetails userDetails) {
449 yield userDetails.getBooleanUserProperty(KEY_DRAWINGS_ADMIN)
450 || userDetails.getBooleanUserProperty(KEY_DRAWINGS_READ_ALL);
451 }
452 }
453 yield false;
454 }
455 case SHARED -> isAuthenticated;
456 case PUBLIC -> true;
457 };
458
459 if (!canRead) {
460 throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Insufficient permissions to access drawing");
461 }
462 }
463
464
465
466
467
468
469
470
471 private void canSaveOrDeleteDrawing(@NonNull Drawing drawing, @NonNull Authentication authentication)
472 throws ResponseStatusException {
473 if (authentication instanceof AnonymousAuthenticationToken) {
474
475 throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Insufficient permissions to save drawing");
476 }
477
478 boolean canSave =
479 switch (drawing.getAccess()) {
480 case PRIVATE -> {
481 if (Objects.equals(authentication.getName(), drawing.getCreatedBy())) {
482
483 yield true;
484 }
485 if (authentication.getPrincipal() instanceof TailormapUserDetails userDetails) {
486 yield userDetails.getBooleanUserProperty(KEY_DRAWINGS_ADMIN);
487 }
488 yield false;
489 }
490 case SHARED, PUBLIC -> true;
491 };
492
493 if (!canSave) {
494 throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Insufficient permissions to save drawing");
495 }
496 }
497 }