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 0
Metric Value
cc 22
eloc 32
nc 146
nop 1
dl 0
loc 62
ccs 30
cts 33
cp 0.9091
crap 22.3634
rs 3.7833
c 0
b 0
f 0

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