Merge "Use MediaWiki\SuppressWarnings around trigger_error('') instead @"
[lhc/web/wiklou.git] / includes / PathRouter.php
1 <?php
2 /**
3 * Parser to extract query parameters out of REQUEST_URI paths.
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 */
22
23 /**
24 * PathRouter class.
25 * This class can take patterns such as /wiki/$1 and use them to
26 * parse query parameters out of REQUEST_URI paths.
27 *
28 * $router->add( "/wiki/$1" );
29 * - Matches /wiki/Foo style urls and extracts the title
30 * $router->add( [ 'edit' => "/edit/$key" ], [ 'action' => '$key' ] );
31 * - Matches /edit/Foo style urls and sets action=edit
32 * $router->add( '/$2/$1',
33 * [ 'variant' => '$2' ],
34 * [ '$2' => [ 'zh-hant', 'zh-hans' ] ]
35 * );
36 * - Matches /zh-hant/Foo or /zh-hans/Foo
37 * $router->addStrict( "/foo/Bar", [ 'title' => 'Baz' ] );
38 * - Matches /foo/Bar explicitly and uses "Baz" as the title
39 * $router->add( '/help/$1', [ 'title' => 'Help:$1' ] );
40 * - Matches /help/Foo with "Help:Foo" as the title
41 * $router->add( '/$1', [ 'foo' => [ 'value' => 'bar$2' ] ] );
42 * - Matches /Foo and sets 'foo' to 'bar$2' without $2 being replaced
43 * $router->add( '/$1', [ 'data:foo' => 'bar' ], [ 'callback' => 'functionname' ] );
44 * - Matches /Foo, adds the key 'foo' with the value 'bar' to the data array
45 * and calls functionname( &$matches, $data );
46 *
47 * Path patterns:
48 * - Paths may contain $# patterns such as $1, $2, etc...
49 * - $1 will match 0 or more while the rest will match 1 or more
50 * - Unless you use addStrict "/wiki" and "/wiki/" will be expanded to "/wiki/$1"
51 *
52 * Params:
53 * - In a pattern $1, $2, etc... will be replaced with the relevant contents
54 * - If you used a keyed array as a path pattern, $key will be replaced with
55 * the relevant contents
56 * - The default behavior is equivalent to `array( 'title' => '$1' )`,
57 * if you don't want the title parameter you can explicitly use `array( 'title' => false )`
58 * - You can specify a value that won't have replacements in it
59 * using `'foo' => [ 'value' => 'bar' ];`
60 *
61 * Options:
62 * - The option keys $1, $2, etc... can be specified to restrict the possible values
63 * of that variable. A string can be used for a single value, or an array for multiple.
64 * - When the option key 'strict' is set (Using addStrict is simpler than doing this directly)
65 * the path won't have $1 implicitly added to it.
66 * - The option key 'callback' can specify a callback that will be run when a path is matched.
67 * The callback will have the arguments ( &$matches, $data ) and the matches array can
68 * be modified.
69 *
70 * @since 1.19
71 * @author Daniel Friesen
72 */
73 class PathRouter {
74
75 /**
76 * @var array
77 */
78 private $patterns = [];
79
80 /**
81 * Protected helper to do the actual bulk work of adding a single pattern.
82 * This is in a separate method so that add() can handle the difference between
83 * a single string $path and an array() $path that contains multiple path
84 * patterns each with an associated $key to pass on.
85 * @param string $path
86 * @param array $params
87 * @param array $options
88 * @param null|string $key
89 */
90 protected function doAdd( $path, $params, $options, $key = null ) {
91 // Make sure all paths start with a /
92 if ( $path[0] !== '/' ) {
93 $path = '/' . $path;
94 }
95
96 if ( !isset( $options['strict'] ) || !$options['strict'] ) {
97 // Unless this is a strict path make sure that the path has a $1
98 if ( strpos( $path, '$1' ) === false ) {
99 if ( substr( $path, -1 ) !== '/' ) {
100 $path .= '/';
101 }
102 $path .= '$1';
103 }
104 }
105
106 // If 'title' is not specified and our path pattern contains a $1
107 // Add a default 'title' => '$1' rule to the parameters.
108 if ( !isset( $params['title'] ) && strpos( $path, '$1' ) !== false ) {
109 $params['title'] = '$1';
110 }
111 // If the user explicitly marked 'title' as false then omit it from the matches
112 if ( isset( $params['title'] ) && $params['title'] === false ) {
113 unset( $params['title'] );
114 }
115
116 // Loop over our parameters and convert basic key => string
117 // patterns into fully descriptive array form
118 foreach ( $params as $paramName => $paramData ) {
119 if ( is_string( $paramData ) ) {
120 if ( preg_match( '/\$(\d+|key)/u', $paramData ) ) {
121 $paramArrKey = 'pattern';
122 } else {
123 // If there's no replacement use a value instead
124 // of a pattern for a little more efficiency
125 $paramArrKey = 'value';
126 }
127 $params[$paramName] = [
128 $paramArrKey => $paramData
129 ];
130 }
131 }
132
133 // Loop over our options and convert any single value $# restrictions
134 // into an array so we only have to do in_array tests.
135 foreach ( $options as $optionName => $optionData ) {
136 if ( preg_match( '/^\$\d+$/u', $optionName ) ) {
137 if ( !is_array( $optionData ) ) {
138 $options[$optionName] = [ $optionData ];
139 }
140 }
141 }
142
143 $pattern = (object)[
144 'path' => $path,
145 'params' => $params,
146 'options' => $options,
147 'key' => $key,
148 ];
149 $pattern->weight = self::makeWeight( $pattern );
150 $this->patterns[] = $pattern;
151 }
152
153 /**
154 * Add a new path pattern to the path router
155 *
156 * @param string|array $path The path pattern to add
157 * @param array $params The params for this path pattern
158 * @param array $options The options for this path pattern
159 */
160 public function add( $path, $params = [], $options = [] ) {
161 if ( is_array( $path ) ) {
162 foreach ( $path as $key => $onePath ) {
163 $this->doAdd( $onePath, $params, $options, $key );
164 }
165 } else {
166 $this->doAdd( $path, $params, $options );
167 }
168 }
169
170 /**
171 * Add a new path pattern to the path router with the strict option on
172 * @see self::add
173 * @param string|array $path
174 * @param array $params
175 * @param array $options
176 */
177 public function addStrict( $path, $params = [], $options = [] ) {
178 $options['strict'] = true;
179 $this->add( $path, $params, $options );
180 }
181
182 /**
183 * Protected helper to re-sort our patterns so that the most specific
184 * (most heavily weighted) patterns are at the start of the array.
185 */
186 protected function sortByWeight() {
187 $weights = [];
188 foreach ( $this->patterns as $key => $pattern ) {
189 $weights[$key] = $pattern->weight;
190 }
191 array_multisort( $weights, SORT_DESC, SORT_NUMERIC, $this->patterns );
192 }
193
194 /**
195 * @param object $pattern
196 * @return float|int
197 */
198 protected static function makeWeight( $pattern ) {
199 # Start with a weight of 0
200 $weight = 0;
201
202 // Explode the path to work with
203 $path = explode( '/', $pattern->path );
204
205 # For each level of the path
206 foreach ( $path as $piece ) {
207 if ( preg_match( '/^\$(\d+|key)$/u', $piece ) ) {
208 # For a piece that is only a $1 variable add 1 points of weight
209 $weight += 1;
210 } elseif ( preg_match( '/\$(\d+|key)/u', $piece ) ) {
211 # For a piece that simply contains a $1 variable add 2 points of weight
212 $weight += 2;
213 } else {
214 # For a solid piece add a full 3 points of weight
215 $weight += 3;
216 }
217 }
218
219 foreach ( $pattern->options as $key => $option ) {
220 if ( preg_match( '/^\$\d+$/u', $key ) ) {
221 # Add 0.5 for restrictions to values
222 # This way given two separate "/$2/$1" patterns the
223 # one with a limited set of $2 values will dominate
224 # the one that'll match more loosely
225 $weight += 0.5;
226 }
227 }
228
229 return $weight;
230 }
231
232 /**
233 * Parse a path and return the query matches for the path
234 *
235 * @param string $path The path to parse
236 * @return array The array of matches for the path
237 */
238 public function parse( $path ) {
239 // Make sure our patterns are sorted by weight so the most specific
240 // matches are tested first
241 $this->sortByWeight();
242
243 $matches = $this->internalParse( $path );
244 if ( is_null( $matches ) ) {
245 // Try with the normalized path (T100782)
246 $path = wfRemoveDotSegments( $path );
247 $path = preg_replace( '#/+#', '/', $path );
248 $matches = $this->internalParse( $path );
249 }
250
251 // We know the difference between null (no matches) and
252 // array() (a match with no data) but our WebRequest caller
253 // expects array() even when we have no matches so return
254 // a array() when we have null
255 return is_null( $matches ) ? [] : $matches;
256 }
257
258 /**
259 * Match a path against each defined pattern
260 *
261 * @param string $path
262 * @return array|null
263 */
264 protected function internalParse( $path ) {
265 $matches = null;
266
267 foreach ( $this->patterns as $pattern ) {
268 $matches = self::extractTitle( $path, $pattern );
269 if ( !is_null( $matches ) ) {
270 break;
271 }
272 }
273 return $matches;
274 }
275
276 /**
277 * @param string $path
278 * @param object $pattern
279 * @return array|null
280 */
281 protected static function extractTitle( $path, $pattern ) {
282 // Convert the path pattern into a regexp we can match with
283 $regexp = preg_quote( $pattern->path, '#' );
284 // .* for the $1
285 $regexp = preg_replace( '#\\\\\$1#u', '(?P<par1>.*)', $regexp );
286 // .+ for the rest of the parameter numbers
287 $regexp = preg_replace( '#\\\\\$(\d+)#u', '(?P<par$1>.+?)', $regexp );
288 $regexp = "#^{$regexp}$#";
289
290 $matches = [];
291 $data = [];
292
293 // Try to match the path we were asked to parse with our regexp
294 if ( preg_match( $regexp, $path, $m ) ) {
295 // Ensure that any $# restriction we have set in our {$option}s
296 // matches properly here.
297 foreach ( $pattern->options as $key => $option ) {
298 if ( preg_match( '/^\$\d+$/u', $key ) ) {
299 $n = intval( substr( $key, 1 ) );
300 $value = rawurldecode( $m["par{$n}"] );
301 if ( !in_array( $value, $option ) ) {
302 // If any restriction does not match return null
303 // to signify that this rule did not match.
304 return null;
305 }
306 }
307 }
308
309 // Give our $data array a copy of every $# that was matched
310 foreach ( $m as $matchKey => $matchValue ) {
311 if ( preg_match( '/^par\d+$/u', $matchKey ) ) {
312 $n = intval( substr( $matchKey, 3 ) );
313 $data['$' . $n] = rawurldecode( $matchValue );
314 }
315 }
316 // If present give our $data array a $key as well
317 if ( isset( $pattern->key ) ) {
318 $data['$key'] = $pattern->key;
319 }
320
321 // Go through our parameters for this match and add data to our matches and data arrays
322 foreach ( $pattern->params as $paramName => $paramData ) {
323 $value = null;
324 // Differentiate data: from normal parameters and keep the correct
325 // array key around (ie: foo for data:foo)
326 if ( preg_match( '/^data:/u', $paramName ) ) {
327 $isData = true;
328 $key = substr( $paramName, 5 );
329 } else {
330 $isData = false;
331 $key = $paramName;
332 }
333
334 if ( isset( $paramData['value'] ) ) {
335 // For basic values just set the raw data as the value
336 $value = $paramData['value'];
337 } elseif ( isset( $paramData['pattern'] ) ) {
338 // For patterns we have to make value replacements on the string
339 $value = self::expandParamValue( $m, $pattern->key ?? null,
340 $paramData['pattern'] );
341 if ( $value === false ) {
342 // Pattern required data that wasn't available, abort
343 return null;
344 }
345 }
346
347 // Send things that start with data: to $data, the rest to $matches
348 if ( $isData ) {
349 $data[$key] = $value;
350 } else {
351 $matches[$key] = $value;
352 }
353 }
354
355 // If this match includes a callback, execute it
356 if ( isset( $pattern->options['callback'] ) ) {
357 call_user_func_array( $pattern->options['callback'], [ &$matches, $data ] );
358 }
359 } else {
360 // Our regexp didn't match, return null to signify no match.
361 return null;
362 }
363 // Fall through, everything went ok, return our matches array
364 return $matches;
365 }
366
367 /**
368 * Replace $key etc. in param values with the matched strings from the path.
369 *
370 * @param array $pathMatches The match results from the path
371 * @param string|null $key The key of the matching pattern
372 * @param string $value The param value to be expanded
373 * @return string|false
374 */
375 protected static function expandParamValue( $pathMatches, $key, $value ) {
376 $error = false;
377
378 $replacer = function ( $m ) use ( $pathMatches, $key, &$error ) {
379 if ( $m[1] == "key" ) {
380 if ( is_null( $key ) ) {
381 $error = true;
382
383 return '';
384 }
385
386 return $key;
387 } else {
388 $d = $m[1];
389 if ( !isset( $pathMatches["par$d"] ) ) {
390 $error = true;
391
392 return '';
393 }
394
395 return rawurldecode( $pathMatches["par$d"] );
396 }
397 };
398
399 $value = preg_replace_callback( '/\$(\d+|key)/u', $replacer, $value );
400 if ( $error ) {
401 return false;
402 }
403
404 return $value;
405 }
406 }