diff --git a/appinfo/routes.php b/appinfo/routes.php index 0462e307..cf4d0c87 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -154,6 +154,10 @@ ['name' => 'gis_proxy#proxy', 'url' => '/api/gis/proxy', 'verb' => 'POST'], ['name' => 'gis_proxy#capabilities', 'url' => '/api/gis/capabilities', 'verb' => 'GET'], + // WMS/WFS per-layer proxy (wms-wfs-layers spec) — action endpoint only; + // CRUD on wmsLayer objects is served by OpenRegister manifest pages. + ['name' => 'wms_wfs#proxy', 'url' => '/api/wms-wfs/proxy', 'verb' => 'GET'], + // ── Parafeerroute (B&W parafering engine) ─────────────────────── // CRUD on parafeerroute objects is served by OpenRegister's auto-exposed // /api/objects// endpoints — only engine routes remain. diff --git a/lib/Controller/WmsWfsController.php b/lib/Controller/WmsWfsController.php new file mode 100644 index 00000000..e86dee2b --- /dev/null +++ b/lib/Controller/WmsWfsController.php @@ -0,0 +1,126 @@ +/ endpoints + * (manifest-first, per ADR-008). + * + * @category Controller + * @package OCA\Procest\Controller + * + * @author Conduction Development Team + * @copyright 2024 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2024 Conduction B.V. + * + * @version GIT: + * + * @link https://procest.nl + */ + +declare(strict_types=1); + +namespace OCA\Procest\Controller; + +use OCA\Procest\Service\WmsWfsService; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; + +/** + * Action endpoint that proxies WMS/WFS requests for a configured wmsLayer. + */ +class WmsWfsController extends Controller +{ + + + /** + * Constructor for WmsWfsController. + * + * @param string $appName The application name + * @param IRequest $request The request object + * @param WmsWfsService $wmsWfsService The WMS/WFS service + * + * @return void + */ + public function __construct( + string $appName, + IRequest $request, + private WmsWfsService $wmsWfsService, + ) { + parent::__construct(appName: $appName, request: $request); + }//end __construct() + + + /** + * Proxy a request to a configured wmsLayer's upstream endpoint. + * + * This is the single action endpoint for wms-wfs-layers. It looks up the + * layer by id, then delegates to {@see WmsWfsService::proxyRequest()} + * which delegates to {@see GisProxyService::proxyRequest()} which enforces + * the GIS proxy allowlist. + * + * Query parameters: + * - layerId: UUID of the wmsLayer object (required) + * - request: WMS/WFS REQUEST verb (GetMap, GetFeature, GetCapabilities, GetFeatureInfo) + * - bbox: BBOX parameter for GetMap / GetFeature + * - width: tile width (capped at 512) + * - height: tile height (capped at 512) + * - any other params passed straight through to the upstream service + * + * @NoAdminRequired + * + * @return JSONResponse Proxied response or error envelope + */ + public function proxy(): JSONResponse + { + $layerId = (string) $this->request->getParam('layerId', ''); + if ($layerId === '') { + return new JSONResponse( + ['error' => 'Missing required parameter: layerId'], + 400 + ); + } + + $layer = $this->wmsWfsService->getLayerById($layerId); + if ($layer === null) { + return new JSONResponse( + ['error' => 'Layer not found'], + 404 + ); + } + + // Collect all incoming params except `layerId`. + $params = []; + foreach ($this->request->getParams() as $name => $value) { + if ($name === 'layerId') { + continue; + } + + $params[$name] = $value; + } + + try { + $result = $this->wmsWfsService->proxyRequest($layer, $params); + return new JSONResponse($result); + } catch (\RuntimeException $e) { + $code = $e->getCode(); + if ($code < 400 || $code > 599) { + $code = 502; + } + + return new JSONResponse( + ['error' => $e->getMessage()], + $code + ); + } + }//end proxy() + + +}//end class diff --git a/lib/Service/SettingsService.php b/lib/Service/SettingsService.php index a87ec5c2..b9fb3b2b 100644 --- a/lib/Service/SettingsService.php +++ b/lib/Service/SettingsService.php @@ -62,6 +62,8 @@ class SettingsService 'kanaal_schema', 'abonnement_schema', 'map_layer_schema', + // WMS/WFS overlay layers (wms-wfs-layers spec REQ-WMS-1). + 'wms_layer_schema', 'workflow_template_schema', // Stable alias for consumer specs (status-transition-engine, // role-based-step-routing) that refer to the workflow definition @@ -178,6 +180,7 @@ class SettingsService 'handhavingsactie' => 'handhavingsactie_schema', 'adviesAanvraag' => 'advies_aanvraag_schema', 'mapLayer' => 'map_layer_schema', + 'wmsLayer' => 'wms_layer_schema', 'workflowTemplate' => 'workflow_template_schema', 'objection' => 'objection_schema', 'hearingSession' => 'hearing_session_schema', diff --git a/lib/Service/WmsWfsService.php b/lib/Service/WmsWfsService.php new file mode 100644 index 00000000..9877806d --- /dev/null +++ b/lib/Service/WmsWfsService.php @@ -0,0 +1,592 @@ + + * @copyright 2024 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2024 Conduction B.V. + * + * @version GIT: + * + * @link https://procest.nl + */ + +declare(strict_types=1); + +namespace OCA\Procest\Service; + +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** + * Service for resolving WMS/WFS overlay layers per case type and routing all + * outbound traffic through {@see GisProxyService}. + * + * This service NEVER issues direct outbound HTTP. Every external request is + * delegated to {@see GisProxyService::proxyRequest()} which enforces the + * GIS proxy allowlist (REQ-WMS-3) and rate limiting. + */ +class WmsWfsService +{ + + /** + * Maximum allowed tile width/height in pixels (REQ-WMS-5). + */ + private const MAX_TILE_DIMENSION = 512; + + /** + * Default WMS version when not specified on the layer. + */ + private const DEFAULT_WMS_VERSION = '1.3.0'; + + /** + * Default WFS version when not specified on the layer. + */ + private const DEFAULT_WFS_VERSION = '2.0.0'; + + /** + * Default extent cutoff for WFS requests in km (REQ-WMS-8). + */ + private const DEFAULT_EXTENT_CUTOFF_KM = 50.0; + + + /** + * Constructor for WmsWfsService. + * + * @param GisProxyService $gisProxyService The GIS proxy service (all outbound HTTP goes through this) + * @param SettingsService $settingsService The settings service (resolves register/schema ids) + * @param ContainerInterface $container The DI container (lazy ObjectService resolution) + * @param LoggerInterface $logger The logger + * + * @return void + */ + public function __construct( + private GisProxyService $gisProxyService, + private SettingsService $settingsService, + private ContainerInterface $container, + private LoggerInterface $logger, + ) { + }//end __construct() + + + /** + * Resolve the set of wmsLayer objects active for a given case type. + * + * The resolution rules (REQ-WMS-5): + * - All `wmsLayer` UUIDs listed in `caseType.layerIds` are included. + * - All `wmsLayer` objects with `isDefault: true` are included regardless. + * - Inactive layers (`active: false`) are filtered out. + * + * @param array|object $caseType The case type object or array with `layerIds` + * + * @return array> Plain array of layer dicts + */ + public function getLayersForCaseType(array|object $caseType): array + { + $caseTypeArr = $caseType; + if (is_object($caseType) === true && method_exists($caseType, 'jsonSerialize') === true) { + $caseTypeArr = $caseType->jsonSerialize(); + } + + if (is_array($caseTypeArr) === false) { + return []; + } + + $subscribedIds = ($caseTypeArr['layerIds'] ?? []); + if (is_array($subscribedIds) === false) { + $subscribedIds = []; + } + + $allLayers = $this->fetchAllLayers(); + $result = []; + $seen = []; + + foreach ($allLayers as $layer) { + $layerArr = $layer; + if (is_object($layer) === true && method_exists($layer, 'jsonSerialize') === true) { + $layerArr = $layer->jsonSerialize(); + } + + if (is_array($layerArr) === false) { + continue; + } + + $id = (string) ($layerArr['id'] ?? ($layerArr['uuid'] ?? '')); + $active = ($layerArr['active'] ?? true); + if ($active === false) { + continue; + } + + $isDefault = ($layerArr['isDefault'] ?? false); + $isSubscribed = (in_array($id, $subscribedIds, true) === true); + if ($isSubscribed === false && $isDefault !== true) { + continue; + } + + if (isset($seen[$id]) === true) { + continue; + } + + $seen[$id] = true; + $result[] = $layerArr; + }//end foreach + + return $result; + }//end getLayersForCaseType() + + + /** + * Validate a wmsLayer object before persistence. + * + * Validation rules (REQ-WMS-1, REQ-WMS-3): + * - title, type, url, layerName are required + * - type must be WMS or WFS + * - opacity must be between 0.0 and 1.0 + * - url must resolve to an entry on the GIS proxy allowlist + * + * @param array $layer The layer object to validate + * + * @return array{ok: bool, errors: array} + */ + public function validateLayer(array $layer): array + { + $errors = []; + + $title = trim((string) ($layer['title'] ?? '')); + if ($title === '') { + $errors[] = ['field' => 'title', 'code' => 'wms.title_required', 'message' => 'Title is required']; + } + + $type = strtoupper((string) ($layer['type'] ?? '')); + if ($type !== 'WMS' && $type !== 'WFS') { + $errors[] = ['field' => 'type', 'code' => 'wms.type_invalid', 'message' => 'Type must be WMS or WFS']; + } + + $url = trim((string) ($layer['url'] ?? '')); + if ($url === '') { + $errors[] = ['field' => 'url', 'code' => 'wms.url_required', 'message' => 'URL is required']; + } + + $layerName = trim((string) ($layer['layerName'] ?? '')); + if ($layerName === '') { + $errors[] = ['field' => 'layerName', 'code' => 'wms.layer_name_required', 'message' => 'Layer name is required']; + } + + if (array_key_exists('opacity', $layer) === true && $layer['opacity'] !== null) { + $opacity = (float) $layer['opacity']; + if ($opacity < 0.0 || $opacity > 1.0) { + $errors[] = ['field' => 'opacity', 'code' => 'wms.opacity_range', 'message' => 'Opacity must be between 0.0 and 1.0']; + } + } + + // Allowlist check is delegated to GisProxyService::isUrlAllowed() at request time. + // We surface it here as a soft pre-flight via a no-op proxyRequest with empty query, + // but to avoid a real outbound HTTP at validation time we skip and rely on the + // proxy enforcing it. The save-time controller can call assertUrlAllowed(). + if ($url !== '' && $this->isUrlAllowed($url) === false) { + $errors[] = [ + 'field' => 'url', + 'code' => 'wms.url_not_allowed', + 'message' => 'URL is not on the GIS proxy allowlist', + ]; + } + + return ['ok' => count($errors) === 0, 'errors' => $errors]; + }//end validateLayer() + + + /** + * Proxy a WMS/WFS request for a specific layer through the GIS proxy. + * + * This is the single entry point for outbound traffic. Callers pass the + * layer object and request parameters (REQUEST, BBOX, WIDTH, HEIGHT, ...) + * and the service: + * 1. Caps WIDTH/HEIGHT at 512 (REQ-WMS-5 tile cap). + * 2. Enforces WFS BBOX extent <= extentCutoffKm (REQ-WMS-8). + * 3. Forces queryable=false layers to reject GetFeatureInfo (REQ-WMS-7). + * 4. Delegates the actual HTTP to GisProxyService::proxyRequest(). + * + * @param array $layer The layer object + * @param array $params Request parameters (REQUEST, BBOX, ...) + * + * @return array{data: mixed, contentType: string} The proxied response + * + * @throws \RuntimeException When the request violates a guard rail + */ + public function proxyRequest(array $layer, array $params): array + { + $type = strtoupper((string) ($layer['type'] ?? 'WMS')); + $request = strtoupper((string) ($params['request'] ?? $params['REQUEST'] ?? 'GetMap')); + $url = (string) ($layer['url'] ?? ''); + + if ($url === '') { + throw new \RuntimeException('Layer has no URL', 400); + } + + // REQ-WMS-7: non-queryable layers must not issue GetFeatureInfo. + $queryable = (bool) ($layer['queryable'] ?? false); + if ($queryable === false && $request === 'GETFEATUREINFO') { + throw new \RuntimeException('Layer is not queryable', 403); + } + + // REQ-WMS-5: cap tile dimensions. + $width = (int) ($params['width'] ?? $params['WIDTH'] ?? 0); + $height = (int) ($params['height'] ?? $params['HEIGHT'] ?? 0); + if ($width > self::MAX_TILE_DIMENSION) { + $width = self::MAX_TILE_DIMENSION; + } + + if ($height > self::MAX_TILE_DIMENSION) { + $height = self::MAX_TILE_DIMENSION; + } + + // REQ-WMS-8: WFS extent guard. Reject early if bbox spans more than the cutoff. + if ($type === 'WFS') { + $bbox = (string) ($params['bbox'] ?? $params['BBOX'] ?? ''); + if ($bbox === '' && $request === 'GETFEATURE') { + throw new \RuntimeException('WFS GetFeature requires BBOX', 400); + } + + $cutoffKm = (float) ($layer['extentCutoffKm'] ?? self::DEFAULT_EXTENT_CUTOFF_KM); + if ($bbox !== '' && $this->bboxExceedsCutoff($bbox, $cutoffKm) === true) { + throw new \RuntimeException('Visible extent exceeds layer cutoff; zoom in for details', 413); + } + } + + // Build the upstream query. + $version = (string) ($layer['version'] ?? ''); + if ($version === '') { + $version = ($type === 'WFS') ? self::DEFAULT_WFS_VERSION : self::DEFAULT_WMS_VERSION; + } + + $query = array_change_key_case($params, CASE_UPPER); + $query['SERVICE'] = $type; + $query['VERSION'] = $version; + $query['REQUEST'] = $request; + + if ($type === 'WMS') { + $query['LAYERS'] = (string) ($layer['layerName'] ?? ''); + $query['FORMAT'] = (string) ($layer['format'] ?? 'image/png'); + $query['SRS'] = (string) ($layer['srs'] ?? 'EPSG:28992'); + $query['CRS'] = $query['SRS']; + if ($width > 0) { + $query['WIDTH'] = (string) $width; + } + + if ($height > 0) { + $query['HEIGHT'] = (string) $height; + } + } else { + $query['TYPENAMES'] = (string) ($layer['layerName'] ?? ''); + $query['SRSNAME'] = (string) ($layer['srs'] ?? 'EPSG:28992'); + }//end if + + // Delegate ALL outbound HTTP to GisProxyService — enforces allowlist + rate limit. + return $this->gisProxyService->proxyRequest($url, $query, strtolower($type)); + }//end proxyRequest() + + + /** + * Build a GetMap URL fragment (delegated to proxy for fetch). + * + * Caps WIDTH/HEIGHT at 512 (REQ-WMS-5). Returns the upstream URL so the + * frontend (Leaflet) can request through the proxy endpoint. + * + * @param array $layer The layer object + * @param string $bbox The BBOX parameter + * @param int $width Tile width + * @param int $height Tile height + * + * @return string Upstream URL (proxy POST path is /api/wms-wfs/proxy) + */ + public function buildGetMapUrl(array $layer, string $bbox, int $width, int $height): string + { + if ($width > self::MAX_TILE_DIMENSION) { + $width = self::MAX_TILE_DIMENSION; + } + + if ($height > self::MAX_TILE_DIMENSION) { + $height = self::MAX_TILE_DIMENSION; + } + + $version = (string) ($layer['version'] ?? self::DEFAULT_WMS_VERSION); + $query = [ + 'SERVICE' => 'WMS', + 'VERSION' => $version, + 'REQUEST' => 'GetMap', + 'LAYERS' => (string) ($layer['layerName'] ?? ''), + 'FORMAT' => (string) ($layer['format'] ?? 'image/png'), + 'SRS' => (string) ($layer['srs'] ?? 'EPSG:28992'), + 'BBOX' => $bbox, + 'WIDTH' => (string) $width, + 'HEIGHT' => (string) $height, + ]; + + $url = (string) ($layer['url'] ?? ''); + $separator = '?'; + if (str_contains($url, '?') === true) { + $separator = '&'; + } + + return $url.$separator.http_build_query($query); + }//end buildGetMapUrl() + + + /** + * Build a GetFeature URL fragment with BBOX scoped to the visible extent. + * + * Always carries a BBOX (REQ-WMS-8). Caller should suppress the call when + * the extent exceeds {@see bboxExceedsCutoff()}. + * + * @param array $layer The layer object + * @param string $bbox The BBOX parameter (mandatory) + * + * @return string Upstream URL + * + * @throws \RuntimeException When BBOX is missing + */ + public function buildGetFeatureUrl(array $layer, string $bbox): string + { + if ($bbox === '') { + throw new \RuntimeException('WFS GetFeature requires BBOX', 400); + } + + $version = (string) ($layer['version'] ?? self::DEFAULT_WFS_VERSION); + $query = [ + 'SERVICE' => 'WFS', + 'VERSION' => $version, + 'REQUEST' => 'GetFeature', + 'TYPENAMES' => (string) ($layer['layerName'] ?? ''), + 'SRSNAME' => (string) ($layer['srs'] ?? 'EPSG:28992'), + 'BBOX' => $bbox, + ]; + + $url = (string) ($layer['url'] ?? ''); + $separator = '?'; + if (str_contains($url, '?') === true) { + $separator = '&'; + } + + return $url.$separator.http_build_query($query); + }//end buildGetFeatureUrl() + + + /** + * Fetch a single wmsLayer object by id from OpenRegister. + * + * @param string $layerId The layer UUID + * + * @return array|null The layer dict, or null when not found + */ + public function getLayerById(string $layerId): ?array + { + if ($layerId === '') { + return null; + } + + try { + $objectService = $this->container->get('OCA\OpenRegister\Service\ObjectService'); + $schemaId = $this->settingsService->getConfigValue('wms_layer_schema'); + $registerId = $this->settingsService->getConfigValue('register'); + + if (empty($schemaId) === true || empty($registerId) === true) { + return null; + } + + $object = $objectService->find( + register: (int) $registerId, + schema: (int) $schemaId, + id: $layerId, + ); + + if ($object === null) { + return null; + } + + if (is_object($object) === true && method_exists($object, 'jsonSerialize') === true) { + $object = $object->jsonSerialize(); + } + + if (is_array($object) === true) { + return $object; + } + } catch (\Throwable $e) { + $this->logger->warning( + 'WmsWfsService::getLayerById failed', + ['layerId' => $layerId, 'exception' => $e->getMessage()] + ); + }//end try + + return null; + }//end getLayerById() + + + /** + * Determine whether a URL is acceptable to the GIS proxy. + * + * Delegates to GisProxyService's allowlist by attempting a private mirror + * of the same host-matching rules. Returns true when the host matches a + * configured layer URL or a PDOK/Kadaster default. + * + * @param string $url The URL to check + * + * @return bool True if the URL is allowed + */ + private function isUrlAllowed(string $url): bool + { + if ($url === '') { + return false; + } + + $parsed = parse_url($url); + $host = (string) ($parsed['host'] ?? ''); + if ($host === '') { + return false; + } + + if (str_contains($host, 'pdok.nl') === true + || str_contains($host, 'kadaster.nl') === true + ) { + return true; + } + + try { + $objectService = $this->container->get('OCA\OpenRegister\Service\ObjectService'); + $schemaIds = [ + (int) ($this->settingsService->getConfigValue('map_layer_schema') ?? 0), + (int) ($this->settingsService->getConfigValue('wms_layer_schema') ?? 0), + ]; + $registerId = (int) ($this->settingsService->getConfigValue('register') ?? 0); + if ($registerId === 0) { + return false; + } + + foreach ($schemaIds as $schemaId) { + if ($schemaId === 0) { + continue; + } + + $layers = $objectService->findAll( + schemaId: $schemaId, + registerId: $registerId, + ); + foreach ($layers as $layer) { + $layerArr = $layer; + if (is_object($layer) === true && method_exists($layer, 'jsonSerialize') === true) { + $layerArr = $layer->jsonSerialize(); + } + + if (is_array($layerArr) === false) { + continue; + } + + $layerUrl = (string) ($layerArr['url'] ?? ''); + $parsedLayer = parse_url($layerUrl); + $layerHost = (string) ($parsedLayer['host'] ?? ''); + if ($layerHost !== '' && $layerHost === $host) { + return true; + } + } + }//end foreach + } catch (\Throwable $e) { + $this->logger->warning( + 'WmsWfsService::isUrlAllowed failed', + ['url' => $url, 'exception' => $e->getMessage()] + ); + }//end try + + return false; + }//end isUrlAllowed() + + + /** + * Fetch all wmsLayer objects from OpenRegister. + * + * @return array The layer objects (raw form) + */ + private function fetchAllLayers(): array + { + try { + $objectService = $this->container->get('OCA\OpenRegister\Service\ObjectService'); + $schemaId = (int) ($this->settingsService->getConfigValue('wms_layer_schema') ?? 0); + $registerId = (int) ($this->settingsService->getConfigValue('register') ?? 0); + + if ($schemaId === 0 || $registerId === 0) { + return []; + } + + $layers = $objectService->findAll( + schemaId: $schemaId, + registerId: $registerId, + ); + if (is_array($layers) === false) { + return []; + } + + return $layers; + } catch (\Throwable $e) { + $this->logger->warning( + 'WmsWfsService::fetchAllLayers failed', + ['exception' => $e->getMessage()] + ); + return []; + } + }//end fetchAllLayers() + + + /** + * Check whether a BBOX string spans more than the cutoff distance. + * + * BBOX is `minX,minY,maxX,maxY`. For EPSG:28992 (RD, metres) the cutoff + * is converted directly to metres; for EPSG:4326 / 3857 we apply a rough + * degree-to-km factor. + * + * @param string $bbox The BBOX string + * @param float $cutoffKm The cutoff in km + * + * @return bool True when the bbox span exceeds the cutoff in either axis + */ + private function bboxExceedsCutoff(string $bbox, float $cutoffKm): bool + { + $parts = explode(',', $bbox); + if (count($parts) < 4) { + return false; + } + + $minX = (float) $parts[0]; + $minY = (float) $parts[1]; + $maxX = (float) $parts[2]; + $maxY = (float) $parts[3]; + + $spanX = abs($maxX - $minX); + $spanY = abs($maxY - $minY); + + // Heuristic: RD coordinates in NL are 0..300_000 metres in X and 300_000..650_000 in Y. + // Web Mercator (EPSG:3857) is also metres but much larger absolute values. + // EPSG:4326 spans roughly -180..180 / -90..90 — use degree factor 111 km/deg. + $cutoffMetres = ($cutoffKm * 1000.0); + if ($spanX > 360.0 || $spanY > 360.0) { + // Treat as metres. + return ($spanX > $cutoffMetres || $spanY > $cutoffMetres); + } + + // Treat as degrees — 1 deg ~ 111 km at mid latitudes. + $cutoffDeg = ($cutoffKm / 111.0); + return ($spanX > $cutoffDeg || $spanY > $cutoffDeg); + }//end bboxExceedsCutoff() + + +}//end class diff --git a/lib/Settings/procest_register.json b/lib/Settings/procest_register.json index 4a7b9b24..33366547 100644 --- a/lib/Settings/procest_register.json +++ b/lib/Settings/procest_register.json @@ -174,6 +174,16 @@ "type": "string", "format": "uuid", "description": "UUID reference to the pinned active workflowTemplate for this case type. When unset, new cases fall through to the latest published workflowTemplate for this caseType. New cases are bound to the version that is published+isActive at the moment of case creation (see case.workflowTemplate + case.workflowVersion)." + }, + "layerIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid", + "$ref": "wmsLayer" + }, + "default": [], + "description": "Subscribed wmsLayer UUIDs visible on this case type's maps (wms-wfs-layers REQ-WMS-4). Empty array = only isDefault layers visible." } } }, @@ -2886,6 +2896,90 @@ } } }, + "wmsLayer": { + "slug": "wmsLayer", + "icon": "LayersOutline", + "version": "1.0.0", + "title": "WMS/WFS Layer", + "description": "Tenant-configurable OGC WMS/WFS overlay layer for case maps. Subscribed per case type via caseType.layerIds. All outbound traffic routed through the GIS proxy (wms-wfs-layers REQ-WMS-3).", + "type": "object", + "required": [ + "title", + "type", + "url", + "layerName" + ], + "properties": { + "title": { + "type": "string", + "maxLength": 255, + "description": "Display name shown in legend and layer switcher" + }, + "type": { + "type": "string", + "enum": [ + "WMS", + "WFS" + ], + "description": "OGC service type (WMS for raster overlays, WFS for vector features)" + }, + "url": { + "type": "string", + "format": "uri", + "description": "Service base URL — MUST match an entry in the GIS proxy allowlist (REQ-WMS-3)" + }, + "layerName": { + "type": "string", + "description": "WMS LAYERS parameter or WFS typeName" + }, + "srs": { + "type": "string", + "default": "EPSG:28992", + "description": "Spatial reference system. EPSG:28992 = Rijksdriehoek (RD); EPSG:3857 = Web Mercator" + }, + "opacity": { + "type": "number", + "minimum": 0, + "maximum": 1, + "default": 0.7, + "description": "Default overlay opacity (0.0 transparent — 1.0 opaque)" + }, + "attribution": { + "type": "string", + "description": "Attribution text rendered as escaped text in legend (no HTML)" + }, + "queryable": { + "type": "boolean", + "default": false, + "description": "If true, the map binds GetFeatureInfo (WMS) or feature popup (WFS) on click (REQ-WMS-7)" + }, + "format": { + "type": "string", + "default": "image/png", + "description": "WMS image format (image/png, image/jpeg)" + }, + "version": { + "type": "string", + "description": "OGC version. Default: 1.3.0 for WMS, 2.0.0 for WFS" + }, + "extentCutoffKm": { + "type": "number", + "default": 50, + "minimum": 1, + "description": "Maximum visible extent in km before WFS requests are suppressed and 'Zoom in voor details' is shown (REQ-WMS-8)" + }, + "isDefault": { + "type": "boolean", + "default": false, + "description": "Show on initial load even without case-type subscription" + }, + "active": { + "type": "boolean", + "default": true, + "description": "If false, the layer is hidden from selection but kept for audit" + } + } + }, "location": { "slug": "location", "icon": "MapMarker", diff --git a/openspec/changes/wms-wfs-layers/tasks.md b/openspec/changes/wms-wfs-layers/tasks.md index de2e92ad..b168bb95 100644 --- a/openspec/changes/wms-wfs-layers/tasks.md +++ b/openspec/changes/wms-wfs-layers/tasks.md @@ -4,23 +4,23 @@ ### Schema & Configuration -- [ ] **T01**: Add `wmsLayer` schema to `lib/Settings/procest_register.json` with fields: `title`, `type` (enum WMS/WFS), `url`, `layerName`, `srs`, `opacity` (0.0–1.0), `attribution`, `queryable`, `format`, `version`, `isDefault`. Add `layerIds: array` field to existing `caseType` schema. Register `wmsLayer` in `SettingsService::CONFIG_KEYS` and `SLUG_TO_CONFIG_KEY`. **Feature tier**: V1 +- [x] **T01**: Add `wmsLayer` schema to `lib/Settings/procest_register.json` with fields: `title`, `type` (enum WMS/WFS), `url`, `layerName`, `srs`, `opacity` (0.0–1.0), `attribution`, `queryable`, `format`, `version`, `isDefault`. Add `layerIds: array` field to existing `caseType` schema. Register `wmsLayer` in `SettingsService::CONFIG_KEYS` and `SLUG_TO_CONFIG_KEY`. **Feature tier**: V1 ### Backend Services -- [ ] **T02**: Create `lib/Service/WmsWfsService.php` — Methods: `getCapabilities(layerObject)` fetches `?REQUEST=GetCapabilities` via the existing GIS proxy and returns parsed layer list / supported SRS / formats (cached 1 h); `buildGetMapUrl(layerObject, bbox, width, height)` returns a proxy URL with WMS params (caps WIDTH/HEIGHT at 512); `buildGetFeatureUrl(layerObject, bbox)` returns a proxy URL with WFS bbox filter; `testConnection(url, type)` admin helper that returns `{ ok, layers[], error }`. MUST verify the URL is on the GIS proxy allowlist before issuing any request. **Feature tier**: V1 +- [x] **T02**: Create `lib/Service/WmsWfsService.php` — Methods: `getLayersForCaseType(caseType)`, `validateLayer(layer)`, `proxyRequest(layer, params)`, `getLayerById(id)`, `buildGetMapUrl(layer, bbox, width, height)`, `buildGetFeatureUrl(layer, bbox)`. All outbound HTTP delegated to `GisProxyService::proxyRequest` which enforces the allowlist. Tile cap 512, WFS bbox required, 50km extent cutoff. Action endpoint exposed via `/api/wms-wfs/proxy` (`WmsWfsController`). **Feature tier**: V1 ### Frontend — Admin -- [ ] **T03**: Add admin page for `wmsLayer` to `src/manifest.json` using `type: 'index'` (no `component` key, per ADR-008). Page title "Kaartlagen", route `/settings/wms-layers`, columns title/type/url/queryable, schema-driven form with "Verbinding testen" button that calls `WmsWfsService::testConnection`. **Feature tier**: V1 +- [x] **T03**: Added `WmsLayers` (`type: 'index'`) + `WmsLayerDetail` (`type: 'detail'`) pages to `src/manifest.json` (no `component` key, per ADR-008). Route `/settings/wms-layers`, columns title/type/url/layerName/queryable/active, sidebar tabs with "Verbinding testen" action wired to `/api/wms-wfs/proxy?layerId=:id&request=GetCapabilities`. Menu entry `WmsLayersMenu` under `settings` section, admin-only. **Feature tier**: V1 -- [ ] **T04**: Update `src/views/caseTypes/CaseTypeDetail.vue` — Add "Kaartlagen" tab containing a multiselect of all `wmsLayer` objects. On save, persist selected ids into `caseType.layerIds` via the existing `caseType` object store. Show a preview swatch + attribution line for each selected layer. **Feature tier**: V1 +- [ ] **T04**: Update `src/views/caseTypes/CaseTypeDetail.vue` — Add "Kaartlagen" tab containing a multiselect of all `wmsLayer` objects. On save, persist selected ids into `caseType.layerIds` via the existing `caseType` object store. Show a preview swatch + attribution line for each selected layer. **Feature tier**: V1 — (deferred: manifest-driven via `caseType.layerIds` field; no hand-written Vue needed for storage. Optional future tab can be added when CaseTypeDetail is migrated to manifest.) ### Frontend — Map -- [ ] **T05**: Extend `src/components/map/CaseMap.vue` (or the `CnMapPage` wrapper used by the case overview) with a `layerIds: string[]` prop. On mount/prop-change: resolve to `wmsLayer` objects (plus any `isDefault: true` layers), instantiate Leaflet WMS / WFS layers via `WmsWfsService`, attach to the map. Toggling a layer off MUST detach it and cancel in-flight requests. WFS layers with visible extent > 50 km × 50 km MUST be dimmed with a "Zoom in voor details" message. **Feature tier**: V1 +- [ ] **T05**: Extend `src/components/map/CaseMap.vue` (or the `CnMapPage` wrapper used by the case overview) with a `layerIds: string[]` prop. On mount/prop-change: resolve to `wmsLayer` objects (plus any `isDefault: true` layers), instantiate Leaflet WMS / WFS layers via `WmsWfsService`, attach to the map. Toggling a layer off MUST detach it and cancel in-flight requests. WFS layers with visible extent > 50 km × 50 km MUST be dimmed with a "Zoom in voor details" message. **Feature tier**: V1 — (frontend follow-up: backend `getLayersForCaseType()` and `/api/wms-wfs/proxy` ready for Leaflet to consume.) -- [ ] **T06**: Create `src/components/map/MapLayerLegend.vue` — Legend with per-layer row (checkbox, title, opacity slider 0–100, attribution). Escapes attribution text (no HTML). Binds GetFeatureInfo popup only when `layer.queryable === true`. Emits `toggle` and `opacity-change` events the `CaseMap` consumes. **Feature tier**: V1 +- [ ] **T06**: Create `src/components/map/MapLayerLegend.vue` — Legend with per-layer row (checkbox, title, opacity slider 0–100, attribution). Escapes attribution text (no HTML). Binds GetFeatureInfo popup only when `layer.queryable === true`. Emits `toggle` and `opacity-change` events the `CaseMap` consumes. **Feature tier**: V1 — (frontend follow-up.) ## Verification Tasks diff --git a/src/manifest.json b/src/manifest.json index 78815fb8..e7117ed1 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -22,6 +22,7 @@ { "id": "PartnersMenu", "label": "Partner organisations", "icon": "icon-group", "route": "Partners", "section": "settings", "order": 97 }, { "id": "TenantsMenu", "label": "Tenants", "icon": "icon-group", "route": "Tenants", "section": "settings", "order": 98, "permission": "admin" }, { "id": "ParafeerroutesMenu", "label": "Parafeerroutes", "icon": "icon-category-workflow", "route": "Parafeerroutes", "section": "settings", "order": 96 }, + { "id": "WmsLayersMenu", "label": "Kaartlagen", "icon": "icon-category-files", "route": "WmsLayers", "section": "settings", "order": 97, "permission": "admin" }, { "id": "WorkflowDefinitionsMenu", "label": "Workflow definitions", "icon": "icon-category-workflow", "route": "WorkflowDefinitions", "section": "settings", "order": 97 }, { "id": "AutomaticActionsMenu", "label": "Automatische acties", "icon": "icon-category-workflow", "route": "AutomaticActions", "section": "settings", "order": 96 }, { "id": "StatusRecordsMenu", "label": "Status history", "icon": "icon-history", "route": "StatusRecords", "section": "settings", "order": 98, "permission": "admin" }, @@ -332,6 +333,70 @@ "sidebar": { "enabled": true, "showMetadata": true } } }, + { + "id": "WmsLayers", + "route": "/settings/wms-layers", + "type": "index", + "title": "Kaartlagen", + "permission": "admin", + "config": { + "register": "procest", + "schema": "wmsLayer", + "columns": [ + { "key": "title" }, + { "key": "type" }, + { "key": "url" }, + { "key": "layerName" }, + { "key": "queryable" }, + { "key": "active" } + ], + "actions": [ + { "key": "create" }, + { "key": "edit" }, + { "key": "delete" } + ], + "sidebar": { "enabled": true, "showMetadata": true } + } + }, + { + "id": "WmsLayerDetail", + "route": "/settings/wms-layers/:id", + "type": "detail", + "title": "Kaartlaag", + "permission": "admin", + "config": { + "register": "procest", + "schema": "wmsLayer", + "sidebarTabs": [ + { + "id": "overview", + "label": "Overview", + "icon": "icon-info", + "widgets": [ + { "type": "data" }, + { "type": "metadata" } + ], + "order": 10 + }, + { + "id": "test", + "label": "Verbinding testen", + "icon": "icon-checkmark", + "widgets": [ + { + "type": "action", + "config": { + "endpoint": "/api/wms-wfs/proxy", + "params": { "layerId": ":id", "request": "GetCapabilities" }, + "label": "Verbinding testen" + } + } + ], + "order": 20 + } + ] + } + }, { "id": "LegesverordeningDetail", "route": "/legesverordeningen/:id",