Completed
Branch master (939199)
by
unknown
39:35
created

includes/PathRouter.php (1 issue)

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

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 View Code Duplication
		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 View Code Duplication
		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 = null;
244
245
		foreach ( $this->patterns as $pattern ) {
246
			$matches = self::extractTitle( $path, $pattern );
247
			if ( !is_null( $matches ) ) {
248
				break;
249
			}
250
		}
251
252
		// We know the difference between null (no matches) and
253
		// array() (a match with no data) but our WebRequest caller
254
		// expects array() even when we have no matches so return
255
		// a array() when we have null
256
		return is_null( $matches ) ? [] : $matches;
257
	}
258
259
	/**
260
	 * @param string $path
261
	 * @param string $pattern
262
	 * @return array|null
263
	 */
264
	protected static function extractTitle( $path, $pattern ) {
265
		// Convert the path pattern into a regexp we can match with
266
		$regexp = preg_quote( $pattern->path, '#' );
267
		// .* for the $1
268
		$regexp = preg_replace( '#\\\\\$1#u', '(?P<par1>.*)', $regexp );
269
		// .+ for the rest of the parameter numbers
270
		$regexp = preg_replace( '#\\\\\$(\d+)#u', '(?P<par$1>.+?)', $regexp );
271
		$regexp = "#^{$regexp}$#";
272
273
		$matches = [];
274
		$data = [];
275
276
		// Try to match the path we were asked to parse with our regexp
277
		if ( preg_match( $regexp, $path, $m ) ) {
278
			// Ensure that any $# restriction we have set in our {$option}s
279
			// matches properly here.
280
			foreach ( $pattern->options as $key => $option ) {
281
				if ( preg_match( '/^\$\d+$/u', $key ) ) {
282
					$n = intval( substr( $key, 1 ) );
283
					$value = rawurldecode( $m["par{$n}"] );
284
					if ( !in_array( $value, $option ) ) {
285
						// If any restriction does not match return null
286
						// to signify that this rule did not match.
287
						return null;
288
					}
289
				}
290
			}
291
292
			// Give our $data array a copy of every $# that was matched
293
			foreach ( $m as $matchKey => $matchValue ) {
294
				if ( preg_match( '/^par\d+$/u', $matchKey ) ) {
295
					$n = intval( substr( $matchKey, 3 ) );
296
					$data['$' . $n] = rawurldecode( $matchValue );
297
				}
298
			}
299
			// If present give our $data array a $key as well
300
			if ( isset( $pattern->key ) ) {
301
				$data['$key'] = $pattern->key;
302
			}
303
304
			// Go through our parameters for this match and add data to our matches and data arrays
305
			foreach ( $pattern->params as $paramName => $paramData ) {
306
				$value = null;
307
				// Differentiate data: from normal parameters and keep the correct
308
				// array key around (ie: foo for data:foo)
309
				if ( preg_match( '/^data:/u', $paramName ) ) {
310
					$isData = true;
311
					$key = substr( $paramName, 5 );
312
				} else {
313
					$isData = false;
314
					$key = $paramName;
315
				}
316
317
				if ( isset( $paramData['value'] ) ) {
318
					// For basic values just set the raw data as the value
319
					$value = $paramData['value'];
320
				} elseif ( isset( $paramData['pattern'] ) ) {
321
					// For patterns we have to make value replacements on the string
322
					$value = $paramData['pattern'];
323
					$replacer = new PathRouterPatternReplacer;
324
					$replacer->params = $m;
325
					if ( isset( $pattern->key ) ) {
326
						$replacer->key = $pattern->key;
327
					}
328
					$value = $replacer->replace( $value );
329
					if ( $value === false ) {
330
						// Pattern required data that wasn't available, abort
331
						return null;
332
					}
333
				}
334
335
				// Send things that start with data: to $data, the rest to $matches
336
				if ( $isData ) {
337
					$data[$key] = $value;
338
				} else {
339
					$matches[$key] = $value;
340
				}
341
			}
342
343
			// If this match includes a callback, execute it
344
			if ( isset( $pattern->options['callback'] ) ) {
345
				call_user_func_array( $pattern->options['callback'], [ &$matches, $data ] );
346
			}
347
		} else {
348
			// Our regexp didn't match, return null to signify no match.
349
			return null;
350
		}
351
		// Fall through, everything went ok, return our matches array
352
		return $matches;
353
	}
354
355
}
356
357
class PathRouterPatternReplacer {
358
359
	public $key, $params, $error;
0 ignored issues
show
It is generally advisable to only define one property per statement.

Only declaring a single property per statement allows you to later on add doc comments more easily.

It is also recommended by PSR2, so it is a common style that many people expect.

Loading history...
360
361
	/**
362
	 * Replace keys inside path router patterns with text.
363
	 * We do this inside of a replacement callback because after replacement we can't tell the
364
	 * difference between a $1 that was not replaced and a $1 that was part of
365
	 * the content a $1 was replaced with.
366
	 * @param string $value
367
	 * @return string
368
	 */
369
	public function replace( $value ) {
370
		$this->error = false;
371
		$value = preg_replace_callback( '/\$(\d+|key)/u', [ $this, 'callback' ], $value );
372
		if ( $this->error ) {
373
			return false;
374
		}
375
		return $value;
376
	}
377
378
	/**
379
	 * @param array $m
380
	 * @return string
381
	 */
382
	protected function callback( $m ) {
383
		if ( $m[1] == "key" ) {
384
			if ( is_null( $this->key ) ) {
385
				$this->error = true;
386
				return '';
387
			}
388
			return $this->key;
389
		} else {
390
			$d = $m[1];
391
			if ( !isset( $this->params["par$d"] ) ) {
392
				$this->error = true;
393
				return '';
394
			}
395
			return rawurldecode( $this->params["par$d"] );
396
		}
397
	}
398
399
}
400