diff --git a/invenio.cfg b/invenio.cfg index 5482f1e..ae490ee 100644 --- a/invenio.cfg +++ b/invenio.cfg @@ -6,6 +6,7 @@ This file was automatically generated by 'invenio-cli init'. For the full list of settings and their values, see https://inveniordm.docs.cern.ch/reference/configuration/. """ +from invenio_records_resources.services.custom_fields import TextCF, BooleanCF #required to use custom permissions from ultraviolet_saml.handlers import acs_handler_factory from ultraviolet_permissions.policies import UltraVioletPermissionPolicy @@ -71,6 +72,13 @@ APP_DEFAULT_SECURE_HEADERS = { "blob:", # for pdf preview # Add your own policies here (e.g. analytics) ], + 'img-src': [ + "'self'", + 'data:', + "https://*.basemaps.cartocdn.com", + "https://maps-public.geo.nyu.edu", + "https://maps-restricted.geo.nyu.edu", + ] }, 'content_security_policy_report_only': False, 'content_security_policy_report_uri': None, @@ -421,6 +429,8 @@ if APP_ENVIRONMENT == 'local': 'adminUV@test.com': 'adminUV' } + RATELIMIT_ENABLED = False + # UltraViolet custom vars # ======================= @@ -451,3 +461,72 @@ NYU_LIBRARIES_HOMEPAGE = 'https://library.nyu.edu' # Max file size for displaying download buttons and link (50 GB) MAX_FILE_SIZE = 50 * 1024 * 1024 * 1024 + +RDM_NAMESPACES = { + "geoserver": "https://geoserver.org/" +} + +RDM_CUSTOM_FIELDS = [ + BooleanCF(name="geoserver:has_wms_layer"), + BooleanCF(name="geoserver:has_wfs_layer"), + TextCF(name="geoserver:layer_name"), + TextCF(name="geoserver:bounds"), +] + +GEOSERVER_PUBLIC_URL = "https://maps-public.geo.nyu.edu/geoserver/sdr" +GEOSERVER_RESTRICTED_URL = "https://maps-restricted.geo.nyu.edu/geoserver/sdr" + +RDM_CUSTOM_FIELDS_UI = [ + { + "section": _("GeoServer"), + "hide_from_landing_page": True, + "fields": [ + dict( + field="geoserver:layer_name", + ui_widget="Input", + props=dict( + label="Layer Name", + placeholder="sdr:nyu_2451_12345", + icon="pencil", + description="Name of the GeoServer Layer this data can be found in", + required=False + ) + ), + dict( + field="geoserver:has_wms_layer", + ui_widget="BooleanCheckbox", + props=dict( + label="WMS Layer?", + icon="check", + description="Does this record have a WMS layer in GeoServer?", + required=False, + trueLabel="Yes", + falseLabel="No", + ) + ), + dict( + field="geoserver:has_wfs_layer", + ui_widget="BooleanCheckbox", + props=dict( + label="WFS Layer?", + icon="check", + description="Does this record have a WFS layer in GeoServer?", + required=False, + trueLabel="Yes", + falseLabel="No", + ) + ), + dict( + field="geoserver:bounds", + ui_widget="Input", + props=dict( + label="Bounds", + placeholder="ENVELOPE(-178.2176, -66.969275, 71.406235818, 18.921781818)", + icon="pencil", + description="The envelope for the bounds of this layer", + required=False + ) + ) + ] + } +] diff --git a/site/ultraviolet/assets/semantic-ui/css/ultraviolet/.gitkeep b/site/ultraviolet/assets/semantic-ui/css/ultraviolet/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/site/ultraviolet/assets/semantic-ui/css/ultraviolet/geoserver.css b/site/ultraviolet/assets/semantic-ui/css/ultraviolet/geoserver.css new file mode 100644 index 0000000..6747d5e --- /dev/null +++ b/site/ultraviolet/assets/semantic-ui/css/ultraviolet/geoserver.css @@ -0,0 +1,16 @@ +@import "leaflet/dist/leaflet.css"; +@import 'ol/ol.css'; + +#map { + width: 100%; + height: 440px; + margin-top: 1em; + cursor: crosshair !important; + border: 1px solid #ced4da; + z-index: 99; /* Place map under invenio-menu */ +} + +.attributes { + max-height: 450px; + overflow-y: scroll; +} diff --git a/site/ultraviolet/assets/semantic-ui/js/ultraviolet/geoserver.js b/site/ultraviolet/assets/semantic-ui/js/ultraviolet/geoserver.js new file mode 100644 index 0000000..a9f6564 --- /dev/null +++ b/site/ultraviolet/assets/semantic-ui/js/ultraviolet/geoserver.js @@ -0,0 +1,162 @@ +import L from "leaflet" +import "leaflet/dist/leaflet.css"; + +const populateAttributeTable = (data) => { + const attributesElement = document.getElementById("attributes"); + attributesElement.innerHTML = ''; + + Object.keys(data.properties).sort().forEach(property => { + const nameTd = document.createElement('td'); + nameTd.textContent = property + const typeTd = document.createElement("td") + typeTd.textContent = data.properties[property] + + const tr = document.createElement("tr") + tr.appendChild(nameTd) + tr.appendChild(typeTd) + attributesElement.appendChild(tr) + }) +} + +const retrieveAttributeTypes = (wfsUrl, layerNames) => { + const attributesElement = document.getElementById("attributes"); + + const formData = new FormData(); + formData.append("url", wfsUrl); + formData.append("layers", layerNames); + + fetch("/geoserver/describe_feature_type", { + method: "POST", body: formData + }) + .then(response => response.json()) + .then(data => { + const attributes = data.featureTypes[0].properties.sort((a, b) => { + return a.name.localeCompare(b.name) + }) + + attributes.forEach(attribute => { + const nameTd = document.createElement('td'); + nameTd.textContent = attribute.name + const typeTd = document.createElement("td") + typeTd.textContent = attribute.localType + + const tr = document.createElement("tr") + tr.appendChild(nameTd) + tr.appendChild(typeTd) + attributesElement.appendChild(tr) + }) + }) + .catch(error => { + console.error('Error:', error); + }); +}; + +const addFeatureInspectionHandler = (map, url, layerNames) => { + map.on("click", async (e) => { + const attributesElement = document.getElementById("attributes"); + attributesElement.innerHTML = 'Loading...'; + + try { + const response = await fetch("/geoserver/get_feature_info", { + method: "POST", headers: { + "Content-Type": "application/json", + }, body: JSON.stringify({ + url: url, + layers: layerNames, + bbox: map.getBounds().toBBoxString(), + width: Math.round(document.getElementById("map").clientWidth), + height: Math.round(document.getElementById("map").clientHeight), + query_layers: layerNames, + x: Math.round(e.containerPoint.x), + y: Math.round(e.containerPoint.y), + }), + }); + + if (!response.ok) throw new Error("Network response was not ok."); + + const response_data = await response.json(); + + if (response_data.hasOwnProperty("error") || response_data.hasOwnProperty('exceptions') || response_data.features.length === 0) { + const attributesElement = document.getElementById("attributes"); + attributesElement.innerHTML = 'No feature found'; + + return; + } + const data = response_data.features[0]; + + populateAttributeTable(data); + } catch (error) { + console.error("Fetch error: ", error); + } + }); +} + +const zoomMapToBoundingBox = (map) => { + const mapElement = document.getElementById("map"); + const preview = mapElement.getAttribute("data-preview"); + const bounds = mapElement.getAttribute("data-bounds") + + const regex = /ENVELOPE\(([-\d.]+), ([-\d.]+), ([-\d.]+), ([-\d.]+)\)/; + const match = bounds.match(regex); + + if (match) { + let [_, minLon, maxLon, minLat, maxLat] = match; + + const bounds = [[minLat, minLon], [maxLat, maxLon]]; + + map.fitBounds(bounds); + + if (preview === "False") { + map.addLayer(L.rectangle(bounds, {color: "#3388FF", weight: 3})); + } + } +}; + +const addWmsLayer = (map) => { + const mapElement = document.getElementById("map"); + const baseUrl = mapElement.getAttribute("data-wms-url") + const layerName = mapElement.getAttribute("data-layer-name") + + const wmsLayer = L.tileLayer.wms(baseUrl, { + layers: layerName, + format: 'image/png', + transparent: true, + opacity: 0.75 + }); + + wmsLayer.addTo(map); + wmsLayer.setOpacity(0.75); +}; + +const addWfsInspection = map => { + const attributesElement = document.getElementById("attributes"); + + if (attributesElement) { + const wfsUrl = attributesElement.getAttribute("data-wfs-url"); + const layerNames = attributesElement.getAttribute("data-layer-names"); + + addFeatureInspectionHandler(map, wfsUrl, layerNames); + retrieveAttributeTypes(wfsUrl, layerNames); + } +}; + +document.addEventListener("DOMContentLoaded", async () => { + const mapElement = document.getElementById("map"); + const preview = mapElement.getAttribute("data-preview"); + + const map = L.map('map').setView([0, 0], 13); + + L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{retina}.png', { + attribution: '© OpenStreetMap contributors, © Carto', + maxZoom: 18, + worldCopyJump: true, + retina: "@2x", + }).addTo(map); + + if (preview === "True") { + addWmsLayer(map); + addWfsInspection(map); + } + + zoomMapToBoundingBox(map); +}); diff --git a/site/ultraviolet/geoserver/__init__.py b/site/ultraviolet/geoserver/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/ultraviolet/geoserver/describe_feature_type.py b/site/ultraviolet/geoserver/describe_feature_type.py new file mode 100644 index 0000000..132005e --- /dev/null +++ b/site/ultraviolet/geoserver/describe_feature_type.py @@ -0,0 +1,27 @@ +import urllib + +from flask import request, Response +from flask.views import MethodView + +import requests + + +class DescribeFeatureType(MethodView): + """Proxy for GeoServer DescribeFeatureType requests.""" + + def post(self): + """Pass DescribeFeatureType requests to GeoServer and hand back results.""" + url = request.form.get('url', default=None) + layers = request.form.get('layers', default=None) + + query_string = urllib.parse.urlencode({ + "outputFormat": "application/json", + "request": "DescribeFeatureType", + "service": "WFS", + "typeName": layers, + "version": "1.1.0", + }) + + response = requests.get("{0}?{1}".format(url, query_string)) + + return Response(response.text, mimetype='application/json') diff --git a/site/ultraviolet/geoserver/get_feature_info.py b/site/ultraviolet/geoserver/get_feature_info.py new file mode 100644 index 0000000..c88bfbd --- /dev/null +++ b/site/ultraviolet/geoserver/get_feature_info.py @@ -0,0 +1,35 @@ +import urllib + +from flask import request, Response +from flask.views import MethodView + +import requests + + +class GetFeatureInfo(MethodView): + """Proxy for GeoServer GetFeatureInfo requests.""" + + def post(self): + """Pass GetFeatureInfo requests to GeoServer and hand back results.""" + data = request.get_json() + + url = data.get('url', None) + query_string = urllib.parse.urlencode({ + "service": "WMS", + "version": "1.1.1", + "request": "GetFeatureInfo", + "layers": data.get('layers', None), + "query_layers": data.get('layers', None), + "bbox": data.get('bbox', None), + "width": data.get('width', None), + "height": data.get('height', None), + "x": data.get('x', None), + "y": data.get('y', None), + "srs": "EPSG:4326", + "info_format": "application/json", + "styles": "" + }) + + response = requests.get("{0}?{1}".format(url, query_string)) + + return Response(response.text, mimetype='application/json') diff --git a/site/ultraviolet/templates/semantic-ui/ultraviolet/records/details/map.html b/site/ultraviolet/templates/semantic-ui/ultraviolet/records/details/map.html new file mode 100644 index 0000000..17e0348 --- /dev/null +++ b/site/ultraviolet/templates/semantic-ui/ultraviolet/records/details/map.html @@ -0,0 +1,55 @@ +{% set previewable = record.access.files == 'public' or (current_user and current_user.is_authenticated) %} +{% set geoserver_base_url = config.GEOSERVER_PUBLIC_URL if (record.access.files == 'public') else config.GEOSERVER_RESTRICTED_URL %} + +{% if record.custom_fields["geoserver:layer_name"] and record.custom_fields["geoserver:has_wms_layer"] %} +

Map

+ +
+ + {% if record.custom_fields["geoserver:has_wfs_layer"] and (previewable) %} +

Attributes

+ +
+ + + + + + + + +
AttributeType/Value
+
+ {% endif %} + + {% if previewable %} +

Web Services

+ +
+
Layer Name
+
{{ record.custom_fields["geoserver:layer_name"] }}
+ {% if record.custom_fields["geoserver:has_wfs_layer"] %} +
Web Feature Service (WFS)
+
{{ geoserver_base_url }}/wfs
+ {% endif %} + {% if record.custom_fields["geoserver:has_wms_layer"] %} +
Web Mapping Service (WMS)
+
{{ geoserver_base_url }}/wms
+ {% endif %} +
+ {% endif %} +{% endif %} + +{% block javascript %} + {{ webpack['geoserver_js.js'] }} + {{ webpack['geoserver_css.css'] }} +{% endblock %} diff --git a/site/ultraviolet/views.py b/site/ultraviolet/views.py index 89f90fa..4e21e38 100644 --- a/site/ultraviolet/views.py +++ b/site/ultraviolet/views.py @@ -2,6 +2,9 @@ from flask import Blueprint +from .geoserver.describe_feature_type import DescribeFeatureType +from .geoserver.get_feature_info import GetFeatureInfo + # # Registration # @@ -13,5 +16,14 @@ def create_blueprint(app): template_folder="./templates", ) - # Add URL rules + blueprint.add_url_rule( + "/geoserver/describe_feature_type", + view_func=DescribeFeatureType.as_view("describe_feature_type"), + ) + + blueprint.add_url_rule( + "/geoserver/get_feature_info", + view_func=GetFeatureInfo.as_view("get_feature_info"), + ) + return blueprint diff --git a/site/ultraviolet/webpack.py b/site/ultraviolet/webpack.py index c277969..d79ecfb 100644 --- a/site/ultraviolet/webpack.py +++ b/site/ultraviolet/webpack.py @@ -9,8 +9,13 @@ themes={ "semantic-ui": dict( entry={ - # Add your webpack entrypoints + "geoserver_js": "./js/ultraviolet/geoserver.js", + "geoserver_css": "./css/ultraviolet/geoserver.css", }, + dependencies={ + "leaflet": "^1.9.4", + "ol": "^10.2.1" + } ), }, ) diff --git a/templates/semantic-ui/invenio_app_rdm/records/details/description.html b/templates/semantic-ui/invenio_app_rdm/records/details/description.html new file mode 100644 index 0000000..7c364b2 --- /dev/null +++ b/templates/semantic-ui/invenio_app_rdm/records/details/description.html @@ -0,0 +1,25 @@ +{# + Copyright (C) 2020 CERN. + Copyright (C) 2020 Northwestern University. + Copyright (C) 2024 New York University. + + Invenio RDM Records is free software; you can redistribute it and/or modify + it under the terms of the MIT License; see LICENSE file for more details. + #} + +{%- from "invenio_app_rdm/records/macros/detail.html" import show_add_descriptions %} + +{% set description = metadata.get('description') %} +{% if description %} +
+

{{ _('Description') }}

+ {# description data is being sanitized by marshmallow in the backend #} +
+

{{ description | safe }}

+
+
+{% endif %} + +{{ show_add_descriptions(record.ui.additional_descriptions) if record.ui.additional_descriptions }} + +{% include "ultraviolet/records/details/map.html" %} diff --git a/tests/conftest.py b/tests/conftest.py index 6ac0e8d..f3c79ea 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,7 +10,6 @@ # conftest.py """Pytest fixtures for ultraviolet testing""" - import sys import pytest import os @@ -68,11 +67,13 @@ def app_config(app_config): } return app_config + # overriding instance path allows us to make sure we use ultraviolet templates @pytest.fixture(scope="module") def instance_path(): return os.path.join(sys.prefix, "var", "instance") + # Copied from https://github.dev/inveniosoftware/invenio-rdm-records/tree/maint-1.3.x/tests/records # -*- coding: utf-8 -*- @@ -132,6 +133,7 @@ def instance_path(): from invenio_vocabularies.records.api import Vocabulary from invenio_access.models import ActionUsers + @pytest.fixture(scope="function") def full_record(users): """Full record data as dict coming from the external world.""" @@ -327,6 +329,26 @@ def minimal_record(): } +@pytest.fixture() +def geospatial_record(minimal_record): + minimal_record["metadata"]["title"] = "Geospatial Data" + minimal_record["custom_fields"] = { + "geoserver:has_wms_layer": True, + "geoserver:has_wfs_layer": True, + "geoserver:layer_name": "sdr:nyu_2451_34156", + "geoserver:bounds": "ENVELOPE(-74.2556640887564, -73.700009054899, 40.9157739339836, 40.4960925239255)" + } + + return minimal_record + + +@pytest.fixture() +def restricted_geospatial_record(geospatial_record): + geospatial_record["access"]["files"] = "restricted" + + return geospatial_record + + @pytest.fixture() def client_with_login(app, client, users): """Log in a user to the client.""" @@ -654,8 +676,8 @@ def licenses_v(app, licenses): "tags": ["recommended", "all"], "description": { "en": "The Creative Commons Attribution license allows" - " re-distribution and re-use of a licensed work on" - " the condition that the creator is appropriately credited." + " re-distribution and re-use of a licensed work on" + " the condition that the creator is appropriately credited." }, "type": "licenses", }, diff --git a/tests/ui/test_map_view.py b/tests/ui/test_map_view.py new file mode 100644 index 0000000..aeb25e9 --- /dev/null +++ b/tests/ui/test_map_view.py @@ -0,0 +1,50 @@ +from invenio_access.permissions import system_identity + + +def test_public_map_view_when_anonymous(services, geospatial_record, client): + published_record = publish_draft(geospatial_record, services) + + html = client.get("/records/" + published_record['id']).data.decode("utf-8") + + assert "Geospatial Data" in html + assert "Web Services" in html + + assert "data-preview=\"True\"" in html + assert "data-wms-url=\"https://maps-public.geo.nyu.edu/geoserver/sdr/wms\"" in html + assert "data-layer-name=\"sdr:nyu_2451_34156\"" in html + assert "data-bounds=\"ENVELOPE(-74.2556640887564, -73.700009054899, 40.9157739339836, 40.4960925239255)\"" in html + + +def test_restricted_map_view_when_logged_in(services, restricted_geospatial_record, client_with_login): + published_record = publish_draft(restricted_geospatial_record, services) + + html = client_with_login.get("/records/" + published_record['id']).data.decode("utf-8") + + assert "Geospatial Data" in html + assert "Web Services" in html + + assert "data-preview=\"True\"" in html + assert "data-wms-url=\"https://maps-restricted.geo.nyu.edu/geoserver/sdr/wms\"" in html + assert "data-layer-name=\"sdr:nyu_2451_34156\"" in html + assert "data-bounds=\"ENVELOPE(-74.2556640887564, -73.700009054899, 40.9157739339836, 40.4960925239255)\"" in html + + +def test_restricted_map_view_when_anonymous(services, restricted_geospatial_record, client): + published_record = publish_draft(restricted_geospatial_record, services) + + html = client.get("/records/" + published_record['id']).data.decode("utf-8") + + assert "Geospatial Data" in html + assert "data-preview=\"False\"" in html + assert "data-bounds=\"ENVELOPE(-74.2556640887564, -73.700009054899, 40.9157739339836, 40.4960925239255)\"" in html + + assert "Web Services" not in html + assert "data-wms-url=\"https://maps-restricted.geo.nyu.edu/geoserver/sdr/wms\"" not in html + assert "data-layer-name=\"sdr:nyu_2451_34156\"" not in html + + +def publish_draft(data, services): + draft = services.create(system_identity, data) + record = services.publish(system_identity, draft.id) + + return record