web-base/Core/API/Request.class.php

588 lines
18 KiB
PHP
Raw Normal View History

2020-02-09 23:02:19 +01:00
<?php
2022-11-18 18:06:46 +01:00
namespace Core\API;
2020-02-09 23:02:19 +01:00
2022-11-18 18:06:46 +01:00
use Core\Driver\Logger\Logger;
2023-01-16 21:47:23 +01:00
use Core\Driver\SQL\Query\Insert;
2022-11-18 18:06:46 +01:00
use Core\Objects\Context;
use Core\Objects\DatabaseEntity\TwoFactorToken;
use Core\Objects\TwoFactor\KeyBasedTwoFactorToken;
2022-02-20 16:53:26 +01:00
use PhpMqtt\Client\MqttClient;
2020-04-03 15:56:04 +02:00
// TODO: many things are only checked for external calls, e.g. loginRequired. If we call the API internally, we might get null-pointers for $context->user
2022-02-21 13:01:03 +01:00
abstract class Request {
2020-02-09 23:02:19 +01:00
2022-06-20 19:52:31 +02:00
protected Context $context;
2022-05-31 16:14:49 +02:00
protected Logger $logger;
2020-04-03 15:56:04 +02:00
protected array $params;
protected string $lastError;
protected array $result;
protected bool $success;
protected bool $isPublic;
protected bool $loginRequired;
protected bool $variableParamCount;
protected bool $isDisabled;
protected bool $apiKeyAllowed;
2020-06-14 19:39:52 +02:00
protected bool $csrfTokenRequired;
2020-04-03 15:56:04 +02:00
2021-04-02 22:41:24 +02:00
private array $defaultParams;
2020-04-03 15:56:04 +02:00
private array $allowedMethods;
2020-04-03 18:09:01 +02:00
private bool $externalCall;
2020-04-03 15:56:04 +02:00
2022-06-20 19:52:31 +02:00
public function __construct(Context $context, bool $externalCall = false, array $params = array()) {
$this->context = $context;
$this->logger = new Logger($this->getAPIName(), $this->context->getSQL());
2021-04-02 22:41:24 +02:00
$this->defaultParams = $params;
2024-03-29 16:37:42 +01:00
$this->externalCall = $externalCall;
$this->variableParamCount = false;
2020-04-03 18:09:01 +02:00
2024-03-29 16:37:42 +01:00
// result
$this->lastError = "";
2020-02-09 23:02:19 +01:00
$this->success = false;
$this->result = array();
2024-03-29 16:37:42 +01:00
// restrictions
2020-02-09 23:02:19 +01:00
$this->isPublic = true;
$this->isDisabled = false;
$this->loginRequired = false;
$this->apiKeyAllowed = true;
$this->allowedMethods = array("GET", "POST");
$this->csrfTokenRequired = true;
2020-02-09 23:02:19 +01:00
}
2022-05-31 16:14:49 +02:00
public function getAPIName(): string {
if (get_class($this) === Request::class) {
return "API";
}
$reflection = new \ReflectionClass($this);
if ($reflection->getParentClass()->isAbstract() && $reflection->getParentClass()->isSubclassOf(Request::class)) {
return $reflection->getParentClass()->getShortName() . "/" . $reflection->getShortName();
} else {
return $reflection->getShortName();
}
}
2023-01-16 21:47:23 +01:00
protected function forbidMethod($method): void {
2020-02-09 23:02:19 +01:00
if (($key = array_search($method, $this->allowedMethods)) !== false) {
2021-04-02 21:58:06 +02:00
unset($this->allowedMethods[$key]);
2020-02-09 23:02:19 +01:00
}
}
2022-02-20 16:53:26 +01:00
public function getDefaultParams(): array {
return $this->defaultParams;
}
public function isDisabled(): bool {
return $this->isDisabled;
}
2023-01-16 21:47:23 +01:00
protected function allowMethod($method): void {
2021-12-08 16:53:43 +01:00
$availableMethods = ["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "TRACE", "CONNECT"];
if (in_array($method, $availableMethods) && !in_array($method, $this->allowedMethods)) {
$this->allowedMethods[] = $method;
}
}
protected function getRequestMethod() {
return $_SERVER["REQUEST_METHOD"];
}
2021-11-11 14:25:26 +01:00
public function parseParams($values, $structure = NULL): bool {
2020-06-26 23:32:45 +02:00
2021-11-11 14:25:26 +01:00
if ($structure === NULL) {
$structure = $this->params;
}
foreach ($structure as $name => $param) {
2020-06-24 21:18:26 +02:00
$value = $values[$name] ?? NULL;
2020-02-09 23:02:19 +01:00
2024-03-27 16:27:26 +01:00
$isEmpty = is_string($value) && strlen($value) === 0;
2021-04-02 21:58:06 +02:00
if (!$param->optional && (is_null($value) || $isEmpty)) {
2020-06-27 22:47:12 +02:00
return $this->createError("Missing parameter: $name");
2020-02-09 23:02:19 +01:00
}
2022-02-20 16:53:26 +01:00
$param->reset();
2021-04-02 21:58:06 +02:00
if (!is_null($value) && !$isEmpty) {
if (!$param->parseParam($value)) {
2020-02-09 23:02:19 +01:00
$value = print_r($value, true);
2020-06-27 22:47:12 +02:00
return $this->createError("Invalid Type for parameter: $name '$value' (Required: " . $param->getTypeName() . ")");
2020-02-09 23:02:19 +01:00
}
}
}
2021-04-02 21:58:06 +02:00
2020-02-09 23:02:19 +01:00
return true;
}
2023-01-16 21:47:23 +01:00
public function parseVariableParams($values): void {
2021-04-02 21:58:06 +02:00
foreach ($values as $name => $value) {
if (isset($this->params[$name])) continue;
2020-02-09 23:02:19 +01:00
$type = Parameter\Parameter::parseType($value);
$param = new Parameter\Parameter($name, $type, true);
$param->parseParam($value);
$this->params[$name] = $param;
}
}
2021-12-08 16:53:43 +01:00
// wrapper for unit tests
protected function _die(string $data = ""): bool {
die($data);
}
2022-02-21 13:01:03 +01:00
protected abstract function _execute(): bool;
// TODO: replace this function with two abstract methods: getDefaultPermittedGroups and getDescription
2023-01-16 21:47:23 +01:00
public static function getDefaultACL(Insert $insert): void { }
2022-02-21 13:01:03 +01:00
protected function check2FA(?TwoFactorToken $tfaToken = null): bool {
// do not require 2FA for verifying endpoints
if ($this instanceof \Core\API\Tfa\VerifyTotp || $this instanceof \Core\API\Tfa\VerifyKey) {
return true;
}
if ($tfaToken === null) {
$tfaToken = $this->context->getUser()?->getTwoFactorToken();
}
if ($tfaToken && $tfaToken->isConfirmed() && !$tfaToken->isAuthenticated()) {
if ($tfaToken instanceof KeyBasedTwoFactorToken && !$tfaToken->hasChallenge()) {
$tfaToken->generateChallenge();
}
$this->lastError = '2FA-Authorization is required';
$this->result["twoFactorToken"] = $tfaToken->jsonSerialize([
"type", "challenge", "authenticated", "confirmed", "credentialID"
]);
return false;
}
return true;
}
2022-02-21 13:01:03 +01:00
public final function execute($values = array()): bool {
2022-02-20 16:53:26 +01:00
2021-04-02 22:42:53 +02:00
$this->params = array_merge([], $this->defaultParams);
2020-02-09 23:02:19 +01:00
$this->success = false;
$this->result = array();
$this->lastError = '';
2022-06-20 19:52:31 +02:00
$session = $this->context->getSession();
if ($session) {
$this->result['logoutIn'] = $session->getExpiresSeconds();
2020-02-09 23:02:19 +01:00
}
2021-04-02 21:58:06 +02:00
if ($this->externalCall) {
2020-02-10 00:52:25 +01:00
$values = $_REQUEST;
2023-01-15 00:32:17 +01:00
if ($_SERVER['REQUEST_METHOD'] === 'POST' && in_array("application/json", explode(";", $_SERVER["CONTENT_TYPE"] ?? ""))) {
2020-02-09 23:02:19 +01:00
$jsonData = json_decode(file_get_contents('php://input'), true);
2021-11-11 14:25:26 +01:00
if ($jsonData !== null) {
2020-06-14 22:35:01 +02:00
$values = array_merge($values, $jsonData);
} else {
$this->lastError = 'Invalid request body.';
2021-12-08 16:53:43 +01:00
http_response_code(400);
2020-06-14 22:35:01 +02:00
return false;
}
2020-02-09 23:02:19 +01:00
}
}
2021-04-02 21:58:06 +02:00
if ($this->isDisabled) {
2020-02-09 23:02:19 +01:00
$this->lastError = "This function is currently disabled.";
2021-12-08 16:53:43 +01:00
http_response_code(503);
2020-02-09 23:02:19 +01:00
return false;
}
2021-04-02 21:58:06 +02:00
if ($this->externalCall && !$this->isPublic) {
2020-02-09 23:02:19 +01:00
$this->lastError = 'This function is private.';
2021-12-08 16:53:43 +01:00
http_response_code(403);
2020-02-09 23:02:19 +01:00
return false;
}
2021-04-02 21:58:06 +02:00
if ($this->externalCall) {
2021-04-07 00:03:14 +02:00
2021-12-08 16:53:43 +01:00
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204); # No content
header("Allow: OPTIONS, " . implode(", ", $this->allowedMethods));
return $this->_die();
}
2021-04-07 00:03:14 +02:00
// check the request method
if (!in_array($_SERVER['REQUEST_METHOD'], $this->allowedMethods)) {
$this->lastError = 'This method is not allowed';
2021-12-08 16:53:43 +01:00
http_response_code(405);
2021-04-07 00:03:14 +02:00
return false;
}
2020-06-14 19:39:52 +02:00
$apiKeyAuthorized = false;
2022-06-20 19:52:31 +02:00
if (!$session && $this->apiKeyAllowed) {
2021-12-08 16:53:43 +01:00
if (isset($_SERVER["HTTP_AUTHORIZATION"])) {
2021-11-11 14:25:26 +01:00
$authHeader = $_SERVER["HTTP_AUTHORIZATION"];
if (startsWith($authHeader, "Bearer ")) {
$apiKey = substr($authHeader, strlen("Bearer "));
2022-06-20 19:52:31 +02:00
$apiKeyAuthorized = $this->context->loadApiKey($apiKey);
2021-11-11 14:25:26 +01:00
}
2020-06-27 01:18:10 +02:00
}
2021-12-08 16:53:43 +01:00
}
2020-06-27 01:18:10 +02:00
2021-12-08 16:53:43 +01:00
// Logged in or api key authorized?
if ($this->loginRequired) {
2022-06-20 19:52:31 +02:00
if (!$session && !$apiKeyAuthorized) {
2020-06-27 01:18:10 +02:00
$this->lastError = 'You are not logged in.';
$this->result["loggedIn"] = false;
2021-12-08 16:53:43 +01:00
http_response_code(401);
2020-06-27 01:18:10 +02:00
return false;
} else if ($session && !$this->check2FA()) {
http_response_code(401);
return false;
2020-06-27 01:18:10 +02:00
}
2020-06-27 22:47:12 +02:00
}
2020-02-09 23:02:19 +01:00
2020-06-27 22:47:12 +02:00
// CSRF Token
2022-06-20 19:52:31 +02:00
if ($this->csrfTokenRequired && $session) {
2020-06-27 22:47:12 +02:00
// csrf token required + external call
2023-01-07 15:34:05 +01:00
// if it's not a call with API_KEY, check for csrfToken
$csrfToken = $values["csrfToken"] ?? $_SERVER["HTTP_XSRF_TOKEN"] ?? null;
2022-06-20 19:52:31 +02:00
if (!$csrfToken || strcmp($csrfToken, $session->getCsrfToken()) !== 0) {
2020-06-27 22:47:12 +02:00
$this->lastError = "CSRF-Token mismatch";
2021-12-08 16:53:43 +01:00
http_response_code(403);
2020-06-27 22:47:12 +02:00
return false;
2020-06-14 19:39:52 +02:00
}
2020-02-09 23:02:19 +01:00
}
2020-06-27 01:18:10 +02:00
// Check for permission
2024-03-27 15:15:46 +01:00
$req = new \Core\API\Permission\Check($this->context);
2024-04-04 12:46:58 +02:00
$this->success = $req->execute(["method" => self::getEndpoint()]);
2024-03-27 15:15:46 +01:00
$this->lastError = $req->getLastError();
if (!$this->success) {
2024-04-07 18:29:33 +02:00
$res = $req->getResult();
if (!$this->context->getUser()) {
$this->result["loggedIn"] = false;
2024-04-07 18:29:33 +02:00
} else if (isset($res["twoFactorToken"])) {
$this->result["twoFactorToken"] = $res["twoFactorToken"];
}
2024-03-27 15:15:46 +01:00
return false;
2020-06-27 01:18:10 +02:00
}
2020-02-09 23:02:19 +01:00
}
2021-04-02 21:58:06 +02:00
if (!$this->parseParams($values)) {
2020-02-09 23:02:19 +01:00
return false;
2021-04-02 21:58:06 +02:00
}
2020-02-09 23:02:19 +01:00
2021-04-02 21:58:06 +02:00
if ($this->variableParamCount) {
2020-02-10 00:52:25 +01:00
$this->parseVariableParams($values);
2021-04-02 21:58:06 +02:00
}
2020-02-09 23:02:19 +01:00
2022-06-20 19:52:31 +02:00
$sql = $this->context->getSQL();
2022-11-19 01:15:34 +01:00
if ($sql === null || !$sql->isConnected()) {
$this->lastError = $sql ? $sql->getLastError() : "Database not connected yet.";
2020-02-09 23:02:19 +01:00
return false;
}
2022-05-31 16:14:49 +02:00
$this->success = true;
2024-03-29 16:37:42 +01:00
try {
$success = $this->_execute();
if ($this->success !== $success) {
// _execute might return a different value then it set for $this->success
// this should actually not occur, how to handle this case?
$this->success = $success;
}
2024-04-07 18:29:33 +02:00
} catch (\Throwable $err) {
http_response_code(500);
2024-03-29 16:37:42 +01:00
$this->createError($err->getMessage());
$this->logger->error($err->getMessage());
2022-02-21 13:01:03 +01:00
}
2024-03-29 16:37:42 +01:00
$sql->setLastError("");
2022-02-21 13:01:03 +01:00
return $this->success;
2020-02-09 23:02:19 +01:00
}
2021-04-02 21:58:06 +02:00
protected function createError($err): bool {
2020-02-09 23:02:19 +01:00
$this->success = false;
$this->lastError = $err;
return false;
}
2022-11-19 01:15:34 +01:00
protected function getParam($name, $obj = NULL): mixed {
2021-11-11 14:25:26 +01:00
if ($obj === NULL) {
$obj = $this->params;
}
2023-01-16 21:47:23 +01:00
// I don't know why phpstorm
2021-11-11 14:25:26 +01:00
return (isset($obj[$name]) ? $obj[$name]->value : NULL);
2021-04-02 21:58:06 +02:00
}
2022-02-20 16:53:26 +01:00
public function isMethodAllowed(string $method): bool {
return in_array($method, $this->allowedMethods);
}
2021-04-02 21:58:06 +02:00
public function isPublic(): bool {
return $this->isPublic;
}
public function getLastError(): string {
return $this->lastError;
}
2020-04-03 15:56:04 +02:00
2021-04-02 21:58:06 +02:00
public function getResult(): array {
return $this->result;
}
public function success(): bool {
return $this->success;
}
public function loginRequired(): bool {
return $this->loginRequired;
}
public function isExternalCall(): bool {
return $this->externalCall;
}
2020-02-09 23:02:19 +01:00
2023-01-16 21:47:23 +01:00
public static function getEndpoint(string $prefix = ""): ?string {
$reflectionClass = new \ReflectionClass(get_called_class());
if ($reflectionClass->isAbstract()) {
return null;
}
2024-04-04 12:46:58 +02:00
$parentClass = $reflectionClass->getParentClass();
if ($parentClass === false) {
return null;
}
$isNestedAPI = $parentClass->getName() !== Request::class;
2023-01-16 21:47:23 +01:00
if (!$isNestedAPI) {
# e.g. /api/stats or /api/info
$methodName = $reflectionClass->getShortName();
return $prefix . lcfirst($methodName);
2024-04-04 12:46:58 +02:00
} else if ($parentClass->getName() === \TestRequest::class) {
$methodName = $reflectionClass->getShortName();
return $prefix . "/e2e-test/" . lcfirst($methodName);
2023-01-16 21:47:23 +01:00
} else {
# e.g. /api/user/login
$methodClass = $reflectionClass;
$nestedClass = $reflectionClass->getParentClass();
while (!endsWith($nestedClass->getName(), "API")) {
$methodClass = $nestedClass;
$nestedClass = $nestedClass->getParentClass();
2024-04-04 12:46:58 +02:00
if (!$nestedClass) {
return null;
}
2023-01-16 21:47:23 +01:00
}
$nestedAPI = substr(lcfirst($nestedClass->getShortName()), 0, -3);
$methodName = lcfirst($methodClass->getShortName());
return $prefix . $nestedAPI . "/" . $methodName;
}
2020-06-27 01:18:10 +02:00
}
2021-04-02 21:58:06 +02:00
public function getJsonResult(): string {
2020-02-09 23:02:19 +01:00
$this->result['success'] = $this->success;
$this->result['msg'] = $this->lastError;
return json_encode($this->result);
}
2021-11-11 14:25:26 +01:00
2023-01-16 21:47:23 +01:00
protected function disableOutputBuffer(): void {
2021-11-11 14:25:26 +01:00
ob_implicit_flush(true);
$levels = ob_get_level();
for ( $i = 0; $i < $levels; $i ++ ) {
ob_end_flush();
}
flush();
}
2021-12-08 16:53:43 +01:00
2023-01-16 21:47:23 +01:00
protected function disableCache(): void {
2022-02-20 16:53:26 +01:00
header("Last-Modified: " . (new \DateTime())->format("D, d M Y H:i:s T"));
header("Expires: Sat, 26 Jul 1997 05:00:00 GMT");
header("Cache-Control: no-store, no-cache, must-revalidate, max-age=0");
header("Cache-Control: post-check=0, pre-check=0", false);
header("Pragma: no-cache");
}
2023-01-16 21:47:23 +01:00
protected function setupSSE(): void {
2022-06-20 19:52:31 +02:00
$this->context->sendCookies();
$this->context->getSQL()?->close();
2021-12-08 16:53:43 +01:00
set_time_limit(0);
ignore_user_abort(true);
header('Content-Type: text/event-stream');
header('Connection: keep-alive');
header('X-Accel-Buffering: no');
2022-02-20 16:53:26 +01:00
$this->disableCache();
2021-12-08 16:53:43 +01:00
$this->disableOutputBuffer();
}
2022-02-20 16:53:26 +01:00
/**
* @throws \PhpMqtt\Client\Exceptions\ProtocolViolationException
* @throws \PhpMqtt\Client\Exceptions\DataTransferException
* @throws \PhpMqtt\Client\Exceptions\MqttClientException
*/
2023-01-16 21:47:23 +01:00
protected function startMqttSSE(MqttClient $mqtt, callable $onPing): void {
2022-02-20 16:53:26 +01:00
$lastPing = 0;
$mqtt->registerLoopEventHandler(function(MqttClient $mqtt, $elapsed) use (&$lastPing, $onPing) {
if ($elapsed - $lastPing >= 5) {
$onPing();
$lastPing = $elapsed;
}
if (connection_status() !== 0) {
$mqtt->interrupt();
}
});
$mqtt->loop();
$this->lastError = "MQTT Loop disconnected";
$mqtt->disconnect();
}
2023-01-16 21:47:23 +01:00
protected function processImageUpload(string $uploadDir, array $allowedExtensions = ["jpg","jpeg","png","gif"], $transformCallback = null): bool|array {
2021-12-08 16:53:43 +01:00
if (empty($_FILES)) {
return $this->createError("You need to upload an image.");
} else if (count($_FILES) > 1) {
return $this->createError("You can only upload one image at once.");
}
$upload = array_values($_FILES)[0];
if (is_array($upload["name"])) {
return $this->createError("You can only upload one image at once.");
} else if ($upload["error"] !== UPLOAD_ERR_OK) {
return $this->createError("There was an error uploading the image, code: " . $upload["error"]);
}
$imageName = $upload["name"];
$ext = strtolower(pathinfo($imageName, PATHINFO_EXTENSION));
if (!in_array($ext, $allowedExtensions)) {
return $this->createError("Only the following file extensions are allowed: " . implode(",", $allowedExtensions));
}
if (!is_dir($uploadDir) && !mkdir($uploadDir, 0777, true)) {
return $this->createError("Upload directory does not exist and could not be created.");
}
$srcPath = $upload["tmp_name"];
$mimeType = mime_content_type($srcPath);
if (!startsWith($mimeType, "image/")) {
return $this->createError("Uploaded file is not an image.");
}
try {
$image = new \Imagick($srcPath);
// strip exif
$profiles = $image->getImageProfiles("icc", true);
$image->stripImage();
if (!empty($profiles)) {
$image->profileImage("icc", $profiles['icc']);
}
} catch (\ImagickException $ex) {
return $this->createError("Error loading image: " . $ex->getMessage());
}
try {
if ($transformCallback) {
$fileName = call_user_func([$this, $transformCallback], $image, $uploadDir);
} else {
$image->writeImage($srcPath);
$image->destroy();
$uuid = uuidv4();
$fileName = "$uuid.$ext";
$destPath = "$uploadDir/$fileName";
if (!file_exists($destPath)) {
if (!@move_uploaded_file($srcPath, $destPath)) {
return $this->createError("Could not store uploaded file.");
}
}
}
return [$fileName, $imageName];
} catch (\ImagickException $ex) {
return $this->createError("Error processing image: " . $ex->getMessage());
}
}
2023-01-15 00:32:17 +01:00
protected function getFileUpload(string $name, bool $allowMultiple = false, ?array $extensions = null): false|array {
if (!isset($_FILES[$name]) || (is_array($_FILES[$name]["name"]) && empty($_FILES[$name]["name"])) || empty($_FILES[$name]["name"])) {
return $this->createError("Missing form-field '$name'");
}
$files = [];
if (is_array($_FILES[$name]["name"])) {
$numFiles = count($_FILES[$name]["name"]);
if (!$allowMultiple && $numFiles > 1) {
return $this->createError("Only one file allowed for form-field '$name'");
} else {
for ($i = 0; $i < $numFiles; $i++) {
$fileName = $_FILES[$name]["name"][$i];
$filePath = $_FILES[$name]["tmp_name"][$i];
$files[$fileName] = $filePath;
if (!empty($extensions) && !in_array(pathinfo($fileName, PATHINFO_EXTENSION), $extensions)) {
return $this->createError("File '$fileName' has forbidden extension, allowed: " . implode(",", $extensions));
}
}
}
} else {
$fileName = $_FILES[$name]["name"];
$filePath = $_FILES[$name]["tmp_name"];
$files[$fileName] = $filePath;
if (!empty($extensions) && !in_array(pathinfo($fileName, PATHINFO_EXTENSION), $extensions)) {
return $this->createError("File '$fileName' has forbidden extension, allowed: " . implode(",", $extensions));
}
}
if ($allowMultiple) {
return $files;
} else {
$fileName = key($files);
return [$fileName, $files[$fileName]];
}
}
2023-01-16 21:47:23 +01:00
public static function getApiEndpoints(): array {
// first load all direct classes
$classes = [];
$apiDirs = ["Core", "Site"];
foreach ($apiDirs as $apiDir) {
$basePath = realpath(WEBROOT . "/$apiDir/API/");
if (!$basePath) {
continue;
}
foreach (scandir($basePath) as $fileName) {
$fullPath = $basePath . "/" . $fileName;
if (is_file($fullPath) && endsWith($fileName, ".class.php")) {
require_once $fullPath;
$apiName = explode(".", $fileName)[0];
$className = "\\$apiDir\\API\\$apiName";
if (!class_exists($className)) {
continue;
}
$reflectionClass = new \ReflectionClass($className);
if (!$reflectionClass->isSubclassOf(Request::class) || $reflectionClass->isAbstract()) {
continue;
}
$endpoint = "$className::getEndpoint"();
$classes[$endpoint] = $reflectionClass;
}
}
}
// then load all inheriting classes
foreach (get_declared_classes() as $declaredClass) {
$reflectionClass = new \ReflectionClass($declaredClass);
if (!$reflectionClass->isAbstract() && $reflectionClass->isSubclassOf(Request::class)) {
$className = $reflectionClass->getName();
$endpoint = "$className::getEndpoint"();
$classes[$endpoint] = $reflectionClass;
}
}
return $classes;
}
2020-04-03 15:56:04 +02:00
}