feat(Cookies): manage them
This commit is contained in:
parent
c0695eca2f
commit
49055e4671
@ -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
174
src/Cookies.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
||||||
|
61
src/SpecialOutputException.php
Normal file
61
src/SpecialOutputException.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user