feat(Cookies): manage them

This commit is contained in:
Jérémy Dufraisse 2023-03-02 16:44:25 +01:00
parent c0695eca2f
commit 49055e4671
4 changed files with 345 additions and 20 deletions

View File

@ -2,11 +2,12 @@
// SPDX-License-Identifier: EUPL-1.2 // SPDX-License-Identifier: EUPL-1.2
// Authors: see README.md // Authors: see README.md
use Pico;
use SeaCMS\Api\ApiAware; use SeaCMS\Api\ApiAware;
use SeaCMS\Api\BadMethodException; use SeaCMS\Api\BadMethodException;
use SeaCMS\Api\Cookies;
use SeaCMS\Api\JsonResponse; use SeaCMS\Api\JsonResponse;
use SeaCMS\Api\NotFoundRouteException; use SeaCMS\Api\NotFoundRouteException;
use SeaCMS\Api\SpecialOutputException;
/** /**
* An api plugin for Pico 3. * An api plugin for Pico 3.
@ -25,6 +26,33 @@ class SeacmsApi extends AbstractPicoPlugin implements ApiAware
*/ */
protected $routes ; 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 api routes
* @return array * @return array
@ -33,6 +61,7 @@ class SeacmsApi extends AbstractPicoPlugin implements ApiAware
{ {
return [ return [
'POST test' => 'api', 'POST test' => 'api',
'GET test/cookies/(.*)' => 'apiTestCookie',
'GET test/(.*)' => 'apiWithText', 'GET test/(.*)' => 'apiWithText',
]; ];
} }
@ -56,6 +85,26 @@ class SeacmsApi extends AbstractPicoPlugin implements ApiAware
return new JsonResponse(200,['text'=>$text]); 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 * 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 * Triggered after Pico has rendered the page
* *
@ -92,15 +162,17 @@ class SeacmsApi extends AbstractPicoPlugin implements ApiAware
*/ */
public function onPageRendered(&$output) public function onPageRendered(&$output)
{ {
$this->resolveApi($output); if (JsonResponse::canSendHeaders()){
$this->getCookies()->sendCookiesOnce();
}
} }
/** /**
* resolve api * resolve api
* @param string $$output * @param null|string|JsonResponse $output
* @return bool $outputChanged * @return bool $outputChanged
*/ */
protected function resolveApi(string &$output): bool protected function resolveApi(&$output): bool
{ {
$outputChanged = false; $outputChanged = false;
if (isset($_GET['api'])){ if (isset($_GET['api'])){
@ -116,13 +188,10 @@ class SeacmsApi extends AbstractPicoPlugin implements ApiAware
] ]
); );
$route = trim($route); $route = trim($route);
$callable = function() {
$this->getPico()->triggerEvent('sendCookies');
};
if (empty($route)){ 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)) { } 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 { } else {
ob_start(); ob_start();
$response = null; $response = null;
@ -132,6 +201,8 @@ class SeacmsApi extends AbstractPicoPlugin implements ApiAware
if (!($response instanceof JsonResponse)){ if (!($response instanceof JsonResponse)){
$response = null; $response = null;
throw new Exception("Return of '{$data['methodName']}' should be instanceof of 'JsonResponse'"); throw new Exception("Return of '{$data['methodName']}' should be instanceof of 'JsonResponse'");
} else {
$response->setCookies($this->cookies);
} }
} catch (BadMethodException $th) { } catch (BadMethodException $th) {
$code = 405; $code = 405;
@ -150,11 +221,11 @@ class SeacmsApi extends AbstractPicoPlugin implements ApiAware
$content['rawOutput'] = $rawOutput; $content['rawOutput'] = $rawOutput;
} }
$content = array_merge(['code'=>$code],$content); $content = array_merge(['code'=>$code],$content);
$response = (new JsonResponse($code,$content,[],$callable)); $response = (new JsonResponse($code,$content,[],$this->cookies));
} elseif (!empty($rawOutput)) { } elseif (!empty($rawOutput)) {
$response->mergeInContent(compact(['rawOutput'])); $response->mergeInContent(compact(['rawOutput']));
} }
$output = $response->send(); $output = $response;
} }
$outputChanged = true; $outputChanged = true;
} }

174
src/Cookies.php Normal file
View File

@ -0,0 +1,174 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
// Authors: see README.md
namespace SeaCMS\Api;
use DateTimeInterface;
use Exception;
use Throwable;
/**
* Exception for bad http method
*/
class Cookies
{
/**
* list of reserved chars in cookies name
* @var array
*/
private const RESERVED_CHARS_FROM = ['=', ',', ';', ' ', "\t", "\r", "\n", "\v", "\f"];
/**
* list of replacements in cookies name
* @var array
*/
private const RESERVED_CHARS_TO = ['%3D', '%2C', '%3B', '%20', '%09', '%0D', '%0A', '%0B', '%0C'];
/**
* structured data for cookies
* first level 'domain'
* second level 'path'
* third level 'name'
* @var array
*/
protected $data;
/**
* cookies already sent
* @var bool
*/
protected $sent;
public function __construct() {
$this->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();
}
}
}
}
}
}
}

View File

@ -5,6 +5,7 @@
namespace SeaCMS\Api; namespace SeaCMS\Api;
use JsonSerializable; use JsonSerializable;
use SeaCMS\Api\Cookies;
use Throwable; use Throwable;
/** /**
@ -38,10 +39,10 @@ class JsonResponse implements JsonSerializable
/** /**
* callable to send cookies * cookies to send
* @var callable * @var null|Cookies
*/ */
protected $callableToSendCookies; protected $cookies;
/** /**
* HTTP CODE * HTTP CODE
* @var int * @var int
@ -59,7 +60,7 @@ class JsonResponse implements JsonSerializable
protected $headers; 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->code = array_key_exists($code, self::HTTP_CODES) ? $code : 501; // default
$this->content = $content; $this->content = $content;
$this->headers = array_merge([ $this->headers = array_merge([
@ -71,7 +72,16 @@ class JsonResponse implements JsonSerializable
'Access-Control-Allow-Methods' => 'POST, GET, OPTIONS, DELETE, PUT, PATCH', 'Access-Control-Allow-Methods' => 'POST, GET, OPTIONS, DELETE, PUT, PATCH',
'Access-Control-Max-Age' => '86400' 'Access-Control-Max-Age' => '86400'
], $headers); ], $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 public function sendHeaders(): JsonResponse
{ {
// headers have already been sent by the developer // 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 // headers
foreach ($this->headers as $name => $value) { foreach ($this->headers as $name => $value) {
@ -142,8 +152,8 @@ class JsonResponse implements JsonSerializable
} }
// cookies // cookies
if (!empty($this->callableToSendCookies)){ if (!is_null($this->cookies)){
call_user_func($this->callableToSendCookies); $this->cookies->sendCookiesOnce();
} }
// status // 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 * send headers and return output
* @return string * @return string

View File

@ -0,0 +1,61 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
// Authors: see README.md
namespace SeaCMS\Api;
use Exception;
use SeaCMS\Api\JsonResponse;
use Throwable;
/**
* Exception for bad http method
*/
class SpecialOutputException extends Exception
{
/**
* jsonResponse of the Exception
* @var JsonResponse
*/
protected $jsonResponse;
// Redefine the exception to be able to define $jsonResponse
public function __construct($message = "", $code = 0, ?Throwable $previous = null, ?JsonResponse $jsonResponse = null) {
if ($message instanceof JsonResponse){
$this->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;
}
}