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