Passed
Push — master ( c0a3a7...3b84a4 )
by Jeroen
58:51
created

engine/classes/Elgg/Http/ResponseFactory.php (5 issues)

1
<?php
2
3
namespace Elgg\Http;
4
5
use Elgg\Ajax\Service as AjaxService;
6
use Elgg\PluginHooksService;
7
use ElggEntity;
8
use InvalidArgumentException;
9
use InvalidParameterException;
10
use Symfony\Component\HttpFoundation\RedirectResponse as SymfonyRedirectResponse;
11
use Symfony\Component\HttpFoundation\Response;
12
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
13
14
/**
15
 * WARNING: API IN FLUX. DO NOT USE DIRECTLY.
16
 *
17
 * @since 2.3
18
 * @access private
19
 */
20
class ResponseFactory {
21
22
	/**
23
	 * @var Request
24
	 */
25
	private $request;
26
27
	/**
28
	 * @var AjaxService
29
	 */
30
	private $ajax;
31
32
	/**
33
	 * @var PluginHooksService
34
	 */
35
	private $hooks;
36
37
	/**
38
	 * @var ResponseTransport
39
	 */
40
	private $transport;
41
42
	/**
43
	 * @var Response|bool
44
	 */
45
	private $response_sent = false;
46
47
	/**
48
	 * @var ResponseHeaderBag
49
	 */
50
	private $headers;
51
52
	/**
53
	 * Constructor
54
	 *
55
	 * @param Request            $request   HTTP request
56
	 * @param PluginHooksService $hooks     Plugin hooks service
57
	 * @param AjaxService        $ajax      AJAX service
58
	 * @param ResponseTransport  $transport Response transport
59
	 */
60 154
	public function __construct(Request $request, PluginHooksService $hooks, AjaxService $ajax, ResponseTransport $transport) {
61 154
		$this->request = $request;
62 154
		$this->hooks = $hooks;
63 154
		$this->ajax = $ajax;
64 154
		$this->transport = $transport;
65 154
		$this->headers = new ResponseHeaderBag();
66 154
	}
67
68
	/**
69
	 * Sets headers to apply to all responses being sent
70
	 *
71
	 * @param string $name    Header name
72
	 * @param string $value   Header value
73
	 * @param bool   $replace Replace existing headers
74
	 * @return void
75
	 */
76 27
	public function setHeader($name, $value, $replace = true) {
77 27
		$this->headers->set($name, $value, $replace);
78 27
	}
79
80
	/**
81
	 * Get headers set to apply to all responses
82
	 *
83
	 * @param bool $remove_existing Remove existing headers found in headers_list()
84
	 * @return ResponseHeaderBag
85
	 */
86 77
	public function getHeaders($remove_existing = true) {
87
		// Add headers that have already been set by underlying views
88
		// e.g. viewtype page shells set content-type headers
89 77
		$headers_list = headers_list();
90 77
		foreach ($headers_list as $header) {
91
			if (stripos($header, 'HTTP/1.1') !== false) {
92
				continue;
93
			}
94
95
			list($name, $value) = explode(':', $header, 2);
96
			$this->setHeader($name, ltrim($value), false);
97
			if ($remove_existing) {
98
				header_remove($name);
99
			}
100
		}
101
102 77
		return $this->headers;
103
	}
104
105
	/**
106
	 * Creates an HTTP response
107
	 *
108
	 * @param string  $content The response content
109
	 * @param integer $status  The response status code
110
	 * @param array   $headers An array of response headers
111
	 * @return Response
112
	 */
113 62
	public function prepareResponse($content = '', $status = 200, array $headers = []) {
114 62
		$header_bag = $this->getHeaders();
115 62
		$header_bag->add($headers);
116 62
		$response = new Response($content, $status, $header_bag->all());
117 62
		$response->prepare($this->request);
118 62
		return $response;
119
	}
120
121
	/**
122
	 * Creates a redirect response
123
	 *
124
	 * @param string  $url     URL to redirect to
125
	 * @param integer $status  The status code (302 by default)
126
	 * @param array   $headers An array of response headers (Location is always set to the given URL)
127
	 * @return SymfonyRedirectResponse
128
	 * @throws InvalidArgumentException
129
	 */
130 14
	public function prepareRedirectResponse($url, $status = 302, array $headers = []) {
131 14
		$header_bag = $this->getHeaders();
132 14
		$header_bag->add($headers);
133 14
		$response = new SymfonyRedirectResponse($url, $status, $header_bag->all());
134 13
		$response->prepare($this->request);
135 13
		return $response;
136
	}
137
138
	/**
139
	 * Send a response
140
	 *
141
	 * @param Response $response Response object
142
	 * @return Response|false
143
	 */
144 102
	public function send(Response $response) {
145
146 102
		if ($this->response_sent) {
147 35
			if ($this->response_sent !== $response) {
148 4
				_elgg_services()->logger->error('Unable to send the following response: ' . PHP_EOL
149 4
						. (string) $response . PHP_EOL
150 4
						. 'because another response has already been sent: ' . PHP_EOL
151 35
						. (string) $this->response_sent);
152
			}
153
		} else {
154 102
			if (!elgg_trigger_before_event('send', 'http_response', $response)) {
155
				return false;
156
			}
157
158 102
			$request = $this->request;
159 102
			$method = $request->getRealMethod() ? : 'GET';
160 102
			$path = $request->getElggPath();
161
162 102
			_elgg_services()->logger->notice("Responding to {$method} {$path}");
163 102
			if (!$this->transport->send($response)) {
164
				return false;
165
			}
166
167 102
			elgg_trigger_after_event('send', 'http_response', $response);
168 102
			$this->response_sent = $response;
169
		}
170
171 102
		return $this->response_sent;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->response_sent also could return the type true which is incompatible with the documented return type Symfony\Component\HttpFoundation\Response|false.
Loading history...
172
	}
173
174
	/**
175
	 * Returns a response that was sent to the client
176
	 *
177
	 * @return Response|false
178
	 */
179 95
	public function getSentResponse() {
180 95
		return $this->response_sent;
1 ignored issue
show
Bug Best Practice introduced by
The expression return $this->response_sent also could return the type boolean which is incompatible with the documented return type Symfony\Component\HttpFoundation\Response|false.
Loading history...
181
	}
182
183
	/**
184
	 * Send HTTP response
185
	 *
186
	 * @param ResponseBuilder $response ResponseBuilder instance
187
	 *                                  An instance of an ErrorResponse, OkResponse or RedirectResponse
188
	 * @return Response
189
	 * @throws \InvalidParameterException
190
	 */
191 88
	public function respond(ResponseBuilder $response) {
192
193 88
		$response_type = $this->parseContext();
194 88
		$response = $this->hooks->trigger('response', $response_type, $response, $response);
195 88
		if (!$response instanceof ResponseBuilder) {
196
			throw new InvalidParameterException("Handlers for 'response','$response_type' plugin hook must "
197
			. "return an instanceof " . ResponseBuilder::class);
198
		}
199
200 88
		if ($response->isNotModified()) {
201
			return $this->send($this->prepareResponse('', ELGG_HTTP_NOT_MODIFIED));
202
		}
203
204 88
		$is_xhr = $this->request->isXmlHttpRequest();
205
206 88
		$is_action = false;
207 88
		if (0 === strpos($response_type, 'action:')) {
208 22
			$is_action = true;
209
		}
210
211 88
		if ($is_action && $response->getForwardURL() === null) {
212
			// actions must always set a redirect url
213
			$response->setForwardURL(REFERRER);
214
		}
215
216 88
		if ($response->getForwardURL() === REFERRER) {
217 16
			$response->setForwardURL($this->request->headers->get('Referer'));
218
		}
219
220 88
		if ($response->getForwardURL() !== null && !$is_xhr) {
221
			// non-xhr requests should issue a forward if redirect url is set
222
			// unless it's an error, in which case we serve an error page
223 11
			if ($this->isAction() || (!$response->isClientError() && !$response->isServerError())) {
224 7
				$response->setStatusCode(ELGG_HTTP_FOUND);
225
			}
226
		}
227
228 88
		if ($is_xhr && $is_action && !$this->ajax->isAjax2Request()) {
229
			// xhr actions using legacy ajax API should return 200 with wrapped data
230 6
			$response->setStatusCode(ELGG_HTTP_OK);
231 6
			$response->setContent($this->wrapLegacyAjaxResponse($response->getContent(), $response->getForwardURL()));
232
		}
233
234 88
		if ($is_xhr && $is_action) {
235
			// Actions always respond with JSON on xhr calls
236 17
			$headers = $response->getHeaders();
237 17
			$headers['Content-Type'] = 'application/json; charset=UTF-8';
238 17
			$response->setHeaders($headers);
239
		}
240
241 88
		$content = $this->stringify($response->getContent());
242 88
		$status_code = $response->getStatusCode();
243 88
		$headers = $response->getHeaders();
244
245 88
		if ($response->isRedirection()) {
246 10
			$redirect_url = $response->getForwardURL();
247 10
			return $this->redirect($redirect_url, $status_code);
248
		}
249
250 81
		if ($this->ajax->isReady() && $response->isSuccessful()) {
251 20
			return $this->respondFromContent($content, $status_code, $headers);
252
		}
253
254 61
		if ($response->isClientError() || $response->isServerError() || $response instanceof ErrorResponse) {
255 16
			return $this->respondWithError($content, $status_code, $headers);
256
		}
257
258 45
		return $this->respondFromContent($content, $status_code, $headers);
259
	}
260
261
	/**
262
	 * Send error HTTP response
263
	 *
264
	 * @param string $error       Error message
265
	 * @param int    $status_code HTTP status code
266
	 * @param array  $headers     HTTP headers (will be discarded on AJAX requests)
267
	 * @return Response
268
	 * @throws \InvalidParameterException
269
	 */
270 16
	public function respondWithError($error, $status_code = ELGG_HTTP_BAD_REQUEST, array $headers = []) {
271 16
		if ($this->ajax->isReady()) {
272 7
			return $this->send($this->ajax->respondWithError($error, $status_code));
273
		}
274
275 9
		if ($this->isXhr()) {
276
			// xhr calls to non-actions (e.g. ajax/view or ajax/form) need to receive proper HTTP status code
277 5
			return $this->send($this->prepareResponse($error, $status_code, $headers));
278
		}
279
280 4
		$forward_url = $this->request->headers->get('Referer');
281
282 4
		if (!$this->isAction()) {
283
			$params = [
284 4
				'current_url' => current_page_url(),
285 4
				'forward_url' => $forward_url,
286
			];
287
			// For BC, let plugins serve their own error page
288
			// @see elgg_error_page_handler
289 4
			$forward_reason = (string) $status_code;
290
291 4
			$forward_url = $this->hooks->trigger('forward', $forward_reason, $params, $forward_url);
292
293 4
			if ($this->response_sent) {
294
				// Response was sent from a forward hook
295
				return $this->response_sent;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->response_sent also could return the type true which is incompatible with the documented return type Symfony\Component\HttpFoundation\Response.
Loading history...
296
			}
297
298 4
			$params['type'] = $forward_reason;
299 4
			$params['params']['error'] = $error;
300 4
			$error_page = elgg_view_resource('error', $params);
301 4
			return $this->send($this->prepareResponse($error_page, $status_code));
302
		}
303
304
		$forward_url = elgg_normalize_url($forward_url);
305
		return $this->send($this->prepareRedirectResponse($forward_url));
306
	}
307
308
	/**
309
	 * Send OK response
310
	 *
311
	 * @param string $content     Response body
312
	 * @param int    $status_code HTTP status code
313
	 * @param array  $headers     HTTP headers (will be discarded for AJAX requests)
314
	 *
315
	 * @return Response|false
316
	 */
317 65
	public function respondFromContent($content = '', $status_code = ELGG_HTTP_OK, array $headers = []) {
318
319 65
		if ($this->ajax->isReady()) {
320 20
			$hook_type = $this->parseContext();
321
			// $this->ajax->setStatusCode($status_code);
322 20
			return $this->send($this->ajax->respondFromOutput($content, $hook_type));
323
		}
324
325 45
		return $this->send($this->prepareResponse($content, $status_code, $headers));
326
	}
327
328
	/**
329
	 * Wraps content for compability with legacy Elgg ajax calls
330
	 *
331
	 * @param string $content     Response content
332
	 * @param string $forward_url Forward URL
333
	 * @return string
334
	 */
335 11
	public function wrapLegacyAjaxResponse($content = '', $forward_url = REFERRER) {
336
337 11
		$content = $this->stringify($content);
338
339 11
		if ($forward_url === REFERRER) {
340
			$forward_url = $this->request->headers->get('Referer');
341
		}
342
343
		// always pass the full structure to avoid boilerplate JS code.
344
		$params = [
345 11
			'output' => '',
346 11
			'status' => 0,
347
			'system_messages' => [
348
				'error' => [],
349
				'success' => []
350
			],
351 11
			'current_url' => current_page_url(),
352 11
			'forward_url' => elgg_normalize_url($forward_url),
353
		];
354
355 11
		$params['output'] = $this->ajax->decodeJson($content);
356
357
		// Grab any system messages so we can inject them via ajax too
358 11
		$system_messages = _elgg_services()->systemMessages->dumpRegister();
359
360 11
		if (isset($system_messages['success'])) {
361 2
			$params['system_messages']['success'] = $system_messages['success'];
362
		}
363
364 11
		if (isset($system_messages['error'])) {
365 3
			$params['system_messages']['error'] = $system_messages['error'];
366 3
			$params['status'] = -1;
367
		}
368
369 11
		$response_type = $this->parseContext();
370 11
		list($service, $name) = explode(':', $response_type);
371
		$context = [
372 11
			$service => $name,
373
		];
374 11
		$params = $this->hooks->trigger('output', 'ajax', $context, $params);
375
376 11
		return $this->stringify($params);
377
	}
378
379
	/**
380
	 * Prepares a redirect response
381
	 *
382
	 * @param string $forward_url Redirection URL
383
	 * @param mixed  $status_code HTTP status code or forward reason
384
	 * @return SymfonyRedirectResponse
385
	 * @throws InvalidParameterException
386
	 */
387 26
	public function redirect($forward_url = REFERRER, $status_code = ELGG_HTTP_FOUND) {
388
389 26
		if ($forward_url === REFERRER) {
390 5
			$forward_url = $this->request->headers->get('Referer');
391
		}
392
393 26
		$forward_url = elgg_normalize_url($forward_url);
394
395
		// allow plugins to rewrite redirection URL
396 26
		$current_page = current_page_url();
397
		$params = [
398 26
			'current_url' => $current_page,
399 26
			'forward_url' => $forward_url
400
		];
401
402 26
		$forward_reason = (string) $status_code;
403
404 26
		$forward_url = $this->hooks->trigger('forward', $forward_reason, $params, $forward_url);
405
406 26
		if ($this->response_sent) {
407
			// Response was sent from a forward hook
408
			// Clearing handlers to void infinite loops
409 1
			return $this->response_sent;
1 ignored issue
show
Bug Best Practice introduced by
The expression return $this->response_sent returns the type true which is incompatible with the documented return type Symfony\Component\HttpFoundation\RedirectResponse.
Loading history...
410
		}
411
412 26
		if ($forward_url === REFERRER) {
413
			$forward_url = $this->request->headers->get('Referer');
414
		}
415
416 26
		if (!is_string($forward_url)) {
417
			throw new InvalidParameterException("'forward', '$forward_reason' hook must return a valid redirection URL");
418
		}
419
420 26
		$forward_url = elgg_normalize_url($forward_url);
421
422 26
		switch ($status_code) {
423 24
			case 'system':
424 24
			case 'csrf':
425 2
				$status_code = ELGG_HTTP_OK;
426 2
				break;
427 24
			case 'admin':
428 24
			case 'login':
429 24
			case 'member':
430 24
			case 'walled_garden':
431
			default :
432 25
				$status_code = (int) $status_code;
433 25
				if (!$status_code || $status_code < 100 || $status_code > 599) {
434 1
					$status_code = ELGG_HTTP_SEE_OTHER;
435
				}
436 25
				break;
437
		}
438
439 26
		if ($this->isXhr()) {
440 12
			if ($status_code < 100 || ($status_code >= 300 && $status_code <= 399) || $status_code > 599) {
441
				// We only want to preserve OK and error codes
442
				// Redirect responses should be converted to OK responses as this is an XHR request
443 9
				$status_code = ELGG_HTTP_OK;
444
			}
445 12
			$output = ob_get_clean();
446 12
			if (!$this->isAction() && !$this->ajax->isAjax2Request()) {
447
				// legacy ajax calls are always OK
448
				// actions are wrapped by ResponseFactory::respond()
449 5
				$status_code = ELGG_HTTP_OK;
450 5
				$output = $this->wrapLegacyAjaxResponse($output, $forward_url);
451
			}
452
453 12
			$response = new OkResponse($output, $status_code, $forward_url);
454 12
			$headers = $response->getHeaders();
455 12
			$headers['Content-Type'] = 'application/json; charset=UTF-8';
456 12
			$response->setHeaders($headers);
457 12
			return $this->respond($response);
458
		}
459
460 14
		if ($this->isAction()) {
461
			// actions should always redirect on non xhr-calls
462 7
			if (!is_int($status_code) || $status_code < 300 || $status_code > 399) {
463 1
				$status_code = ELGG_HTTP_SEE_OTHER;
464
			}
465
		}
466
467 14
		$response = new OkResponse('', $status_code, $forward_url);
468 14
		if ($response->isRedirection()) {
469 12
			return $this->send($this->prepareRedirectResponse($forward_url, $status_code));
470
		}
471 3
		return $this->respond($response);
472
	}
473
474
	/**
475
	 * Parses response type to be used as plugin hook type
476
	 * @return string
477
	 */
478 102
	public function parseContext() {
479
480 102
		$segments = $this->request->getUrlSegments();
481
482 102
		$identifier = array_shift($segments);
483 102
		switch ($identifier) {
484 22
			case 'ajax' :
485 21
				$page = array_shift($segments);
486 21
				if ($page === 'view') {
487 14
					$view = implode('/', $segments);
488 14
					return "view:$view";
489 7
				} else if ($page === 'form') {
490 5
					$form = implode('/', $segments);
491 5
					return "form:$form";
492
				}
493 2
				array_unshift($segments, $page);
494 2
				break;
495
496 22
			case 'action' :
497 27
				$action = implode('/', $segments);
498 27
				return "action:$action";
499
		}
500
501 57
		array_unshift($segments, $identifier);
502 57
		$path = implode('/', $segments);
503 57
		return "path:$path";
504
	}
505
506
	/**
507
	 * Check if the request is an XmlHttpRequest
508
	 * @return bool
509
	 */
510 35
	public function isXhr() {
511 35
		return $this->request->isXmlHttpRequest();
512
	}
513
514
	/**
515
	 * Check if the requested path is an action
516
	 * @return bool
517
	 */
518 29
	public function isAction() {
519 29
		if (0 === strpos($this->parseContext(), 'action:')) {
520 11
			return true;
521
		}
522 19
		return false;
523
	}
524
525
	/**
526
	 * Normalizes content into serializable data by walking through arrays
527
	 * and objectifying Elgg entities
528
	 *
529
	 * @param mixed $content Data to normalize
530
	 * @return mixed
531
	 */
532 88
	public function normalize($content = '') {
533 88
		if ($content instanceof ElggEntity) {
534
			$content = (array) $content->toObject();
535
		}
536 88
		if (is_array($content)) {
537 14
			foreach ($content as $key => $value) {
538 14
				$content[$key] = $this->normalize($value);
539
			}
540
		}
541 88
		return $content;
542
	}
543
544
	/**
545
	 * Stringify/serialize response data
546
	 *
547
	 * Casts objects implementing __toString method to strings
548
	 * Serializes non-scalar values to JSON
549
	 *
550
	 * @param mixed $content Content to serialize
551
	 * @return string
552
	 */
553 88
	public function stringify($content = '') {
554 88
		$content = $this->normalize($content);
555 88
		if (empty($content) || (is_object($content) && is_callable($content, '__toString'))) {
0 ignored issues
show
'__toString' of type string is incompatible with the type boolean expected by parameter $syntax_only of is_callable(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

555
		if (empty($content) || (is_object($content) && is_callable($content, /** @scrutinizer ignore-type */ '__toString'))) {
Loading history...
556 36
			return (string) $content;
557
		}
558 57
		if (is_scalar($content)) {
559 54
			return $content;
560
		}
561 14
		return json_encode($content, ELGG_JSON_ENCODING);
562
	}
563
564
	/**
565
	 * Replaces response transport
566
	 *
567
	 * @param ResponseTransport $transport Transport interface
568
	 * @return void
569
	 */
570
	public function setTransport(ResponseTransport $transport) {
571
		$this->transport = $transport;
572
	}
573
}
574