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