Elgg  Version master
ResponseFactory.php
Go to the documentation of this file.
1 <?php
2 
3 namespace Elgg\Http;
4 
5 use Elgg\Ajax\Service as AjaxService;
14 
22 
23  use Loggable;
24 
26 
27  protected ResponseHeaderBag $headers;
28 
29  protected ?SymfonyResponse $response_sent = null;
30 
38  public function __construct(
39  protected Request $request,
40  protected AjaxService $ajax,
41  protected EventsService $events
42  ) {
43  $this->transport = \Elgg\Application::getResponseTransport();
44  $this->headers = new ResponseHeaderBag();
45  }
46 
56  public function setHeader(string $name, string $value, bool $replace = true): void {
57  $this->headers->set($name, $value, $replace);
58  }
59 
69  public function setCookie(\ElggCookie $cookie): bool {
70  if (!$this->events->trigger('init:cookie', $cookie->name, $cookie)) {
71  return false;
72  }
73 
74  $symfony_cookie = new Cookie(
75  $cookie->name,
76  $cookie->value,
77  $cookie->expire,
78  $cookie->path,
79  $cookie->domain,
80  $cookie->secure,
81  $cookie->httpOnly
82  );
83 
84  $this->headers->setCookie($symfony_cookie);
85  return true;
86  }
87 
95  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  $headers_list = headers_list();
99  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  return $this->headers;
112  }
113 
123  public function prepareResponse(?string $content = '', int $status = 200, array $headers = []): SymfonyResponse {
124  $header_bag = $this->getHeaders();
125  $header_bag->add($headers);
126 
127  $response = new SymfonyResponse($content, $status, $header_bag->all());
128 
129  return $response->prepare($this->request);
130  }
131 
141  public function prepareRedirectResponse(string $url, int $status = 302, array $headers = []): SymfonyRedirectResponse {
142  $header_bag = $this->getHeaders();
143  $header_bag->add($headers);
144 
145  $response = new SymfonyRedirectResponse($url, $status, $header_bag->all());
146 
147  return $response->prepare($this->request);
148  }
149 
159  public function prepareJsonResponse($content = '', int $status = 200, array $headers = []): JsonResponse {
160  $header_bag = $this->getHeaders();
161  $header_bag->add($headers);
162 
169  $header_bag->remove('Content-Type');
170 
171  $response = new JsonResponse($content, $status, $header_bag->all());
172 
173  return $response->prepare($this->request);
174  }
175 
183  public function send(SymfonyResponse $response): SymfonyResponse|false {
184  if (isset($this->response_sent)) {
185  if ($this->response_sent !== $response) {
186  $this->getLogger()->error('Unable to send the following response: ' . PHP_EOL
187  . (string) $response . PHP_EOL
188  . 'because another response has already been sent: ' . PHP_EOL
189  . (string) $this->response_sent);
190  }
191  } else {
192  if (!$this->events->triggerBefore('send', 'http_response', $response)) {
193  return false;
194  }
195 
197  $method = $request->getRealMethod() ?: 'GET';
198  $path = $request->getElggPath();
199 
200  $this->getLogger()->notice("Responding to {$method} {$path}");
201  if (!$this->transport->send($response)) {
202  return false;
203  }
204 
205  $this->events->triggerAfter('send', 'http_response', $response);
206  $this->response_sent = $response;
207 
208  $this->closeSession();
209  }
210 
211  return $this->response_sent;
212  }
213 
219  public function getSentResponse(): ?SymfonyResponse {
220  return $this->response_sent;
221  }
222 
233  public function respond(ResponseBuilder $response) {
234  $response_type = $this->parseContext();
235  $response = $this->events->triggerResults('response', $response_type, [], $response);
236  if (!$response instanceof ResponseBuilder) {
237  throw new UnexpectedValueException("Handlers for 'response', '{$response_type}' event must return an instanceof " . ResponseBuilder::class);
238  }
239 
240  if ($response->isNotModified()) {
241  return $this->send($this->prepareResponse('', ELGG_HTTP_NOT_MODIFIED));
242  }
243 
244  // Prevent content type sniffing by the browser
245  $headers = $response->getHeaders();
246  $headers['X-Content-Type-Options'] = 'nosniff';
247  $response->setHeaders($headers);
248 
249  $is_xhr = $this->request->isXmlHttpRequest();
250 
251  $is_action = $this->isAction();
252 
253  if ($is_action && $response->getForwardURL() === null) {
254  // actions must always set a redirect url
255  $response->setForwardURL(REFERRER);
256  }
257 
258  if ($response->getForwardURL() === REFERRER) {
259  $response->setForwardURL((string) $this->request->headers->get('Referer'));
260  }
261 
262  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  if ($is_action || (!$response->isClientError() && !$response->isServerError())) {
266  $response->setStatusCode(ELGG_HTTP_FOUND);
267  }
268  }
269 
270  if ($is_xhr && ($is_action || $this->ajax->isAjax2Request())) {
271  // Actions and calls from elgg/Ajax always respond with JSON on xhr calls
272  $headers = $response->getHeaders();
273  $headers['Content-Type'] = 'application/json; charset=UTF-8';
274  $response->setHeaders($headers);
275 
276  if ($response->isOk()) {
277  $response->setContent($this->wrapAjaxResponse($response->getContent(), $response->getForwardURL()));
278  }
279  }
280 
281  if ($response->isRedirection()) {
282  $redirect_url = $response->getForwardURL();
283  return $this->redirect($redirect_url, $response->getStatusCode());
284  }
285 
286  if ($this->ajax->isReady() && $response->isSuccessful()) {
287  return $this->respondFromContent($response);
288  }
289 
290  if ($response->isClientError() || $response->isServerError() || $response instanceof ErrorResponse) {
291  return $this->respondWithError($response);
292  }
293 
294  return $this->respondFromContent($response);
295  }
296 
306  $error = $this->stringify($response->getContent());
307  $status_code = $response->getStatusCode();
308 
309  if ($this->ajax->isReady()) {
310  return $this->send($this->ajax->respondWithError($error, $status_code));
311  }
312 
313  if ($this->isXhr()) {
314  // xhr calls to non-actions (e.g. ajax/view or ajax/form) need to receive proper HTTP status code
315  return $this->send($this->prepareResponse($error, $status_code, $response->getHeaders()));
316  }
317 
318  $forward_url = $this->getSiteRefererUrl();
319 
320  if ($this->isAction()) {
322  return $this->send($this->prepareRedirectResponse($forward_url));
323  }
324 
325  $params = [
326  'current_url' => $this->request->getCurrentURL(),
327  'forward_url' => $forward_url,
328  ];
329 
330  // For BC, let plugins serve their own error page
331  // @todo can this event be dropped
332  $forward_reason = (string) $status_code;
333 
334  $this->events->triggerResults('forward', $forward_reason, $params, $forward_url);
335 
336  if (isset($this->response_sent)) {
337  // Response was sent from a forward event
338  return $this->response_sent;
339  }
340 
341  if (elgg_view_exists('resources/error')) {
342  $params['type'] = $forward_reason;
343  $params['exception'] = $response->getException();
344  if (!elgg_is_empty($error)) {
345  $params['params']['error'] = $error;
346  }
347 
348  $error_page = elgg_view_resource('error', $params);
349  } else {
350  $error_page = $error;
351  }
352 
353  return $this->send($this->prepareResponse($error_page, $status_code));
354  }
355 
365  $content = $this->stringify($response->getContent());
366 
367  if ($this->ajax->isReady()) {
368  return $this->send($this->ajax->respondFromOutput($content, $this->parseContext()));
369  }
370 
371  return $this->send($this->prepareResponse($content, $response->getStatusCode(), $response->getHeaders()));
372  }
373 
382  public function wrapAjaxResponse($content = '', string $forward_url = null): string {
383  $content = $this->stringify($content);
384 
385  if ($forward_url === REFERRER) {
386  $forward_url = $this->getSiteRefererUrl();
387  }
388 
389  return $this->stringify([
390  'value' => $this->ajax->decodeJson($content),
391  'current_url' => $this->request->getCurrentURL(),
392  'forward_url' => elgg_normalize_url((string) $forward_url),
393  ]);
394  }
395 
405  public function redirect(string $forward_url = REFERRER, $status_code = ELGG_HTTP_FOUND) {
407 
408  if ($forward_url === REFERRER) {
409  $forward_url = $this->getSiteRefererUrl();
410  }
411 
413 
414  // allow plugins to rewrite redirection URL
415  $params = [
416  'current_url' => $this->request->getCurrentURL(),
417  'forward_url' => $forward_url,
418  'location' => $location,
419  ];
420 
421  $forward_reason = (string) $status_code;
422 
423  $forward_url = (string) $this->events->triggerResults('forward', $forward_reason, $params, $forward_url);
424 
425  if (isset($this->response_sent)) {
426  // Response was sent from a forward event
427  // Clearing handlers to void infinite loops
428  return $this->response_sent;
429  }
430 
431  if ($forward_url === REFERRER) {
432  $forward_url = $this->getSiteRefererUrl();
433  }
434 
436 
437  switch ($status_code) {
438  case 'system':
439  case 'csrf':
440  $status_code = ELGG_HTTP_OK;
441  break;
442  case 'admin':
443  case 'login':
444  case 'member':
445  case 'walled_garden':
446  default:
447  $status_code = (int) $status_code;
448  if (!$status_code || $status_code < 100 || $status_code > 599) {
449  $status_code = ELGG_HTTP_SEE_OTHER;
450  }
451  break;
452  }
453 
454  if ($this->isXhr()) {
455  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  $status_code = ELGG_HTTP_OK;
459  }
460 
461  $output = ob_get_clean();
462 
463  $response = new RedirectResponse($forward_url, $status_code);
464  $response->setContent($output);
465  $headers = $response->getHeaders();
466  $headers['Content-Type'] = 'application/json; charset=UTF-8';
467  $response->setHeaders($headers);
468  return $this->respond($response);
469  }
470 
471  if ($this->isAction()) {
472  // actions should always redirect on non xhr-calls
473  if (!is_int($status_code) || $status_code < 300 || $status_code > 399) {
474  $status_code = ELGG_HTTP_SEE_OTHER;
475  }
476  }
477 
478  $response = new RedirectResponse($forward_url, $status_code);
479  if ($response->isRedirection()) {
480  return $this->send($this->prepareRedirectResponse($forward_url, $status_code));
481  }
482 
483  return $this->respond($response);
484  }
485 
491  public function parseContext(): string {
492  $segments = $this->request->getUrlSegments();
493 
494  $identifier = array_shift($segments);
495  switch ($identifier) {
496  case 'ajax':
497  $page = array_shift($segments);
498  if ($page === 'view') {
499  $view = implode('/', $segments);
500  return "view:{$view}";
501  } elseif ($page === 'form') {
502  $form = implode('/', $segments);
503  return "form:{$form}";
504  }
505 
506  array_unshift($segments, $page);
507  break;
508 
509  case 'action':
510  $action = implode('/', $segments);
511  return "action:{$action}";
512  }
513 
514  array_unshift($segments, $identifier);
515  $path = implode('/', $segments);
516  return "path:{$path}";
517  }
518 
524  public function isXhr(): bool {
525  return $this->request->isXmlHttpRequest();
526  }
527 
533  public function isAction(): bool {
534  return str_starts_with($this->parseContext(), 'action:');
535  }
536 
545  public function normalize($content = '') {
546  if ($content instanceof \ElggEntity) {
547  $content = (array) $content->toObject();
548  }
549 
550  if (is_array($content)) {
551  foreach ($content as $key => $value) {
552  $content[$key] = $this->normalize($value);
553  }
554  }
555 
556  return $content;
557  }
558 
569  public function stringify($content = ''): string {
570  $content = $this->normalize($content);
571 
572  if (is_object($content) && is_callable([$content, '__toString'])) {
573  return (string) $content;
574  }
575 
576  if (is_scalar($content)) {
577  return (string) $content;
578  }
579 
580  if (empty($content)) {
581  return '';
582  }
583 
584  return json_encode($content, ELGG_JSON_ENCODING);
585  }
586 
594  public function setTransport(ResponseTransport $transport): void {
595  $this->transport = $transport;
596  }
597 
603  protected function getSiteRefererUrl(): string {
604  return (string) elgg_normalize_site_url((string) $this->request->headers->get('Referer'));
605  }
606 
614  protected function makeSecureForwardUrl(string $url): string {
615  $url = elgg_normalize_url($url);
616  if (!preg_match('/^(http|https|ftp|sftp|ftps):\/\//', $url)) {
617  return elgg_get_site_url();
618  }
619 
620  return $url;
621  }
622 
633  protected function closeSession(): void {
635  if ($session->isStarted()) {
636  $session->save();
637  }
638  }
639 }
ResponseTransport $transport
makeSecureForwardUrl(string $url)
Ensure the url has a valid protocol for browser use.
HTTP response service.
HTTP response builder interface.
respondWithError(ResponseBuilder $response)
Send error HTTP response.
Elgg HTTP request.
Definition: Request.php:17
getStatusCode()
Returns status code.
$params
Saves global plugin settings.
Definition: save.php:13
elgg_normalize_site_url(string $unsafe_url)
From untrusted input, get a site URL safe for forwarding.
Definition: output.php:175
isRedirection()
Check if response is redirection.
setCookie(\ElggCookie $cookie)
Set a cookie, but allow plugins to customize it first.
getContent()
Returns response body.
if(!$user||!$user->canDelete()) $name
Definition: delete.php:22
$response
Definition: content.php:10
send(SymfonyResponse $response)
Send a response.
const ELGG_HTTP_OK
Definition: constants.php:45
isAction()
Check if the requested path is an action.
$request
Definition: livesearch.php:12
Error response builder.
prepareResponse(?string $content= '', int $status=200, array $headers=[])
Creates an HTTP response.
elgg_get_session()
Gets Elgg&#39;s session object.
Definition: sessions.php:15
isNotModified()
Check if response has been modified.
c Accompany it with the information you received as to the offer to distribute corresponding source complete source code means all the source code for all modules it plus any associated interface definition plus the scripts used to control compilation and installation of the executable as a special the source code distributed need not include anything that is normally and so on of the operating system on which the executable unless that component itself accompanies the executable If distribution of executable or object code is made by offering access to copy from a designated then offering equivalent access to copy the source code from the same place counts as distribution of the source even though third parties are not compelled to copy the source along with the object code You may not or distribute the Program except as expressly provided under this License Any attempt otherwise to sublicense or distribute the Program is void
Definition: LICENSE.txt:215
Events service.
stringify($content= '')
Stringify/serialize response data.
if(!$widget instanceof\ElggWidget) if(!$widget->canEdit()) $form
Definition: edit.php:19
__construct(protected Request $request, protected AjaxService $ajax, protected EventsService $events)
Constructor.
isOk()
Check if response is OK.
ResponseHeaderBag $headers
setHeader(string $name, string $value, bool $replace=true)
Sets headers to apply to all responses being sent.
getSentResponse()
Returns a response that was sent to the client.
Models the Ajax API service.
Definition: Service.php:19
elgg_view_resource(string $name, array $vars=[])
Render a resource view.
Definition: views.php:307
if($item instanceof\ElggEntity) elseif($item instanceof\ElggRiverItem) elseif($item instanceof\ElggRelationship) elseif(is_callable([$item, 'getType']))
Definition: item.php:48
$value
Definition: generic.php:51
elgg_is_empty($value)
Check if a value isn&#39;t empty, but allow 0 and &#39;0&#39;.
Definition: input.php:176
$page
Definition: admin.php:23
HTTP response transport interface.
$path
Definition: details.php:70
trait Loggable
Enables adding a logger.
Definition: Loggable.php:14
$error
Bad request error.
Definition: 400.php:6
isServerError()
Check if response is server error.
const REFERRER
Used in calls to forward() to specify the browser should be redirected to the referring page...
Definition: constants.php:37
redirect(string $forward_url=REFERRER, $status_code=ELGG_HTTP_FOUND)
Prepares a redirect response.
static getResponseTransport()
Build a transport for sending responses.
if(!empty($avatar)&&!$avatar->isValid()) elseif(empty($avatar)) if(!$owner->saveIconFromUploadedFile('avatar')) if(!elgg_trigger_event('profileiconupdate', $owner->type, $owner)) $view
Definition: upload.php:39
Exception thrown if a value does not match with a set of values.
wrapAjaxResponse($content= '', string $forward_url=null)
Wraps response content in an Ajax2 compatible format.
setTransport(ResponseTransport $transport)
Replaces response transport.
isXhr()
Check if the request is an XmlHttpRequest.
const ELGG_JSON_ENCODING
Default JSON encoding.
Definition: constants.php:106
getHeaders(bool $remove_existing=true)
Get headers set to apply to all responses.
isSuccessful()
Check if response is successful.
if(!$entity->delete(true, true)) $forward_url
Definition: delete.php:30
respondFromContent(ResponseBuilder $response)
Send OK response.
setForwardURL(string $forward_url=REFERRER)
Sets redirect URL.
const ELGG_HTTP_FOUND
Definition: constants.php:57
elgg_get_site_url()
Get the URL for the current (or specified) site, ending with "/".
getSiteRefererUrl()
Ensures the referer header is a site url.
Redirect response builder.
$action
Definition: subscribe.php:11
if($container instanceof ElggGroup &&$container->guid!=elgg_get_page_owner_guid()) $key
Definition: summary.php:44
const ELGG_HTTP_NOT_MODIFIED
Definition: constants.php:59
if(isset($_COOKIE['elggperm'])) $session
Definition: login_as.php:29
getLogger()
Returns logger.
Definition: Loggable.php:37
setStatusCode(int $status_code)
Sets response HTTP status code.
isClientError()
Check if response is client error.
$location
Definition: member.php:29
foreach($periods as $period) $header
Definition: cron.php:76
getException()
Get the exception for this reponse.
$content
Set robots.txt action.
Definition: set_robots.php:6
parseContext()
Parses response type to be used as event type.
foreach($plugin_guids as $guid) if(empty($deactivated_plugins)) $url
Definition: deactivate.php:39
prepareRedirectResponse(string $url, int $status=302, array $headers=[])
Creates a redirect response.
$segments
Definition: admin.php:13
setHeaders(array $headers=[])
Sets additional response headers.
const ELGG_HTTP_SEE_OTHER
Definition: constants.php:58
setContent($content= '')
Sets response body.
getHeaders()
Returns additional response headers.
elgg_normalize_url(string $url)
Definition: output.php:163
respond(ResponseBuilder $response)
Send HTTP response.
closeSession()
Closes the session.
$output
Definition: download.php:9
elgg_view_exists(string $view, string $viewtype= '', bool $recurse=true)
Returns whether the specified view exists.
Definition: views.php:131
normalize($content= '')
Normalizes content into serializable data by walking through arrays and objectifying Elgg entities...
getForwardURL()
Returns redirect URL.
prepareJsonResponse($content= '', int $status=200, array $headers=[])
Creates an JSON response.
SymfonyResponse $response_sent