1<?php
2
3namespace Elgg\Http;
4
5use Elgg\Ajax\Service as AjaxService;
6use Elgg\EventsService;
7use Elgg\PluginHooksService;
8use ElggEntity;
9use InvalidArgumentException;
10use InvalidParameterException;
11use Symfony\Component\HttpFoundation\Cookie;
12use Symfony\Component\HttpFoundation\RedirectResponse as SymfonyRedirectResponse;
13use Symfony\Component\HttpFoundation\Response;
14use Symfony\Component\HttpFoundation\ResponseHeaderBag;
15use Symfony\Component\HttpFoundation\JsonResponse;
16
17/**
18 * WARNING: API IN FLUX. DO NOT USE DIRECTLY.
19 *
20 * @since 2.3
21 * @internal
22 */
23class 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();
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
679		if (is_object($content) && is_callable([$content, '__toString'])) {
680			return (string) $content;
681		}
682
683		if (is_scalar($content)) {
684			return (string) $content;
685		}
686
687		if (empty($content)) {
688			return '';
689		}
690
691		return json_encode($content, ELGG_JSON_ENCODING);
692	}
693
694	/**
695	 * Replaces response transport
696	 *
697	 * @param ResponseTransport $transport Transport interface
698	 * @return void
699	 */
700	public function setTransport(ResponseTransport $transport) {
701		$this->transport = $transport;
702	}
703
704	/**
705	 * Ensures the referer header is a site url
706	 *
707	 * @return string
708	 */
709	protected function getSiteRefererUrl() {
710		$unsafe_url = $this->request->headers->get('Referer');
711		$safe_url = elgg_normalize_site_url($unsafe_url);
712		if ($safe_url !== false) {
713			return $safe_url;
714		}
715
716		return '';
717	}
718
719	/**
720	 * Ensure the url has a valid protocol for browser use
721	 *
722	 * @param string $url url the secure
723	 *
724	 * @return string
725	 */
726	protected function makeSecureForwardUrl($url) {
727		$url = elgg_normalize_url($url);
728		if (!preg_match('/^(http|https|ftp|sftp|ftps):\/\//', $url)) {
729			return elgg_get_site_url();
730		}
731
732		return $url;
733	}
734
735	/**
736	 * Closes the session
737	 *
738	 * Force closing the session so session is saved to the database before headers are sent
739	 * preventing race conditions with session data
740	 *
741	 * @see https://github.com/Elgg/Elgg/issues/12348
742	 *
743	 * @return void
744	 */
745	protected function closeSession() {
746		$session = elgg_get_session();
747		if ($session->isStarted()) {
748			$session->save();
749		}
750	}
751}
752