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;
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 
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()) {
321  $forward_url = $this->makeSecureForwardUrl($forward_url);
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 
412  $forward_url = $this->makeSecureForwardUrl($forward_url);
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 
435  $forward_url = $this->makeSecureForwardUrl($forward_url);
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 {
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 }
$error
Bad request error.
Definition: 400.php:6
$content
Set robots.txt action.
Definition: set_robots.php:6
$site name
Definition: settings.php:15
if(! $user||! $user->canDelete()) $name
Definition: delete.php:22
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
if(! $entity->delete(true, true)) $forward_url
Definition: delete.php:30
$params
Saves global plugin settings.
Definition: save.php:13
return[ 'admin/delete_admin_notices'=>['access'=> 'admin'], 'admin/menu/save'=>['access'=> 'admin'], 'admin/plugins/activate'=>['access'=> 'admin'], 'admin/plugins/activate_all'=>['access'=> 'admin'], 'admin/plugins/deactivate'=>['access'=> 'admin'], 'admin/plugins/deactivate_all'=>['access'=> 'admin'], 'admin/plugins/set_priority'=>['access'=> 'admin'], 'admin/security/security_txt'=>['access'=> 'admin'], 'admin/security/settings'=>['access'=> 'admin'], 'admin/security/regenerate_site_secret'=>['access'=> 'admin'], 'admin/site/cache/invalidate'=>['access'=> 'admin'], 'admin/site/flush_cache'=>['access'=> 'admin'], 'admin/site/icons'=>['access'=> 'admin'], 'admin/site/set_maintenance_mode'=>['access'=> 'admin'], 'admin/site/set_robots'=>['access'=> 'admin'], 'admin/site/theme'=>['access'=> 'admin'], 'admin/site/unlock_upgrade'=>['access'=> 'admin'], 'admin/site/settings'=>['access'=> 'admin'], 'admin/upgrade'=>['access'=> 'admin'], 'admin/upgrade/reset'=>['access'=> 'admin'], 'admin/user/ban'=>['access'=> 'admin'], 'admin/user/bulk/ban'=>['access'=> 'admin'], 'admin/user/bulk/delete'=>['access'=> 'admin'], 'admin/user/bulk/unban'=>['access'=> 'admin'], 'admin/user/bulk/validate'=>['access'=> 'admin'], 'admin/user/change_email'=>['access'=> 'admin'], 'admin/user/delete'=>['access'=> 'admin'], 'admin/user/login_as'=>['access'=> 'admin'], 'admin/user/logout_as'=>[], 'admin/user/makeadmin'=>['access'=> 'admin'], 'admin/user/resetpassword'=>['access'=> 'admin'], 'admin/user/removeadmin'=>['access'=> 'admin'], 'admin/user/unban'=>['access'=> 'admin'], 'admin/user/validate'=>['access'=> 'admin'], 'annotation/delete'=>[], 'avatar/upload'=>[], 'comment/save'=>[], 'diagnostics/download'=>['access'=> 'admin'], 'entity/chooserestoredestination'=>[], 'entity/delete'=>[], 'entity/mute'=>[], 'entity/restore'=>[], 'entity/subscribe'=>[], 'entity/trash'=>[], 'entity/unmute'=>[], 'entity/unsubscribe'=>[], 'login'=>['access'=> 'logged_out'], 'logout'=>[], 'notifications/mute'=>['access'=> 'public'], 'plugins/settings/remove'=>['access'=> 'admin'], 'plugins/settings/save'=>['access'=> 'admin'], 'plugins/usersettings/save'=>[], 'register'=>['access'=> 'logged_out', 'middleware'=>[\Elgg\Router\Middleware\RegistrationAllowedGatekeeper::class,],], 'river/delete'=>[], 'settings/notifications'=>[], 'settings/notifications/subscriptions'=>[], 'user/changepassword'=>['access'=> 'public'], 'user/requestnewpassword'=>['access'=> 'public'], 'useradd'=>['access'=> 'admin'], 'usersettings/save'=>[], 'widgets/add'=>[], 'widgets/delete'=>[], 'widgets/move'=>[], 'widgets/save'=>[],]
Definition: actions.php:73
Models the Ajax API service.
Definition: Service.php:19
static getResponseTransport()
Build a transport for sending responses.
Events service.
Exception thrown if a value does not match with a set of values.
Error response builder.
Redirect response builder.
Elgg HTTP request.
Definition: Request.php:17
HTTP response service.
ResponseHeaderBag $headers
redirect(string $forward_url=REFERRER, $status_code=ELGG_HTTP_FOUND)
Prepares a redirect response.
SymfonyResponse $response_sent
setTransport(ResponseTransport $transport)
Replaces response transport.
stringify($content='')
Stringify/serialize response data.
__construct(protected Request $request, protected AjaxService $ajax, protected EventsService $events)
Constructor.
makeSecureForwardUrl(string $url)
Ensure the url has a valid protocol for browser use.
respondWithError(ResponseBuilder $response)
Send error HTTP response.
prepareRedirectResponse(string $url, int $status=302, array $headers=[])
Creates a redirect response.
closeSession()
Closes the session.
setCookie(\ElggCookie $cookie)
Set a cookie, but allow plugins to customize it first.
getHeaders(bool $remove_existing=true)
Get headers set to apply to all responses.
send(SymfonyResponse $response)
Send a response.
parseContext()
Parses response type to be used as event type.
getSentResponse()
Returns a response that was sent to the client.
normalize($content='')
Normalizes content into serializable data by walking through arrays and objectifying Elgg entities.
wrapAjaxResponse($content='', ?string $forward_url=null)
Wraps response content in an Ajax2 compatible format.
setHeader(string $name, string $value, bool $replace=true)
Sets headers to apply to all responses being sent.
ResponseTransport $transport
respondFromContent(ResponseBuilder $response)
Send OK response.
prepareJsonResponse($content='', int $status=200, array $headers=[])
Creates an JSON response.
isXhr()
Check if the request is an XmlHttpRequest.
getSiteRefererUrl()
Ensures the referer header is a site url.
isAction()
Check if the requested path is an action.
prepareResponse(?string $content='', int $status=200, array $headers=[])
Creates an HTTP response.
respond(ResponseBuilder $response)
Send HTTP response.
elgg_get_site_url()
Get the URL for the current (or specified) site, ending with "/".
const ELGG_HTTP_FOUND
Definition: constants.php:57
const ELGG_HTTP_OK
Definition: constants.php:45
const ELGG_JSON_ENCODING
Default JSON encoding.
Definition: constants.php:106
const ELGG_HTTP_NOT_MODIFIED
Definition: constants.php:59
const ELGG_HTTP_SEE_OTHER
Definition: constants.php:58
const REFERRER
Used in calls to forward() to specify the browser should be redirected to the referring page.
Definition: constants.php:37
foreach($periods as $period) $header
Definition: cron.php:81
foreach($plugin_guids as $guid) if(empty($deactivated_plugins)) $url
Definition: deactivate.php:39
if($item instanceof \ElggEntity) elseif($item instanceof \ElggRiverItem) elseif($item instanceof \ElggRelationship) elseif(is_callable([ $item, 'getType']))
Definition: item.php:48
$output
Definition: download.php:9
elgg_is_empty($value)
Check if a value isn't empty, but allow 0 and '0'.
Definition: input.php:176
$value
Definition: generic.php:51
HTTP response builder interface.
HTTP response transport interface.
elgg_view_exists(string $view, string $viewtype='', bool $recurse=true)
Returns whether the specified view exists.
Definition: views.php:131
elgg_view_resource(string $name, array $vars=[])
Render a resource view.
Definition: views.php:307
$request
Definition: livesearch.php:12
if(isset($_COOKIE['elggperm'])) $session
Definition: login_as.php:29
$location
Definition: member.php:29
$headers
Definition: section.php:21
$path
Definition: details.php:70
if(! $widget instanceof \ElggWidget) if(! $widget->canEdit()) $form
Definition: edit.php:19
elgg_normalize_site_url(string $unsafe_url)
From untrusted input, get a site URL safe for forwarding.
Definition: output.php:175
elgg_normalize_url(string $url)
Definition: output.php:163
if($container instanceof ElggGroup && $container->guid !=elgg_get_page_owner_guid()) $key
Definition: summary.php:44
if(parse_url(elgg_get_site_url(), PHP_URL_PATH) !=='/') if(file_exists(elgg_get_root_path() . 'robots.txt'))
Set robots.txt.
Definition: robots.php:10
elgg_get_session()
Gets Elgg's session object.
Definition: sessions.php:15
$action
Definition: subscribe.php:11
$page
Definition: admin.php:24
$segments
Definition: admin.php:13
$response
Definition: content.php:10