Completed
Push — 3.3 ( 5fced4...d53524 )
by Jerome
22:15 queued 11s
created

engine/classes/Elgg/Http/ResponseFactory.php (1 issue)

Labels
Severity
1
<?php
2
3
namespace Elgg\Http;
4
5
use Elgg\Ajax\Service as AjaxService;
6
use Elgg\EventsService;
7
use Elgg\PluginHooksService;
8
use ElggEntity;
9
use InvalidArgumentException;
10
use InvalidParameterException;
11
use Symfony\Component\HttpFoundation\Cookie;
12
use Symfony\Component\HttpFoundation\RedirectResponse as SymfonyRedirectResponse;
13
use Symfony\Component\HttpFoundation\Response;
14
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
15
use Symfony\Component\HttpFoundation\JsonResponse;
16
17
/**
18
 * WARNING: API IN FLUX. DO NOT USE DIRECTLY.
19
 *
20
 * @since 2.3
21
 * @internal
22
 */
23
class ResponseFactory {
24
25
	/**
26
	 * @var Request
27
	 */
28
	private $request;
29
30
	/**
31
	 * @var AjaxService
32
	 */
33
	private $ajax;
34
35
	/**
36
	 * @var PluginHooksService
37
	 */
38
	private $hooks;
39
40
	/**
41
	 * @var ResponseTransport
42
	 */
43
	private $transport;
44
45
	/**
46
	 * @var Response|false
47
	 */
48
	private $response_sent = false;
49
50
	/**
51
	 * @var ResponseHeaderBag
52
	 */
53
	private $headers;
54
	
55
	/**
56
	 * @var EventsService
57
	 */
58
	private $events;
59
60
	/**
61
	 * Constructor
62
	 *
63
	 * @param Request            $request   HTTP request
64
	 * @param PluginHooksService $hooks     Plugin hooks service
65
	 * @param AjaxService        $ajax      AJAX service
66
	 * @param ResponseTransport  $transport Response transport
67
	 * @param EventsService      $events    Events service
68
	 */
69
	public function __construct(Request $request, PluginHooksService $hooks, AjaxService $ajax, ResponseTransport $transport, EventsService $events) {
70
		$this->request = $request;
71
		$this->hooks = $hooks;
72
		$this->ajax = $ajax;
73
		$this->transport = $transport;
74
		$this->events = $events;
75
		
76
		$this->headers = new ResponseHeaderBag();
77
	}
78
79
	/**
80
	 * Sets headers to apply to all responses being sent
81
	 *
82
	 * @param string $name    Header name
83
	 * @param string $value   Header value
84
	 * @param bool   $replace Replace existing headers
85
	 * @return void
86
	 */
87
	public function setHeader($name, $value, $replace = true) {
88
		$this->headers->set($name, $value, $replace);
89
	}
90
91
	/**
92
	 * Set a cookie, but allow plugins to customize it first.
93
	 *
94
	 * To customize all cookies, register for the 'init:cookie', 'all' event.
95
	 *
96
	 * @param \ElggCookie $cookie The cookie that is being set
97
	 * @return bool
98
	 */
99
	public function setCookie(\ElggCookie $cookie) {
100
		if (!$this->events->trigger('init:cookie', $cookie->name, $cookie)) {
101
			return false;
102
		}
103
104
		$symfony_cookie = new Cookie(
105
			$cookie->name,
106
			$cookie->value,
107
			$cookie->expire,
108
			$cookie->path,
109
			$cookie->domain,
110
			$cookie->secure,
111
			$cookie->httpOnly
112
		);
113
114
		$this->headers->setCookie($symfony_cookie);
115
		return true;
116
	}
117
118
	/**
119
	 * Get headers set to apply to all responses
120
	 *
121
	 * @param bool $remove_existing Remove existing headers found in headers_list()
122
	 * @return ResponseHeaderBag
123
	 */
124
	public function getHeaders($remove_existing = true) {
125
		// Add headers that have already been set by underlying views
126
		// e.g. viewtype page shells set content-type headers
127
		$headers_list = headers_list();
128
		foreach ($headers_list as $header) {
129
			if (stripos($header, 'HTTP/1.1') !== false) {
130
				continue;
131
			}
132
133
			list($name, $value) = explode(':', $header, 2);
134
			$this->setHeader($name, ltrim($value), false);
135
			if ($remove_existing) {
136
				header_remove($name);
137
			}
138
		}
139
140
		return $this->headers;
141
	}
142
143
	/**
144
	 * Creates an HTTP response
145
	 *
146
	 * @param mixed   $content The response content
147
	 * @param integer $status  The response status code
148
	 * @param array   $headers An array of response headers
149
	 *
150
	 * @return Response
151
	 * @throws InvalidArgumentException
152
	 */
153
	public function prepareResponse($content = '', $status = 200, array $headers = []) {
154
		$header_bag = $this->getHeaders();
155
		$header_bag->add($headers);
156
		
157
		$response = new Response($content, $status, $header_bag->all());
158
		
159
		$response->prepare($this->request);
160
		
161
		return $response;
162
	}
163
164
	/**
165
	 * Creates a redirect response
166
	 *
167
	 * @param string  $url     URL to redirect to
168
	 * @param integer $status  The status code (302 by default)
169
	 * @param array   $headers An array of response headers (Location is always set to the given URL)
170
	 *
171
	 * @return SymfonyRedirectResponse
172
	 * @throws InvalidArgumentException
173
	 */
174
	public function prepareRedirectResponse($url, $status = 302, array $headers = []) {
175
		$header_bag = $this->getHeaders();
176
		$header_bag->add($headers);
177
		
178
		$response = new SymfonyRedirectResponse($url, $status, $header_bag->all());
179
		
180
		$response->prepare($this->request);
181
		
182
		return $response;
183
	}
184
	
185
	/**
186
	 * Creates an JSON response
187
	 *
188
	 * @param mixed   $content The response content
189
	 * @param integer $status  The response status code
190
	 * @param array   $headers An array of response headers
191
	 *
192
	 * @return JsonResponse
193
	 * @throws InvalidArgumentException
194
	 */
195
	public function prepareJsonResponse($content = '', $status = 200, array $headers = []) {
196
		$header_bag = $this->getHeaders();
197
		$header_bag->add($headers);
198
		
199
		/**
200
		 * Removing Content-Type header because in some cases content-type headers were already set
201
		 * This is a problem when serving a cachable view (for example a .css) in ajax/view
202
		 *
203
		 * @see https://github.com/Elgg/Elgg/issues/9794
204
		 */
205
		$header_bag->remove('Content-Type');
206
		
207
		$response = new JsonResponse($content, $status, $header_bag->all());
208
		
209
		$response->prepare($this->request);
210
		
211
		return $response;
212
	}
213
214
	/**
215
	 * Send a response
216
	 *
217
	 * @param Response $response Response object
218
	 * @return Response|false
219
	 */
220
	public function send(Response $response) {
221
222
		if ($this->response_sent) {
223
			if ($this->response_sent !== $response) {
224
				_elgg_services()->logger->error('Unable to send the following response: ' . PHP_EOL
225
						. (string) $response . PHP_EOL
226
						. 'because another response has already been sent: ' . PHP_EOL
227
						. (string) $this->response_sent);
228
			}
229
		} else {
230
			if (!$this->events->triggerBefore('send', 'http_response', $response)) {
231
				return false;
232
			}
233
234
			$request = $this->request;
235
			$method = $request->getRealMethod() ? : 'GET';
236
			$path = $request->getElggPath();
237
238
			_elgg_services()->logger->notice("Responding to {$method} {$path}");
239
			if (!$this->transport->send($response)) {
240
				return false;
241
			}
242
243
			$this->events->triggerAfter('send', 'http_response', $response);
244
			$this->response_sent = $response;
245
			
246
			$this->closeSession();
247
		}
248
249
		return $this->response_sent;
250
	}
251
252
	/**
253
	 * Returns a response that was sent to the client
254
	 *
255
	 * @return Response|false
256
	 */
257
	public function getSentResponse() {
258
		return $this->response_sent;
259
	}
260
261
	/**
262
	 * Send HTTP response
263
	 *
264
	 * @param ResponseBuilder $response ResponseBuilder instance
265
	 *                                  An instance of an ErrorResponse, OkResponse or RedirectResponse
266
	 * @return false|Response
267
	 * @throws \InvalidParameterException
268
	 */
269
	public function respond(ResponseBuilder $response) {
270
271
		$response_type = $this->parseContext();
272
		$response = $this->hooks->trigger('response', $response_type, $response, $response);
273
		if (!$response instanceof ResponseBuilder) {
274
			throw new InvalidParameterException("Handlers for 'response','$response_type' plugin hook must "
275
			. "return an instanceof " . ResponseBuilder::class);
276
		}
277
278
		if ($response->isNotModified()) {
279
			return $this->send($this->prepareResponse('', ELGG_HTTP_NOT_MODIFIED));
280
		}
281
282
		// Prevent content type sniffing by the browser
283
		$headers = $response->getHeaders();
284
		$headers['X-Content-Type-Options'] = 'nosniff';
285
		$response->setHeaders($headers);
286
		
287
		$is_xhr = $this->request->isXmlHttpRequest();
288
289
		$is_action = false;
290
		if (0 === strpos($response_type, 'action:')) {
291
			$is_action = true;
292
		}
293
294
		if ($is_action && $response->getForwardURL() === null) {
295
			// actions must always set a redirect url
296
			$response->setForwardURL(REFERRER);
297
		}
298
299
		if ($response->getForwardURL() === REFERRER) {
300
			$response->setForwardURL($this->request->headers->get('Referer'));
301
		}
302
303
		if ($response->getForwardURL() !== null && !$is_xhr) {
304
			// non-xhr requests should issue a forward if redirect url is set
305
			// unless it's an error, in which case we serve an error page
306
			if ($this->isAction() || (!$response->isClientError() && !$response->isServerError())) {
307
				$response->setStatusCode(ELGG_HTTP_FOUND);
308
			}
309
		}
310
311
		if ($is_xhr && ($is_action || $this->ajax->isAjax2Request())) {
312
			if (!$this->ajax->isAjax2Request()) {
313
				// xhr actions using legacy ajax API should return 200 with wrapped data
314
				$response->setStatusCode(ELGG_HTTP_OK);
315
			}
316
317
			// Actions always respond with JSON on xhr calls
318
			$headers = $response->getHeaders();
319
			$headers['Content-Type'] = 'application/json; charset=UTF-8';
320
			$response->setHeaders($headers);
321
322
			if ($response->isOk()) {
323
				$response->setContent($this->wrapAjaxResponse($response->getContent(), $response->getForwardURL()));
324
			}
325
		}
326
327
		if ($response->isRedirection()) {
328
			$redirect_url = $response->getForwardURL();
329
			return $this->redirect($redirect_url, $response->getStatusCode());
330
		}
331
332
		if ($this->ajax->isReady() && $response->isSuccessful()) {
333
			return $this->respondFromContent($response);
334
		}
335
336
		if ($response->isClientError() || $response->isServerError() || $response instanceof ErrorResponse) {
337
			return $this->respondWithError($response);
338
		}
339
340
		return $this->respondFromContent($response);
341
	}
342
343
	/**
344
	 * Send error HTTP response
345
	 *
346
	 * @param ResponseBuilder $response ResponseBuilder instance
347
	 *                                  An instance of an ErrorResponse, OkResponse or RedirectResponse
348
	 *
349
	 * @return false|Response
350
	 * @throws \InvalidParameterException
351
	 */
352
	public function respondWithError(ResponseBuilder $response) {
353
		$error = $this->stringify($response->getContent());
354
		$status_code = $response->getStatusCode();
355
356
		if ($this->ajax->isReady()) {
357
			return $this->send($this->ajax->respondWithError($error, $status_code));
358
		}
359
360
		if ($this->isXhr()) {
361
			// xhr calls to non-actions (e.g. ajax/view or ajax/form) need to receive proper HTTP status code
362
			return $this->send($this->prepareResponse($error, $status_code, $response->getHeaders()));
363
		}
364
365
		$forward_url = $this->getSiteRefererUrl();
366
367
		if (!$this->isAction()) {
368
			$params = [
369
				'current_url' => current_page_url(),
370
				'forward_url' => $forward_url,
371
			];
372
			// For BC, let plugins serve their own error page
373
			// @see elgg_error_page_handler
374
			$forward_reason = (string) $status_code;
375
376
			$this->hooks->trigger('forward', $forward_reason, $params, $forward_url);
377
378
			if ($this->response_sent) {
379
				// Response was sent from a forward hook
380
				return $this->response_sent;
381
			}
382
383
			if (elgg_view_exists('resources/error')) {
384
				$params['type'] = $forward_reason;
385
				$params['exception'] = $response->getException();
0 ignored issues
show
The method getException() does not exist on Elgg\Http\ResponseBuilder. Since it exists in all sub-types, consider adding an abstract or default implementation to Elgg\Http\ResponseBuilder. ( Ignorable by Annotation )

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

385
				/** @scrutinizer ignore-call */ 
386
    $params['exception'] = $response->getException();
Loading history...
386
				if (!elgg_is_empty($error)) {
387
					$params['params']['error'] = $error;
388
				}
389
				$error_page = elgg_view_resource('error', $params);
390
			} else {
391
				$error_page = $error;
392
			}
393
394
			return $this->send($this->prepareResponse($error_page, $status_code));
395
		}
396
397
		$forward_url = $this->makeSecureForwardUrl($forward_url);
398
		return $this->send($this->prepareRedirectResponse($forward_url));
399
	}
400
401
	/**
402
	 * Send OK response
403
	 *
404
	 * @param ResponseBuilder $response ResponseBuilder instance
405
	 *                                  An instance of an ErrorResponse, OkResponse or RedirectResponse
406
	 *
407
	 * @return Response|false
408
	 */
409
	public function respondFromContent(ResponseBuilder $response) {
410
		$content = $this->stringify($response->getContent());
411
		
412
		if ($this->ajax->isReady()) {
413
			$hook_type = $this->parseContext();
414
			return $this->send($this->ajax->respondFromOutput($content, $hook_type));
415
		}
416
417
		return $this->send($this->prepareResponse($content, $response->getStatusCode(), $response->getHeaders()));
418
	}
419
420
	/**
421
	 * Wraps response content in an Ajax2 compatible format
422
	 *
423
	 * @param string $content     Response content
424
	 * @param string $forward_url Forward URL
425
	 * @return string
426
	 */
427
	public function wrapAjaxResponse($content = '', $forward_url = null) {
428
429
		if (!$this->ajax->isAjax2Request()) {
430
			return $this->wrapLegacyAjaxResponse($content, $forward_url);
431
		}
432
433
		$content = $this->stringify($content);
434
435
		if ($forward_url === REFERRER) {
436
			$forward_url = $this->getSiteRefererUrl();
437
		}
438
439
		$params = [
440
			'value' => '',
441
			'current_url' => current_page_url(),
442
			'forward_url' => elgg_normalize_url($forward_url),
443
		];
444
445
		$params['value'] = $this->ajax->decodeJson($content);
446
447
		return $this->stringify($params);
448
	}
449
450
	/**
451
	 * Wraps content for compability with legacy Elgg ajax calls
452
	 *
453
	 * @param string $content     Response content
454
	 * @param string $forward_url Forward URL
455
	 * @return string
456
	 */
457
	public function wrapLegacyAjaxResponse($content = '', $forward_url = REFERRER) {
458
459
		$content = $this->stringify($content);
460
461
		if ($forward_url === REFERRER) {
462
			$forward_url = $this->getSiteRefererUrl();
463
		}
464
465
		// always pass the full structure to avoid boilerplate JS code.
466
		$params = [
467
			'output' => '',
468
			'status' => 0,
469
			'system_messages' => [
470
				'error' => [],
471
				'success' => []
472
			],
473
			'current_url' => current_page_url(),
474
			'forward_url' => elgg_normalize_url($forward_url),
475
		];
476
477
		$params['output'] = $this->ajax->decodeJson($content);
478
479
		// Grab any system messages so we can inject them via ajax too
480
		$system_messages = _elgg_services()->systemMessages->dumpRegister();
481
482
		if (isset($system_messages['success'])) {
483
			$params['system_messages']['success'] = $system_messages['success'];
484
		}
485
486
		if (isset($system_messages['error'])) {
487
			$params['system_messages']['error'] = $system_messages['error'];
488
			$params['status'] = -1;
489
		}
490
491
		$response_type = $this->parseContext();
492
		list($service, $name) = explode(':', $response_type);
493
		$context = [
494
			$service => $name,
495
		];
496
		$params = $this->hooks->trigger('output', 'ajax', $context, $params);
497
498
		return $this->stringify($params);
499
	}
500
501
	/**
502
	 * Prepares a redirect response
503
	 *
504
	 * @param string $forward_url Redirection URL
505
	 * @param mixed  $status_code HTTP status code or forward reason
506
	 * @return false|Response
507
	 * @throws InvalidParameterException
508
	 */
509
	public function redirect($forward_url = REFERRER, $status_code = ELGG_HTTP_FOUND) {
510
		$location = $forward_url;
511
		
512
		if ($forward_url === REFERRER) {
513
			$forward_url = $this->getSiteRefererUrl();
514
		}
515
516
		$forward_url = $this->makeSecureForwardUrl($forward_url);
517
518
		// allow plugins to rewrite redirection URL
519
		$params = [
520
			'current_url' => current_page_url(),
521
			'forward_url' => $forward_url,
522
			'location' => $location,
523
		];
524
525
		$forward_reason = (string) $status_code;
526
527
		$forward_url = $this->hooks->trigger('forward', $forward_reason, $params, $forward_url);
528
		
529
		if ($this->response_sent) {
530
			// Response was sent from a forward hook
531
			// Clearing handlers to void infinite loops
532
			return $this->response_sent;
533
		}
534
535
		if ($forward_url === REFERRER) {
536
			$forward_url = $this->getSiteRefererUrl();
537
		}
538
539
		if (!is_string($forward_url)) {
540
			throw new InvalidParameterException("'forward', '$forward_reason' hook must return a valid redirection URL");
541
		}
542
543
		$forward_url = $this->makeSecureForwardUrl($forward_url);
544
545
		switch ($status_code) {
546
			case 'system':
547
			case 'csrf':
548
				$status_code = ELGG_HTTP_OK;
549
				break;
550
			case 'admin':
551
			case 'login':
552
			case 'member':
553
			case 'walled_garden':
554
			default :
555
				$status_code = (int) $status_code;
556
				if (!$status_code || $status_code < 100 || $status_code > 599) {
557
					$status_code = ELGG_HTTP_SEE_OTHER;
558
				}
559
				break;
560
		}
561
562
		if ($this->isXhr()) {
563
			if ($status_code < 100 || ($status_code >= 300 && $status_code <= 399) || $status_code > 599) {
564
				// We only want to preserve OK and error codes
565
				// Redirect responses should be converted to OK responses as this is an XHR request
566
				$status_code = ELGG_HTTP_OK;
567
			}
568
			$output = ob_get_clean();
569
			if (!$this->isAction() && !$this->ajax->isAjax2Request()) {
570
				// legacy ajax calls are always OK
571
				// actions are wrapped by ResponseFactory::respond()
572
				$status_code = ELGG_HTTP_OK;
573
				$output = $this->wrapLegacyAjaxResponse($output, $forward_url);
574
			}
575
576
			$response = new OkResponse($output, $status_code, $forward_url);
577
			$headers = $response->getHeaders();
578
			$headers['Content-Type'] = 'application/json; charset=UTF-8';
579
			$response->setHeaders($headers);
580
			return $this->respond($response);
581
		}
582
583
		if ($this->isAction()) {
584
			// actions should always redirect on non xhr-calls
585
			if (!is_int($status_code) || $status_code < 300 || $status_code > 399) {
586
				$status_code = ELGG_HTTP_SEE_OTHER;
587
			}
588
		}
589
590
		$response = new OkResponse('', $status_code, $forward_url);
591
		if ($response->isRedirection()) {
592
			return $this->send($this->prepareRedirectResponse($forward_url, $status_code));
593
		}
594
		return $this->respond($response);
595
	}
596
597
	/**
598
	 * Parses response type to be used as plugin hook type
599
	 * @return string
600
	 */
601
	public function parseContext() {
602
603
		$segments = $this->request->getUrlSegments();
604
605
		$identifier = array_shift($segments);
606
		switch ($identifier) {
607
			case 'ajax' :
608
				$page = array_shift($segments);
609
				if ($page === 'view') {
610
					$view = implode('/', $segments);
611
					return "view:$view";
612
				} else if ($page === 'form') {
613
					$form = implode('/', $segments);
614
					return "form:$form";
615
				}
616
				array_unshift($segments, $page);
617
				break;
618
619
			case 'action' :
620
				$action = implode('/', $segments);
621
				return "action:$action";
622
		}
623
624
		array_unshift($segments, $identifier);
625
		$path = implode('/', $segments);
626
		return "path:$path";
627
	}
628
629
	/**
630
	 * Check if the request is an XmlHttpRequest
631
	 * @return bool
632
	 */
633
	public function isXhr() {
634
		return $this->request->isXmlHttpRequest();
635
	}
636
637
	/**
638
	 * Check if the requested path is an action
639
	 * @return bool
640
	 */
641
	public function isAction() {
642
		if (0 === strpos($this->parseContext(), 'action:')) {
643
			return true;
644
		}
645
		return false;
646
	}
647
648
	/**
649
	 * Normalizes content into serializable data by walking through arrays
650
	 * and objectifying Elgg entities
651
	 *
652
	 * @param mixed $content Data to normalize
653
	 * @return mixed
654
	 */
655
	public function normalize($content = '') {
656
		if ($content instanceof ElggEntity) {
657
			$content = (array) $content->toObject();
658
		}
659
		if (is_array($content)) {
660
			foreach ($content as $key => $value) {
661
				$content[$key] = $this->normalize($value);
662
			}
663
		}
664
		return $content;
665
	}
666
667
	/**
668
	 * Stringify/serialize response data
669
	 *
670
	 * Casts objects implementing __toString method to strings
671
	 * Serializes non-scalar values to JSON
672
	 *
673
	 * @param mixed $content Content to serialize
674
	 * @return string
675
	 */
676
	public function stringify($content = '') {
677
		$content = $this->normalize($content);
678
		if (empty($content) || (is_object($content) && is_callable([$content, '__toString']))) {
679
			return (string) $content;
680
		}
681
		if (is_scalar($content)) {
682
			return $content;
683
		}
684
		return json_encode($content, ELGG_JSON_ENCODING);
685
	}
686
687
	/**
688
	 * Replaces response transport
689
	 *
690
	 * @param ResponseTransport $transport Transport interface
691
	 * @return void
692
	 */
693
	public function setTransport(ResponseTransport $transport) {
694
		$this->transport = $transport;
695
	}
696
	
697
	/**
698
	 * Ensures the referer header is a site url
699
	 *
700
	 * @return string
701
	 */
702
	protected function getSiteRefererUrl() {
703
		$unsafe_url = $this->request->headers->get('Referer');
704
		$safe_url = elgg_normalize_site_url($unsafe_url);
705
		if ($safe_url !== false) {
706
			return $safe_url;
707
		}
708
		
709
		return '';
710
	}
711
	
712
	/**
713
	 * Ensure the url has a valid protocol for browser use
714
	 *
715
	 * @param string $url url the secure
716
	 *
717
	 * @return string
718
	 */
719
	protected function makeSecureForwardUrl($url) {
720
		$url = elgg_normalize_url($url);
721
		if (!preg_match('/^(http|https|ftp|sftp|ftps):\/\//', $url)) {
722
			return elgg_get_site_url();
723
		}
724
		
725
		return $url;
726
	}
727
	
728
	/**
729
	 * Closes the session
730
	 *
731
	 * Force closing the session so session is saved to the database before headers are sent
732
	 * preventing race conditions with session data
733
	 *
734
	 * @see https://github.com/Elgg/Elgg/issues/12348
735
	 *
736
	 * @return void
737
	 */
738
	protected function closeSession() {
739
		$session = elgg_get_session();
740
		if ($session->isStarted()) {
741
			$session->save();
742
		}
743
	}
744
}
745