feat(Cookies): manage them

master
dufraissejeremy 2 years ago
parent c0695eca2f
commit 49055e4671
  1. 95
      SeacmsApi.php
  2. 174
      src/Cookies.php
  3. 35
      src/JsonResponse.php
  4. 61
      src/SpecialOutputException.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;
}

@ -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();
}
}
}
}
}
}
}

@ -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

@ -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;
}
}
Loading…
Cancel
Save