Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.01% covered (success)
98.01%
148 / 151
83.33% covered (warning)
83.33%
10 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
SimpleStyleParser
98.01% covered (success)
98.01%
148 / 151
83.33% covered (warning)
83.33%
10 / 12
75
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 newFromParser
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 parse
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
4.59
 parseObject
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 normalizeAndSanitize
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 updateMarkerSymbolCounters
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
9
 findFirstMarkerSymbol
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
7
 validateGeoJSON
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
7
 recursivelySanitizeAndParseWikitext
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
8
 recursivelyNormalizeExternalData
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 normalizeExternalDataServices
97.73% covered (success)
97.73%
43 / 44
0.00% covered (danger)
0.00%
0 / 1
15
 parseWikitextProperties
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
13
1<?php
2
3namespace Kartographer;
4
5use InvalidArgumentException;
6use JsonSchema\Validator;
7use MediaWiki\Config\Config;
8use MediaWiki\Extension\JsonConfig\JCMapDataContent;
9use MediaWiki\Extension\JsonConfig\JCSingleton;
10use MediaWiki\Json\FormatJson;
11use MediaWiki\Parser\Parser;
12use MediaWiki\Parser\PPFrame;
13use StatusValue;
14use stdClass;
15
16/**
17 * Parses and sanitizes text properties of GeoJSON/simplestyle by putting them through the MediaWiki
18 * wikitext parser.
19 *
20 * @license MIT
21 */
22class SimpleStyleParser {
23
24    /**
25     * Maximum for marker-symbol="-number…" counters. See T141335 for discussion to possibly
26     * increase this to 199 or even 999.
27     */
28    private const MAX_NUMERIC_COUNTER = 99;
29    public const WIKITEXT_PROPERTIES = [ 'title', 'description' ];
30
31    private readonly string $mapServer;
32
33    /**
34     * @param WikitextParser $parser
35     * @param Config $config
36     * @param array $options Set ['saveUnparsed' => true] to back up the original values of title
37     *                       and description in _origtitle and _origdescription
38     */
39    public function __construct(
40        private readonly WikitextParser $parser,
41        Config $config,
42        private readonly array $options = [],
43    ) {
44        // @fixme: More precise config?
45        $this->mapServer = $config->get( 'KartographerMapServer' );
46    }
47
48    public static function newFromParser(
49        Parser $parser,
50        Config $config,
51        ?PPFrame $frame = null,
52        array $options = []
53    ): self {
54        return new self(
55            new MediaWikiWikitextParser( $parser, $frame ),
56            $config,
57            $options
58        );
59    }
60
61    /**
62     * Parses string into JSON and performs validation/sanitization
63     *
64     * @param string|null $input
65     * @return StatusValue<array> with the value being [ 'data' => stdClass[], 'schema-errors' => array[] ]
66     */
67    public function parse( ?string $input ): StatusValue {
68        if ( !$input || trim( $input ) === '' ) {
69            return StatusValue::newGood( [ 'data' => [] ] );
70        }
71
72        $status = FormatJson::parse( $input, FormatJson::TRY_FIXING | FormatJson::STRIP_COMMENTS );
73        if ( !$status->isOK() ) {
74            return StatusValue::newFatal( 'kartographer-error-json', $status->getMessage() );
75        }
76
77        // @phan-suppress-next-line PhanTypeMismatchPropertyByRef phan confused by generic and pass-by-ref
78        return $this->parseObject( $status->value );
79    }
80
81    /**
82     * Validate and sanitize a parsed GeoJSON data object
83     *
84     * @param array|stdClass &$data
85     * @return StatusValue<array>
86     */
87    public function parseObject( &$data ): StatusValue {
88        if ( !is_array( $data ) ) {
89            $data = [ $data ];
90        }
91        $status = $this->validateGeoJSON( $data );
92        if ( $status->isOK() ) {
93            $status = $this->normalizeAndSanitize( $data );
94        }
95        return $status;
96    }
97
98    /**
99     * @param stdClass[]|stdClass &$data
100     * @return StatusValue<array>
101     */
102    public function normalizeAndSanitize( &$data ): StatusValue {
103        $status = $this->recursivelyNormalizeExternalData( $data );
104        $this->recursivelySanitizeAndParseWikitext( $data );
105        return $status;
106    }
107
108    /**
109     * @param stdClass[] $values
110     * @param array<string,int> &$counters
111     * @return array{string,stdClass}|null [ string $firstMarkerSymbol, stdClass $firstMarkerProperties ]
112     */
113    public static function updateMarkerSymbolCounters( array $values, array &$counters = [] ): ?array {
114        $firstMarker = null;
115        foreach ( $values as $item ) {
116            // While the input should be validated, it's still arbitrary user input.
117            if ( !( $item instanceof stdClass ) ) {
118                continue;
119            }
120
121            $marker = $item->properties->{'marker-symbol'} ?? '';
122            $isNumber = str_starts_with( $marker, '-number' );
123            if ( $isNumber || str_starts_with( $marker, '-letter' ) ) {
124                // numbers 1..99 or letters a..z
125                $count = $counters[$marker] ?? 0;
126                if ( $count < ( $isNumber ? self::MAX_NUMERIC_COUNTER : 26 ) ) {
127                    $counters[$marker] = ++$count;
128                }
129                $marker = $isNumber ? strval( $count ) : chr( ord( 'a' ) + $count - 1 );
130                $item->properties->{'marker-symbol'} = $marker;
131                // GeoJSON is in lowercase, but the letter is shown as uppercase
132                $firstMarker ??= [ mb_strtoupper( $marker ), $item->properties ];
133            }
134
135            // Recurse into FeatureCollection and GeometryCollection
136            $features = $item->features ?? $item->geometries ?? null;
137            if ( $features ) {
138                $firstMarker ??= self::updateMarkerSymbolCounters( $features, $counters );
139            }
140        }
141        return $firstMarker;
142    }
143
144    /**
145     * @param stdClass[] $values
146     * @return array{string,stdClass}|null Same as {@see updateMarkerSymbolCounters}, but with the
147     *  $firstMarkerSymbol name not updated
148     */
149    public static function findFirstMarkerSymbol( array $values ): ?array {
150        foreach ( $values as $item ) {
151            // While the input should be validated, it's still arbitrary user input.
152            if ( !( $item instanceof stdClass ) ) {
153                continue;
154            }
155
156            $marker = $item->properties->{'marker-symbol'} ?? '';
157            if ( str_starts_with( $marker, '-number' ) || str_starts_with( $marker, '-letter' ) ) {
158                return [ $marker, $item->properties ];
159            }
160
161            // Recurse into FeatureCollection and GeometryCollection
162            $features = $item->features ?? $item->geometries ?? null;
163            if ( $features ) {
164                $found = self::findFirstMarkerSymbol( $features );
165                if ( $found ) {
166                    return $found;
167                }
168            }
169        }
170
171        return null;
172    }
173
174    /**
175     * @param stdClass[] $data
176     * @return StatusValue
177     */
178    private function validateGeoJSON( array $data ): StatusValue {
179        // Basic top-level validation. The JSON schema validation below does this again, but gives
180        // terrible, very hard to understand error messages.
181        foreach ( $data as $geoJSON ) {
182            if ( !( $geoJSON instanceof stdClass ) ) {
183                return StatusValue::newFatal( 'kartographer-error-json-object' );
184            }
185            if ( !isset( $geoJSON->type ) || !is_string( $geoJSON->type ) || !$geoJSON->type ) {
186                return StatusValue::newFatal( 'kartographer-error-json-type' );
187            }
188        }
189
190        $schema = (object)[ '$ref' => 'file://' . dirname( __DIR__ ) . '/schemas/geojson.json' ];
191        $validator = new Validator();
192        $validator->check( $data, $schema );
193
194        if ( !$validator->isValid() ) {
195            $errors = $validator->getErrors( Validator::ERROR_DOCUMENT_VALIDATION );
196            $status = StatusValue::newFatal( 'kartographer-error-bad_data' );
197            $status->setResult( false, [ 'schema-errors' => $errors ] );
198            return $status;
199        }
200
201        return StatusValue::newGood();
202    }
203
204    /**
205     * Performs recursive sanitizaton.
206     * Does not attempt to be smart, just recurses through everything that can be dangerous even
207     * if not a valid GeoJSON.
208     *
209     * @param stdClass[]|stdClass &$json
210     */
211    private function recursivelySanitizeAndParseWikitext( &$json ): void {
212        if ( is_array( $json ) ) {
213            foreach ( $json as &$element ) {
214                $this->recursivelySanitizeAndParseWikitext( $element );
215            }
216        } elseif ( is_object( $json ) ) {
217            foreach ( array_keys( get_object_vars( $json ) ) as $prop ) {
218                // https://phabricator.wikimedia.org/T134719
219                if ( str_starts_with( $prop, '_' ) ) {
220                    unset( $json->$prop );
221                } else {
222                    $this->recursivelySanitizeAndParseWikitext( $json->$prop );
223                }
224            }
225
226            if ( isset( $json->properties ) && is_object( $json->properties ) ) {
227                $this->parseWikitextProperties( $json->properties );
228            }
229        }
230    }
231
232    /**
233     * @param stdClass[]|stdClass &$json
234     * @return StatusValue<array>
235     */
236    private function recursivelyNormalizeExternalData( &$json ): StatusValue {
237        $status = StatusValue::newGood();
238        if ( is_array( $json ) ) {
239            foreach ( $json as &$element ) {
240                $status->merge( $this->recursivelyNormalizeExternalData( $element ) );
241            }
242            unset( $element );
243        } elseif ( is_object( $json ) && isset( $json->type ) && $json->type === 'ExternalData' ) {
244            $status->merge( $this->normalizeExternalDataServices( $json ) );
245        }
246        $status->value = [ 'data' => $json ];
247
248        return $status;
249    }
250
251    /**
252     * Canonicalizes an ExternalData object
253     *
254     * @param stdClass &$object
255     * @return StatusValue
256     */
257    private function normalizeExternalDataServices( stdClass &$object ): StatusValue {
258        $service = $object->service ?? null;
259        $ret = (object)[
260            'type' => 'ExternalData',
261            'service' => $service,
262        ];
263
264        switch ( $service ) {
265            case 'geoshape':
266            case 'geopoint':
267            case 'geoline':
268            case 'geomask':
269                $query = [ 'getgeojson' => 1 ];
270                if ( isset( $object->ids ) ) {
271                    $query['ids'] =
272                        is_array( $object->ids ) ? implode( ',', $object->ids )
273                            : preg_replace( '/\s*,\s*/', ',', $object->ids );
274                }
275                if ( isset( $object->query ) ) {
276                    $query['query'] = $object->query;
277                }
278                $ret->url = $this->mapServer . '/' .
279                    // 'geomask' service is the same as inverted geoshape service
280                    // Kartotherian does not support it, request it as geoshape
281                    ( $service === 'geomask' ? 'geoshape' : $service ) .
282                    '?' . wfArrayToCgi( $query );
283                if ( isset( $object->properties ) ) {
284                    $ret->properties = $object->properties;
285                }
286                break;
287
288            case 'page':
289                $jct = JCSingleton::parseTitle( $object->title, NS_DATA );
290                if ( !$jct ) {
291                    return StatusValue::newFatal( 'kartographer-error-title', $object->title );
292                }
293                $contentClass = JCSingleton::getContentClass( $jct->getConfig()->model );
294                if ( $contentClass !== JCMapDataContent::class &&
295                    // Support older config with the old class name
296                    $contentClass !== 'JsonConfig\JCMapDataContent'
297                ) {
298                    return StatusValue::newFatal( 'kartographer-error-title', $object->title );
299                }
300                $query = [
301                    'format' => 'json',
302                    'formatversion' => '2',
303                    'action' => 'jsondata',
304                    'title' => $jct->getText(),
305                    'uselang' => $this->options['uselang'] ?? null,
306                ];
307                $ret->url = wfScript( 'api' ) . '?' . wfArrayToCgi( $query );
308                break;
309
310            default:
311                throw new InvalidArgumentException( "Unexpected service name '$service'" );
312        }
313
314        $object = $ret;
315        return StatusValue::newGood();
316    }
317
318    /**
319     * HACK: this function supports JsonConfig-style localization that doesn't pass validation
320     */
321    private function parseWikitextProperties( stdClass $properties ) {
322        $saveUnparsed = $this->options['saveUnparsed'] ?? false;
323
324        foreach ( self::WIKITEXT_PROPERTIES as $prop ) {
325            if ( !property_exists( $properties, $prop ) ) {
326                continue;
327            }
328
329            $origProp = "_orig$prop";
330            $property = &$properties->$prop;
331
332            if ( is_string( $property ) && $property !== '' ) {
333                if ( $saveUnparsed ) {
334                    $properties->$origProp = $property;
335                }
336                $property = $this->parser->parseWikitext( $property );
337            } elseif ( is_object( $property ) ) {
338                if ( $saveUnparsed ) {
339                    $properties->$origProp = (object)[];
340                }
341                foreach ( $property as $language => &$text ) {
342                    if ( !is_string( $text ) || $text === '' ) {
343                        unset( $property->$language );
344                    } else {
345                        if ( $saveUnparsed ) {
346                            $properties->$origProp->$language = $text;
347                        }
348                        $text = $this->parser->parseWikitext( $text );
349                    }
350                }
351                unset( $text );
352
353                // Delete empty localizations
354                if ( !get_object_vars( $property ) ) {
355                    unset( $properties->$prop );
356                    unset( $properties->$origProp );
357                }
358            } else {
359                // Dunno what the hell it is, ditch
360                unset( $properties->$prop );
361            }
362        }
363    }
364
365}