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.apache.commons.lang3.tuple.Triple;
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   public 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     public MapResponseLayerBuilder(Application app, MapResponse mapResponse) {
201       this.app = app;
202       this.mapResponse = mapResponse;
203     }
204 
205     public 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     }
220 
221     private void buildOverlayLayers() {
222       if (app.getContentRoot().getLayerNodes() != null) {
223         for (AppTreeNode node : app.getContentRoot().getLayerNodes()) {
224           addAppTreeNodeItem(node, mapResponse.getLayerTreeNodes());
225         }
226       }
227     }
228 
229     private void buildTerrainLayers() {
230       if (app.getContentRoot().getTerrainLayerNodes() != null) {
231         for (AppTreeNode node : app.getContentRoot().getTerrainLayerNodes()) {
232           addAppTreeNodeItem(node, mapResponse.getTerrainLayerTreeNodes());
233         }
234       }
235     }
236 
237     private void addAppTreeNodeItem(AppTreeNode node, List<LayerTreeNode> layerTreeNodeList) {
238       LayerTreeNode layerTreeNode = new LayerTreeNode();
239       if ("AppTreeLayerNode".equals(node.getObjectType())) {
240         AppTreeLayerNode appTreeLayerNode = (AppTreeLayerNode) node;
241         layerTreeNode.setId(appTreeLayerNode.getId());
242         layerTreeNode.setAppLayerId(appTreeLayerNode.getId());
243         if (!addAppLayerItem(appTreeLayerNode)) {
244           return;
245         }
246         // This name is not displayed in the frontend, the title from the appLayer node is used
247         layerTreeNode.setName(appTreeLayerNode.getLayerName());
248         layerTreeNode.setDescription(appTreeLayerNode.getDescription());
249       } else if ("AppTreeLevelNode".equals(node.getObjectType())) {
250         AppTreeLevelNode appTreeLevelNode = (AppTreeLevelNode) node;
251         layerTreeNode.setId(appTreeLevelNode.getId());
252         layerTreeNode.setChildrenIds(appTreeLevelNode.getChildrenIds());
253         layerTreeNode.setRoot(Boolean.TRUE.equals(appTreeLevelNode.getRoot()));
254         // The name for a level node does show in the frontend
255         layerTreeNode.setName(appTreeLevelNode.getTitle());
256         layerTreeNode.setDescription(appTreeLevelNode.getDescription());
257       }
258       layerTreeNodeList.add(layerTreeNode);
259     }
260 
261     private boolean addAppLayerItem(AppTreeLayerNode layerRef) {
262       Triple<GeoService, GeoServiceLayer, GeoServiceLayerSettings> serviceWithLayer = findServiceLayer(layerRef);
263       GeoService service = serviceWithLayer.getLeft();
264       GeoServiceLayer serviceLayer = serviceWithLayer.getMiddle();
265 
266       if (service == null || serviceLayer == null) {
267         return false;
268       }
269 
270       // Some settings can be set on the app layer level, layer level or service level (default
271       // layer settings). These settings should be used in-order: from app layer if set, otherwise
272       // from layer level, from default layer setting or the default.
273 
274       // An empty (blank) string means it is not set. To explicitly clear a layer level string
275       // setting for an app, an admin should set the app layer setting to spaces.
276 
277       // The JSON wrapper classes have "null" values as defaults which means not-set. The defaults
278       // (such as tilingDisabled being true) are applied below although the frontend would also
279       // treat null as non-truthy.
280 
281       // When default layer settings or settings for a specific layer are missing, construct new
282       // settings objects so no null check is needed. All properties are initialized to null
283       // (not-set) by default.
284       GeoServiceDefaultLayerSettings defaultLayerSettings = Optional.ofNullable(
285               service.getSettings().getDefaultLayerSettings())
286           .orElseGet(GeoServiceDefaultLayerSettings::new);
287       GeoServiceLayerSettings serviceLayerSettings =
288           Optional.ofNullable(serviceWithLayer.getRight()).orElseGet(GeoServiceLayerSettings::new);
289 
290       AppLayerSettings appLayerSettings = app.getAppLayerSettings(layerRef);
291 
292       String title = Objects.requireNonNullElse(
293           nullIfEmpty(appLayerSettings.getTitle()),
294           // Get title from layer settings, title from capabilities or the layer name -- never
295           // null
296           service.getTitleWithSettingsOverrides(layerRef.getLayerName()));
297 
298       // These settings can be overridden per appLayer
299 
300       String description = ObjectUtils.firstNonNull(
301           nullIfEmpty(appLayerSettings.getDescription()),
302           nullIfEmpty(serviceLayerSettings.getDescription()),
303           nullIfEmpty(defaultLayerSettings.getDescription()));
304 
305       String attribution = ObjectUtils.firstNonNull(
306           nullIfEmpty(appLayerSettings.getAttribution()),
307           nullIfEmpty(serviceLayerSettings.getAttribution()),
308           nullIfEmpty(defaultLayerSettings.getAttribution()));
309 
310       // These settings can't be overridden per appLayer but can be set on a per-layer and
311       // service-level default basis
312 
313       boolean tilingDisabled = ObjectUtils.firstNonNull(
314           serviceLayerSettings.getTilingDisabled(), defaultLayerSettings.getTilingDisabled(), true);
315       Integer tilingGutter = ObjectUtils.firstNonNull(
316           serviceLayerSettings.getTilingGutter(), defaultLayerSettings.getTilingGutter(), 0);
317       boolean hiDpiDisabled = ObjectUtils.firstNonNull(
318           serviceLayerSettings.getHiDpiDisabled(), defaultLayerSettings.getHiDpiDisabled(), true);
319       TileLayerHiDpiMode hiDpiMode = ObjectUtils.firstNonNull(
320           serviceLayerSettings.getHiDpiMode(), defaultLayerSettings.getHiDpiMode(), null);
321       // Do not get from defaultLayerSettings because a default wouldn't make sense
322       String hiDpiSubstituteLayer = serviceLayerSettings.getHiDpiSubstituteLayer();
323 
324       TMFeatureType tmft = service.findFeatureTypeForLayer(serviceLayer, featureSourceRepository);
325 
326       boolean proxied = service.getSettings().getUseProxy();
327 
328       String legendImageUrl = serviceLayerSettings.getLegendImageId();
329       AppLayer.LegendTypeEnum legendType = AppLayer.LegendTypeEnum.STATIC;
330 
331       if (legendImageUrl == null && serviceLayer.getStyles() != null) {
332         // no user defined legend image, try to get legend image from styles
333         legendImageUrl = Optional.ofNullable(
334                 GeoServiceHelper.getLayerLegendUrlFromStyles(service, serviceLayer))
335             .map(URI::toString)
336             .orElse(null);
337 
338         if (legendImageUrl != null) {
339           // Check whether the legend is dynamic or static based on the original legend URL, before it is
340           // possibly replaced by URL to the proxy controller
341           legendType = "GetLegendGraphic".equalsIgnoreCase(getWmsRequest(legendImageUrl))
342               ? AppLayer.LegendTypeEnum.DYNAMIC
343               : AppLayer.LegendTypeEnum.STATIC;
344 
345           if (proxied) {
346             // service styles provides a legend image, but we need to proxy it
347             legendImageUrl = getLegendProxyUrl(app, layerRef);
348           }
349         }
350       }
351 
352       SearchIndex searchIndex = null;
353       if (appLayerSettings.getSearchIndexId() != null) {
354         searchIndex = searchIndexRepository
355             .findById(appLayerSettings.getSearchIndexId())
356             .orElse(null);
357       }
358 
359       boolean webMercatorAvailable = this.isWebMercatorAvailable(service, serviceLayer, hiDpiSubstituteLayer);
360 
361       mapResponse.addAppLayersItem(new AppLayer()
362           .id(layerRef.getId())
363           .serviceId(serviceLayerServiceIds.get(serviceLayer))
364           .layerName(layerRef.getLayerName())
365           .hasAttributes(tmft != null)
366           .editable(TMFeatureTypeHelper.isEditable(app, layerRef, tmft))
367           .url(proxied ? getProxyUrl(service, app, layerRef) : null)
368           // Can't set whether layer is opaque, not mapped from WMS capabilities by GeoTools
369           // gt-wms Layer class?
370           .maxScale(serviceLayer.getMaxScale())
371           .minScale(serviceLayer.getMinScale())
372           .title(title)
373           .tilingDisabled(tilingDisabled)
374           .tilingGutter(tilingGutter)
375           .hiDpiDisabled(hiDpiDisabled)
376           .hiDpiMode(hiDpiMode)
377           .hiDpiSubstituteLayer(hiDpiSubstituteLayer)
378           .minZoom(serviceLayerSettings.getMinZoom())
379           .maxZoom(serviceLayerSettings.getMaxZoom())
380           .tileSize(serviceLayerSettings.getTileSize())
381           .tileGridExtent(serviceLayerSettings.getTileGridExtent())
382           .opacity(appLayerSettings.getOpacity())
383           .autoRefreshInSeconds(appLayerSettings.getAutoRefreshInSeconds())
384           .searchIndex(
385               searchIndex != null
386                   ? new LayerSearchIndex()
387                       .id(searchIndex.getId())
388                       .name(searchIndex.getName())
389                   : null)
390           .legendImageUrl(legendImageUrl)
391           .legendType(legendType)
392           .visible(layerRef.getVisible())
393           .attribution(attribution)
394           .description(description)
395           .webMercatorAvailable(webMercatorAvailable)
396           .hiddenFunctionality(appLayerSettings.getHiddenFunctionality()));
397 
398       return true;
399     }
400 
401     private Triple<GeoService, GeoServiceLayer, GeoServiceLayerSettings> findServiceLayer(
402         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 Triple.of(null, null, null);
412       }
413 
414       if (!authorisationService.userAllowedToViewGeoService(service)) {
415         return Triple.of(null, null, null);
416       }
417 
418       if (authorisationService.mustDenyAccessForSecuredProxy(app, service)) {
419         return Triple.of(null, null, 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 Triple.of(null, null, 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 Triple.of(null, null, 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 Triple.of(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 }