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