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 
228  protected function getResponseType(): string {
229  $route = $this->request->getRoute();
230  $route_name = $route?->getName();
231 
232  if ($route_name === 'ajax') {
233  $params = $route->getMatchedParameters();
234  $route_name = elgg_extract('type', $params) . ':';
235  $route_name .= elgg_extract('segments', $params);
236  }
237 
238  return $route_name ?: 'route:not_found';
239  }
240 
251  public function respond(ResponseBuilder $response) {
252  $response_type = $this->getResponseType();
253 
254  $response = $this->events->triggerResults('response', $response_type, ['request' => $this->request], $response);
255  if (!$response instanceof ResponseBuilder) {
256  throw new UnexpectedValueException("Handlers for 'response', '{$response_type}' event must return an instanceof " . ResponseBuilder::class);
257  }
258 
259  if ($response->isNotModified()) {
260  return $this->send($this->prepareResponse('', ELGG_HTTP_NOT_MODIFIED));
261  }
262 
263  // Prevent content type sniffing by the browser
264  $headers = $response->getHeaders();
265  $headers['X-Content-Type-Options'] = 'nosniff';
266  $response->setHeaders($headers);
267 
268  $is_xhr = $this->request->isXmlHttpRequest();
269 
270  $is_action = $this->request->isAction();
271 
272  if ($is_action && $response->getForwardURL() === null) {
273  // actions must always set a redirect url
274  $response->setForwardURL(REFERRER);
275  }
276 
277  if ($response->getForwardURL() === REFERRER) {
278  $response->setForwardURL((string) $this->request->headers->get('Referer'));
279  }
280 
281  if ($response->getForwardURL() !== null && !$is_xhr && !$response->isRedirection()) {
282  // non-xhr requests should issue a forward if redirect url is set
283  // unless it's an error, in which case we serve an error page
284  if ($is_action || (!$response->isClientError() && !$response->isServerError())) {
285  $response->setStatusCode(ELGG_HTTP_FOUND);
286  }
287  }
288 
289  if ($is_xhr && ($is_action || $this->ajax->isAjax2Request())) {
290  // Actions and calls from elgg/Ajax always respond with JSON on xhr calls
291  $headers = $response->getHeaders();
292  $headers['Content-Type'] = 'application/json; charset=UTF-8';
293  $response->setHeaders($headers);
294 
295  if ($response->isOk()) {
296  $response->setContent($this->wrapAjaxResponse($response->getContent(), $response->getForwardURL()));
297  }
298  }
299 
300  if ($response->isRedirection()) {
301  $redirect_url = $response->getForwardURL();
302  return $this->redirect($redirect_url, $response->getStatusCode());
303  }
304 
305  if ($this->ajax->isReady() && $response->isSuccessful()) {
306  return $this->respondFromContent($response);
307  }
308 
309  if ($response->isClientError() || $response->isServerError() || $response instanceof ErrorResponse) {
310  return $this->respondWithError($response);
311  }
312 
313  return $this->respondFromContent($response);
314  }
315 
325  $error = $this->stringify($response->getContent());
326  $status_code = $response->getStatusCode();
327 
328  if ($this->ajax->isReady()) {
329  return $this->send($this->ajax->respondWithError($error, $status_code));
330  }
331 
332  if ($this->request->isXmlHttpRequest()) {
333  // xhr calls to non-actions (e.g. ajax/view or ajax/form) need to receive proper HTTP status code
334  return $this->send($this->prepareResponse($error, $status_code, $response->getHeaders()));
335  }
336 
337  $forward_url = $this->getSiteRefererUrl();
338 
339  if ($this->request->isAction()) {
340  $forward_url = $this->makeSecureForwardUrl($forward_url);
341  return $this->send($this->prepareRedirectResponse($forward_url));
342  }
343 
344  if (isset($this->response_sent)) {
345  // Clearing handlers to void infinite loops
346  return $this->response_sent;
347  }
348 
349  $params = [
350  'current_url' => $this->request->getCurrentURL(),
351  'forward_url' => $forward_url,
352  ];
353 
354  if (elgg_view_exists('resources/error')) {
355  $params['type'] = (string) $status_code;
356  $params['exception'] = $response->getException();
357  if (!elgg_is_empty($error)) {
358  $params['params']['error'] = $error;
359  }
360 
361  $error_page = elgg_view_resource('error', $params);
362  } else {
363  $error_page = $error;
364  }
365 
366  return $this->send($this->prepareResponse($error_page, $status_code));
367  }
368 
378  $content = $this->stringify($response->getContent());
379 
380  if ($this->ajax->isReady()) {
381  return $this->send($this->ajax->respondFromOutput($content, $this->getResponseType()));
382  }
383 
384  return $this->send($this->prepareResponse($content, $response->getStatusCode(), $response->getHeaders()));
385  }
386 
395  public function wrapAjaxResponse($content = '', ?string $forward_url = null): string {
396  $content = $this->stringify($content);
397 
398  if ($forward_url === REFERRER) {
399  $forward_url = $this->getSiteRefererUrl();
400  }
401 
402  return $this->stringify([
403  'value' => $this->ajax->decodeJson($content),
404  'current_url' => $this->request->getCurrentURL(),
405  'forward_url' => elgg_normalize_url((string) $forward_url),
406  ]);
407  }
408 
418  public function redirect(string $forward_url = REFERRER, $status_code = ELGG_HTTP_FOUND) {
419  if (isset($this->response_sent)) {
420  // Clearing handlers to void infinite loops
421  return $this->response_sent;
422  }
423 
424  if ($forward_url === REFERRER) {
425  $forward_url = $this->getSiteRefererUrl();
426  }
427 
428  $forward_url = $this->makeSecureForwardUrl($forward_url);
429 
430  switch ($status_code) {
431  case 'system':
432  case 'csrf':
433  $status_code = ELGG_HTTP_OK;
434  break;
435  case 'admin':
436  case 'login':
437  case 'member':
438  case 'walled_garden':
439  default:
440  $status_code = (int) $status_code;
441  if (!$status_code || $status_code < 100 || $status_code > 599) {
442  $status_code = ELGG_HTTP_SEE_OTHER;
443  }
444  break;
445  }
446 
447  if ($this->request->isXmlHttpRequest()) {
448  if ($status_code < 100 || ($status_code >= 300 && $status_code <= 399) || $status_code > 599) {
449  // We only want to preserve OK and error codes
450  // Redirect responses should be converted to OK responses as this is an XHR request
451  $status_code = ELGG_HTTP_OK;
452  }
453 
454  $output = ob_get_clean();
455 
456  $response = new RedirectResponse($forward_url, $status_code);
457  $response->setContent($output);
458  $headers = $response->getHeaders();
459  $headers['Content-Type'] = 'application/json; charset=UTF-8';
460  $response->setHeaders($headers);
461  return $this->respond($response);
462  }
463 
464  if ($this->request->isAction()) {
465  // actions should always redirect on non xhr-calls
466  if (!is_int($status_code) || $status_code < 300 || $status_code > 399) {
467  $status_code = ELGG_HTTP_SEE_OTHER;
468  }
469  }
470 
471  $response = new RedirectResponse($forward_url, $status_code);
472  if ($response->isRedirection()) {
473  return $this->send($this->prepareRedirectResponse($forward_url, $status_code));
474  }
475 
476  return $this->respond($response);
477  }
478 
487  public function normalize($content = '') {
488  if ($content instanceof \ElggEntity) {
489  $content = (array) $content->toObject();
490  }
491 
492  if (is_array($content)) {
493  foreach ($content as $key => $value) {
494  $content[$key] = $this->normalize($value);
495  }
496  }
497 
498  return $content;
499  }
500 
511  public function stringify($content = ''): string {
512  $content = $this->normalize($content);
513 
514  if (is_object($content) && is_callable([$content, '__toString'])) {
515  return (string) $content;
516  }
517 
518  if (is_scalar($content)) {
519  return (string) $content;
520  }
521 
522  if (empty($content)) {
523  return '';
524  }
525 
526  return json_encode($content, ELGG_JSON_ENCODING);
527  }
528 
536  public function setTransport(ResponseTransport $transport): void {
537  $this->transport = $transport;
538  }
539 
545  protected function getSiteRefererUrl(): string {
546  return (string) elgg_normalize_site_url((string) $this->request->headers->get('Referer'));
547  }
548 
556  protected function makeSecureForwardUrl(string $url): string {
558  if (!preg_match('/^(http|https|ftp|sftp|ftps):\/\//', $url)) {
559  return elgg_get_site_url();
560  }
561 
562  return $url;
563  }
564 
575  protected function closeSession(): void {
577  if ($session->isStarted()) {
578  $session->save();
579  }
580  }
581 }
$error
Bad request error.
Definition: 400.php:6
$content
Set robots.txt action.
Definition: set_robots.php:6
$site name
Definition: settings.php:13
if(! $user||! $user->canDelete()) $name
Definition: delete.php:22
if(! $entity->delete(true, true)) $forward_url
Definition: delete.php:30
catch(AuthenticationException|LoginException $e) if(elgg_is_xhr()) $output
Definition: login.php:86
$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/clear'=>['access'=> 'admin'], 'admin/site/cache/invalidate'=>['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', 'controller'=> \Elgg\Diagnostics\DownloadController::class,], '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:76
Models the Ajax API service.
Definition: Service.php:21
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.
getResponseType()
Returns the response type based on the route for use in events.
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.
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.
getSiteRefererUrl()
Ensures the referer header is a site url.
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
elgg_extract($key, $array, $default=null, bool $strict=true)
Checks for $array[$key] and returns its value if it exists, else returns $default.
Definition: elgglib.php:246
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:315
$request
Definition: livesearch.php:12
if(isset($_COOKIE['elggperm'])) $session
Definition: login_as.php:29
$headers
Definition: section.php:21
$path
Definition: details.php:70
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
$response
Definition: content.php:10