Test Failed
Push — master ( 8c47c2...3acf9f )
by Steve
12:37
created

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

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
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 133
	public function __construct(Request $request, PluginHooksService $hooks, AjaxService $ajax, ResponseTransport $transport) {
61 133
		$this->request = $request;
62 133
		$this->hooks = $hooks;
63 133
		$this->ajax = $ajax;
64 133
		$this->transport = $transport;
65 133
		$this->headers = new ResponseHeaderBag();
66 133
	}
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 17
	public function setHeader($name, $value, $replace = true) {
77 17
		$this->headers->set($name, $value, $replace);
78 17
	}
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 60
	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 60
		$headers_list = headers_list();
90 60
		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 60
		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 45 View Code Duplication
	public function prepareResponse($content = '', $status = 200, array $headers = []) {
114 45
		$header_bag = $this->getHeaders();
115 45
		$header_bag->add($headers);
116 45
		$response = new Response($content, $status, $header_bag->all());
117 45
		$response->prepare($this->request);
118 45
		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 View Code Duplication
	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
143
	 */
144 85
	public function send(Response $response) {
145
146 85
		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 85
			if (!elgg_trigger_before_event('send', 'http_response', $response)) {
155
				return false;
0 ignored issues
show
Bug Best Practice introduced by
The return type of return false; (false) is incompatible with the return type documented by Elgg\Http\ResponseFactory::send of type Symfony\Component\HttpFoundation\Response.

If you return a value from a function or method, it should be a sub-type of the type that is given by the parent type f.e. an interface, or abstract method. This is more formally defined by the Lizkov substitution principle, and guarantees that classes that depend on the parent type can use any instance of a child type interchangably. This principle also belongs to the SOLID principles for object oriented design.

Let’s take a look at an example:

class Author {
    private $name;

    public function __construct($name) {
        $this->name = $name;
    }

    public function getName() {
        return $this->name;
    }
}

abstract class Post {
    public function getAuthor() {
        return 'Johannes';
    }
}

class BlogPost extends Post {
    public function getAuthor() {
        return new Author('Johannes');
    }
}

class ForumPost extends Post { /* ... */ }

function my_function(Post $post) {
    echo strtoupper($post->getAuthor());
}

Our function my_function expects a Post object, and outputs the author of the post. The base class Post returns a simple string and outputting a simple string will work just fine. However, the child class BlogPost which is a sub-type of Post instead decided to return an object, and is therefore violating the SOLID principles. If a BlogPost were passed to my_function, PHP would not complain, but ultimately fail when executing the strtoupper call in its body.

Loading history...
156
			}
157
158 85
			if (!$this->transport->send($response)) {
159
				return false;
0 ignored issues
show
Bug Best Practice introduced by
The return type of return false; (false) is incompatible with the return type documented by Elgg\Http\ResponseFactory::send of type Symfony\Component\HttpFoundation\Response.

If you return a value from a function or method, it should be a sub-type of the type that is given by the parent type f.e. an interface, or abstract method. This is more formally defined by the Lizkov substitution principle, and guarantees that classes that depend on the parent type can use any instance of a child type interchangably. This principle also belongs to the SOLID principles for object oriented design.

Let’s take a look at an example:

class Author {
    private $name;

    public function __construct($name) {
        $this->name = $name;
    }

    public function getName() {
        return $this->name;
    }
}

abstract class Post {
    public function getAuthor() {
        return 'Johannes';
    }
}

class BlogPost extends Post {
    public function getAuthor() {
        return new Author('Johannes');
    }
}

class ForumPost extends Post { /* ... */ }

function my_function(Post $post) {
    echo strtoupper($post->getAuthor());
}

Our function my_function expects a Post object, and outputs the author of the post. The base class Post returns a simple string and outputting a simple string will work just fine. However, the child class BlogPost which is a sub-type of Post instead decided to return an object, and is therefore violating the SOLID principles. If a BlogPost were passed to my_function, PHP would not complain, but ultimately fail when executing the strtoupper call in its body.

Loading history...
160
			}
161
162 85
			elgg_trigger_after_event('send', 'http_response', $response);
163 85
			$this->response_sent = $response;
164
		}
165
166 85
		return $this->response_sent;
0 ignored issues
show
Bug Compatibility introduced by
The expression $this->response_sent; of type Symfony\Component\HttpFoundation\Response|boolean adds the type boolean to the return on line 166 which is incompatible with the return type documented by Elgg\Http\ResponseFactory::send of type Symfony\Component\HttpFoundation\Response.
Loading history...
167
	}
168
169
	/**
170
	 * Returns a response that was sent to the client
171
	 * @return Response|false
172
	 */
173 78
	public function getSentResponse() {
174 78
		return $this->response_sent;
175
	}
176
177
	/**
178
	 * Send HTTP response
179
	 *
180
	 * @param ResponseBuilder $response ResponseBuilder instance
181
	 *                                  An instance of an ErrorResponse, OkResponse or RedirectResponse
182
	 * @return Response
183
	 */
184 70
	public function respond(ResponseBuilder $response) {
185
186 70
		$response_type = $this->parseContext();
187 70
		$response = $this->hooks->trigger('response', $response_type, $response, $response);
188 70
		if (!$response instanceof ResponseBuilder) {
189
			throw new InvalidParameterException("Handlers for 'response','$response_type' plugin hook must "
190
			. "return an instanceof " . ResponseBuilder::class);
191
		}
192
193 70
		if ($response->isNotModified()) {
194
			return $this->send($this->prepareResponse('', ELGG_HTTP_NOT_MODIFIED));
195
		}
196
197 70
		$is_xhr = $this->request->isXmlHttpRequest();
198
199 70
		$is_action = false;
200 70
		if (0 === strpos($response_type, 'action:')) {
201 22
			$is_action = true;
202
		}
203
204 70
		if ($is_action && $response->getForwardURL() === null) {
205
			// actions must always set a redirect url
206
			$response->setForwardURL(REFERRER);
207
		}
208
209 70
		if ($response->getForwardURL() === REFERRER) {
210 16
			$response->setForwardURL($this->request->headers->get('Referer'));
211
		}
212
213 70
		if ($response->getForwardURL() !== null && !$is_xhr) {
214
			// non-xhr requests should issue a forward if redirect url is set
215
			// unless it's an error, in which case we serve an error page
216 10
			if ($this->isAction() || (!$response->isClientError() && !$response->isServerError())) {
217 6
				$response->setStatusCode(ELGG_HTTP_FOUND);
218
			}
219
		}
220
221 70
		if ($is_xhr && $is_action && !$this->ajax->isAjax2Request()) {
222
			// xhr actions using legacy ajax API should return 200 with wrapped data
223 6
			$response->setStatusCode(ELGG_HTTP_OK);
224 6
			$response->setContent($this->wrapLegacyAjaxResponse($response->getContent(), $response->getForwardURL()));
225
		}
226
227 70
		if ($is_xhr && $is_action) {
228
			// Actions always respond with JSON on xhr calls
229 17
			$headers = $response->getHeaders();
230 17
			$headers['Content-Type'] = 'application/json; charset=UTF-8';
231 17
			$response->setHeaders($headers);
232
		}
233
234 70
		$content = $this->stringify($response->getContent());
235 70
		$status_code = $response->getStatusCode();
236 70
		$headers = $response->getHeaders();
237
238 70
		if ($response->isRedirection()) {
239 9
			$redirect_url = $response->getForwardURL();
240 9
			return $this->redirect($redirect_url, $status_code);
241
		}
242
243 64
		if ($this->ajax->isReady() && $response->isSuccessful()) {
244 20
			return $this->respondFromContent($content, $status_code, $headers);
245
		}
246
247 44
		if ($response->isClientError() || $response->isServerError() || $response instanceof ErrorResponse) {
248 16
			return $this->respondWithError($content, $status_code, $headers);
249
		}
250
251 28
		return $this->respondFromContent($content, $status_code, $headers);
252
	}
253
254
	/**
255
	 * Send error HTTP response
256
	 *
257
	 * @param string $error       Error message
258
	 * @param int    $status_code HTTP status code
259
	 * @param array  $headers     HTTP headers (will be discarded on AJAX requests)
260
	 * @return Response
261
	 * @throws InvalidParameterException
262
	 */
263 16
	public function respondWithError($error, $status_code = ELGG_HTTP_BAD_REQUEST, array $headers = []) {
264 16
		if ($this->ajax->isReady()) {
265 7
			return $this->send($this->ajax->respondWithError($error, $status_code));
266
		}
267
268 9
		if ($this->isXhr()) {
269
			// xhr calls to non-actions (e.g. ajax/view or ajax/form) need to receive proper HTTP status code
270 5
			return $this->send($this->prepareResponse($error, $status_code, $headers));
271
		}
272
273 4
		$forward_url = $this->request->headers->get('Referer');
274
275 4
		if (!$this->isAction()) {
276
			$params = [
277 4
				'current_url' => current_page_url(),
278 4
				'forward_url' => $forward_url,
279
			];
280
			// For BC, let plugins serve their own error page
281
			// @see elgg_error_page_handler
282 4
			$forward_reason = (string) $status_code;
283
284 4
			$forward_url = $this->hooks->trigger('forward', $forward_reason, $params, $forward_url);
285
286 4
			if ($this->response_sent) {
287
				// Response was sent from a forward hook
288
				return $this->response_sent;
289
			}
290
291 4
			$params['type'] = $forward_reason;
292 4
			$error_page = elgg_view_resource('error', $params);
293 4
			return $this->send($this->prepareResponse($error_page, $status_code));
294
		}
295
296
		$forward_url = elgg_normalize_url($forward_url);
0 ignored issues
show
It seems like $forward_url can also be of type array; however, elgg_normalize_url() does only seem to accept string, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
297
		return $this->send($this->prepareRedirectResponse($forward_url));
298
	}
299
300
	/**
301
	 * Send OK response
302
	 *
303
	 * @param string $content     Response body
304
	 * @param int    $status_code HTTP status code
305
	 * @param array  $headers     HTTP headers (will be discarded for AJAX requets)
306
	 * @return type
307
	 */
308 48
	public function respondFromContent($content = '', $status_code = ELGG_HTTP_OK, array $headers = []) {
309
310 48
		if ($this->ajax->isReady()) {
311 20
			$hook_type = $this->parseContext();
312
			// $this->ajax->setStatusCode($status_code);
313 20
			return $this->send($this->ajax->respondFromOutput($content, $hook_type));
314
		}
315
316 28
		return $this->send($this->prepareResponse($content, $status_code, $headers));
317
	}
318
319
	/**
320
	 * Wraps content for compability with legacy Elgg ajax calls
321
	 *
322
	 * @param string $content     Response content
323
	 * @param string $forward_url Forward URL
324
	 * @return string
325
	 */
326 11
	public function wrapLegacyAjaxResponse($content = '', $forward_url = REFERRER) {
327
328 11
		$content = $this->stringify($content);
329
330 11
		if ($forward_url === REFERRER) {
331
			$forward_url = $this->request->headers->get('Referer');
332
		}
333
334
		// always pass the full structure to avoid boilerplate JS code.
335
		$params = [
336 11
			'output' => '',
337 11
			'status' => 0,
338
			'system_messages' => [
339
				'error' => [],
340
				'success' => []
341
			],
342 11
			'current_url' => current_page_url(),
343 11
			'forward_url' => elgg_normalize_url($forward_url),
344
		];
345
346 11
		$params['output'] = $this->ajax->decodeJson($content);
347
348
		// Grab any system messages so we can inject them via ajax too
349 11
		$system_messages = _elgg_services()->systemMessages->dumpRegister();
350
351 11
		if (isset($system_messages['success'])) {
352 2
			$params['system_messages']['success'] = $system_messages['success'];
353
		}
354
355 11
		if (isset($system_messages['error'])) {
356 3
			$params['system_messages']['error'] = $system_messages['error'];
357 3
			$params['status'] = -1;
358
		}
359
360 11
		$response_type = $this->parseContext();
361 11
		list($service, $name) = explode(':', $response_type);
362
		$context = [
363 11
			$service => $name,
364
		];
365 11
		$params = $this->hooks->trigger('output', 'ajax', $context, $params);
366
367 11
		return $this->stringify($params);
368
	}
369
370
	/**
371
	 * Prepares a redirect response
372
	 *
373
	 * @param string $forward_url Redirection URL
374
	 * @param mixed  $status_code HTTP status code or forward reason
375
	 * @return SymfonyRedirectResponse
376
	 * @throws InvalidParameterException
377
	 */
378 26
	public function redirect($forward_url = REFERRER, $status_code = ELGG_HTTP_FOUND) {
379
380 26
		if ($forward_url === REFERRER) {
381 5
			$forward_url = $this->request->headers->get('Referer');
382
		}
383
384 26
		$forward_url = elgg_normalize_url($forward_url);
385
386
		// allow plugins to rewrite redirection URL
387 26
		$current_page = current_page_url();
388
		$params = [
389 26
			'current_url' => $current_page,
390 26
			'forward_url' => $forward_url
391
		];
392
393 26
		$forward_reason = (string) $status_code;
394
395 26
		$forward_url = $this->hooks->trigger('forward', $forward_reason, $params, $forward_url);
396
397 26
		if ($this->response_sent) {
398
			// Response was sent from a forward hook
399
			// Clearing handlers to void infinite loops
400 1
			return $this->response_sent;
401
		}
402
403 26
		if ($forward_url === REFERRER) {
404
			$forward_url = $this->request->headers->get('Referer');
405
		}
406
407 26
		if (!is_string($forward_url)) {
408
			throw new InvalidParameterException("'forward', '$forward_reason' hook must return a valid redirection URL");
409
		}
410
411 26
		$forward_url = elgg_normalize_url($forward_url);
412
413
		switch ($status_code) {
414 26
			case 'system':
415 26
			case 'csrf':
416 2
				$status_code = ELGG_HTTP_OK;
417 2
				break;
418 24
			case 'admin':
419 24
			case 'login':
420 24
			case 'member':
421 24
			case 'walled_garden':
422
			default :
423 24
				$status_code = (int) $status_code;
424 24
				if (!$status_code || $status_code < 100 || $status_code > 599) {
425 1
					$status_code = ELGG_HTTP_SEE_OTHER;
426
				}
427 24
				break;
428
		}
429
430 26
		if ($this->isXhr()) {
431 12 View Code Duplication
			if ($status_code < 100 || ($status_code >= 300 && $status_code <= 399) || $status_code > 599) {
432
				// We only want to preserve OK and error codes
433
				// Redirect responses should be converted to OK responses as this is an XHR request
434 9
				$status_code = ELGG_HTTP_OK;
435
			}
436 12
			$output = ob_get_clean();
437 12
			if (!$this->isAction() && !$this->ajax->isAjax2Request()) {
438
				// legacy ajax calls are always OK
439
				// actions are wrapped by ResponseFactory::respond()
440 5
				$status_code = ELGG_HTTP_OK;
441 5
				$output = $this->wrapLegacyAjaxResponse($output, $forward_url);
442
			}
443
444 12
			$response = new OkResponse($output, $status_code, $forward_url);
445 12
			$headers = $response->getHeaders();
446 12
			$headers['Content-Type'] = 'application/json; charset=UTF-8';
447 12
			$response->setHeaders($headers);
448 12
			return $this->respond($response);
449
		}
450
451 14
		if ($this->isAction()) {
452
			// actions should always redirect on non xhr-calls
453 8
			if (!is_int($status_code) || $status_code < 300 || $status_code > 399) {
454 2
				$status_code = ELGG_HTTP_SEE_OTHER;
455
			}
456
		}
457
458 14
		$response = new OkResponse('', $status_code, $forward_url);
459 14
		if ($response->isRedirection()) {
460 12
			return $this->send($this->prepareRedirectResponse($forward_url, $status_code));
461
		}
462 2
		return $this->respond($response);
463
	}
464
465
	/**
466
	 * Parses response type to be used as plugin hook type
467
	 * @return string
468
	 */
469 85
	public function parseContext() {
470
471 85
		$segments = $this->request->getUrlSegments();
472
473 85
		$identifier = array_shift($segments);
474
		switch ($identifier) {
475 85
			case 'ajax' :
476 21
				$page = array_shift($segments);
477 21
				if ($page === 'view') {
478 14
					$view = implode('/', $segments);
479 14
					return "view:$view";
480 7
				} else if ($page === 'form') {
481 5
					$form = implode('/', $segments);
482 5
					return "form:$form";
483
				}
484 2
				array_unshift($segments, $page);
485 2
				break;
486
487 64
			case 'action' :
488 28
				$action = implode('/', $segments);
489 28
				return "action:$action";
490
		}
491
492 39
		array_unshift($segments, $identifier);
493 39
		$path = implode('/', $segments);
494 39
		return "path:$path";
495
	}
496
497
	/**
498
	 * Check if the request is an XmlHttpRequest
499
	 * @return bool
500
	 */
501 35
	public function isXhr() {
502 35
		return $this->request->isXmlHttpRequest();
503
	}
504
505
	/**
506
	 * Check if the requested path is an action
507
	 * @return bool
508
	 */
509 29
	public function isAction() {
510 29
		if (0 === strpos($this->parseContext(), 'action:')) {
511 12
			return true;
512
		}
513 18
		return false;
514
	}
515
516
	/**
517
	 * Normalizes content into serializable data by walking through arrays
518
	 * and objectifying Elgg entities
519
	 *
520
	 * @param mixed $content Data to normalize
521
	 * @return mixed
522
	 */
523 70
	public function normalize($content = '') {
524 70
		if ($content instanceof ElggEntity) {
525
			$content = (array) $content->toObject();
526
		}
527 70
		if (is_array($content)) {
528 14
			foreach ($content as $key => $value) {
529 14
				$content[$key] = $this->normalize($value);
530
			}
531
		}
532 70
		return $content;
533
	}
534
535
	/**
536
	 * Stringify/serialize response data
537
	 *
538
	 * Casts objects implementing __toString method to strings
539
	 * Serializes non-scalar values to JSON
540
	 *
541
	 * @param mixed $content Content to serialize
542
	 * @return string
543
	 */
544 70
	public function stringify($content = '') {
545 70
		$content = $this->normalize($content);
546 70
		if (empty($content) || (is_object($content) && is_callable($content, '__toString'))) {
547 21
			return (string) $content;
548
		}
549 54
		if (is_scalar($content)) {
550 51
			return $content;
551
		}
552 14
		return json_encode($content, ELGG_JSON_ENCODING);
553
	}
554
555
}
556