diff --git a/SeacmsApi.php b/SeacmsApi.php index 7c39641..cb13386 100644 --- a/SeacmsApi.php +++ b/SeacmsApi.php @@ -2,11 +2,12 @@ // SPDX-License-Identifier: EUPL-1.2 // Authors: see README.md -use Pico; use SeaCMS\Api\ApiAware; use SeaCMS\Api\BadMethodException; +use SeaCMS\Api\Cookies; use SeaCMS\Api\JsonResponse; use SeaCMS\Api\NotFoundRouteException; +use SeaCMS\Api\SpecialOutputException; /** * An api plugin for Pico 3. @@ -25,6 +26,33 @@ class SeacmsApi extends AbstractPicoPlugin implements ApiAware */ protected $routes ; + /** + * cookies to send + * @var Cookies + */ + protected $cookies; + + /** + * Constructs a new instance of a Pico plugin + * + * @param Pico $pico current instance of Pico + */ + public function __construct(Pico $pico) + { + parent::__construct($pico); + $this->routes = []; + $this->cookies= new Cookies(); + } + + /** + * return $cookies + * @return Cookies + */ + public function getCookies(): Cookies + { + return $this->cookies; + } + /** * return api routes * @return array @@ -33,6 +61,7 @@ class SeacmsApi extends AbstractPicoPlugin implements ApiAware { return [ 'POST test' => 'api', + 'GET test/cookies/(.*)' => 'apiTestCookie', 'GET test/(.*)' => 'apiWithText', ]; } @@ -56,6 +85,26 @@ class SeacmsApi extends AbstractPicoPlugin implements ApiAware return new JsonResponse(200,['text'=>$text]); } + /** + * method to test cookie via api + * @param string $text + * @return JsonResponse + */ + public function apiTestCookie(string $text): JsonResponse + { + if (empty($text)){ + $text= ''; + } + $this->cookies->addCookie( + 'Test-Cookie', + $text, + time()+3, + !empty($_SERVER['SCRIPT_NAME']) ? dirname($_SERVER['SCRIPT_NAME']) : '/', + !empty($_SERVER['SERVER_NAME']) ? $_SERVER['SERVER_NAME'] : '' + ); + return new JsonResponse(200,['text'=>$text]); + } + /** * Triggered after Pico has loaded all available plugins * @@ -83,7 +132,28 @@ class SeacmsApi extends AbstractPicoPlugin implements ApiAware } /** - * If the call is a save query, save the edited regions and output the JSON response. + * trig api + * Triggered before Pico loads its theme + * use this event because could be reach in 15 ms instead of 60 ms after rendering + * + * @see Pico::loadTheme() + * @see DummyPlugin::onThemeLoaded() + * + * @param string &$theme name of current theme + */ + public function onThemeLoading(&$theme) + { + $output = null; + if ($this->resolveApi($output)){ + if (!($output instanceof JsonResponse)){ + throw new Exception("Return of resolveApi should be JsonResponse at this point", 1); + } else { + throw new SpecialOutputException($output); + } + } + } + /** + * send cookies of not already sent. * * Triggered after Pico has rendered the page * @@ -92,15 +162,17 @@ class SeacmsApi extends AbstractPicoPlugin implements ApiAware */ public function onPageRendered(&$output) { - $this->resolveApi($output); + if (JsonResponse::canSendHeaders()){ + $this->getCookies()->sendCookiesOnce(); + } } /** * resolve api - * @param string $$output + * @param null|string|JsonResponse $output * @return bool $outputChanged */ - protected function resolveApi(string &$output): bool + protected function resolveApi(&$output): bool { $outputChanged = false; if (isset($_GET['api'])){ @@ -116,13 +188,10 @@ class SeacmsApi extends AbstractPicoPlugin implements ApiAware ] ); $route = trim($route); - $callable = function() { - $this->getPico()->triggerEvent('sendCookies'); - }; if (empty($route)){ - $output = (new JsonResponse(404,['code'=>404,'reason'=>'Empty api route'],[],$callable))->send(); + $output = new JsonResponse(404,['code'=>404,'reason'=>'Empty api route'],[],$this->cookies); } elseif (!preg_match('/^[A-Za-z0-9_\-.\/]+$/',$route)) { - $output = (new JsonResponse(404,['code'=>404,'reason'=>"Route '$route' use forbidden characters !"],[],$callable))->send(); + $output = new JsonResponse(404,['code'=>404,'reason'=>"Route '$route' use forbidden characters !"],[],$this->cookies); } else { ob_start(); $response = null; @@ -132,6 +201,8 @@ class SeacmsApi extends AbstractPicoPlugin implements ApiAware if (!($response instanceof JsonResponse)){ $response = null; throw new Exception("Return of '{$data['methodName']}' should be instanceof of 'JsonResponse'"); + } else { + $response->setCookies($this->cookies); } } catch (BadMethodException $th) { $code = 405; @@ -150,11 +221,11 @@ class SeacmsApi extends AbstractPicoPlugin implements ApiAware $content['rawOutput'] = $rawOutput; } $content = array_merge(['code'=>$code],$content); - $response = (new JsonResponse($code,$content,[],$callable)); + $response = (new JsonResponse($code,$content,[],$this->cookies)); } elseif (!empty($rawOutput)) { $response->mergeInContent(compact(['rawOutput'])); } - $output = $response->send(); + $output = $response; } $outputChanged = true; } diff --git a/src/Cookies.php b/src/Cookies.php new file mode 100644 index 0000000..6e8d07b --- /dev/null +++ b/src/Cookies.php @@ -0,0 +1,174 @@ +data = []; + $this->sent = false; + } + + /** + * add a cookie, if existing, overwrite it + * @param string $name The name of the cookie + * @param string $value The value of the cookie + * @param int|string|DateTimeInterface $expire The time the cookie expires + * @param string $path The path on the server in which the cookie will be available on + * @param string $domain The domain that the cookie is available to + * @param bool $secure Whether the client should send back the cookie only over HTTPS or null to auto-enable this when the request is already using HTTPS + * @param bool $httpOnly Whether the cookie will be made accessible only through the HTTP protocol + * @param string $sameSite Whether the cookie will be available for cross-site requests + * @throws Exception + */ + public function addCookie( + string $name, + string $value = '', + $expire = 0, + string $path = '/', + string $domain = '', + bool $secure = true, + bool $httpOnly = true, + string $sameSite = 'Lax') + { + if ($this->sent){ + throw new Exception('Cookies already sent ! Not possible to change cookies'); + } + if (empty($name)){ + throw new Exception('\'$name\' should not be empty !', 1); + } + if (!in_array($sameSite,['None','Lax','Strict'])){ + throw new Exception('\'$sameSite\' should be \'None\',\'Lax\' or \'Strict\' !', 1); + } + + // convert expiration time to a Unix timestamp + if ($expire instanceof DateTimeInterface) { + $expire = $expire->format('U'); + } elseif (is_string($expire)) { + $expire = strtotime($expire); + + if (false === $expire) { + throw new Exception('The cookie expiration time is not valid.'); + } + } elseif (!is_integer($expire)) { + $expire = 0; + } + + $expire = (0 < $expire) ? (int) $expire : 0; + + if (!array_key_exists($domain,$this->data)){ + $this->data[$domain] = []; + } + if (!array_key_exists($path,$this->data[$domain])){ + $this->data[$domain][$path] = []; + } + $this->data[$domain][$path][$name] = [ + 'value' => $value, + 'expire' => $expire, + 'secure' => $secure, + 'httpOnly' => $httpOnly, + 'sameSite' => $sameSite + ]; + } + + /** + * delete a cookie, if existing + * @param string $name The name of the cookie + * @param string $path The path on the server in which the cookie will be available on + * @param string $domain The domain that the cookie is available to + * @throws Exception + */ + public function deleteCookie( + string $name, + string $path = '/', + string $domain = '') + { + if ($this->sent){ + throw new Exception('Cookies already sent ! Not possible to change cookies'); + } + if (empty($name)){ + throw new Exception('\'$name\' should not be empty !', 1); + } + if (array_key_exists($domain,$this->data)){ + if (array_key_exists($path,$this->data[$domain])){ + if (array_key_exists($name,$this->data[$domain][$path])){ + unset($this->data[$domain][$path][$name]); + } + if (empty($this->data[$domain][$path])){ + unset($this->data[$domain][$path]); + } + } + if (empty($this->data[$domain])){ + unset($this->data[$domain]); + } + } + } + + /** + * send cookies if not already sent + */ + public function sendCookiesOnce() + { + if (!$this->sent){ + $this->sent = true; + foreach($this->data as $domain => $domainCookies){ + foreach ($domainCookies as $path => $pathCookies) { + foreach ($pathCookies as $name => $values) { + try { + setcookie( + str_replace(self::RESERVED_CHARS_FROM, self::RESERVED_CHARS_TO, $name), + $values['value'], + [ + 'expires' => $values['expire'], + 'path' => $path, + 'domain' => $domain, + 'secure' => $values['secure'], + 'httponly' => $values['httpOnly'], + 'samesite' => $values['sameSite'] + ] + ); + } catch (Throwable $th) { + echo json_encode(['error'=>$th->__toString()]); + exit(); + } + } + } + } + } + } +} \ No newline at end of file diff --git a/src/JsonResponse.php b/src/JsonResponse.php index 59fe02d..90a2ccf 100644 --- a/src/JsonResponse.php +++ b/src/JsonResponse.php @@ -5,6 +5,7 @@ namespace SeaCMS\Api; use JsonSerializable; +use SeaCMS\Api\Cookies; use Throwable; /** @@ -38,10 +39,10 @@ class JsonResponse implements JsonSerializable /** - * callable to send cookies - * @var callable + * cookies to send + * @var null|Cookies */ - protected $callableToSendCookies; + protected $cookies; /** * HTTP CODE * @var int @@ -59,7 +60,7 @@ class JsonResponse implements JsonSerializable protected $headers; - public function __construct(int $code, array $content, array $headers = [],$callableToSendCookies = null){ + public function __construct(int $code, array $content, array $headers = [],?Cookies $cookies = null){ $this->code = array_key_exists($code, self::HTTP_CODES) ? $code : 501; // default $this->content = $content; $this->headers = array_merge([ @@ -71,7 +72,16 @@ class JsonResponse implements JsonSerializable 'Access-Control-Allow-Methods' => 'POST, GET, OPTIONS, DELETE, PUT, PATCH', 'Access-Control-Max-Age' => '86400' ], $headers); - $this->callableToSendCookies = is_callable($callableToSendCookies) ? callableToSendCookies : null; + $this->cookies = $cookies; + } + + /** + * set Cookies instance + * @param Cookies $cookies + */ + public function setCookies(Cookies $cookies) + { + $this->cookies = $cookies; } /** @@ -134,7 +144,7 @@ class JsonResponse implements JsonSerializable public function sendHeaders(): JsonResponse { // headers have already been sent by the developer - if (!headers_sent() && !in_array(php_sapi_name(), ['cli', 'cli-server',' phpdbg'], true)) { + if (JsonResponse::canSendHeaders()) { // headers foreach ($this->headers as $name => $value) { @@ -142,8 +152,8 @@ class JsonResponse implements JsonSerializable } // cookies - if (!empty($this->callableToSendCookies)){ - call_user_func($this->callableToSendCookies); + if (!is_null($this->cookies)){ + $this->cookies->sendCookiesOnce(); } // status @@ -156,6 +166,15 @@ class JsonResponse implements JsonSerializable } + /** + * test if headers can be sent + * @return bool + */ + public static function canSendHeaders(): bool + { + return !headers_sent() && !in_array(php_sapi_name(), ['cli', 'cli-server',' phpdbg'], true); + } + /** * send headers and return output * @return string diff --git a/src/SpecialOutputException.php b/src/SpecialOutputException.php new file mode 100644 index 0000000..815c740 --- /dev/null +++ b/src/SpecialOutputException.php @@ -0,0 +1,61 @@ +jsonResponse = $message; + $message = "Forced output with JsonResponse"; + if (!is_integer($code)){ + $int = 0; + } + } else { + if (!is_string($message)){ + $message = ""; + } + if ($code instanceof JsonResponse){ + $this->jsonResponse = $code; + $code = 0; + } else { + if (!is_integer($code)){ + $int = 0; + } + if (is_null($jsonResponse)){ + throw new Exception("It is not possible to instanciate a SpecialOutputException because \$jsonResponse is null !"); + } else { + $this->jsonResponse = $jsonResponse; + } + } + } + + // make sure everything is assigned properly + parent::__construct($message, $code, $previous); + } + + /** + * get JsonResponse + * @return JsonResponse + */ + public function getJsonResponse(): JsonResponse + { + return $this->jsonResponse; + } +} \ No newline at end of file