Skip to content

Commit 5503e8b

Browse files
JeroenDeDauwclaude
andcommitted
Add on-wiki JSON config page for Leaflet layer settings
Fixes #924 Adds a MediaWiki:Maps JSON config page for the Leaflet layer settings, combined with LocalSettings.php with the wiki page winning. Core enforces the editinterface and editsitejson rights via a forced JSON content model (ContentHandlerDefaultModelFor), and edits are validated on save (EditFilter) with precise, i18n'd errors. The page is read lazily at parse time and falls back to the PHP config when it is missing, invalid or the database is unavailable. A new $egMapsEnableInWikiConfig (default true) disables it. Exposed settings: custom layerDefinitions plus the default and available base layers and overlays. The singular $egMapsLeafletLayer is intentionally omitted as it is unused. The layer-definition contract is hardened for both the PHP and wiki sources through one pipeline (breaking change, hence 14.0.0): url and errorTileUrl must be http(s), only allowlisted Leaflet options are kept, and attribution is sanitized server-side to plain text and http(s) links. Save-time validation is hand-rolled to give precise per-field errors without adding a dependency. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent ef85518 commit 5503e8b

30 files changed

Lines changed: 1864 additions & 67 deletions

DefaultSettings.php

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@
2626
// Allows disabling the Semantic MediaWiki integration.
2727
'egMapsDisableSmwIntegration' => false,
2828

29+
// Boolean. Whether Maps configuration may also be set on the MediaWiki:Maps JSON config page,
30+
// which is editable only by users with the editinterface and editsitejson rights. When false,
31+
// that page is never read and only LocalSettings.php applies. The wiki page currently exposes
32+
// the Leaflet layer settings; its values take precedence over LocalSettings.php.
33+
'egMapsEnableInWikiConfig' => true,
34+
2935

3036

3137

@@ -255,14 +261,22 @@
255261
// Custom named tile layers that can be used as base layers or overlays, in addition to the
256262
// stock leaflet-providers layers listed above. The name of each definition becomes a valid
257263
// value for the layers and overlays parameters. Only the definitions actually used by a map
258-
// are sent to the browser.
264+
// are sent to the browser. The same layers can be defined on-wiki via the MediaWiki:Maps
265+
// config page (see egMapsEnableInWikiConfig).
259266
//
260-
// Each definition has a 'url' (required, an XYZ tile template or, for WMS, the service
261-
// endpoint), an optional 'options' array passed straight to Leaflet, and an optional 'wms'
262-
// flag. Definitions without a non-empty url string are ignored.
267+
// Each definition has a required 'url' plus optional 'options' and 'wms' entries:
268+
// - 'url' must be an http(s) URL. XYZ tile templates ({z}, {x}, {y}, {s}) and, for WMS, the
269+
// service endpoint are supported. Definitions without a valid url are ignored.
270+
// - 'wms' (boolean) renders the layer with L.tileLayer.wms instead of L.tileLayer.
271+
// - 'options' is passed to Leaflet, but only a safe allowlist of keys is kept: attribution,
272+
// minZoom, maxZoom, minNativeZoom, maxNativeZoom, subdomains, errorTileUrl, zoomOffset,
273+
// zoomReverse, tms, detectRetina, bounds, opacity, zIndex and noWrap; plus, for WMS layers,
274+
// layers, styles, format, transparent, version and uppercase. Unknown options are dropped.
275+
// 'errorTileUrl' must be an http(s) URL, and 'attribution' is sanitized to plain text and
276+
// http(s) links.
263277
//
264278
// A definition whose name matches a stock layer (e.g. 'OpenStreetMap') overrides that stock
265-
// layer. The 'options' are passed to Leaflet as-is; note that 'attribution' is rendered as HTML.
279+
// layer.
266280
//
267281
// Example:
268282
// 'egMapsLeafletLayerDefinitions' => [

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,31 @@ via [Professional.Wiki](https://professional.wiki/). Discounts for work that is
4242
* Ask a question on [the mailing list](https://www.semantic-mediawiki.org/wiki/Mailing_list)
4343
* File an issue on [our issue tracker](https://github.com/JeroenDeDauw/Maps/issues)
4444

45+
## On-wiki configuration
46+
47+
Most settings are configured in `LocalSettings.php` (see the
48+
[configuration documentation](https://maps.extension.wiki/wiki/Configuration)). Wiki administrators
49+
without server access can also configure the Leaflet layers on the `MediaWiki:Maps` page. It holds
50+
JSON and, like other site configuration, is editable only by users with the `editinterface` and
51+
`editsitejson` rights. For example, to add a custom tile layer that authors can then select with the
52+
`layers` parameter:
53+
54+
```json
55+
{
56+
"leaflet": {
57+
"layerDefinitions": {
58+
"Historic 1904": {
59+
"url": "https://tiles.example.org/historic1904/{z}/{x}/{y}.png",
60+
"options": { "attribution": "Historic map tiles", "maxZoom": 18 }
61+
}
62+
}
63+
}
64+
}
65+
```
66+
67+
The page is validated when saved and combined with `LocalSettings.php`, with the wiki page taking
68+
precedence. Changes take effect the next time a page with a map is parsed.
69+
4570
## Project status
4671

4772
* Latest version [![Latest Stable Version](https://poser.pugx.org/mediawiki/maps/v/stable)](https://packagist.org/packages/mediawiki/maps)

RELEASE-NOTES.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@ different releases and which versions of PHP and MediaWiki they support, see the
33
[platform compatibility tables](INSTALL.md#platform-compatibility-and-release-status).
44

55

6+
## Maps 14.0.0
7+
8+
Released on TBD.
9+
10+
* Added an on-wiki JSON configuration page at `MediaWiki:Maps` for the Leaflet layer settings (custom layer definitions, the default and available base layers and overlays). It is editable only by administrators and interface administrators, is validated on save, and is combined with `LocalSettings.php` with the wiki page taking precedence. Set `$egMapsEnableInWikiConfig` to `false` to disable it.
11+
* Hardened the custom Leaflet layer definitions used by `$egMapsLeafletLayerDefinitions` and the config page (breaking change): the `url` and `errorTileUrl` must be `http(s)` URLs, only an allowlist of Leaflet `options` is kept, and `attribution` is sanitized to plain text and `http(s)` links. Definitions relying on raw HTML attribution or non-allowlisted options need updating.
12+
613
## Maps 13.1.0
714

815
Released on July 19th, 2026.

i18n/en.json

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,5 +265,18 @@
265265
"maps-validator-describe-header-description": "Description",
266266
"maps-validator-describe-required": "required",
267267
"maps-validator-describe-empty": "empty",
268-
"maps-validator-message-nodesc": "No description provided"
268+
"maps-validator-message-nodesc": "No description provided",
269+
"maps-config-invalid": "The Maps configuration was not saved because it is invalid:",
270+
"maps-config-error-invalid-json": "The configuration must be a JSON object.",
271+
"maps-config-error-not-object": "The value of \"$1\" must be a JSON object.",
272+
"maps-config-error-unknown-key": "Unknown configuration key \"$1\".",
273+
"maps-config-error-invalid-layer-name": "\"$1\" is not a valid layer name.",
274+
"maps-config-error-unknown-layer-key": "Layer \"$1\" has an unknown property \"$2\".",
275+
"maps-config-error-invalid-url": "Layer \"$1\" must have a \"url\" that starts with http:// or https://.",
276+
"maps-config-error-invalid-wms": "The \"wms\" property of layer \"$1\" must be true or false.",
277+
"maps-config-error-unknown-option": "Layer \"$1\" has an unknown option \"$2\".",
278+
"maps-config-error-invalid-option-url": "The \"$2\" option of layer \"$1\" must be a URL that starts with http:// or https://.",
279+
"maps-config-error-invalid-attribution": "The \"attribution\" option of layer \"$1\" must be text.",
280+
"maps-config-error-invalid-default-list": "\"$1\" must be a list of layer names.",
281+
"maps-config-error-invalid-availability": "\"$1\" must be a map of layer names to true or false."
269282
}

i18n/qqq.json

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,5 +243,18 @@
243243
"maps-validator-describe-header-description": "{{Identical|Description}}\n{{Related|Validator-describe}}",
244244
"maps-validator-describe-required": "{{related|Validator-describe}}\n{{Identical|Required}}",
245245
"maps-validator-describe-empty": "{{Identical|Empty}}\n{{related|Validator-describe}}",
246-
"maps-validator-message-nodesc": "Indicates that no parameter description is available"
246+
"maps-validator-message-nodesc": "Indicates that no parameter description is available",
247+
"maps-config-invalid": "Heading shown above the list of errors when an edit to the MediaWiki:Maps JSON config page is rejected.",
248+
"maps-config-error-invalid-json": "Error shown when the MediaWiki:Maps config is not a JSON object.",
249+
"maps-config-error-not-object": "Error shown when a value that must be a JSON object is not. $1 is the location, e.g. \"leaflet\" or \"layerDefinitions\".",
250+
"maps-config-error-unknown-key": "Error shown when the config contains an unrecognized key. $1 is the unknown key.",
251+
"maps-config-error-invalid-layer-name": "Error shown when a custom Leaflet layer has an invalid name. $1 is the name.",
252+
"maps-config-error-unknown-layer-key": "Error shown when a custom Leaflet layer definition has an unrecognized property. $1 is the layer name, $2 is the property.",
253+
"maps-config-error-invalid-url": "Error shown when a custom Leaflet layer has no valid url. $1 is the layer name.",
254+
"maps-config-error-invalid-wms": "Error shown when the wms property of a custom Leaflet layer is not a boolean. $1 is the layer name.",
255+
"maps-config-error-unknown-option": "Error shown when a custom Leaflet layer has an option that is not allowed. $1 is the layer name, $2 is the option.",
256+
"maps-config-error-invalid-option-url": "Error shown when a URL option of a custom Leaflet layer is not a valid URL. $1 is the layer name, $2 is the option.",
257+
"maps-config-error-invalid-attribution": "Error shown when the attribution option of a custom Leaflet layer is not text. $1 is the layer name.",
258+
"maps-config-error-invalid-default-list": "Error shown when a default layer list is not a list of strings. $1 is defaultLayers or defaultOverlays.",
259+
"maps-config-error-invalid-availability": "Error shown when a layer availability map is invalid. $1 is availableLayers or availableOverlays."
247260
}

src/AttributionSanitizer.php

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
<?php
2+
3+
declare( strict_types = 1 );
4+
5+
namespace Maps;
6+
7+
use DOMDocument;
8+
use DOMElement;
9+
10+
/**
11+
* Sanitizes a Leaflet layer attribution string so that it is safe to render as HTML.
12+
*
13+
* Leaflet inserts the attribution into the map via innerHTML, so untrusted markup here is a
14+
* stored-XSS vector (see advisory GHSA-4h7g-5542-v3fc). The allowlist is deliberately narrow:
15+
* plain text plus <a> links with an http(s) href and an optional title. Everything else, including
16+
* <img>, event handlers and javascript:/data:/vbscript: hrefs, is removed. The text content of
17+
* disallowed tags is kept as inert text.
18+
*
19+
* @licence GNU GPL v2+
20+
*/
21+
class AttributionSanitizer {
22+
23+
public function sanitize( string $attribution ): string {
24+
if ( strpos( $attribution, '<' ) === false ) {
25+
return $attribution;
26+
}
27+
28+
$onlyAnchors = strip_tags( $attribution, '<a>' );
29+
30+
if ( strpos( $onlyAnchors, '<' ) === false ) {
31+
return $onlyAnchors;
32+
}
33+
34+
return $this->cleanAnchors( $onlyAnchors );
35+
}
36+
37+
private function cleanAnchors( string $html ): string {
38+
$document = new DOMDocument();
39+
40+
$previousErrorHandling = libxml_use_internal_errors( true );
41+
$loaded = $document->loadHTML(
42+
'<?xml encoding="UTF-8"?><div>' . $html . '</div>',
43+
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
44+
);
45+
libxml_clear_errors();
46+
libxml_use_internal_errors( $previousErrorHandling );
47+
48+
if ( !$loaded || $document->documentElement === null ) {
49+
return strip_tags( $html );
50+
}
51+
52+
foreach ( $document->getElementsByTagName( 'a' ) as $anchor ) {
53+
$this->cleanAnchor( $anchor );
54+
}
55+
56+
return $this->innerHtml( $document->documentElement );
57+
}
58+
59+
private function cleanAnchor( DOMElement $anchor ): void {
60+
$href = $anchor->getAttribute( 'href' );
61+
$title = $anchor->getAttribute( 'title' );
62+
63+
foreach ( iterator_to_array( $anchor->attributes ) as $attribute ) {
64+
$anchor->removeAttribute( $attribute->name );
65+
}
66+
67+
if ( $this->isAllowedHref( $href ) ) {
68+
$anchor->setAttribute( 'href', $href );
69+
}
70+
71+
if ( $title !== '' ) {
72+
$anchor->setAttribute( 'title', $title );
73+
}
74+
}
75+
76+
private function isAllowedHref( string $href ): bool {
77+
$normalized = strtolower( (string)preg_replace( '/[\x00-\x20]+/', '', $href ) );
78+
79+
return preg_match( '#^https?://#', $normalized ) === 1;
80+
}
81+
82+
private function innerHtml( DOMElement $element ): string {
83+
$html = '';
84+
85+
foreach ( $element->childNodes as $child ) {
86+
$html .= $element->ownerDocument->saveHTML( $child );
87+
}
88+
89+
return $html;
90+
}
91+
92+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
<?php
2+
3+
declare( strict_types = 1 );
4+
5+
namespace Maps;
6+
7+
/**
8+
* Resolves the effective Leaflet configuration by combining the PHP settings with the
9+
* MediaWiki:Maps config page, with the wiki page taking precedence:
10+
*
11+
* * Layer definitions and availability maps are merged per name, the wiki value winning on
12+
* collision.
13+
* * The default layer and overlay selections are replaced wholesale when the wiki page sets them.
14+
*
15+
* The wiki page is read lazily on first use and the result is memoized for the request. When wiki
16+
* config is disabled, or the page is missing or unreadable, the PHP settings are used unchanged.
17+
*
18+
* @licence GNU GPL v2+
19+
*/
20+
class CombiningLeafletConfigLookup implements LeafletConfigLookup {
21+
22+
private ?LeafletConfig $config = null;
23+
24+
/**
25+
* @param array{
26+
* layerDefinitions: array,
27+
* defaultLayers: string[],
28+
* defaultOverlays: string[],
29+
* availableLayers: array<string, bool>,
30+
* availableOverlays: array<string, bool>
31+
* } $phpConfig
32+
*/
33+
public function __construct(
34+
private array $phpConfig,
35+
private LeafletConfigSource $wikiConfigSource,
36+
private bool $wikiConfigEnabled
37+
) {
38+
}
39+
40+
public function getConfig(): LeafletConfig {
41+
$this->config ??= $this->buildConfig();
42+
43+
return $this->config;
44+
}
45+
46+
private function buildConfig(): LeafletConfig {
47+
$raw = $this->phpConfig;
48+
49+
if ( $this->wikiConfigEnabled ) {
50+
$wikiConfig = $this->wikiConfigSource->getLeafletConfig();
51+
52+
if ( $wikiConfig !== null ) {
53+
$raw = $this->combine( $this->phpConfig, $wikiConfig );
54+
}
55+
}
56+
57+
return new LeafletConfig(
58+
new LeafletLayerDefinitions( $raw['layerDefinitions'] ),
59+
$raw['defaultLayers'],
60+
$raw['defaultOverlays'],
61+
$raw['availableLayers'],
62+
$raw['availableOverlays']
63+
);
64+
}
65+
66+
private function combine( array $php, array $wiki ): array {
67+
return [
68+
'layerDefinitions' => $this->mergeByName( $php['layerDefinitions'], $wiki['layerDefinitions'] ?? null ),
69+
'defaultLayers' => $this->stringList( $wiki['defaultLayers'] ?? null ) ?? $php['defaultLayers'],
70+
'defaultOverlays' => $this->stringList( $wiki['defaultOverlays'] ?? null ) ?? $php['defaultOverlays'],
71+
'availableLayers' => $this->mergeAvailability( $php['availableLayers'], $wiki['availableLayers'] ?? null ),
72+
'availableOverlays' => $this->mergeAvailability( $php['availableOverlays'], $wiki['availableOverlays'] ?? null ),
73+
];
74+
}
75+
76+
private function mergeByName( array $php, mixed $wiki ): array {
77+
if ( !is_array( $wiki ) ) {
78+
return $php;
79+
}
80+
81+
// Union rather than array_merge: array_merge renumbers integer-like keys, which would drop
82+
// a custom layer whose name is purely numeric (e.g. "1904"). Wiki entries go on the left so
83+
// they take precedence over a same-named PHP entry.
84+
return $wiki + $php;
85+
}
86+
87+
private function mergeAvailability( array $php, mixed $wiki ): array {
88+
if ( !is_array( $wiki ) ) {
89+
return $php;
90+
}
91+
92+
$coerced = [];
93+
94+
foreach ( $wiki as $name => $enabled ) {
95+
$coerced[$name] = (bool)$enabled;
96+
}
97+
98+
return $coerced + $php;
99+
}
100+
101+
private function stringList( mixed $value ): ?array {
102+
if ( !is_array( $value ) ) {
103+
return null;
104+
}
105+
106+
foreach ( $value as $item ) {
107+
if ( !is_string( $item ) ) {
108+
return null;
109+
}
110+
}
111+
112+
return array_values( $value );
113+
}
114+
115+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php
2+
3+
declare( strict_types = 1 );
4+
5+
namespace Maps\DataAccess;
6+
7+
use Maps\LeafletConfigSource;
8+
use MediaWiki\Content\JsonContent;
9+
use MediaWiki\Json\FormatJson;
10+
11+
/**
12+
* Reads the raw Leaflet configuration from the MediaWiki:Maps JSON config page.
13+
*
14+
* Returns the decoded "leaflet" section, or null when the page is missing, is not JSON, cannot be
15+
* decoded, or the database is unavailable (such as during installation). Validation and hardening
16+
* of the returned data happen downstream, so this only needs to decode the page defensively.
17+
*
18+
* @licence GNU GPL v2+
19+
*/
20+
class WikiLeafletConfigSource implements LeafletConfigSource {
21+
22+
public function __construct(
23+
private PageContentFetcher $contentFetcher,
24+
private string $configPageName
25+
) {
26+
}
27+
28+
public function getLeafletConfig(): ?array {
29+
$data = $this->getPageData();
30+
31+
if ( $data === null ) {
32+
return null;
33+
}
34+
35+
$leaflet = $data['leaflet'] ?? null;
36+
37+
return is_array( $leaflet ) ? $leaflet : null;
38+
}
39+
40+
private function getPageData(): ?array {
41+
try {
42+
$content = $this->contentFetcher->getPageContent( $this->configPageName, NS_MEDIAWIKI );
43+
} catch ( \Throwable $e ) {
44+
return null;
45+
}
46+
47+
if ( !$content instanceof JsonContent ) {
48+
return null;
49+
}
50+
51+
$decoded = FormatJson::decode( $content->getText(), true );
52+
53+
return is_array( $decoded ) ? $decoded : null;
54+
}
55+
56+
}

0 commit comments

Comments
 (0)