Added API controller
This commit is contained in:
parent
e95b8d93d8
commit
03a6809510
18 changed files with 285 additions and 72 deletions
|
@ -1 +1 @@
|
||||||
Subproject commit 78597486abb1bd6344a64a70c87d9deca14b7ff7
|
Subproject commit 36a45be354e5a623a21f21d48c63b11e98b95ddf
|
|
@ -1,7 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
final class Api
|
final class Api
|
||||||
{
|
{
|
||||||
public static function run($job, $jobArgs)
|
public static function run(IJob $job, $jobArgs)
|
||||||
{
|
{
|
||||||
$user = Auth::getCurrentUser();
|
$user = Auth::getCurrentUser();
|
||||||
|
|
||||||
|
@ -91,4 +91,20 @@ final class Api
|
||||||
elseif (!$job->hasArgument($item))
|
elseif (!$job->hasArgument($item))
|
||||||
throw new ApiJobUnsatisfiedException($job, $item);
|
throw new ApiJobUnsatisfiedException($job, $item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function getAllJobClassNames()
|
||||||
|
{
|
||||||
|
$pathToJobs = Core::getConfig()->rootDir . DS . 'src' . DS . 'Api' . DS . 'Jobs';
|
||||||
|
$directory = new RecursiveDirectoryIterator($pathToJobs);
|
||||||
|
$iterator = new RecursiveIteratorIterator($directory);
|
||||||
|
$regex = new RegexIterator($iterator, '/^.+Job\.php$/i');
|
||||||
|
$files = array_keys(iterator_to_array($regex));
|
||||||
|
|
||||||
|
\Chibi\Util\Reflection::loadClasses($files);
|
||||||
|
return array_filter(get_declared_classes(), function($x)
|
||||||
|
{
|
||||||
|
$class = new ReflectionClass($x);
|
||||||
|
return !$class->isAbstract() and $class->isSubClassOf('AbstractJob');
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
/**
|
/**
|
||||||
* Used for serializing files output from jobs
|
* Used for serializing files output from jobs
|
||||||
*/
|
*/
|
||||||
class ApiFileOutput
|
class ApiFileOutput implements ISerializable
|
||||||
{
|
{
|
||||||
public $fileContent;
|
public $fileContent;
|
||||||
public $fileName;
|
public $fileName;
|
||||||
|
@ -16,4 +16,15 @@ class ApiFileOutput
|
||||||
$this->lastModified = filemtime($filePath);
|
$this->lastModified = filemtime($filePath);
|
||||||
$this->mimeType = mime_content_type($filePath);
|
$this->mimeType = mime_content_type($filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function serializeToArray()
|
||||||
|
{
|
||||||
|
return
|
||||||
|
[
|
||||||
|
'name ' => $this->fileName,
|
||||||
|
'modification-time' => $this->lastModified,
|
||||||
|
'mime-type' => $this->mimeType,
|
||||||
|
'content' => base64_encode(gzencode($this->fileContent)),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,6 +16,17 @@ abstract class AbstractJob implements IJob
|
||||||
|
|
||||||
public abstract function getRequiredArguments();
|
public abstract function getRequiredArguments();
|
||||||
|
|
||||||
|
public function getName()
|
||||||
|
{
|
||||||
|
$name = get_called_class();
|
||||||
|
$name = str_replace('Job', '', $name);
|
||||||
|
$name = TextCaseConverter::convert(
|
||||||
|
$name,
|
||||||
|
TextCaseConverter::UPPER_CAMEL_CASE,
|
||||||
|
TextCaseConverter::SPINAL_CASE);
|
||||||
|
return $name;
|
||||||
|
}
|
||||||
|
|
||||||
public function getRequiredPrivileges()
|
public function getRequiredPrivileges()
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
|
|
59
src/Controllers/ApiController.php
Normal file
59
src/Controllers/ApiController.php
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
<?php
|
||||||
|
class ApiController extends AbstractController
|
||||||
|
{
|
||||||
|
public function runAction()
|
||||||
|
{
|
||||||
|
$context = Core::getContext();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!Auth::isLoggedIn())
|
||||||
|
{
|
||||||
|
$auth = InputHelper::get('auth');
|
||||||
|
if ($auth)
|
||||||
|
{
|
||||||
|
Auth::login($auth['user'], $auth['pass'], false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$jobName = InputHelper::get('name');
|
||||||
|
$jobArgs = InputHelper::get('args');
|
||||||
|
|
||||||
|
$job = $this->jobFromName($jobName);
|
||||||
|
if (!$job)
|
||||||
|
throw new SimpleException('Unknown job: ' . $jobName);
|
||||||
|
|
||||||
|
if (isset($_FILES['args']))
|
||||||
|
{
|
||||||
|
foreach (array_keys($_FILES['args']['name']) as $key)
|
||||||
|
{
|
||||||
|
$jobArgs[$key] = new ApiFileInput(
|
||||||
|
$_FILES['args']['tmp_name'][$key],
|
||||||
|
$_FILES['args']['name'][$key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$context->transport->status = Api::run($job, $jobArgs);
|
||||||
|
}
|
||||||
|
catch (Exception $e)
|
||||||
|
{
|
||||||
|
Messenger::fail($e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->renderAjax();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private function jobFromName($jobName)
|
||||||
|
{
|
||||||
|
$jobClassNames = Api::getAllJobClassNames();
|
||||||
|
foreach ($jobClassNames as $className)
|
||||||
|
{
|
||||||
|
$job = (new ReflectionClass($className))->newInstance();
|
||||||
|
if ($job->getName() == $jobName)
|
||||||
|
return $job;
|
||||||
|
$job = null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
|
@ -96,8 +96,8 @@ class CustomMarkdown extends \Michelf\MarkdownExtra
|
||||||
|
|
||||||
$codeblock = preg_replace('/\A\n+|\n+\z/', '', $codeblock);
|
$codeblock = preg_replace('/\A\n+|\n+\z/', '', $codeblock);
|
||||||
$codeblock = preg_replace('/\n/', '<br/>', $codeblock);
|
$codeblock = preg_replace('/\n/', '<br/>', $codeblock);
|
||||||
$codeblock = preg_replace('/\t/', '&tab;', $codeblock);
|
#$codeblock = preg_replace('/\t/', '&tab;', $codeblock);
|
||||||
$codeblock = preg_replace('/ /', ' ', $codeblock);
|
#$codeblock = preg_replace('/ /', ' ', $codeblock);
|
||||||
|
|
||||||
$codeblock = "<pre><code>$codeblock\n</code></pre>";
|
$codeblock = "<pre><code>$codeblock\n</code></pre>";
|
||||||
return "\n\n".$this->hashBlock($codeblock)."\n\n";
|
return "\n\n".$this->hashBlock($codeblock)."\n\n";
|
||||||
|
|
|
@ -151,51 +151,6 @@ class TextHelper
|
||||||
return self::stripUnits($string, 1000, ['', 'K', 'M']);
|
return self::stripUnits($string, 1000, ['', 'K', 'M']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function removeUnsafeKeys(&$input, $regex)
|
|
||||||
{
|
|
||||||
if (is_array($input))
|
|
||||||
{
|
|
||||||
foreach ($input as $key => $val)
|
|
||||||
{
|
|
||||||
if (preg_match($regex, $key))
|
|
||||||
unset($input[$key]);
|
|
||||||
else
|
|
||||||
self::removeUnsafeKeys($input[$key], $regex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
elseif (is_object($input))
|
|
||||||
{
|
|
||||||
foreach ($input as $key => $val)
|
|
||||||
{
|
|
||||||
if (preg_match($regex, $key))
|
|
||||||
unset($input->$key);
|
|
||||||
else
|
|
||||||
self::removeUnsafeKeys($input->$key, $regex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function jsonEncode($obj, $illegalKeysRegex = '')
|
|
||||||
{
|
|
||||||
if (is_array($obj))
|
|
||||||
$set = function($key, $val) use ($obj) { $obj[$key] = $val; };
|
|
||||||
else
|
|
||||||
$set = function($key, $val) use ($obj) { $obj->$key = $val; };
|
|
||||||
|
|
||||||
foreach ($obj as $key => $val)
|
|
||||||
{
|
|
||||||
if ($val instanceof Exception)
|
|
||||||
{
|
|
||||||
$set($key, ['message' => $val->getMessage(), 'trace' => explode("\n", $val->getTraceAsString())]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!empty($illegalKeysRegex))
|
|
||||||
self::removeUnsafeKeys($obj, $illegalKeysRegex);
|
|
||||||
|
|
||||||
return json_encode($obj, JSON_UNESCAPED_UNICODE);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function parseMarkdown($text, $simple = false)
|
public static function parseMarkdown($text, $simple = false)
|
||||||
{
|
{
|
||||||
if ($simple)
|
if ($simple)
|
||||||
|
|
5
src/ISerializable.php
Normal file
5
src/ISerializable.php
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
<?php
|
||||||
|
interface ISerializable
|
||||||
|
{
|
||||||
|
public function serializeToArray();
|
||||||
|
}
|
|
@ -20,6 +20,16 @@ final class CommentEntity extends AbstractEntity implements IValidatable
|
||||||
$this->commenterId = TextHelper::toIntegerOrNull($row['commenter_id']);
|
$this->commenterId = TextHelper::toIntegerOrNull($row['commenter_id']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function serializeToArray()
|
||||||
|
{
|
||||||
|
return
|
||||||
|
[
|
||||||
|
'text' => $this->getText(),
|
||||||
|
'comment-time' => $this->getCreationTime(),
|
||||||
|
'commenter' => $this->getCommenter() ? $this->getCommenter()->getName() : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function validate()
|
public function validate()
|
||||||
{
|
{
|
||||||
$config = Core::getConfig();
|
$config = Core::getConfig();
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
use \Chibi\Sql as Sql;
|
use \Chibi\Sql as Sql;
|
||||||
use \Chibi\Database as Database;
|
use \Chibi\Database as Database;
|
||||||
|
|
||||||
final class PostEntity extends AbstractEntity implements IValidatable
|
final class PostEntity extends AbstractEntity implements IValidatable, ISerializable
|
||||||
{
|
{
|
||||||
private $type;
|
private $type;
|
||||||
private $name;
|
private $name;
|
||||||
|
@ -52,6 +52,28 @@ final class PostEntity extends AbstractEntity implements IValidatable
|
||||||
$this->setSafety(new PostSafety($row['safety']));
|
$this->setSafety(new PostSafety($row['safety']));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function serializeToArray()
|
||||||
|
{
|
||||||
|
return
|
||||||
|
[
|
||||||
|
'name' => $this->getName(),
|
||||||
|
'id' => $this->getId(),
|
||||||
|
'orig-name' => $this->getOriginalName(),
|
||||||
|
'file-hash' => $this->getFileHash(),
|
||||||
|
'file-size' => $this->getFileSize(),
|
||||||
|
'mime-type' => $this->getMimeType(),
|
||||||
|
'is-hidden' => $this->isHidden(),
|
||||||
|
'creation-time' => $this->getCreationTime(),
|
||||||
|
'image-width' => $this->getImageWidth(),
|
||||||
|
'image-height' => $this->getImageHeight(),
|
||||||
|
'uploader' => $this->getUploader() ? $this->getUploader()->getName() : null,
|
||||||
|
'comments' => array_map(function($comment) { return $comment->serializeToArray(); }, $this->getComments()),
|
||||||
|
'tags' => array_map(function($tag) { return $tag->getName(); }, $this->getTags()),
|
||||||
|
'type' => $this->getType()->toInteger(),
|
||||||
|
'safety' => $this->getSafety()->toInteger(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function validate()
|
public function validate()
|
||||||
{
|
{
|
||||||
if (empty($this->getType()))
|
if (empty($this->getType()))
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
use \Chibi\Sql as Sql;
|
use \Chibi\Sql as Sql;
|
||||||
use \Chibi\Database as Database;
|
use \Chibi\Database as Database;
|
||||||
|
|
||||||
final class TagEntity extends AbstractEntity implements IValidatable
|
final class TagEntity extends AbstractEntity implements IValidatable, ISerializable
|
||||||
{
|
{
|
||||||
private $name;
|
private $name;
|
||||||
|
|
||||||
|
@ -19,6 +19,15 @@ final class TagEntity extends AbstractEntity implements IValidatable
|
||||||
$this->setCache('post_count', (int) $row['post_count']);
|
$this->setCache('post_count', (int) $row['post_count']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function serializeToArray()
|
||||||
|
{
|
||||||
|
return
|
||||||
|
[
|
||||||
|
'name' => $this->getName(),
|
||||||
|
'post-count' => $this->getPostCount(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function validate()
|
public function validate()
|
||||||
{
|
{
|
||||||
$minLength = Core::getConfig()->tags->minLength;
|
$minLength = Core::getConfig()->tags->minLength;
|
||||||
|
|
|
@ -31,6 +31,17 @@ final class TokenEntity extends AbstractEntity implements IValidatable
|
||||||
$this->expires = $row['expires'];
|
$this->expires = $row['expires'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function serializeToArray()
|
||||||
|
{
|
||||||
|
return
|
||||||
|
[
|
||||||
|
'user' => $this->getUser(),
|
||||||
|
'text' => $this->getText(),
|
||||||
|
'is-used' => $this->isUser(),
|
||||||
|
'expiration-time' => $this->getExpirationTime(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function validate()
|
public function validate()
|
||||||
{
|
{
|
||||||
if (empty($this->token))
|
if (empty($this->token))
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
use \Chibi\Sql as Sql;
|
use \Chibi\Sql as Sql;
|
||||||
use \Chibi\Database as Database;
|
use \Chibi\Database as Database;
|
||||||
|
|
||||||
final class UserEntity extends AbstractEntity implements IValidatable
|
final class UserEntity extends AbstractEntity implements IValidatable, ISerializable
|
||||||
{
|
{
|
||||||
private $name;
|
private $name;
|
||||||
private $passSalt;
|
private $passSalt;
|
||||||
|
@ -42,6 +42,18 @@ final class UserEntity extends AbstractEntity implements IValidatable
|
||||||
$this->settings = new UserSettings($row['settings']);
|
$this->settings = new UserSettings($row['settings']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function serializeToArray()
|
||||||
|
{
|
||||||
|
return
|
||||||
|
[
|
||||||
|
'name' => $this->getName(),
|
||||||
|
'join-time' => $this->getJoinTime(),
|
||||||
|
'last-login-time' => $this->getLastLoginTime(),
|
||||||
|
'access-rank' => $this->getAccessRank()->toInteger(),
|
||||||
|
'is-banned' => $this->isBanned(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function validate()
|
public function validate()
|
||||||
{
|
{
|
||||||
$this->validateUserName();
|
$this->validateUserName();
|
||||||
|
|
60
src/RecursiveSerializer.php
Normal file
60
src/RecursiveSerializer.php
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
<?php
|
||||||
|
class RecursiveSerializer implements ISerializable
|
||||||
|
{
|
||||||
|
public function __construct($input)
|
||||||
|
{
|
||||||
|
$this->input = $input;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function serializeToArray()
|
||||||
|
{
|
||||||
|
return
|
||||||
|
$output = $this->traverse($this->input);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function traverse($input)
|
||||||
|
{
|
||||||
|
if (is_array($input))
|
||||||
|
{
|
||||||
|
foreach ($input as $key => $val)
|
||||||
|
{
|
||||||
|
$input[$key] = $this->traverse($input[$key]);
|
||||||
|
}
|
||||||
|
return $input;
|
||||||
|
}
|
||||||
|
elseif ($input instanceof ISerializable)
|
||||||
|
{
|
||||||
|
return $input->serializeToArray();
|
||||||
|
}
|
||||||
|
elseif ($input instanceof Exception)
|
||||||
|
{
|
||||||
|
return $this->serializeException($input);
|
||||||
|
}
|
||||||
|
elseif (is_object($input))
|
||||||
|
{
|
||||||
|
foreach ($input as $key => $val)
|
||||||
|
{
|
||||||
|
$input->$key = $this->traverse($input->$key);
|
||||||
|
}
|
||||||
|
return $input;
|
||||||
|
}
|
||||||
|
return $input;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function serializePost(PostEntity $post)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
[
|
||||||
|
'name' => $post->getName(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function serializeException(Exception $exception)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
[
|
||||||
|
'message' => $exception->getMessage(),
|
||||||
|
'trace' => explode("\n", $exception->getTraceAsString())
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,3 +1,6 @@
|
||||||
<?php
|
<?php
|
||||||
\Chibi\Util\Headers::set('Content-Type', 'application/json');
|
if (!headers_sent())
|
||||||
echo TextHelper::jsonEncode($this->context->transport, '/.*(email|confirm|pass|salt)/i');
|
\Chibi\Util\Headers::set('Content-Type', 'application/json');
|
||||||
|
$serializer = new RecursiveSerializer($this->context->transport);
|
||||||
|
$array = $serializer->serializeToArray();
|
||||||
|
echo json_encode($array, JSON_UNESCAPED_UNICODE);
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
<?php
|
<?php
|
||||||
|
\Chibi\Router::register(['ApiController', 'runAction'], null, '/api');
|
||||||
|
|
||||||
\Chibi\Router::register(['StaticPagesController', 'mainPageView'], 'GET', '');
|
\Chibi\Router::register(['StaticPagesController', 'mainPageView'], 'GET', '');
|
||||||
\Chibi\Router::register(['StaticPagesController', 'mainPageView'], 'GET', '/index');
|
\Chibi\Router::register(['StaticPagesController', 'mainPageView'], 'GET', '/index');
|
||||||
\Chibi\Router::register(['StaticPagesController', 'helpView'], 'GET', '/help');
|
\Chibi\Router::register(['StaticPagesController', 'helpView'], 'GET', '/help');
|
||||||
|
|
|
@ -9,27 +9,11 @@ abstract class AbstractFullApiTest extends AbstractTest
|
||||||
{
|
{
|
||||||
return get_class($job);
|
return get_class($job);
|
||||||
}, $this->testedJobs);
|
}, $this->testedJobs);
|
||||||
$allJobs = $this->getAllJobs();
|
$allJobs = Api::getAllJobClassNames();
|
||||||
foreach ($allJobs as $x)
|
foreach ($allJobs as $x)
|
||||||
{
|
{
|
||||||
if (!in_array($x, $testedJobs))
|
if (!in_array($x, $testedJobs))
|
||||||
$this->assert->fail($x . ' appears to be untested');
|
$this->assert->fail($x . ' appears to be untested');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getAllJobs()
|
|
||||||
{
|
|
||||||
$pathToJobs = Core::getConfig()->rootDir . DS . 'src' . DS . 'Api' . DS . 'Jobs';
|
|
||||||
$directory = new RecursiveDirectoryIterator($pathToJobs);
|
|
||||||
$iterator = new RecursiveIteratorIterator($directory);
|
|
||||||
$regex = new RegexIterator($iterator, '/^.+Job\.php$/i');
|
|
||||||
$files = array_keys(iterator_to_array($regex));
|
|
||||||
|
|
||||||
\Chibi\Util\Reflection::loadClasses($files);
|
|
||||||
return array_filter(get_declared_classes(), function($x)
|
|
||||||
{
|
|
||||||
$class = new ReflectionClass($x);
|
|
||||||
return !$class->isAbstract() and $class->isSubClassOf('AbstractJob');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
43
tests/Tests/ControllerTests/ApiControllerTest.php
Normal file
43
tests/Tests/ControllerTests/ApiControllerTest.php
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
<?php
|
||||||
|
class ApiControllerTest extends AbstractTest
|
||||||
|
{
|
||||||
|
public function testRunning()
|
||||||
|
{
|
||||||
|
Core::getConfig()->registration->needEmailForRegistering = false;
|
||||||
|
Core::getConfig()->registration->needEmailForUploading = false;
|
||||||
|
|
||||||
|
$user = $this->userMocker->mockSingle();
|
||||||
|
$this->grantAccess('addPost');
|
||||||
|
$this->grantAccess('addPostTags');
|
||||||
|
$this->grantAccess('addPostContent');
|
||||||
|
|
||||||
|
$_GET =
|
||||||
|
[
|
||||||
|
'auth' => ['pass' => 'sekai', 'user' => $user->getName()],
|
||||||
|
'name' => 'add-post',
|
||||||
|
'args' => ['new-tag-names' => ['test', 'test2', 'test3']],
|
||||||
|
];
|
||||||
|
|
||||||
|
$tmpPath = tempnam(sys_get_temp_dir(), 'upload') . '.dat';
|
||||||
|
copy($this->testSupport->getPath('image.jpg'), $tmpPath);
|
||||||
|
|
||||||
|
Core::getContext()->transport = new StdClass;
|
||||||
|
|
||||||
|
$_FILES =
|
||||||
|
[
|
||||||
|
'args' =>
|
||||||
|
[
|
||||||
|
'name' => ['new-post-content' => 'image.jpg'],
|
||||||
|
'tmp_name' => ['new-post-content' => $tmpPath],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
$apiController = new ApiController();
|
||||||
|
$apiController->runAction();
|
||||||
|
$output = ob_get_contents();
|
||||||
|
ob_end_clean();
|
||||||
|
|
||||||
|
$this->assert->areEqual(1, PostModel::getCount());
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue