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 
25  protected Request $request;
26 
27  protected AjaxService $ajax;
28 
30 
32 
33  protected ResponseHeaderBag $headers;
34 
35  protected ?SymfonyResponse $response_sent = null;
36 
44  public function __construct(Request $request, AjaxService $ajax, EventsService $events) {
45  $this->request = $request;
46  $this->ajax = $ajax;
47  $this->events = $events;
48 
49  $this->transport = \Elgg\Application::getResponseTransport();
50  $this->headers = new ResponseHeaderBag();
51  }
52 
62  public function setHeader(string $name, string $value, bool $replace = true): void {
63  $this->headers->set($name, $value, $replace);
64  }
65 
75  public function setCookie(\ElggCookie $cookie): bool {
76  if (!$this->events->trigger('init:cookie', $cookie->name, $cookie)) {
77  return false;
78  }
79 
80  $symfony_cookie = new Cookie(
81  $cookie->name,
82  $cookie->value,
83  $cookie->expire,
84  $cookie->path,
85  $cookie->domain,
86  $cookie->secure,
87  $cookie->httpOnly
88  );
89 
90  $this->headers->setCookie($symfony_cookie);
91  return true;
92  }
93 
101  public function getHeaders(bool $remove_existing = true): ResponseHeaderBag {
102  // Add headers that have already been set by underlying views
103  // e.g. viewtype page shells set content-type headers
104  $headers_list = headers_list();
105  foreach ($headers_list as $header) {
106  if (stripos($header, 'HTTP/1.1') !== false) {
107  continue;
108  }
109 
110  list($name, $value) = explode(':', $header, 2);
111  $this->setHeader($name, ltrim($value), false);
112  if ($remove_existing) {
113  header_remove($name);
114  }
115  }
116 
117  return $this->headers;
118  }
119 
129  public function prepareResponse(?string $content = '', int $status = 200, array $headers = []): SymfonyResponse {
130  $header_bag = $this->getHeaders();
131  $header_bag->add($headers);
132 
133  $response = new SymfonyResponse($content, $status, $header_bag->all());
134 
135  return $response->prepare($this->request);
136  }
137 
147  public function prepareRedirectResponse(string $url, int $status = 302, array $headers = []): SymfonyRedirectResponse {
148  $header_bag = $this->getHeaders();
149  $header_bag->add($headers);
150 
151  $response = new SymfonyRedirectResponse($url, $status, $header_bag->all());
152 
153  return $response->prepare($this->request);
154  }
155 
165  public function prepareJsonResponse($content = '', int $status = 200, array $headers = []): JsonResponse {
166  $header_bag = $this->getHeaders();
167  $header_bag->add($headers);
168 
175  $header_bag->remove('Content-Type');
176 
177  $response = new JsonResponse($content, $status, $header_bag->all());
178 
179  return $response->prepare($this->request);
180  }
181 
189  public function send(SymfonyResponse $response): SymfonyResponse|false {
190  if (isset($this->response_sent)) {
191  if ($this->response_sent !== $response) {
192  $this->getLogger()->error('Unable to send the following response: ' . PHP_EOL
193  . (string) $response . PHP_EOL
194  . 'because another response has already been sent: ' . PHP_EOL
195  . (string) $this->response_sent);
196  }
197  } else {
198  if (!$this->events->triggerBefore('send', 'http_response', $response)) {
199  return false;
200  }
201 
202  $request = $this->request;
203  $method = $request->getRealMethod() ?: 'GET';
204  $path = $request->getElggPath();
205 
206  $this->getLogger()->notice("Responding to {$method} {$path}");
207  if (!$this->transport->send($response)) {
208  return false;
209  }
210 
211  $this->events->triggerAfter('send', 'http_response', $response);
212  $this->response_sent = $response;
213 
214  $this->closeSession();
215  }
216 
217  return $this->response_sent;
218  }
219 
225  public function getSentResponse(): ?SymfonyResponse {
226  return $this->response_sent;
227  }
228 
239  public function respond(ResponseBuilder $response) {
240  $response_type = $this->parseContext();
241  $response = $this->events->triggerResults('response', $response_type, [], $response);
242  if (!$response instanceof ResponseBuilder) {
243  throw new UnexpectedValueException("Handlers for 'response', '{$response_type}' event must return an instanceof " . ResponseBuilder::class);
244  }
245 
246  if ($response->isNotModified()) {
247  return $this->send($this->prepareResponse('', ELGG_HTTP_NOT_MODIFIED));
248  }
249 
250  // Prevent content type sniffing by the browser
251  $headers = $response->getHeaders();
252  $headers['X-Content-Type-Options'] = 'nosniff';
253  $response->setHeaders($headers);
254 
255  $is_xhr = $this->request->isXmlHttpRequest();
256 
257  $is_action = str_starts_with($response_type, 'action:');
258 
259  if ($is_action && $response->getForwardURL() === null) {
260  // actions must always set a redirect url
261  $response->setForwardURL(REFERRER);
262  }
263 
264  if ($response->getForwardURL() === REFERRER) {
265  $response->setForwardURL((string) $this->request->headers->get('Referer'));
266  }
267 
268  if ($response->getForwardURL() !== null && !$is_xhr) {
269  // non-xhr requests should issue a forward if redirect url is set
270  // unless it's an error, in which case we serve an error page
271  if ($this->isAction() || (!$response->isClientError() && !$response->isServerError())) {
272  $response->setStatusCode(ELGG_HTTP_FOUND);
273  }
274  }
275 
276  if ($is_xhr && ($is_action || $this->ajax->isAjax2Request())) {
277  // Actions and calls from elgg/Ajax always respond with JSON on xhr calls
278  $headers = $response->getHeaders();
279  $headers['Content-Type'] = 'application/json; charset=UTF-8';
280  $response->setHeaders($headers);
281 
282  if ($response->isOk()) {
283  $response->setContent($this->wrapAjaxResponse($response->getContent(), $response->getForwardURL()));
284  }
285  }
286 
287  if ($response->isRedirection()) {
288  $redirect_url = $response->getForwardURL();
289  return $this->redirect($redirect_url, $response->getStatusCode());
290  }
291 
292  if ($this->ajax->isReady() && $response->isSuccessful()) {
293  return $this->respondFromContent($response);
294  }
295 
296  if ($response->isClientError() || $response->isServerError() || $response instanceof ErrorResponse) {
297  return $this->respondWithError($response);
298  }
299 
300  return $this->respondFromContent($response);
301  }
302 
312  $error = $this->stringify($response->getContent());
313  $status_code = $response->getStatusCode();
314 
315  if ($this->ajax->isReady()) {
316  return $this->send($this->ajax->respondWithError($error, $status_code));
317  }
318 
319  if ($this->isXhr()) {
320  // xhr calls to non-actions (e.g. ajax/view or ajax/form) need to receive proper HTTP status code
321  return $this->send($this->prepareResponse($error, $status_code, $response->getHeaders()));
322  }
323 
324  $forward_url = $this->getSiteRefererUrl();
325 
326  if ($this->isAction()) {
328  return $this->send($this->prepareRedirectResponse($forward_url));
329  }
330 
331  $params = [
332  'current_url' => $this->request->getCurrentURL(),
333  'forward_url' => $forward_url,
334  ];
335 
336  // For BC, let plugins serve their own error page
337  // @todo can this event be dropped
338  $forward_reason = (string) $status_code;
339 
340  $this->events->triggerResults('forward', $forward_reason, $params, $forward_url);
341 
342  if (isset($this->response_sent)) {
343  // Response was sent from a forward event
344  return $this->response_sent;
345  }
346 
347  if (elgg_view_exists('resources/error')) {
348  $params['type'] = $forward_reason;
349  $params['exception'] = $response->getException();
350  if (!elgg_is_empty($error)) {
351  $params['params']['error'] = $error;
352  }
353 
354  $error_page = elgg_view_resource('error', $params);
355  } else {
356  $error_page = $error;
357  }
358 
359  return $this->send($this->prepareResponse($error_page, $status_code));
360  }
361 
371  $content = $this->stringify($response->getContent());
372 
373  if ($this->ajax->isReady()) {
374  return $this->send($this->ajax->respondFromOutput($content, $this->parseContext()));
375  }
376 
377  return $this->send($this->prepareResponse($content, $response->getStatusCode(), $response->getHeaders()));
378  }
379 
388  public function wrapAjaxResponse($content = '', string $forward_url = null): string {
389  $content = $this->stringify($content);
390 
391  if ($forward_url === REFERRER) {
392  $forward_url = $this->getSiteRefererUrl();
393  }
394 
395  return $this->stringify([
396  'value' => $this->ajax->decodeJson($content),
397  'current_url' => $this->request->getCurrentURL(),
398  'forward_url' => elgg_normalize_url((string) $forward_url),
399  ]);
400  }
401 
411  public function redirect(string $forward_url = REFERRER, $status_code = ELGG_HTTP_FOUND) {
413 
414  if ($forward_url === REFERRER) {
415  $forward_url = $this->getSiteRefererUrl();
416  }
417 
419 
420  // allow plugins to rewrite redirection URL
421  $params = [
422  'current_url' => $this->request->getCurrentURL(),
423  'forward_url' => $forward_url,
424  'location' => $location,
425  ];
426 
427  $forward_reason = (string) $status_code;
428 
429  $forward_url = (string) $this->events->triggerResults('forward', $forward_reason, $params, $forward_url);
430 
431  if (isset($this->response_sent)) {
432  // Response was sent from a forward event
433  // Clearing handlers to void infinite loops
434  return $this->response_sent;
435  }
436 
437  if ($forward_url === REFERRER) {
438  $forward_url = $this->getSiteRefererUrl();
439  }
440 
442 
443  switch ($status_code) {
444  case 'system':
445  case 'csrf':
446  $status_code = ELGG_HTTP_OK;
447  break;
448  case 'admin':
449  case 'login':
450  case 'member':
451  case 'walled_garden':
452  default:
453  $status_code = (int) $status_code;
454  if (!$status_code || $status_code < 100 || $status_code > 599) {
455  $status_code = ELGG_HTTP_SEE_OTHER;
456  }
457  break;
458  }
459 
460  if ($this->isXhr()) {
461  if ($status_code < 100 || ($status_code >= 300 && $status_code <= 399) || $status_code > 599) {
462  // We only want to preserve OK and error codes
463  // Redirect responses should be converted to OK responses as this is an XHR request
464  $status_code = ELGG_HTTP_OK;
465  }
466 
467  $output = ob_get_clean();
468 
469  $response = new RedirectResponse($forward_url, $status_code);
470  $response->setContent($output);
471  $headers = $response->getHeaders();
472  $headers['Content-Type'] = 'application/json; charset=UTF-8';
473  $response->setHeaders($headers);
474  return $this->respond($response);
475  }
476 
477  if ($this->isAction()) {
478  // actions should always redirect on non xhr-calls
479  if (!is_int($status_code) || $status_code < 300 || $status_code > 399) {
480  $status_code = ELGG_HTTP_SEE_OTHER;
481  }
482  }
483 
484  $response = new RedirectResponse($forward_url, $status_code);
485  if ($response->isRedirection()) {
486  return $this->send($this->prepareRedirectResponse($forward_url, $status_code));
487  }
488 
489  return $this->respond($response);
490  }
491 
497  public function parseContext(): string {
498  $segments = $this->request->getUrlSegments();
499 
500  $identifier = array_shift($segments);
501  switch ($identifier) {
502  case 'ajax':
503  $page = array_shift($segments);
504  if ($page === 'view') {
505  $view = implode('/', $segments);
506  return "view:{$view}";
507  } elseif ($page === 'form') {
508  $form = implode('/', $segments);
509  return "form:{$form}";
510  }
511 
512  array_unshift($segments, $page);
513  break;
514 
515  case 'action':
516  $action = implode('/', $segments);
517  return "action:{$action}";
518  }
519 
520  array_unshift($segments, $identifier);
521  $path = implode('/', $segments);
522  return "path:{$path}";
523  }
524 
530  public function isXhr(): bool {
531  return $this->request->isXmlHttpRequest();
532  }
533 
539  public function isAction(): bool {
540  return str_starts_with($this->parseContext(), 'action:');
541  }
542 
551  public function normalize($content = '') {
552  if ($content instanceof \ElggEntity) {
553  $content = (array) $content->toObject();
554  }
555 
556  if (is_array($content)) {
557  foreach ($content as $key => $value) {
558  $content[$key] = $this->normalize($value);
559  }
560  }
561 
562  return $content;
563  }
564 
575  public function stringify($content = ''): string {
576  $content = $this->normalize($content);
577 
578  if (is_object($content) && is_callable([$content, '__toString'])) {
579  return (string) $content;
580  }
581 
582  if (is_scalar($content)) {
583  return (string) $content;
584  }
585 
586  if (empty($content)) {
587  return '';
588  }
589 
590  return json_encode($content, ELGG_JSON_ENCODING);
591  }
592 
600  public function setTransport(ResponseTransport $transport): void {
601  $this->transport = $transport;
602  }
603 
609  protected function getSiteRefererUrl(): string {
610  return (string) elgg_normalize_site_url((string) $this->request->headers->get('Referer'));
611  }
612 
620  protected function makeSecureForwardUrl(string $url): string {
621  $url = elgg_normalize_url($url);
622  if (!preg_match('/^(http|https|ftp|sftp|ftps):\/\//', $url)) {
623  return elgg_get_site_url();
624  }
625 
626  return $url;
627  }
628 
639  protected function closeSession(): void {
641  if ($session->isStarted()) {
642  $session->save();
643  }
644  }
645 }
ResponseTransport $transport
makeSecureForwardUrl(string $url)
Ensure the url has a valid protocol for browser use.
getElggPath()
Get the Request URI minus querystring.
Definition: Request.php:290
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:160
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.
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.
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:328
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.
if(!$widget instanceof ElggWidget) $form
Definition: settings.php:13
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.
__construct(Request $request, AjaxService $ajax, EventsService $events)
Constructor.
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 "/".
if(!$entity->delete()) $forward_url
Definition: delete.php:31
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:51
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:148
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:152
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