ResponseFactory::respond()   F
last analyzed

Complexity

Conditions 22
Paths 146

Size

Total Lines 62
Code Lines 32

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 30
CRAP Score 22.3634

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 22
eloc 32
c 1
b 0
f 0
nc 146
nop 1
dl 0
loc 62
ccs 30
cts 33
cp 0.9091
crap 22.3634
rs 3.7833

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
namespace Elgg\Http;
4
5
use Elgg\Ajax\Service as AjaxService;
6
use Elgg\EventsService;
7
use Elgg\Exceptions\UnexpectedValueException;
8
use Elgg\Traits\Loggable;
9
use Symfony\Component\HttpFoundation\Cookie;
10
use Symfony\Component\HttpFoundation\RedirectResponse as SymfonyRedirectResponse;
11
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
12
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
13
use Symfony\Component\HttpFoundation\JsonResponse;
14
15
/**
16
 * HTTP response service
17
 *
18
 * @since 2.3
19
 * @internal
20
 */
21
class ResponseFactory {
22
23
	use Loggable;
24
25
	protected Request $request;
26
	
27
	protected AjaxService $ajax;
28
	
29
	protected EventsService $events;
30
	
31
	protected ResponseTransport $transport;
32
	
33
	protected ResponseHeaderBag $headers;
34
	
35
	protected ?SymfonyResponse $response_sent = null;
36
37
	/**
38
	 * Constructor
39
	 *
40
	 * @param Request       $request HTTP request
41
	 * @param AjaxService   $ajax    AJAX service
42
	 * @param EventsService $events  Events service
43
	 */
44 2525
	public function __construct(Request $request, AjaxService $ajax, EventsService $events) {
45 2525
		$this->request = $request;
46 2525
		$this->ajax = $ajax;
47 2525
		$this->events = $events;
48
		
49 2525
		$this->transport = \Elgg\Application::getResponseTransport();
50 2525
		$this->headers = new ResponseHeaderBag();
51
	}
52
53
	/**
54
	 * Sets headers to apply to all responses being sent
55
	 *
56
	 * @param string $name    Header name
57
	 * @param string $value   Header value
58
	 * @param bool   $replace Replace existing headers
59
	 *
60
	 * @return void
61
	 */
62 90
	public function setHeader(string $name, string $value, bool $replace = true): void {
63 90
		$this->headers->set($name, $value, $replace);
64
	}
65
66
	/**
67
	 * Set a cookie, but allow plugins to customize it first.
68
	 *
69
	 * To customize all cookies, register for the 'init:cookie', 'all' event.
70
	 *
71
	 * @param \ElggCookie $cookie The cookie that is being set
72
	 *
73
	 * @return bool
74
	 */
75 10
	public function setCookie(\ElggCookie $cookie): bool {
76 10
		if (!$this->events->trigger('init:cookie', $cookie->name, $cookie)) {
77
			return false;
78
		}
79
80 10
		$symfony_cookie = new Cookie(
81 10
			$cookie->name,
82 10
			$cookie->value,
83 10
			$cookie->expire,
84 10
			$cookie->path,
85 10
			$cookie->domain,
86 10
			$cookie->secure,
87 10
			$cookie->httpOnly
88 10
		);
89
90 10
		$this->headers->setCookie($symfony_cookie);
91 10
		return true;
92
	}
93
94
	/**
95
	 * Get headers set to apply to all responses
96
	 *
97
	 * @param bool $remove_existing Remove existing headers found in headers_list()
98
	 *
99
	 * @return ResponseHeaderBag
100
	 */
101 161
	public function getHeaders(bool $remove_existing = true): ResponseHeaderBag {
102
		// Add headers that have already been set by underlying views
103
		// e.g. viewtype page shells set content-type headers
104 161
		$headers_list = headers_list();
105 161
		foreach ($headers_list as $header) {
106
			if (stripos($header, 'HTTP/1.1') !== false) {
107
				continue;
108
			}
109
110
			list($name, $value) = explode(':', $header, 2);
111
			$this->setHeader($name, ltrim($value), false);
112
			if ($remove_existing) {
113
				header_remove($name);
114
			}
115
		}
116
117 161
		return $this->headers;
118
	}
119
120
	/**
121
	 * Creates an HTTP response
122
	 *
123
	 * @param string  $content The response content
124
	 * @param integer $status  The response status code
125
	 * @param array   $headers An array of response headers
126
	 *
127
	 * @return SymfonyResponse
128
	 */
129 114
	public function prepareResponse(?string $content = '', int $status = 200, array $headers = []): SymfonyResponse {
130 114
		$header_bag = $this->getHeaders();
131 114
		$header_bag->add($headers);
132
		
133 114
		$response = new SymfonyResponse($content, $status, $header_bag->all());
134
		
135 114
		return $response->prepare($this->request);
136
	}
137
138
	/**
139
	 * Creates a redirect response
140
	 *
141
	 * @param string  $url     URL to redirect to
142
	 * @param integer $status  The status code (302 by default)
143
	 * @param array   $headers An array of response headers (Location is always set to the given URL)
144
	 *
145
	 * @return SymfonyRedirectResponse
146
	 */
147 26
	public function prepareRedirectResponse(string $url, int $status = 302, array $headers = []): SymfonyRedirectResponse {
148 26
		$header_bag = $this->getHeaders();
149 26
		$header_bag->add($headers);
150
		
151 26
		$response = new SymfonyRedirectResponse($url, $status, $header_bag->all());
152
		
153 25
		return $response->prepare($this->request);
154
	}
155
	
156
	/**
157
	 * Creates an JSON response
158
	 *
159
	 * @param mixed   $content The response content
160
	 * @param integer $status  The response status code
161
	 * @param array   $headers An array of response headers
162
	 *
163
	 * @return JsonResponse
164
	 */
165 21
	public function prepareJsonResponse($content = '', int $status = 200, array $headers = []): JsonResponse {
166 21
		$header_bag = $this->getHeaders();
167 21
		$header_bag->add($headers);
168
		
169
		/**
170
		 * Removing Content-Type header because in some cases content-type headers were already set
171
		 * This is a problem when serving a cachable view (for example a .css) in ajax/view
172
		 *
173
		 * @see https://github.com/Elgg/Elgg/issues/9794
174
		 */
175 21
		$header_bag->remove('Content-Type');
176
		
177 21
		$response = new JsonResponse($content, $status, $header_bag->all());
178
		
179 21
		return $response->prepare($this->request);
180
	}
181
182
	/**
183
	 * Send a response
184
	 *
185
	 * @param SymfonyResponse $response Response object
186
	 *
187
	 * @return SymfonyResponse|false
188
	 */
189 162
	public function send(SymfonyResponse $response): SymfonyResponse|false {
190 162
		if (isset($this->response_sent)) {
191 33
			if ($this->response_sent !== $response) {
192 33
				$this->getLogger()->error('Unable to send the following response: ' . PHP_EOL
193 33
						. (string) $response . PHP_EOL
194 33
						. 'because another response has already been sent: ' . PHP_EOL
195 33
						. (string) $this->response_sent);
196
			}
197
		} else {
198 162
			if (!$this->events->triggerBefore('send', 'http_response', $response)) {
199
				return false;
200
			}
201
202 162
			$request = $this->request;
203 162
			$method = $request->getRealMethod() ?: 'GET';
204 162
			$path = $request->getElggPath();
205
206 162
			$this->getLogger()->notice("Responding to {$method} {$path}");
207 162
			if (!$this->transport->send($response)) {
208
				return false;
209
			}
210
211 162
			$this->events->triggerAfter('send', 'http_response', $response);
212 162
			$this->response_sent = $response;
213
			
214 162
			$this->closeSession();
215
		}
216
217 162
		return $this->response_sent;
218
	}
219
220
	/**
221
	 * Returns a response that was sent to the client
222
	 *
223
	 * @return SymfonyResponse|null
224
	 */
225 148
	public function getSentResponse(): ?SymfonyResponse {
226 148
		return $this->response_sent;
227
	}
228
229
	/**
230
	 * Send HTTP response
231
	 *
232
	 * @param ResponseBuilder $response ResponseBuilder instance
233
	 *                                  An instance of an ErrorResponse, OkResponse or RedirectResponse
234
	 *
235
	 * @return false|SymfonyResponse
236
	 *
237
	 * @throws UnexpectedValueException
238
	 */
239 146
	public function respond(ResponseBuilder $response) {
240 146
		$response_type = $this->parseContext();
241 146
		$response = $this->events->triggerResults('response', $response_type, [], $response);
242 146
		if (!$response instanceof ResponseBuilder) {
243
			throw new UnexpectedValueException("Handlers for 'response', '{$response_type}' event must return an instanceof " . ResponseBuilder::class);
244
		}
245
246 146
		if ($response->isNotModified()) {
247
			return $this->send($this->prepareResponse('', ELGG_HTTP_NOT_MODIFIED));
248
		}
249
250
		// Prevent content type sniffing by the browser
251 146
		$headers = $response->getHeaders();
252 146
		$headers['X-Content-Type-Options'] = 'nosniff';
253 146
		$response->setHeaders($headers);
254
		
255 146
		$is_xhr = $this->request->isXmlHttpRequest();
256
257 146
		$is_action = $this->isAction();
258
259 146
		if ($is_action && $response->getForwardURL() === null) {
260
			// actions must always set a redirect url
261
			$response->setForwardURL(REFERRER);
262
		}
263
264 146
		if ($response->getForwardURL() === REFERRER) {
265 19
			$response->setForwardURL((string) $this->request->headers->get('Referer'));
266
		}
267
268 146
		if ($response->getForwardURL() !== null && !$is_xhr && !$response->isRedirection()) {
269
			// non-xhr requests should issue a forward if redirect url is set
270
			// unless it's an error, in which case we serve an error page
271 16
			if ($is_action || (!$response->isClientError() && !$response->isServerError())) {
272 7
				$response->setStatusCode(ELGG_HTTP_FOUND);
273
			}
274
		}
275
276 146
		if ($is_xhr && ($is_action || $this->ajax->isAjax2Request())) {
277
			// Actions and calls from elgg/Ajax always respond with JSON on xhr calls
278 32
			$headers = $response->getHeaders();
279 32
			$headers['Content-Type'] = 'application/json; charset=UTF-8';
280 32
			$response->setHeaders($headers);
281
282 32
			if ($response->isOk()) {
283 23
				$response->setContent($this->wrapAjaxResponse($response->getContent(), $response->getForwardURL()));
284
			}
285
		}
286
287 146
		if ($response->isRedirection()) {
288 25
			$redirect_url = $response->getForwardURL();
289 25
			return $this->redirect($redirect_url, $response->getStatusCode());
290
		}
291
292 125
		if ($this->ajax->isReady() && $response->isSuccessful()) {
293 18
			return $this->respondFromContent($response);
294
		}
295
296 107
		if ($response->isClientError() || $response->isServerError() || $response instanceof ErrorResponse) {
297 24
			return $this->respondWithError($response);
298
		}
299
300 83
		return $this->respondFromContent($response);
301
	}
302
303
	/**
304
	 * Send error HTTP response
305
	 *
306
	 * @param ResponseBuilder $response ResponseBuilder instance
307
	 *                                  An instance of an ErrorResponse, OkResponse or RedirectResponse
308
	 *
309
	 * @return false|SymfonyResponse
310
	 */
311 29
	public function respondWithError(ResponseBuilder $response) {
312 29
		$error = $this->stringify($response->getContent());
313 29
		$status_code = $response->getStatusCode();
314
315 29
		if ($this->ajax->isReady()) {
316 7
			return $this->send($this->ajax->respondWithError($error, $status_code));
317
		}
318
319 22
		if ($this->isXhr()) {
320
			// xhr calls to non-actions (e.g. ajax/view or ajax/form) need to receive proper HTTP status code
321 8
			return $this->send($this->prepareResponse($error, $status_code, $response->getHeaders()));
322
		}
323
324 14
		$forward_url = $this->getSiteRefererUrl();
325
326 14
		if ($this->isAction()) {
327
			$forward_url = $this->makeSecureForwardUrl($forward_url);
328
			return $this->send($this->prepareRedirectResponse($forward_url));
329
		}
330
		
331 14
		$params = [
332 14
			'current_url' => $this->request->getCurrentURL(),
333 14
			'forward_url' => $forward_url,
334 14
		];
335
		
336
		// For BC, let plugins serve their own error page
337
		// @todo can this event be dropped
338 14
		$forward_reason = (string) $status_code;
339
340 14
		$this->events->triggerResults('forward', $forward_reason, $params, $forward_url);
341
342 14
		if (isset($this->response_sent)) {
343
			// Response was sent from a forward event
344
			return $this->response_sent;
345
		}
346
347 14
		if (elgg_view_exists('resources/error')) {
348 13
			$params['type'] = $forward_reason;
349 13
			$params['exception'] = $response->getException();
350 13
			if (!elgg_is_empty($error)) {
351 4
				$params['params']['error'] = $error;
352
			}
353
			
354 13
			$error_page = elgg_view_resource('error', $params);
355
		} else {
356 1
			$error_page = $error;
357
		}
358
359 14
		return $this->send($this->prepareResponse($error_page, $status_code));
360
	}
361
362
	/**
363
	 * Send OK response
364
	 *
365
	 * @param ResponseBuilder $response ResponseBuilder instance
366
	 *                                  An instance of an ErrorResponse, OkResponse or RedirectResponse
367
	 *
368
	 * @return SymfonyResponse|false
369
	 */
370 101
	public function respondFromContent(ResponseBuilder $response) {
371 101
		$content = $this->stringify($response->getContent());
372
		
373 101
		if ($this->ajax->isReady()) {
374 18
			return $this->send($this->ajax->respondFromOutput($content, $this->parseContext()));
375
		}
376
377 83
		return $this->send($this->prepareResponse($content, $response->getStatusCode(), $response->getHeaders()));
378
	}
379
380
	/**
381
	 * Wraps response content in an Ajax2 compatible format
382
	 *
383
	 * @param string $content     Response content
384
	 * @param string $forward_url Forward URL
385
	 *
386
	 * @return string
387
	 */
388 23
	public function wrapAjaxResponse($content = '', string $forward_url = null): string {
389 23
		$content = $this->stringify($content);
390
391 23
		if ($forward_url === REFERRER) {
392
			$forward_url = $this->getSiteRefererUrl();
393
		}
394
395 23
		return $this->stringify([
396 23
			'value' => $this->ajax->decodeJson($content),
397 23
			'current_url' => $this->request->getCurrentURL(),
398 23
			'forward_url' => elgg_normalize_url((string) $forward_url),
399 23
		]);
400
	}
401
402
	/**
403
	 * Prepares a redirect response
404
	 *
405
	 * @param string $forward_url Redirection URL
406
	 * @param mixed  $status_code HTTP status code or forward reason
407
	 *
408
	 * @return false|SymfonyResponse
409
	 * @throws UnexpectedValueException
410
	 */
411 36
	public function redirect(string $forward_url = REFERRER, $status_code = ELGG_HTTP_FOUND) {
412 36
		$location = $forward_url;
413
		
414 36
		if ($forward_url === REFERRER) {
415 3
			$forward_url = $this->getSiteRefererUrl();
416
		}
417
418 36
		$forward_url = $this->makeSecureForwardUrl($forward_url);
419
420
		// allow plugins to rewrite redirection URL
421 36
		$params = [
422 36
			'current_url' => $this->request->getCurrentURL(),
423 36
			'forward_url' => $forward_url,
424 36
			'location' => $location,
425 36
		];
426
427 36
		$forward_reason = (string) $status_code;
428
429 36
		$forward_url = (string) $this->events->triggerResults('forward', $forward_reason, $params, $forward_url);
430
		
431 36
		if (isset($this->response_sent)) {
432
			// Response was sent from a forward event
433
			// Clearing handlers to void infinite loops
434 1
			return $this->response_sent;
435
		}
436
437 36
		if ($forward_url === REFERRER) {
438
			$forward_url = $this->getSiteRefererUrl();
439
		}
440
441 36
		$forward_url = $this->makeSecureForwardUrl($forward_url);
442
443
		switch ($status_code) {
444 36
			case 'system':
445 36
			case 'csrf':
446
				$status_code = ELGG_HTTP_OK;
447
				break;
448 36
			case 'admin':
449 36
			case 'login':
450 36
			case 'member':
451 36
			case 'walled_garden':
452
			default:
453 36
				$status_code = (int) $status_code;
454 36
				if (!$status_code || $status_code < 100 || $status_code > 599) {
455
					$status_code = ELGG_HTTP_SEE_OTHER;
456
				}
457 36
				break;
458
		}
459
460 36
		if ($this->isXhr()) {
461 11
			if ($status_code < 100 || ($status_code >= 300 && $status_code <= 399) || $status_code > 599) {
462
				// We only want to preserve OK and error codes
463
				// Redirect responses should be converted to OK responses as this is an XHR request
464 8
				$status_code = ELGG_HTTP_OK;
465
			}
466
			
467 11
			$output = ob_get_clean();
468
469 11
			$response = new RedirectResponse($forward_url, $status_code);
470 11
			$response->setContent($output);
471 11
			$headers = $response->getHeaders();
472 11
			$headers['Content-Type'] = 'application/json; charset=UTF-8';
473 11
			$response->setHeaders($headers);
474 11
			return $this->respond($response);
475
		}
476
477 25
		if ($this->isAction()) {
478
			// actions should always redirect on non xhr-calls
479 7
			if (!is_int($status_code) || $status_code < 300 || $status_code > 399) {
480
				$status_code = ELGG_HTTP_SEE_OTHER;
481
			}
482
		}
483
484 25
		$response = new RedirectResponse($forward_url, $status_code);
485 25
		if ($response->isRedirection()) {
486 23
			return $this->send($this->prepareRedirectResponse($forward_url, $status_code));
487
		}
488
		
489 2
		return $this->respond($response);
490
	}
491
492
	/**
493
	 * Parses response type to be used as event type
494
	 *
495
	 * @return string
496
	 */
497 162
	public function parseContext(): string {
498 162
		$segments = $this->request->getUrlSegments();
499
500 162
		$identifier = array_shift($segments);
501
		switch ($identifier) {
502 162
			case 'ajax':
503 21
				$page = array_shift($segments);
504 21
				if ($page === 'view') {
505 14
					$view = implode('/', $segments);
506 14
					return "view:{$view}";
507 7
				} elseif ($page === 'form') {
508 5
					$form = implode('/', $segments);
509 5
					return "form:{$form}";
510
				}
511
				
512 2
				array_unshift($segments, $page);
513 2
				break;
514
515 141
			case 'action':
516 28
				$action = implode('/', $segments);
517 28
				return "action:{$action}";
518
		}
519
520 116
		array_unshift($segments, $identifier);
521 116
		$path = implode('/', $segments);
522 116
		return "path:{$path}";
523
	}
524
525
	/**
526
	 * Check if the request is an XmlHttpRequest
527
	 *
528
	 * @return bool
529
	 */
530 56
	public function isXhr(): bool {
531 56
		return $this->request->isXmlHttpRequest();
532
	}
533
534
	/**
535
	 * Check if the requested path is an action
536
	 *
537
	 * @return bool
538
	 */
539 154
	public function isAction(): bool {
540 154
		return str_starts_with($this->parseContext(), 'action:');
541
	}
542
543
	/**
544
	 * Normalizes content into serializable data by walking through arrays
545
	 * and objectifying Elgg entities
546
	 *
547
	 * @param mixed $content Data to normalize
548
	 *
549
	 * @return mixed
550
	 */
551 142
	public function normalize($content = '') {
552 142
		if ($content instanceof \ElggEntity) {
553
			$content = (array) $content->toObject();
554
		}
555
		
556 142
		if (is_array($content)) {
557 26
			foreach ($content as $key => $value) {
558 25
				$content[$key] = $this->normalize($value);
559
			}
560
		}
561
		
562 142
		return $content;
563
	}
564
565
	/**
566
	 * Stringify/serialize response data
567
	 *
568
	 * Casts objects implementing __toString method to strings
569
	 * Serializes non-scalar values to JSON
570
	 *
571
	 * @param mixed $content Content to serialize
572
	 *
573
	 * @return string
574
	 */
575 142
	public function stringify($content = ''): string {
576 142
		$content = $this->normalize($content);
577
		
578 142
		if (is_object($content) && is_callable([$content, '__toString'])) {
579
			return (string) $content;
580
		}
581
		
582 142
		if (is_scalar($content)) {
583 134
			return (string) $content;
584
		}
585
		
586 31
		if (empty($content)) {
587 4
			return '';
588
		}
589
		
590 27
		return json_encode($content, ELGG_JSON_ENCODING);
591
	}
592
593
	/**
594
	 * Replaces response transport
595
	 *
596
	 * @param ResponseTransport $transport Transport interface
597
	 *
598
	 * @return void
599
	 */
600 34
	public function setTransport(ResponseTransport $transport): void {
601 34
		$this->transport = $transport;
602
	}
603
	
604
	/**
605
	 * Ensures the referer header is a site url
606
	 *
607
	 * @return string
608
	 */
609 17
	protected function getSiteRefererUrl(): string {
610 17
		return (string) elgg_normalize_site_url((string) $this->request->headers->get('Referer'));
611
	}
612
	
613
	/**
614
	 * Ensure the url has a valid protocol for browser use
615
	 *
616
	 * @param string $url url the secure
617
	 *
618
	 * @return string
619
	 */
620 36
	protected function makeSecureForwardUrl(string $url): string {
621 36
		$url = elgg_normalize_url($url);
622 36
		if (!preg_match('/^(http|https|ftp|sftp|ftps):\/\//', $url)) {
623
			return elgg_get_site_url();
624
		}
625
		
626 36
		return $url;
627
	}
628
	
629
	/**
630
	 * Closes the session
631
	 *
632
	 * Force closing the session so session is saved to the database before headers are sent
633
	 * preventing race conditions with session data
634
	 *
635
	 * @see https://github.com/Elgg/Elgg/issues/12348
636
	 *
637
	 * @return void
638
	 */
639 162
	protected function closeSession(): void {
640 162
		$session = elgg_get_session();
641 162
		if ($session->isStarted()) {
642 139
			$session->save();
643
		}
644
	}
645
}
646