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