<?php namespace SokoWeb\Response; use ErrorException; use Exception; use SokoWeb\Interfaces\Response\IRedirect; use SokoWeb\Interfaces\Response\IContent; use SokoWeb\Interfaces\Authentication\IAuthenticationRequired; use SokoWeb\Interfaces\Authorization\ISecured; use SokoWeb\Interfaces\Routing\IRouteCollection; use SokoWeb\Interfaces\Database\IConnection; use SokoWeb\Interfaces\Request\IRequest; use SokoWeb\Response\Redirect; use SokoWeb\Response\HtmlContent; use SokoWeb\Response\JsonContent; class HttpResponse { private IRequest $request; private IConnection $dbConnection; private IRouteCollection $routeCollection; private array $appConfig; private string $method; private string $rawUrl; private array $parsedUrl; public function __construct( IRequest $request, IConnection $dbConnection, IRouteCollection $routeCollection, array $appConfig, string $requestMethod, string $requestUrl ) { set_error_handler([$this, 'exceptionsErrorHandler']); $this->request = $request; $this->dbConnection = $dbConnection; $this->routeCollection = $routeCollection; $this->appConfig = $appConfig; $this->method = strtolower($requestMethod); $this->parsedUrl = parse_url($requestUrl); $this->rawUrl = $requestUrl; } public function exceptionsErrorHandler($severity, $message, $filename, $lineno) { throw new ErrorException($message, 0, $severity, $filename, $lineno); } public function render(): void { $this->handleCors(); if ($this->method === 'options') { return; } $match = $this->routeCollection->match($this->method, $this->parsedUrl['path']); if ($match === null) { $this->render404(); return; } list($route, $params) = $match; $this->request->setParsedRouteParams($params); $handler = $route->getHandler(); $controller = new $handler[0](); if ( $controller instanceof IAuthenticationRequired && $controller->isAuthenticationRequired() && $this->request->user() === null ) { $this->redirectToLogin(); return; } if ( $this->method === 'post' && !in_array($this->parsedUrl['path'], $this->appConfig['antiCsrfTokenExceptions']) && $this->request->post($this->appConfig['antiCsrfTokenName']) !== $this->request->session()->get($this->appConfig['antiCsrfTokenName']) ) { $this->renderAntiCsrfError(); return; } if ($controller instanceof ISecured && !$controller->authorize()) { $this->render404(); return; } $this->dbConnection->startTransaction(); try { $response = call_user_func([$controller, $handler[1]]); } catch (Exception $exception) { $this->dbConnection->rollback(); error_log($exception); $this->render500($exception); return; } $this->dbConnection->commit(); if ($response instanceof IContent) { header('Content-Type: ' . $response->getContentType() . '; charset=UTF-8'); $response->render(); } elseif ($response instanceof IRedirect) { header('Location: ' . $this->getRedirectUrl($response), true, $response->getHttpCode()); } else { $this->render404(); } } private function handleCors(): void { $origin = $this->request->header('Origin'); if (!$origin) { return; } if (isset($this->appConfig['cors']['allow_origins'])) { if (in_array($origin, $this->appConfig['cors']['allow_origins']) || in_array('*', $this->appConfig['cors']['allow_origins'])) { header("Access-Control-Allow-Origin: {$origin}"); } } if (!empty($this->appConfig['cors']['allow_credentials'])) { header('Access-Control-Allow-Credentials: true'); } if ($this->method !== 'options') { return; } if (isset($this->appConfig['cors']['allow_headers'])) { $headers = explode(',', $this->request->header('Access-Control-Request-Headers')); if (in_array('*', $this->appConfig['cors']['allow_headers'])) { $allow_headers = $headers; } else { $allow_headers = array_intersect($this->appConfig['cors']['allow_headers'], $headers); } if (count($allow_headers) > 0) { header('Access-Control-Allow-Headers: ' . join(', ', $allow_headers)); } } if (isset($this->appConfig['cors']['allow_methods'])) { if (in_array('*', $this->appConfig['cors']['allow_methods'])) { $allow_methods = ['DELETE', 'GET', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT']; } else { $allow_methods = $this->appConfig['cors']['allow_methods']; } if (count($allow_methods) > 0) { header('Access-Control-Allow-Methods: ' . join(', ', $allow_methods)); } } $max_age = $this->appConfig['cors']['max_age'] ?? 600; header("Access-Control-Max-Age: {$max_age}"); } private function redirectToLogin(): void { $this->request->session()->set('redirect_after_login', $this->rawUrl); $response = new Redirect( $this->routeCollection->getRoute($this->appConfig['loginRouteId']) ->generateLink(['redirect_after_login' => urlencode($this->rawUrl)]), IRedirect::TEMPORARY); header('Location: ' . $this->getRedirectUrl($response), true, $response->getHttpCode()); } private function renderAntiCsrfError(): void { $content = new JsonContent($this->appConfig['antiCsrfTokenErrorResponse']); header('Content-Type: text/html; charset=UTF-8', true, 403); $content->render(); } private function render404(): void { $content = new HtmlContent($this->appConfig['error404View']); header('Content-Type: text/html; charset=UTF-8', true, 404); $content->render(); } private function render500(Exception $exception): void { if (empty($_ENV['DEV'])) { $exceptionToPrint = null; } else { $exceptionToPrint = (string)$exception; } $content = new HtmlContent($this->appConfig['error500View'], ['exceptionToPrint' => $exceptionToPrint]); header('Content-Type: text/html; charset=UTF-8', true, 500); $content->render(); } private function getRedirectUrl(IRedirect $redirect): string { $url = $redirect->getTarget(); if (preg_match('/^http(s)?/', $url) !== 1) { $url = $this->request->getBase() . $url; } return $url; } }