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