1<?php
2
3namespace Elgg\Http;
4
5use Elgg\BadRequestException;
6use Elgg\Context;
7use Elgg\HttpException;
8use Elgg\Router\Route;
9use Symfony\Component\HttpFoundation\File\UploadedFile;
10use Symfony\Component\HttpFoundation\Request as SymfonyRequest;
11use Elgg\Config;
12
13/**
14 * Elgg HTTP request.
15 *
16 * @internal
17 */
18class Request extends SymfonyRequest {
19
20	const REWRITE_TEST_TOKEN = '__testing_rewrite';
21	const REWRITE_TEST_OUTPUT = 'success';
22
23	/**
24	 * @var Context
25	 */
26	protected $context_stack;
27
28	/**
29	 * @var Route
30	 */
31	protected $route;
32
33	/**
34	 * @ var array
35	 */
36	protected $request_overrides;
37
38	/**
39	 * {@inheritdoc}
40	 */
41	public function __construct(
42		array $query = [],
43		array $request = [],
44		array $attributes = [],
45		array $cookies = [],
46		array $files = [],
47		array $server = [],
48		$content = null
49	) {
50		parent::__construct($query, $request, $attributes, $cookies, $files, $server, $content);
51
52		$this->initializeContext();
53
54		$this->request_overrides = [];
55	}
56
57	/**
58	 * Configure trusted proxy servers to allow access to more client information
59	 *
60	 * @return void
61	 */
62	public function initializeTrustedProxyConfiguration(Config $config) {
63		$trusted_proxies = $config->http_request_trusted_proxy_ips;
64		if (empty($trusted_proxies)) {
65			return;
66		}
67
68		$allowed_headers = $config->http_request_trusted_proxy_headers;
69		if (empty($allowed_headers)) {
70			$allowed_headers = self::HEADER_X_FORWARDED_ALL;
71		}
72
73		$this->setTrustedProxies($trusted_proxies, $allowed_headers);
74	}
75
76	/**
77	 * Initialize context stack
78	 * @return static
79	 */
80	public function initializeContext() {
81		$context = new Context($this);
82		$this->context_stack = $context;
83
84		return $this;
85	}
86
87	/**
88	 * Returns context stack
89	 * @return Context
90	 */
91	public function getContextStack() {
92		return $this->context_stack;
93	}
94
95	/**
96	 * Sets the route matched for this request by the router
97	 *
98	 * @param Route $route Route
99	 *
100	 * @return static
101	 */
102	public function setRoute(Route $route) {
103		$this->route = $route;
104		foreach ($route->getMatchedParameters() as $key => $value) {
105			$this->setParam($key, $value);
106		}
107
108		return $this;
109	}
110
111	/**
112	 * Returns the route matched for this request by the router
113	 * @return Route|null
114	 */
115	public function getRoute() {
116		return $this->route;
117	}
118
119	/**
120	 * Sets an input value that may later be retrieved by get_input
121	 *
122	 * Note: this function does not handle nested arrays (ex: form input of param[m][n])
123	 *
124	 * @param string          $key              The name of the variable
125	 * @param string|string[] $value            The value of the variable
126	 * @param bool            $override_request The variable should override request values (default: false)
127	 *
128	 * @return static
129	 */
130	public function setParam($key, $value, $override_request = false) {
131		if ((bool) $override_request) {
132			$this->request_overrides[$key] = $value;
133		} else {
134			$this->request->set($key, $value);
135		}
136
137		return $this;
138	}
139
140	/**
141	 * Get some input from variables passed submitted through GET or POST.
142	 *
143	 * If using any data obtained from get_input() in a web page, please be aware that
144	 * it is a possible vector for a reflected XSS attack. If you are expecting an
145	 * integer, cast it to an int. If it is a string, escape quotes.
146	 *
147	 * Note: this function does not handle nested arrays (ex: form input of param[m][n])
148	 * because of the filtering done in htmlawed from the filter_tags call.
149	 * @todo Is this ^ still true?
150	 *
151	 * @param string $key           The variable name we want.
152	 * @param mixed  $default       A default value for the variable if it is not found.
153	 * @param bool   $filter_result If true, then the result is filtered for bad tags.
154	 *
155	 * @return mixed
156	 */
157	public function getParam($key, $default = null, $filter_result = true) {
158		$result = $default;
159
160		$values = $this->getParams($filter_result);
161
162		$value = elgg_extract($key, $values, $default);
163		if ($value !== null) {
164			$result = $value;
165		}
166
167		return $result;
168	}
169
170	/**
171	 * Returns all values parsed from the request
172	 *
173	 * @param bool $filter_result Sanitize input values
174	 *
175	 * @return array
176	 */
177	public function getParams($filter_result = true) {
178		$request_overrides = $this->request_overrides;
179		$query = $this->query->all();
180		$attributes = $this->attributes->all();
181		$post = $this->request->all();
182
183		$result = array_merge($post, $attributes, $query, $request_overrides);
184
185		if ($filter_result) {
186			$this->getContextStack()->push('input');
187
188			$result = filter_tags($result);
189
190			$this->getContextStack()->pop();
191		}
192
193		return $result;
194	}
195
196	/**
197	 * Returns current page URL
198	 *
199	 * @return string
200	 */
201	public function getCurrentURL() {
202		$url = parse_url(elgg_get_site_url());
203
204		$page = $url['scheme'] . "://" . $url['host'];
205
206		if (isset($url['port']) && $url['port']) {
207			$page .= ":" . $url['port'];
208		}
209
210		$page = trim($page, "/");
211
212		$page .= $this->getRequestUri();
213
214		return $page;
215	}
216
217	/**
218	 * Get the Elgg URL segments
219	 *
220	 * @param bool $raw If true, the segments will not be HTML escaped
221	 *
222	 * @return string[]
223	 */
224	public function getUrlSegments($raw = false) {
225		$path = trim($this->getElggPath(), '/');
226		if (!$raw) {
227			$path = htmlspecialchars($path, ENT_QUOTES, 'UTF-8');
228		}
229		if (!$path) {
230			return [];
231		}
232
233		return explode('/', $path);
234	}
235
236	/**
237	 * Get a cloned request with new Elgg URL segments
238	 *
239	 * @param string[] $segments URL segments
240	 *
241	 * @return Request
242	 */
243	public function setUrlSegments(array $segments) {
244		$base_path = trim($this->getBasePath(), '/');
245		$server = $this->server->all();
246		$server['REQUEST_URI'] = "$base_path/" . implode('/', $segments);
247
248		return $this->duplicate(null, null, null, null, null, $server);
249	}
250
251	/**
252	 * Get first Elgg URL segment
253	 *
254	 * @see \Elgg\Http\Request::getUrlSegments()
255	 *
256	 * @return string
257	 */
258	public function getFirstUrlSegment() {
259		$segments = $this->getUrlSegments();
260		if (!empty($segments)) {
261			return array_shift($segments);
262		}
263
264		return '';
265	}
266
267	/**
268	 * Get the Request URI minus querystring
269	 *
270	 * @return string
271	 */
272	public function getElggPath() {
273		if (php_sapi_name() === 'cli-server') {
274			$path = $this->getRequestUri();
275		} else {
276			$path = $this->getPathInfo();
277		}
278
279		return preg_replace('~(\?.*)$~', '', $path);
280	}
281
282	/**
283	 * {@inheritdoc}
284	 */
285	public function getClientIp() {
286		$ip = parent::getClientIp();
287
288		if ($ip == $this->server->get('REMOTE_ADDR')) {
289			// try one more
290			$ip_addresses = $this->server->get('HTTP_X_REAL_IP');
291			if ($ip_addresses) {
292				$ip_addresses = explode(',', $ip_addresses);
293
294				return array_pop($ip_addresses);
295			}
296		}
297
298		return $ip;
299	}
300
301	/**
302	 * {@inheritdoc}
303	 */
304	public function isXmlHttpRequest() {
305		return (strtolower($this->headers->get('X-Requested-With')) === 'xmlhttprequest'
306			|| $this->query->get('X-Requested-With') === 'XMLHttpRequest'
307			|| $this->request->get('X-Requested-With') === 'XMLHttpRequest');
308		// GET/POST check is necessary for jQuery.form and other iframe-based "ajax". #8735
309	}
310
311	/**
312	 * Sniff the Elgg site URL with trailing slash
313	 *
314	 * @return string
315	 */
316	public function sniffElggUrl() {
317		$base_url = $this->getBaseUrl();
318
319		// baseURL may end with the PHP script
320		if ('.php' === substr($base_url, -4)) {
321			$base_url = dirname($base_url);
322		}
323
324		$base_url = str_replace('\\', '/', $base_url);
325
326		return rtrim($this->getSchemeAndHttpHost() . $base_url, '/') . '/';
327	}
328
329	/**
330	 * Is the request for checking URL rewriting?
331	 *
332	 * @return bool
333	 */
334	public function isRewriteCheck() {
335		if ($this->getPathInfo() !== ('/' . self::REWRITE_TEST_TOKEN)) {
336			return false;
337		}
338
339		if (!$this->get(self::REWRITE_TEST_TOKEN)) {
340			return false;
341		}
342
343		return true;
344	}
345
346	/**
347	 * Is PHP running the CLI server front controller
348	 *
349	 * @return bool
350	 */
351	public function isCliServer() {
352		return php_sapi_name() === 'cli-server';
353	}
354
355	/**
356	 * Is the request pointing to a file that the CLI server can handle?
357	 *
358	 * @param string $root Root directory
359	 *
360	 * @return bool
361	 */
362	public function isCliServable($root) {
363		$file = rtrim($root, '\\/') . $this->getElggPath();
364		if (!is_file($file)) {
365			return false;
366		}
367
368		// http://php.net/manual/en/features.commandline.webserver.php
369		$extensions = ".3gp, .apk, .avi, .bmp, .css, .csv, .doc, .docx, .flac, .gif, .gz, .gzip, .htm, .html, .ics, .jpe, .jpeg, .jpg, .js, .kml, .kmz, .m4a, .mov, .mp3, .mp4, .mpeg, .mpg, .odp, .ods, .odt, .oga, .ogg, .ogv, .pdf, .pdf, .png, .pps, .pptx, .qt, .svg, .swf, .tar, .text, .tif, .txt, .wav, .webm, .wmv, .xls, .xlsx, .xml, .xsl, .xsd, and .zip";
370
371		// The CLI server routes ALL requests here (even existing files), so we have to check for these.
372		$ext = pathinfo($file, PATHINFO_EXTENSION);
373		if (!$ext) {
374			return false;
375		}
376
377		$ext = preg_quote($ext, '~');
378
379		return (bool) preg_match("~\\.{$ext}[,$]~", $extensions);
380	}
381
382	/**
383	 * Returns an array of uploaded file objects regardless of upload status/errors
384	 *
385	 * @param string $input_name Form input name
386	 *
387	 * @return UploadedFile[]
388	 */
389	public function getFiles($input_name) {
390		$files = $this->files->get($input_name);
391		if (empty($files)) {
392			return [];
393		}
394
395		if (!is_array($files)) {
396			$files = [$files];
397		}
398
399		return $files;
400	}
401
402	/**
403	 * Returns the first file found based on the input name
404	 *
405	 * @param string $input_name         Form input name
406	 * @param bool   $check_for_validity If there is an uploaded file, is it required to be valid
407	 *
408	 * @return UploadedFile|false
409	 */
410	public function getFile($input_name, $check_for_validity = true) {
411		$files = $this->getFiles($input_name);
412		if (empty($files)) {
413			return false;
414		}
415
416		$file = $files[0];
417		if (empty($file)) {
418			return false;
419		}
420
421		if ($check_for_validity && !$file->isValid()) {
422			return false;
423		}
424
425		return $file;
426	}
427
428	/**
429	 * Validate the request
430	 *
431	 * @return void
432	 * @throws HttpException
433	 */
434	public function validate() {
435
436		$reported_bytes = $this->server->get('CONTENT_LENGTH');
437
438		// Requests with multipart content type
439		$post_data_count = count($this->request->all());
440
441		// Requests with other content types
442		$content = $this->getContent();
443		$post_body_length = is_string($content) ? elgg_strlen($content) : 0;
444
445		$file_count = count($this->files->all());
446
447		$is_valid = function() use ($reported_bytes, $post_data_count, $post_body_length, $file_count) {
448			if (empty($reported_bytes)) {
449				// Content length is set for POST requests only
450				return true;
451			}
452
453			if (empty($post_data_count) && empty($post_body_length) && empty($file_count)) {
454				// The size of $_POST or uploaded files has exceed the size limit
455				// and the request body/query has been truncated
456				// thus the request reported bytes is set, but no postdata is found
457				return false;
458			}
459
460			return true;
461		};
462
463		if (!$is_valid()) {
464			$error_msg = elgg_trigger_plugin_hook('action_gatekeeper:upload_exceeded_msg', 'all', [
465				'post_size' => $reported_bytes,
466				'visible_errors' => true,
467			], elgg_echo('actiongatekeeper:uploadexceeded'));
468
469			throw new BadRequestException($error_msg);
470		}
471	}
472}
473