View Javadoc
1   /*
2    * Copyright (C) 2023 B3Partners B.V.
3    *
4    * SPDX-License-Identifier: MIT
5    */
6   package org.tailormap.api.persistence.helper;
7   
8   import static java.util.stream.Collectors.toSet;
9   import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo;
10  import static org.tailormap.api.persistence.helper.GeoServiceHelper.getWmsRequest;
11  import static org.tailormap.api.persistence.json.GeoServiceProtocol.LEGEND;
12  import static org.tailormap.api.persistence.json.GeoServiceProtocol.QUANTIZEDMESH;
13  import static org.tailormap.api.persistence.json.GeoServiceProtocol.TILES3D;
14  import static org.tailormap.api.persistence.json.GeoServiceProtocol.XYZ;
15  import static org.tailormap.api.util.TMStringUtils.nullIfEmpty;
16  
17  import jakarta.persistence.EntityManager;
18  import java.lang.invoke.MethodHandles;
19  import java.net.URI;
20  import java.util.HashMap;
21  import java.util.List;
22  import java.util.Map;
23  import java.util.Objects;
24  import java.util.Optional;
25  import java.util.Set;
26  import org.apache.commons.lang3.ObjectUtils;
27  import org.geotools.api.referencing.crs.CoordinateReferenceSystem;
28  import org.geotools.referencing.util.CRSUtilities;
29  import org.geotools.referencing.wkt.Formattable;
30  import org.slf4j.Logger;
31  import org.slf4j.LoggerFactory;
32  import org.springframework.stereotype.Service;
33  import org.springframework.transaction.annotation.Transactional;
34  import org.tailormap.api.controller.GeoServiceProxyController;
35  import org.tailormap.api.persistence.Application;
36  import org.tailormap.api.persistence.Configuration;
37  import org.tailormap.api.persistence.GeoService;
38  import org.tailormap.api.persistence.SearchIndex;
39  import org.tailormap.api.persistence.TMFeatureType;
40  import org.tailormap.api.persistence.json.AppContent;
41  import org.tailormap.api.persistence.json.AppLayerSettings;
42  import org.tailormap.api.persistence.json.AppTreeLayerNode;
43  import org.tailormap.api.persistence.json.AppTreeLevelNode;
44  import org.tailormap.api.persistence.json.AppTreeNode;
45  import org.tailormap.api.persistence.json.Bounds;
46  import org.tailormap.api.persistence.json.GeoServiceDefaultLayerSettings;
47  import org.tailormap.api.persistence.json.GeoServiceLayer;
48  import org.tailormap.api.persistence.json.GeoServiceLayerSettings;
49  import org.tailormap.api.persistence.json.ServicePublishingSettings;
50  import org.tailormap.api.persistence.json.TileLayerHiDpiMode;
51  import org.tailormap.api.repository.ApplicationRepository;
52  import org.tailormap.api.repository.ConfigurationRepository;
53  import org.tailormap.api.repository.FeatureSourceRepository;
54  import org.tailormap.api.repository.GeoServiceRepository;
55  import org.tailormap.api.repository.SearchIndexRepository;
56  import org.tailormap.api.security.AuthorisationService;
57  import org.tailormap.api.viewer.model.AppLayer;
58  import org.tailormap.api.viewer.model.LayerSearchIndex;
59  import org.tailormap.api.viewer.model.LayerTreeNode;
60  import org.tailormap.api.viewer.model.MapResponse;
61  import org.tailormap.api.viewer.model.TMCoordinateReferenceSystem;
62  
63  @Service
64  public class ApplicationHelper {
65    private static final Logger logger =
66        LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
67    private static final String DEFAULT_WEB_MERCATOR_CRS = "EPSG:3857";
68  
69    private final GeoServiceHelper geoServiceHelper;
70    private final GeoServiceRepository geoServiceRepository;
71    private final ConfigurationRepository configurationRepository;
72    private final ApplicationRepository applicationRepository;
73    private final FeatureSourceRepository featureSourceRepository;
74    private final EntityManager entityManager;
75    private final AuthorisationService authorisationService;
76    private final SearchIndexRepository searchIndexRepository;
77  
78    public ApplicationHelper(
79        GeoServiceHelper geoServiceHelper,
80        GeoServiceRepository geoServiceRepository,
81        ConfigurationRepository configurationRepository,
82        ApplicationRepository applicationRepository,
83        FeatureSourceRepository featureSourceRepository,
84        EntityManager entityManager,
85        AuthorisationService authorisationService,
86        SearchIndexRepository searchIndexRepository) {
87      this.geoServiceHelper = geoServiceHelper;
88      this.geoServiceRepository = geoServiceRepository;
89      this.configurationRepository = configurationRepository;
90      this.applicationRepository = applicationRepository;
91      this.featureSourceRepository = featureSourceRepository;
92      this.entityManager = entityManager;
93      this.authorisationService = authorisationService;
94      this.searchIndexRepository = searchIndexRepository;
95    }
96  
97    public Application getServiceApplication(String baseAppName, String projection, GeoService service) {
98      if (baseAppName == null) {
99        baseAppName = Optional.ofNullable(service.getSettings().getPublishing())
100           .map(ServicePublishingSettings::getBaseApp)
101           .orElseGet(() -> configurationRepository.get(Configuration.DEFAULT_BASE_APP));
102     }
103 
104     Application baseApp = null;
105     if (baseAppName != null) {
106       baseApp = applicationRepository.findByName(baseAppName);
107       if (baseApp != null) {
108         // Caller may be changing the app content to add layers from this service, detach so those
109         // aren't saved
110         entityManager.detach(baseApp);
111       }
112     }
113 
114     Application app = baseApp != null ? baseApp : new Application().setContentRoot(new AppContent());
115 
116     if (projection != null) {
117       // TODO: filter layers by projection parameter (layer.crs must inherit crs from parent layers)
118       throw new UnsupportedOperationException("Projection filtering not yet supported");
119     } else {
120       if (baseApp != null) {
121         projection = baseApp.getCrs();
122       } else {
123         projection = DEFAULT_WEB_MERCATOR_CRS;
124       }
125     }
126 
127     app.setName(service.getId()).setTitle(service.getTitle()).setCrs(projection);
128 
129     return app;
130   }
131 
132   @Transactional
133   public MapResponse toMapResponse(Application app) {
134     MapResponse mapResponse = new MapResponse();
135     setCrsAndBounds(app, mapResponse);
136     setLayers(app, mapResponse);
137     return mapResponse;
138   }
139 
140   public void setCrsAndBounds(Application a, MapResponse mapResponse) {
141     CoordinateReferenceSystem gtCrs = a.getGeoToolsCoordinateReferenceSystem();
142     if (gtCrs == null) {
143       throw new IllegalArgumentException("Invalid CRS: " + a.getCrs());
144     }
145 
146     TMCoordinateReferenceSystem crs = new TMCoordinateReferenceSystem()
147         .code(a.getCrs())
148         .definition(((Formattable) gtCrs).toWKT(0))
149         .bounds(GeoToolsHelper.fromCRS(gtCrs))
150         .unit(Optional.ofNullable(CRSUtilities.getUnit(gtCrs.getCoordinateSystem()))
151             .map(Objects::toString)
152             .orElse(null));
153 
154     Bounds maxExtent = a.getMaxExtent() != null ? a.getMaxExtent() : crs.getBounds();
155     Bounds initialExtent = a.getInitialExtent() != null ? a.getInitialExtent() : maxExtent;
156 
157     mapResponse.crs(crs).maxExtent(maxExtent).initialExtent(initialExtent);
158   }
159 
160   private void setLayers(Application app, MapResponse mr) {
161     new MapResponseLayerBuilder(app, mr).buildLayers();
162   }
163 
164   private String getProxyUrl(GeoService geoService, Application application, AppTreeLayerNode appTreeLayerNode) {
165     String baseProxyUrl = linkTo(
166             GeoServiceProxyController.class,
167             Map.of(
168                 "viewerKind", "app", // XXX
169                 "viewerName", application.getName(),
170                 "appLayerId", appTreeLayerNode.getId()))
171         .toString();
172 
173     String protocolPath = "/" + geoService.getProtocol().getValue();
174 
175     if (geoService.getProtocol() == TILES3D) {
176       return baseProxyUrl + protocolPath + "/" + GeoServiceProxyController.TILES3D_DESCRIPTION_PATH;
177     }
178     return baseProxyUrl + protocolPath;
179   }
180 
181   private String getLegendProxyUrl(Application application, AppTreeLayerNode appTreeLayerNode) {
182     return linkTo(
183             GeoServiceProxyController.class,
184             Map.of(
185                 "viewerKind",
186                 "app",
187                 "viewerName",
188                 application.getName(),
189                 "appLayerId",
190                 appTreeLayerNode.getId()))
191         + "/" + LEGEND.getValue();
192   }
193 
194   private class MapResponseLayerBuilder {
195     private final Application app;
196     private final MapResponse mapResponse;
197     // XXX not needed if we have GeoServiceLayer.getService().getName()
198     private final Map<GeoServiceLayer, String> serviceLayerServiceIds = new HashMap<>();
199 
200     MapResponseLayerBuilder(Application app, MapResponse mapResponse) {
201       this.app = app;
202       this.mapResponse = mapResponse;
203     }
204 
205     void buildLayers() {
206       if (app.getContentRoot() != null) {
207         buildBackgroundLayers();
208         buildOverlayLayers();
209         buildTerrainLayers();
210       }
211     }
212 
213     private void buildBackgroundLayers() {
214       if (app.getContentRoot().getBaseLayerNodes() != null) {
215         for (AppTreeNode node : app.getContentRoot().getBaseLayerNodes()) {
216           addAppTreeNodeItem(node, mapResponse.getBaseLayerTreeNodes());
217         }
218 
219         Set<String> validLayerIds =
220             mapResponse.getAppLayers().stream().map(AppLayer::getId).collect(toSet());
221         List<LayerTreeNode> initialLayerTreeNodes = mapResponse.getBaseLayerTreeNodes();
222 
223         mapResponse.setBaseLayerTreeNodes(cleanLayerTreeNodes(validLayerIds, initialLayerTreeNodes));
224       }
225     }
226 
227     private void buildOverlayLayers() {
228       if (app.getContentRoot().getLayerNodes() != null) {
229         for (AppTreeNode node : app.getContentRoot().getLayerNodes()) {
230           addAppTreeNodeItem(node, mapResponse.getLayerTreeNodes());
231         }
232         Set<String> validLayerIds =
233             mapResponse.getAppLayers().stream().map(AppLayer::getId).collect(toSet());
234         List<LayerTreeNode> initialLayerTreeNodes = mapResponse.getLayerTreeNodes();
235 
236         mapResponse.setLayerTreeNodes(cleanLayerTreeNodes(validLayerIds, initialLayerTreeNodes));
237       }
238     }
239 
240     /**
241      * Cleans the layer tree nodes by removing references to non-existing layers and removing level nodes without
242      * children, thus preventing exposure of application internals to unauthorized users.
243      *
244      * @param validLayerIds the ids of the layers that exist
245      * @param initialLayerTreeNodes the initial list of layer tree nodes
246      * @return the cleaned list of layer tree nodes
247      */
248     private List<LayerTreeNode> cleanLayerTreeNodes(
249         Set<String> validLayerIds, List<LayerTreeNode> initialLayerTreeNodes) {
250       List<String> levelNodes = initialLayerTreeNodes.stream()
251           .filter(n -> n.getAppLayerId() == null)
252           .map(LayerTreeNode::getId)
253           .toList();
254 
255       List<LayerTreeNode> newLayerTreeNodes = initialLayerTreeNodes.stream()
256           .peek(n -> {
257             n.getChildrenIds()
258                 .removeIf(childId ->
259                     /* remove invalid layers from the children */
260                     !validLayerIds.contains(childId) && !levelNodes.contains(childId));
261           })
262           .filter(n ->
263               /* remove level nodes without children */
264               !(n.getAppLayerId() == null
265                   && (n.getChildrenIds() != null
266                       && n.getChildrenIds().isEmpty())))
267           .toList();
268 
269       List<String> cleanLevelNodeIds = newLayerTreeNodes.stream()
270           .filter(n -> n.getAppLayerId() == null)
271           .map(LayerTreeNode::getId)
272           .toList();
273 
274       return newLayerTreeNodes.stream()
275           .peek(n -> {
276             n.getChildrenIds()
277                 .removeIf(childId ->
278                     /* remove invalid layers from the children */
279                     !cleanLevelNodeIds.contains(childId) && levelNodes.contains(childId));
280           })
281           .toList();
282     }
283 
284     private void buildTerrainLayers() {
285       if (app.getContentRoot().getTerrainLayerNodes() != null) {
286         for (AppTreeNode node : app.getContentRoot().getTerrainLayerNodes()) {
287           addAppTreeNodeItem(node, mapResponse.getTerrainLayerTreeNodes());
288         }
289       }
290     }
291 
292     private void addAppTreeNodeItem(AppTreeNode node, List<LayerTreeNode> layerTreeNodeList) {
293       LayerTreeNode layerTreeNode = new LayerTreeNode();
294       if ("AppTreeLayerNode".equals(node.getObjectType())) {
295         AppTreeLayerNode appTreeLayerNode = (AppTreeLayerNode) node;
296         layerTreeNode.setId(appTreeLayerNode.getId());
297         layerTreeNode.setAppLayerId(appTreeLayerNode.getId());
298         if (!addAppLayerItem(appTreeLayerNode)) {
299           return;
300         }
301         // This name is not displayed in the frontend, the title from the appLayer node is used
302         layerTreeNode.setName(appTreeLayerNode.getLayerName());
303         layerTreeNode.setDescription(appTreeLayerNode.getDescription());
304       } else if ("AppTreeLevelNode".equals(node.getObjectType())) {
305         AppTreeLevelNode appTreeLevelNode = (AppTreeLevelNode) node;
306         layerTreeNode.setId(appTreeLevelNode.getId());
307         layerTreeNode.setChildrenIds(appTreeLevelNode.getChildrenIds());
308         layerTreeNode.setRoot(Boolean.TRUE.equals(appTreeLevelNode.getRoot()));
309         // The name for a level node does show in the frontend
310         layerTreeNode.setName(appTreeLevelNode.getTitle());
311         layerTreeNode.setDescription(appTreeLevelNode.getDescription());
312       }
313       layerTreeNodeList.add(layerTreeNode);
314     }
315 
316     private boolean addAppLayerItem(AppTreeLayerNode layerRef) {
317       ServiceLayerInfo layerInfo = findServiceLayer(layerRef);
318       if (layerInfo == null) {
319         return false;
320       }
321       GeoService service = layerInfo.service();
322       GeoServiceLayer serviceLayer = layerInfo.serviceLayer();
323 
324       // Some settings can be set on the app layer level, layer level or service level (default
325       // layer settings). These settings should be used in-order: from app layer if set, otherwise
326       // from layer level, from default layer setting or the default.
327 
328       // An empty (blank) string means it is not set. To explicitly clear a layer level string
329       // setting for an app, an admin should set the app layer setting to spaces.
330 
331       // The JSON wrapper classes have "null" values as defaults, which means not-set. The defaults
332       // (such as tilingDisabled being true) are applied below, although the frontend would also
333       // treat null as non-truthy.
334 
335       // When default layer settings or settings for a specific layer are missing, construct new
336       // settings objects so no null check is needed. All properties are initialized to null
337       // (not-set) by default.
338       GeoServiceDefaultLayerSettings defaultLayerSettings = Optional.ofNullable(
339               service.getSettings().getDefaultLayerSettings())
340           .orElseGet(GeoServiceDefaultLayerSettings::new);
341       GeoServiceLayerSettings serviceLayerSettings =
342           Optional.ofNullable(layerInfo.layerSettings()).orElseGet(GeoServiceLayerSettings::new);
343 
344       AppLayerSettings appLayerSettings = app.getAppLayerSettings(layerRef);
345 
346       String title = Objects.requireNonNullElse(
347           nullIfEmpty(appLayerSettings.getTitle()),
348           // Get title from layer settings, title from capabilities or the layer name -- never
349           // null
350           service.getTitleWithSettingsOverrides(layerRef.getLayerName()));
351 
352       // These settings can be overridden per appLayer
353 
354       String description = ObjectUtils.firstNonNull(
355           nullIfEmpty(appLayerSettings.getDescription()),
356           nullIfEmpty(serviceLayerSettings.getDescription()),
357           nullIfEmpty(defaultLayerSettings.getDescription()));
358 
359       String attribution = ObjectUtils.firstNonNull(
360           nullIfEmpty(appLayerSettings.getAttribution()),
361           nullIfEmpty(serviceLayerSettings.getAttribution()),
362           nullIfEmpty(defaultLayerSettings.getAttribution()));
363 
364       // These settings can't be overridden per appLayer but can be set on a per-layer and
365       // service-level default basis
366 
367       boolean tilingDisabled = ObjectUtils.firstNonNull(
368           serviceLayerSettings.getTilingDisabled(), defaultLayerSettings.getTilingDisabled(), true);
369       Integer tilingGutter = ObjectUtils.firstNonNull(
370           serviceLayerSettings.getTilingGutter(), defaultLayerSettings.getTilingGutter(), 0);
371       boolean hiDpiDisabled = ObjectUtils.firstNonNull(
372           serviceLayerSettings.getHiDpiDisabled(), defaultLayerSettings.getHiDpiDisabled(), true);
373       TileLayerHiDpiMode hiDpiMode = ObjectUtils.firstNonNull(
374           serviceLayerSettings.getHiDpiMode(), defaultLayerSettings.getHiDpiMode(), null);
375       // Do not get from defaultLayerSettings because a default wouldn't make sense
376       String hiDpiSubstituteLayer = serviceLayerSettings.getHiDpiSubstituteLayer();
377 
378       TMFeatureType tmft = service.findFeatureTypeForLayer(serviceLayer, featureSourceRepository);
379 
380       boolean proxied = service.getSettings().getUseProxy();
381 
382       String legendImageUrl = serviceLayerSettings.getLegendImageId();
383       AppLayer.LegendTypeEnum legendType = AppLayer.LegendTypeEnum.STATIC;
384 
385       if (legendImageUrl == null && serviceLayer.getStyles() != null) {
386         // no user defined legend image, try to get legend image from styles
387         legendImageUrl = Optional.ofNullable(
388                 GeoServiceHelper.getLayerLegendUrlFromStyles(service, serviceLayer))
389             .map(URI::toString)
390             .orElse(null);
391 
392         if (legendImageUrl != null) {
393           // Check whether the legend is dynamic or static based on the original legend URL, before it is
394           // possibly replaced by URL to the proxy controller
395           legendType = "GetLegendGraphic".equalsIgnoreCase(getWmsRequest(legendImageUrl))
396               ? AppLayer.LegendTypeEnum.DYNAMIC
397               : AppLayer.LegendTypeEnum.STATIC;
398 
399           if (proxied) {
400             // service styles provides a legend image, but we need to proxy it
401             legendImageUrl = getLegendProxyUrl(app, layerRef);
402           }
403         }
404       }
405 
406       SearchIndex searchIndex = null;
407       if (appLayerSettings.getSearchIndexId() != null) {
408         searchIndex = searchIndexRepository
409             .findById(appLayerSettings.getSearchIndexId())
410             .orElse(null);
411       }
412 
413       boolean webMercatorAvailable = this.isWebMercatorAvailable(service, serviceLayer, hiDpiSubstituteLayer);
414 
415       mapResponse.addAppLayersItem(new AppLayer()
416           .id(layerRef.getId())
417           .serviceId(serviceLayerServiceIds.get(serviceLayer))
418           .layerName(layerRef.getLayerName())
419           .hasAttributes(tmft != null)
420           .editable(TMFeatureTypeHelper.isEditable(app, layerRef, tmft))
421           .url(proxied ? getProxyUrl(service, app, layerRef) : null)
422           // Can't set whether layer is opaque, not mapped from WMS capabilities by GeoTools
423           // gt-wms Layer class?
424           .maxScale(serviceLayer.getMaxScale())
425           .minScale(serviceLayer.getMinScale())
426           .title(title)
427           .tilingDisabled(tilingDisabled)
428           .tilingGutter(tilingGutter)
429           .hiDpiDisabled(hiDpiDisabled)
430           .hiDpiMode(hiDpiMode)
431           .hiDpiSubstituteLayer(hiDpiSubstituteLayer)
432           .minZoom(serviceLayerSettings.getMinZoom())
433           .maxZoom(serviceLayerSettings.getMaxZoom())
434           .tileSize(serviceLayerSettings.getTileSize())
435           .tileGridExtent(serviceLayerSettings.getTileGridExtent())
436           .opacity(appLayerSettings.getOpacity())
437           .autoRefreshInSeconds(appLayerSettings.getAutoRefreshInSeconds())
438           .searchIndex(
439               searchIndex != null
440                   ? new LayerSearchIndex()
441                       .id(searchIndex.getId())
442                       .name(searchIndex.getName())
443                   : null)
444           .legendImageUrl(legendImageUrl)
445           .legendType(legendType)
446           .visible(layerRef.getVisible())
447           .attribution(attribution)
448           .description(description)
449           .webMercatorAvailable(webMercatorAvailable)
450           .hiddenFunctionality(appLayerSettings.getHiddenFunctionality()));
451 
452       return true;
453     }
454 
455     private ServiceLayerInfo findServiceLayer(AppTreeLayerNode layerRef) {
456       GeoService service =
457           geoServiceRepository.findById(layerRef.getServiceId()).orElse(null);
458       if (service == null) {
459         logger.warn(
460             "App {} references layer \"{}\" of missing service {}",
461             app.getId(),
462             layerRef.getLayerName(),
463             layerRef.getServiceId());
464         return null;
465       }
466 
467       if (authorisationService.mustDenyAccessForSecuredProxy(service)) {
468         return null;
469       }
470 
471       if (!authorisationService.userAllowedToViewGeoService(service)) {
472         return null;
473       }
474 
475       GeoServiceLayer serviceLayer = service.findLayer(layerRef.getLayerName());
476 
477       if (serviceLayer == null) {
478         logger.warn(
479             "App {} references layer \"{}\" not found in capabilities of service {}",
480             app.getId(),
481             layerRef.getLayerName(),
482             service.getId());
483         return null;
484       }
485 
486       if (!authorisationService.userAllowedToViewGeoServiceLayer(service, serviceLayer)) {
487         logger.debug(
488             "User not allowed to view layer {} of service {}", serviceLayer.getName(), service.getId());
489         return null;
490       }
491 
492       serviceLayerServiceIds.put(serviceLayer, service.getId());
493 
494       if (mapResponse.getServices().stream()
495           .filter(s -> s.getId().equals(service.getId()))
496           .findAny()
497           .isEmpty()) {
498         mapResponse.addServicesItem(service.toJsonPojo(geoServiceHelper));
499       }
500 
501       GeoServiceLayerSettings layerSettings = service.getLayerSettings(layerRef.getLayerName());
502       return new ServiceLayerInfo(service, serviceLayer, layerSettings);
503     }
504 
505     private boolean isWebMercatorAvailable(
506         GeoService service, GeoServiceLayer serviceLayer, String hiDpiSubstituteLayer) {
507       if (service.getProtocol() == XYZ) {
508         return DEFAULT_WEB_MERCATOR_CRS.equals(service.getSettings().getXyzCrs());
509       }
510       if (service.getProtocol() == TILES3D || service.getProtocol() == QUANTIZEDMESH) {
511         return false;
512       }
513       if (hiDpiSubstituteLayer != null) {
514         GeoServiceLayer hiDpiSubstituteServiceLayer = service.findLayer(hiDpiSubstituteLayer);
515         if (hiDpiSubstituteServiceLayer != null
516             && !this.isWebMercatorAvailable(service, hiDpiSubstituteServiceLayer, null)) {
517           return false;
518         }
519       }
520       while (serviceLayer != null) {
521         Set<String> layerCrs = serviceLayer.getCrs();
522         if (layerCrs.contains(DEFAULT_WEB_MERCATOR_CRS)) {
523           return true;
524         }
525         if (serviceLayer.getRoot()) {
526           break;
527         }
528         serviceLayer = service.getParentLayer(serviceLayer.getId());
529       }
530       return false;
531     }
532 
533     record ServiceLayerInfo(
534         GeoService service, GeoServiceLayer serviceLayer, GeoServiceLayerSettings layerSettings) {}
535   }
536 }