diff --git a/appinfo/routes.php b/appinfo/routes.php index f96805d7..e8ee2bf2 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -2,11 +2,11 @@ return [ 'resources' => [ + 'Endpoints' => ['url' => 'api/endpoints'], 'Sources' => ['url' => 'api/sources'], 'Mappings' => ['url' => 'api/mappings'], 'Jobs' => ['url' => 'api/jobs'], 'Synchronizations' => ['url' => 'api/synchronizations'], - 'Endpoints' => ['url' => 'api/endpoints'], 'Consumers' => ['url' => 'api/consumers'], ], 'routes' => [ @@ -24,10 +24,10 @@ ['name' => 'mappings#test', 'url' => '/api/mappings/test', 'verb' => 'POST'], ['name' => 'mappings#saveObject', 'url' => '/api/mappings/objects', 'verb' => 'POST'], ['name' => 'mappings#getObjects', 'url' => '/api/mappings/objects', 'verb' => 'GET'], - // Running endpoints - ['name' => 'endpoints#run', 'url' => '/api/v1/{endpoint}', 'verb' => 'GET'], - ['name' => 'endpoints#run', 'url' => '/api/v1/{endpoint}', 'verb' => 'PUT'], - ['name' => 'endpoints#run', 'url' => '/api/v1/{endpoint}', 'verb' => 'POST'], - ['name' => 'endpoints#run', 'url' => '/api/v1/{endpoint}', 'verb' => 'DELETE'], + // Running endpoints - allow any path after /api/endpoints/ + ['name' => 'endpoints#handlePath', 'url' => '/api/endpoint/{_path}', 'verb' => 'GET', 'requirements' => ['_path' => '.+']], +// ['name' => 'endpoints#handlePath', 'url' => '/api/endpoint/{path}', 'verb' => 'PUT', 'requirements' => ['path' => '.+']], +// ['name' => 'endpoints#handlePath', 'url' => '/api/endpoint/{path}', 'verb' => 'POST', 'requirements' => ['path' => '.+']], +// ['name' => 'endpoints#handlePath', 'url' => '/api/endpoint/{path}', 'verb' => 'DELETE', 'requirements' => ['path' => '.+']], ], ]; diff --git a/lib/Controller/EndpointsController.php b/lib/Controller/EndpointsController.php index 0b571570..2bbab0be 100644 --- a/lib/Controller/EndpointsController.php +++ b/lib/Controller/EndpointsController.php @@ -2,8 +2,11 @@ namespace OCA\OpenConnector\Controller; +use OCA\OpenConnector\Exception\AuthenticationException; +use OCA\OpenConnector\Service\AuthorizationService; use OCA\OpenConnector\Service\ObjectService; use OCA\OpenConnector\Service\SearchService; +use OCA\OpenConnector\Service\EndpointService; use OCA\OpenConnector\Db\Endpoint; use OCA\OpenConnector\Db\EndpointMapper; use OCP\AppFramework\Controller; @@ -13,232 +16,213 @@ use OCP\IRequest; use OCP\AppFramework\Db\DoesNotExistException; +/** + * Controller for handling endpoint related operations + */ class EndpointsController extends Controller { - /** - * Constructor for the EndpointsController - * - * @param string $appName The name of the app - * @param IRequest $request The request object - * @param IAppConfig $config The app configuration object - * @param EndpointMapper $endpointMapper The endpoint mapper object - */ - public function __construct( - $appName, - IRequest $request, - private IAppConfig $config, - private EndpointMapper $endpointMapper - ) - { - parent::__construct($appName, $request); - } - - /** - * Returns the template of the main app's page - * - * This method renders the main page of the application, adding any necessary data to the template. - * - * @NoAdminRequired - * @NoCSRFRequired - * - * @return TemplateResponse The rendered template response - */ - public function page(): TemplateResponse - { - return new TemplateResponse( - 'openconnector', - 'index', - [] - ); - } - - /** - * Retrieves a list of all endpoints - * - * This method returns a JSON response containing an array of all endpoints in the system. - * - * @NoAdminRequired - * @NoCSRFRequired - * - * @return JSONResponse A JSON response containing the list of endpoints - */ - public function index(ObjectService $objectService, SearchService $searchService): JSONResponse - { - $filters = $this->request->getParams(); - $fieldsToSearch = ['name', 'description', 'endpoint']; - - $searchParams = $searchService->createMySQLSearchParams(filters: $filters); - $searchConditions = $searchService->createMySQLSearchConditions(filters: $filters, fieldsToSearch: $fieldsToSearch); - $filters = $searchService->unsetSpecialQueryParams(filters: $filters); - - return new JSONResponse(['results' => $this->endpointMapper->findAll(limit: null, offset: null, filters: $filters, searchConditions: $searchConditions, searchParams: $searchParams)]); - } - - /** - * Retrieves a single endpoint by its ID - * - * This method returns a JSON response containing the details of a specific endpoint. - * - * @NoAdminRequired - * @NoCSRFRequired - * - * @param string $id The ID of the endpoint to retrieve - * @return JSONResponse A JSON response containing the endpoint details - */ - public function show(string $id): JSONResponse - { - try { - return new JSONResponse($this->endpointMapper->find(id: (int) $id)); - } catch (DoesNotExistException $exception) { - return new JSONResponse(data: ['error' => 'Not Found'], statusCode: 404); - } - } - - /** - * Creates a new endpoint - * - * This method creates a new endpoint based on POST data. - * - * @NoAdminRequired - * @NoCSRFRequired - * - * @return JSONResponse A JSON response containing the created endpoint - */ - public function create(): JSONResponse - { - $data = $this->request->getParams(); - - foreach ($data as $key => $value) { - if (str_starts_with($key, '_')) { - unset($data[$key]); - } - } - - if (isset($data['id'])) { - unset($data['id']); - } - - $endpoint = $this->endpointMapper->createFromArray(object: $data); - - return new JSONResponse($endpoint); - } - - /** - * Updates an existing endpoint - * - * This method updates an existing endpoint based on its ID. - * - * @NoAdminRequired - * @NoCSRFRequired - * - * @param string $id The ID of the endpoint to update - * @return JSONResponse A JSON response containing the updated endpoint details - */ - public function update(int $id): JSONResponse - { - $data = $this->request->getParams(); - - foreach ($data as $key => $value) { - if (str_starts_with($key, '_')) { - unset($data[$key]); - } - } - if (isset($data['id'])) { - unset($data['id']); - } - - $endpoint = $this->endpointMapper->updateFromArray(id: (int) $id, object: $data); - - return new JSONResponse($endpoint); - } - - /** - * Deletes an endpoint - * - * This method deletes an endpoint based on its ID. - * - * @NoAdminRequired - * @NoCSRFRequired - * - * @param string $id The ID of the endpoint to delete - * @return JSONResponse An empty JSON response - */ - public function destroy(int $id): JSONResponse - { - $this->endpointMapper->delete($this->endpointMapper->find((int) $id)); - - return new JSONResponse([]); - } - - /** - * Retrieves call logs for an endpoint - * - * This method returns all the call logs associated with an endpoint based on its ID. - * - * @NoAdminRequired - * @NoCSRFRequired - * - * @param int $id The ID of the endpoint to retrieve logs for - * @return JSONResponse A JSON response containing the call logs - */ - public function logs(int $id): JSONResponse - { - try { - $endpoint = $this->endpointMapper->find($id); - $endpointLogs = $this->endpointLogMapper->findAll(null, null, ['endpoint_id' => $endpoint->getId()]); - return new JSONResponse($endpointLogs); - } catch (DoesNotExistException $e) { - return new JSONResponse(['error' => 'Endpoint not found'], 404); - } - } - - /** - * Test an endpoint - * - * This method fires a test call to the endpoint and returns the response. - * - * @NoAdminRequired - * @NoCSRFRequired - * - * Endpoint: /api/endpoints-test/{id} - * - * @param int $id The ID of the endpoint to test - * @return JSONResponse A JSON response containing the test results - */ - public function test(int $id): JSONResponse - { - try { - $endpoint = $this->endpointMapper->find(id: $id); - // Implement the logic to test the endpoint here - // This is a placeholder implementation - $testResult = ['status' => 'success', 'message' => 'Endpoint test successful']; - return new JSONResponse($testResult); - } catch (DoesNotExistException $exception) { - return new JSONResponse(data: ['error' => 'Endpoint not found'], statusCode: 404); - } - } - - /** - * Actually run an endpoint - * - * This method runs an endpoint based on its ID. - * - * @NoAdminRequired - * @NoCSRFRequired - * - * @param int $id The ID of the endpoint to run - * @return JSONResponse A JSON response containing the run results - */ - public function run(int $id): JSONResponse - { - try { - $endpoint = $this->endpointMapper->find(id: $id); - // Implement the logic to run the endpoint here - // This is a placeholder implementation - $runResult = ['status' => 'success', 'message' => 'Endpoint run successful']; - return new JSONResponse($runResult); - } catch (DoesNotExistException $exception) { - return new JSONResponse(data: ['error' => 'Endpoint not found'], statusCode: 404); - } - } + /** + * Constructor for the EndpointsController + * + * @param string $appName The name of the app + * @param IRequest $request The request object + * @param IAppConfig $config The app configuration object + * @param EndpointMapper $endpointMapper The endpoint mapper object + * @param EndpointService $endpointService Service for handling endpoint operations + */ + public function __construct( + $appName, + IRequest $request, + private IAppConfig $config, + private EndpointMapper $endpointMapper, + private EndpointService $endpointService, + private AuthorizationService $authorizationService + ) + { + parent::__construct($appName, $request); + } + + /** + * Returns the template of the main app's page + * + * This method renders the main page of the application, adding any necessary data to the template. + * + * @NoAdminRequired + * @NoCSRFRequired + * + * @return TemplateResponse The rendered template response + */ + public function page(): TemplateResponse + { + return new TemplateResponse( + 'openconnector', + 'index', + [] + ); + } + + /** + * Retrieves a list of all endpoints + * + * This method returns a JSON response containing an array of all endpoints in the system. + * + * @NoAdminRequired + * @NoCSRFRequired + * + * @return JSONResponse A JSON response containing the list of endpoints + */ + public function index(ObjectService $objectService, SearchService $searchService): JSONResponse + { + $filters = $this->request->getParams(); + $fieldsToSearch = ['name', 'description', 'endpoint']; + + $searchParams = $searchService->createMySQLSearchParams(filters: $filters); + $searchConditions = $searchService->createMySQLSearchConditions(filters: $filters, fieldsToSearch: $fieldsToSearch); + $filters = $searchService->unsetSpecialQueryParams(filters: $filters); + + return new JSONResponse(['results' => $this->endpointMapper->findAll(limit: null, offset: null, filters: $filters, searchConditions: $searchConditions, searchParams: $searchParams)]); + } + + /** + * Retrieves a single endpoint by its ID + * + * This method returns a JSON response containing the details of a specific endpoint. + * + * @NoAdminRequired + * @NoCSRFRequired + * + * @param string $id The ID of the endpoint to retrieve + * @return JSONResponse A JSON response containing the endpoint details + */ + public function show(string $id): JSONResponse + { + try { + return new JSONResponse($this->endpointMapper->find(id: (int)$id)); + } catch (DoesNotExistException $exception) { + return new JSONResponse(data: ['error' => 'Not Found'], statusCode: 404); + } + } + + /** + * Creates a new endpoint + * + * This method creates a new endpoint based on POST data. + * + * @NoAdminRequired + * @NoCSRFRequired + * + * @return JSONResponse A JSON response containing the created endpoint + */ + public function create(): JSONResponse + { + $data = $this->request->getParams(); + + foreach ($data as $key => $value) { + if (str_starts_with($key, '_')) { + unset($data[$key]); + } + } + + if (isset($data['id'])) { + unset($data['id']); + } + + $endpoint = $this->endpointMapper->createFromArray(object: $data); + + return new JSONResponse($endpoint); + } + + /** + * Updates an existing endpoint + * + * This method updates an existing endpoint based on its ID. + * + * @NoAdminRequired + * @NoCSRFRequired + * + * @param string $id The ID of the endpoint to update + * @return JSONResponse A JSON response containing the updated endpoint details + */ + public function update(int $id): JSONResponse + { + $data = $this->request->getParams(); + + foreach ($data as $key => $value) { + if (str_starts_with($key, '_')) { + unset($data[$key]); + } + } + if (isset($data['id'])) { + unset($data['id']); + } + + $endpoint = $this->endpointMapper->updateFromArray(id: (int)$id, object: $data); + + return new JSONResponse($endpoint); + } + + /** + * Deletes an endpoint + * + * This method deletes an endpoint based on its ID. + * + * @NoAdminRequired + * @NoCSRFRequired + * + * @param string $id The ID of the endpoint to delete + * @return JSONResponse An empty JSON response + */ + public function destroy(int $id): JSONResponse + { + $this->endpointMapper->delete($this->endpointMapper->find((int)$id)); + + return new JSONResponse([]); + } + + /** + * Handles generic path requests by matching against registered endpoints + * + * This method checks if the current path matches any registered endpoint patterns + * and forwards the request to the appropriate endpoint service if found + * + * @NoAdminRequired + * @NoCSRFRequired + * @PublicPage + * + * @param string $path The request path to match + * @return JSONResponse The response from the endpoint service or 404 if no match + */ + public function handlePath(string $_path): JSONResponse + { + try { + $token = $this->request->getHeader('Authorization'); + $this->authorizationService->authorize(authorization: $token); + } catch (AuthenticationException $exception) { + return new JSONResponse( + data: ['error' => $exception->getMessage(), 'details' => $exception->getDetails()], + statusCode: 401 + ); + } + + // Find matching endpoints for the given path and method + $matchingEndpoints = $this->endpointMapper->findByPathRegex( + path: $_path, + method: $this->request->getMethod() + ); + + // If no matching endpoints found, return 404 + if (empty($matchingEndpoints)) { + return new JSONResponse( + data: ['error' => 'No matching endpoint found for path and method: ' . $_path . ' ' . $this->request->getMethod()], + statusCode: 404 + ); + } + + // Get the first matching endpoint since we already filtered by method + $endpoint = reset($matchingEndpoints); + + // Forward the request to the endpoint service + return $this->endpointService->handleRequest($endpoint, $this->request, $_path); + } + } diff --git a/lib/Db/Consumer.php b/lib/Db/Consumer.php index c5c8b4b5..2fcd8c7b 100644 --- a/lib/Db/Consumer.php +++ b/lib/Db/Consumer.php @@ -18,14 +18,15 @@ class Consumer extends Entity implements JsonSerializable { protected ?string $uuid = null; - protected ?string $name = null; // The name of the consumer + protected ?string $name = null; // The name of the consumer protected ?string $description = null; // The description of the consumer protected ?array $domains = []; // The domains the consumer is allowed to run from protected ?array $ips = []; // The ips the consumer is allowed to run from protected ?string $authorizationType = null; // The authorization type of the consumer, should be one of the following: 'none', 'basic', 'bearer', 'apiKey', 'oauth2', 'jwt'. Keep in mind that the consumer needs to be able to handle the authorization type. - protected ?string $authorizationConfiguration = null; // The authorization configuration of the consumer + protected ?array $authorizationConfiguration = []; // The authorization configuration of the consumer protected ?DateTime $created = null; // the date and time the consumer was created protected ?DateTime $updated = null; // the date and time the consumer was updated + protected ?string $userId = null; /** * Consumer constructor. @@ -38,9 +39,10 @@ public function __construct() { $this->addType('domains', 'json'); $this->addType('ips', 'json'); $this->addType('authorizationType', 'string'); - $this->addType('authorizationConfiguration', 'string'); + $this->addType('authorizationConfiguration', 'json'); $this->addType('created', 'datetime'); $this->addType('updated', 'datetime'); + $this->addType('userId', 'string'); } /** @@ -100,6 +102,7 @@ public function jsonSerialize(): array 'ips' => $this->ips, 'authorizationType' => $this->authorizationType, 'authorizationConfiguration' => $this->authorizationConfiguration, + 'userId' => $this->userId, 'created' => isset($this->created) ? $this->created->format('c') : null, 'updated' => isset($this->updated) ? $this->updated->format('c') : null, ]; diff --git a/lib/Db/Endpoint.php b/lib/Db/Endpoint.php index a4cc78d5..8333009f 100644 --- a/lib/Db/Endpoint.php +++ b/lib/Db/Endpoint.php @@ -8,34 +8,37 @@ class Endpoint extends Entity implements JsonSerializable { - protected ?string $uuid = null; - protected ?string $name = null; // The name of the endpoint - protected ?string $description = null; // The description of the endpoint - protected ?string $reference = null; // The reference of the endpoint - protected ?string $version = '0.0.0'; // The version of the endpoint - protected ?string $endpoint = null; // The actual endpoint e.g /api/buildings/{{id}}. An endpoint may contain parameters e.g {{id}} - protected ?array $endpointArray = null; // An array representation of the endpoint. Automatically generated - protected ?string $endpointRegex = null; // A regex representation of the endpoint. Automatically generated - protected ?string $method = null; // One of GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD. method and endpoint combination should be unique - protected ?string $targetType = null; // The target to attach this endpoint to, should be one of source (to create a proxy endpoint) or register/schema (to create an object endpoint) or job (to fire an event) or synchronization (to create a synchronization endpoint) - protected ?string $targetId = null; // The target id to attach this endpoint to + protected ?string $uuid = null; + protected ?string $name = null; // The name of the endpoint + protected ?string $description = null; // The description of the endpoint + protected ?string $reference = null; // The reference of the endpoint + protected ?string $version = '0.0.0'; // The version of the endpoint + protected ?string $endpoint = null; // The actual endpoint e.g /api/buildings/{{id}}. An endpoint may contain parameters e.g {{id}} + protected ?array $endpointArray = null; // An array representation of the endpoint. Automatically generated + protected ?string $endpointRegex = null; // A regex representation of the endpoint. Automatically generated + protected ?string $method = null; // One of GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD. method and endpoint combination should be unique + protected ?string $targetType = null; // The target to attach this endpoint to, should be one of source (to create a proxy endpoint) or register/schema (to create an object endpoint) or job (to fire an event) or synchronization (to create a synchronization endpoint) + protected ?string $targetId = null; // The target id to attach this endpoint to protected ?DateTime $created = null; protected ?DateTime $updated = null; public function __construct() { - $this->addType('uuid', 'string'); - $this->addType('name', 'string'); - $this->addType('description', 'string'); - $this->addType('reference', 'string'); - $this->addType('version', 'string'); - $this->addType('endpoint', 'string'); - $this->addType('endpointArray', 'json'); - $this->addType('endpointRegex', 'string'); - $this->addType('method', 'string'); - $this->addType('targetType', 'string'); - $this->addType('targetId', 'string'); - $this->addType('created', 'datetime'); - $this->addType('updated', 'datetime'); + $this->addType(fieldName:'uuid', type: 'string'); + $this->addType(fieldName:'name', type: 'string'); + $this->addType(fieldName:'description', type: 'string'); + $this->addType(fieldName:'reference', type: 'string'); + $this->addType(fieldName:'version', type: 'string'); + $this->addType(fieldName:'endpoint', type: 'string'); + $this->addType(fieldName:'endpointArray', type: 'json'); + $this->addType(fieldName:'endpointRegex', type: 'string'); + $this->addType(fieldName:'method', type: 'string'); + $this->addType(fieldName:'targetType', type: 'string'); + $this->addType(fieldName:'targetId', type: 'string'); + $this->addType(fieldName:'schema', type: 'int'); + $this->addType(fieldName:'register', type: 'int'); + $this->addType(fieldName:'source', type: 'int'); + $this->addType(fieldName:'created', type: 'datetime'); + $this->addType(fieldName:'updated', type: 'datetime'); } public function getJsonFields(): array @@ -85,7 +88,7 @@ public function jsonSerialize(): array 'targetId' => $this->targetId, 'created' => isset($this->created) ? $this->created->format('c') : null, 'updated' => isset($this->updated) ? $this->updated->format('c') : null, - + ]; } } diff --git a/lib/Db/EndpointMapper.php b/lib/Db/EndpointMapper.php index b391fc53..a7727cbe 100644 --- a/lib/Db/EndpointMapper.php +++ b/lib/Db/EndpointMapper.php @@ -9,6 +9,9 @@ use OCP\IDBConnection; use Symfony\Component\Uid\Uuid; +/** + * Mapper class for handling Endpoint database operations + */ class EndpointMapper extends QBMapper { public function __construct(IDBConnection $db) @@ -58,6 +61,10 @@ public function findAll(?int $limit = null, ?int $offset = null, ?array $filters return $this->findEntities(query: $qb); } + private function createEndpointRegex(string $endpoint) { + return '#'.preg_replace(pattern: ['#\/{{([^}}]+)}}\/#', '#\/{{([^}}]+)}}$#'], replacement: ['/([^/]+)/', '(/([^/]+))?'], subject: $endpoint).'#'; + } + public function createFromArray(array $object): Endpoint { $obj = new Endpoint(); @@ -66,6 +73,10 @@ public function createFromArray(array $object): Endpoint if ($obj->getUuid() === null){ $obj->setUuid(Uuid::v4()); } + + $obj->setEndpointRegex($this->createEndpointRegex($obj->getEndpoint())); + $obj->setEndpointArray(explode('/', $obj->getEndpoint())); + return $this->insert(entity: $obj); } @@ -79,6 +90,9 @@ public function updateFromArray(int $id, array $object): Endpoint $version[2] = (int)$version[2] + 1; $obj->setVersion(implode('.', $version)); + $obj->setEndpointRegex($this->createEndpointRegex($obj->getEndpoint())); + $obj->setEndpointArray(explode('/', $obj->getEndpoint())); + return $this->update($obj); } @@ -101,4 +115,32 @@ public function getTotalCallCount(): int // Return the total count return (int)$row['count']; } + + /** + * Find endpoints that match a given path and method using regex comparison + * + * @param string $path The path to match against endpoint regex patterns + * @param string $method The HTTP method to filter by (GET, POST, etc) + * @return array Array of matching Endpoint entities + */ + public function findByPathRegex(string $path, string $method): array + { + // Get all endpoints first since we need to do regex comparison + $endpoints = $this->findAll(); + + // Filter endpoints where both path matches regex pattern and method matches + return array_filter($endpoints, function(Endpoint $endpoint) use ($path, $method) { + // Get the regex pattern from the endpoint + $pattern = $endpoint->getEndpointRegex(); + + // Skip if no regex pattern is set + if (empty($pattern) === true) { + return false; + } + + // Check if both path matches the regex pattern and method matches + return preg_match($pattern, $path) === 1 && + $endpoint->getMethod() === $method; + }); + } } diff --git a/lib/Exception/AuthenticationException.php b/lib/Exception/AuthenticationException.php new file mode 100644 index 00000000..e7e04ed9 --- /dev/null +++ b/lib/Exception/AuthenticationException.php @@ -0,0 +1,33 @@ +details = $details; + parent::__construct($message); + } + + /** + * Retrieves the details to display them. + * + * @return array The details array. + */ + public function getDetails(): array + { + return $this->details; + } +} diff --git a/lib/Migration/Version1Date20241218122708.php b/lib/Migration/Version1Date20241218122708.php new file mode 100644 index 00000000..2925f4a0 --- /dev/null +++ b/lib/Migration/Version1Date20241218122708.php @@ -0,0 +1,57 @@ +hasTable(tableName: 'openconnector_consumers') === true) { + $table = $schema->getTable(tableName: 'openconnector_consumers'); + $table->dropColumn('authorization_configuration'); + } + + return $schema; + } + + /** + * @param IOutput $output + * @param Closure(): ISchemaWrapper $schemaClosure + * @param array $options + */ + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + } +} diff --git a/lib/Migration/Version1Date20241218122932.php b/lib/Migration/Version1Date20241218122932.php new file mode 100644 index 00000000..7c66532e --- /dev/null +++ b/lib/Migration/Version1Date20241218122932.php @@ -0,0 +1,59 @@ +hasTable(tableName: 'openconnector_consumers') === true) { + $table = $schema->getTable(tableName: 'openconnector_consumers'); + $table->addColumn('authorization_configuration', Types::JSON); + $table->addColumn('user_id', Types::STRING); + } + + return $schema; + } + + /** + * @param IOutput $output + * @param Closure(): ISchemaWrapper $schemaClosure + * @param array $options + */ + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + } +} diff --git a/lib/Service/AuthorizationService.php b/lib/Service/AuthorizationService.php new file mode 100644 index 00000000..dd50243f --- /dev/null +++ b/lib/Service/AuthorizationService.php @@ -0,0 +1,201 @@ +consumerMapper->findAll(filters: ['name' => $issuer]); + + if (count($consumers) === 0) { + throw new AuthenticationException(message: 'The issuer was not found', details: ['iss' => $issuer]); + } + + return $consumers[0]; + } + + /** + * Check if the headers of a JWT token are valid. + * + * @param JWS $token The unserialized token. + * @return void + */ + private function checkHeaders(JWS $token): void + { + $headerChecker = new HeaderCheckerManager( + checkers: [ + new AlgorithmChecker(array_merge(self::HMAC_ALGORITHMS, self::PKCS1_ALGORITHMS, self::PSS_ALGORITHMS)) + ], + tokenTypes: [new JWSTokenSupport()]); + + $headerChecker->check(jwt: $token, index: 0); + + } + + /** + * Get the Json Web Key for a public key combined with an algorithm. + * + * @param string $publicKey The public key to create a JWK for + * @param string $algorithm The algorithm deciding how the key should be defined. + * @return JWKSet The resulting JWK-set. + * @throws AuthenticationException + */ + private function getJWK(string $publicKey, string $algorithm): JWKSet + { + + if (in_array(needle: $algorithm, haystack: self::HMAC_ALGORITHMS) === true) { + return new JWKSet([ + JWKFactory::createFromSecret( + secret: $publicKey, + additional_values: ['alg' => $algorithm, 'use' => 'sig']) + ]); + } else if (in_array(needle: $algorithm, haystack: self::PKCS1_ALGORITHMS) === true + || in_array(needle: $algorithm, haystack: self::PSS_ALGORITHMS) === true + ) { + $stamp = microtime() . getmypid(); + $filename = "/var/tmp/publickey-$stamp"; + file_put_contents($filename, base64_decode($publicKey)); + $jwk = new JWKSet([JWKFactory::createFromKeyFile(file: $filename)]); + unlink($filename); + return $jwk; + } + throw new AuthenticationException(message: 'The token algorithm is not supported', details: ['algorithm' => $algorithm]); + } + + /** + * Validate data in the payload. + * + * @param array $payload The payload of the JWT token. + * @return void + * @throws AuthenticationException + */ + public function validatePayload(array $payload): void + { + $now = new DateTime(); + + if (isset($payload['iat']) === true) { + $iat = new DateTime('@' . $payload['iat']); + } else { + throw new AuthenticationException(message: 'The token has no time of creation', details: ['iat' => null]); + } + + if (isset($payload['exp']) === true) { + $exp = new DateTime('@' . $payload['exp']); + } else { + $exp = clone $iat; + $exp->modify('+1 Hour'); + } + + if ($exp->diff($now)->format('%R') === '+') { + throw new AuthenticationException(message: 'The token has expired', details: ['iat' => $iat->getTimestamp(), 'exp' => $exp->getTimestamp(), 'time checked' => $now->getTimestamp()]); + } + } + + /** + * Checks if authorization header contains a valid JWT token. + * + * @param string $authorization The authorization header. + * @return void + * @throws AuthenticationException + */ + public function authorize(string $authorization): void + { + $token = substr(string: $authorization, offset: strlen('Bearer ')); + + if ($token === '') { + throw new AuthenticationException(message: 'No token has been provided', details: []); + } + + $algorithmManager = new AlgorithmManager([ + new HS256(), + new HS384(), + new HS256(), + new RS256(), + new RS384(), + new RS512(), + new PS256(), + new PS384(), + new PS512() + ]); + $verifier = new JWSVerifier($algorithmManager); + $serializerManager = new JWSSerializerManager([new CompactSerializer()]); + + + $jws = $serializerManager->unserialize(input: $token); + + try { + $this->checkHeaders($jws); + } catch (InvalidHeaderException $exception) { + throw new AuthenticationException(message: 'The token could not be validated', details: ['reason' => $exception->getMessage()]); + } + + $payload = json_decode(json: $jws->getPayload(), associative: true); + $issuer = $this->findIssuer(issuer: $payload['iss']); + + $publicKey = $issuer->getAuthorizationConfiguration()['publicKey']; + $algorithm = $issuer->getAuthorizationConfiguration()['algorithm']; + + $jwkSet = $this->getJWK(publicKey: $publicKey, algorithm: $algorithm); + + if ($verifier->verifyWithKeySet(jws: $jws, jwkset: $jwkSet, signatureIndex: 0) === false) { + throw new AuthenticationException(message: 'The token could not be validated', details: ['reason' => 'The token does not match the public key']); + } + $this->validatePayload($payload); + $this->userSession->setUser($this->userManager->get($issuer->getUserId())); + } +} diff --git a/lib/Service/EndpointService.php b/lib/Service/EndpointService.php new file mode 100644 index 00000000..a4a7efb4 --- /dev/null +++ b/lib/Service/EndpointService.php @@ -0,0 +1,330 @@ +getTargetType() === 'register/schema') { + // Handle CRUD operations via ObjectService + return $this->handleSchemaRequest($endpoint, $request, $path); + } + + // Check if endpoint connects to a source + if ($endpoint->getTargetType() === 'api') { + // Proxy request to source via CallService + return $this->handleSourceRequest($endpoint, $request); + } + + // Invalid endpoint configuration + throw new Exception('Endpoint must specify either a schema or source connection'); + + } catch (Exception $e) { + $this->logger->error('Error handling endpoint request: ' . $e->getMessage()); + return new JSONResponse( + ['error' => $e->getMessage()], + 400 + ); + } + } + + /** + * Parses a path to get the parameters in a path. + * + * @param array $endpointArray The endpoint array from an endpoint object. + * @param string $path The path called by the client. + * + * @return array The parsed path with the fields having the correct name. + */ + private function getPathParameters(array $endpointArray, string $path): array + { + $pathParts = explode(separator: '/', string: $path); + + $endpointArrayNormalized = array_map( + function ($item) { + return str_replace( + search: ['{{', '{{ ', '}}', '}}'], + replace: '', + subject: $item + ); + }, + $endpointArray); + + try { + $pathParams = array_combine( + keys: $endpointArrayNormalized, + values: $pathParts + ); + } catch (ValueError $error) { + array_pop($endpointArrayNormalized); + $pathParams = array_combine( + keys: $endpointArrayNormalized, + values: $pathParts + ); + } + + return $pathParams; + } + + /** + * Fetch objects for the endpoint. + * + * @param \OCA\OpenRegister\Service\ObjectService|QBMapper $mapper The mapper for the object type + * @param array $parameters The parameters from the request + * @param array $pathParams The parameters in the path + * @param int $status The HTTP status to return. + * @return Entity|array The object(s) confirming to the request. + * @throws Exception + */ + private function getObjects( + \OCA\OpenRegister\Service\ObjectService|QBMapper $mapper, + array $parameters, + array $pathParams, + int &$status = 200 + ): Entity|array + { + if (isset($pathParams['id']) === true && $pathParams['id'] === end($pathParams)) { + return $mapper->find($pathParams['id']); + } else if (isset($pathParams['id']) === true) { + + // Set the array pointer to the location of the id, so we can fetch the parameters further down the line in order. + while (prev($pathParams) !== $pathParams['id']) { + } + + $property = next($pathParams); + + if (next($pathParams) !== false) { + $id = pos($pathParams); + } + + $main = $mapper->find($pathParams['id'])->getObject(); + $ids = $main[$property]; + + if (isset($id) === true && in_array(needle: $id, haystack: $ids) === true) { + + return $mapper->findSubObjects([$id], $property)[0]; + } else if (isset($id) === true) { + $status = 404; + return ['error' => 'not found', 'message' => "the subobject with id $id does not exist"]; + + } + + return $mapper->findSubObjects($ids, $property); + } + + $result = $mapper->findAllPaginated(requestParams: $parameters); + + $returnArray = [ + 'count' => $result['total'], + ]; + + if ($result['page'] < $result['pages']) { + $parameters['page'] = $result['page'] + 1; + $parameters['_path'] = implode('/', $pathParams); + + $returnArray['next'] = $this->urlGenerator->getAbsoluteURL( + $this->urlGenerator->linkToRoute( + routeName: 'openconnector.endpoints.handlepath', + arguments: $parameters + ) + ); + } + if ($result['page'] > 1) { + $parameters['page'] = $result['page'] - 1; + $parameters['_path'] = implode('/', $pathParams); + + $returnArray['previous'] = $this->urlGenerator->getAbsoluteURL( + $this->urlGenerator->linkToRoute( + routeName: 'openconnector.endpoints.handlepath', + arguments: $parameters + ) + ); + } + + $returnArray['results'] = $result['results']; + + return $returnArray; + } + + /** + * Handles requests for schema-based endpoints + * + * @param Endpoint $endpoint The endpoint configuration + * @param IRequest $request The incoming request + * @return JSONResponse + */ + private function handleSchemaRequest(Endpoint $endpoint, IRequest $request, string $path): JSONResponse + { + // Get request method + $method = $request->getMethod(); + $target = explode('/', $endpoint->getTargetId()); + + $register = $target[0]; + $schema = $target[1]; + + $mapper = $this->objectService->getMapper(schema: $schema, register: $register); + + $parameters = $request->getParams(); + + $pathParams = $this->getPathParameters($endpoint->getEndpointArray(), $path); + + unset($parameters['_route'], $parameters['_path']); + + $status = 200; + + // Route to appropriate ObjectService method based on HTTP method + return match ($method) { + 'GET' => new JSONResponse( + $this->getObjects(mapper: $mapper, parameters: $parameters, pathParams: $pathParams, status: $status), statusCode: $status + ), + 'POST' => new JSONResponse( + $mapper->createFromArray(object: $parameters) + ), + 'PUT' => new JSONResponse( + $mapper->updateFromArray($request->getParams()['id'], $request->getParams(), true, true) + ), + 'DELETE' => new JSONResponse( + $mapper->delete($request->getParams()) + ), + default => throw new Exception('Unsupported HTTP method') + }; + } + + /** + * Gets the raw content for an http request from the input stream. + * + * @return string The raw content body for an http request + */ + private function getRawContent(): string + { + return file_get_contents(filename: 'php://input'); + } + + /** + * Get all headers for a HTTP request. + * + * @param array $server The server data from the request. + * @param bool $proxyHeaders Whether the proxy headers should be returned. + * + * @return array The resulting headers. + */ + private function getHeaders(array $server, bool $proxyHeaders = false): array + { + $headers = array_filter( + array: $server, + callback: function (string $key) use ($proxyHeaders) { + if (str_starts_with($key, 'HTTP_') === false) { + return false; + } else if ($proxyHeaders === false + && (str_starts_with(haystack: $key, needle: 'HTTP_X_FORWARDED') + || $key === 'HTTP_X_REAL_IP' || $key === 'HTTP_X_ORIGINAL_URI' + ) + ) { + return false; + } + + return true; + }, + mode: ARRAY_FILTER_USE_KEY + ); + + $keys = array_keys($headers); + + return array_combine( + array_map( + callback: function ($key) { + return strtolower(string: substr(string: $key, offset: 5)); + }, + array: $keys), + $headers + ); + } + + /** + * Handles requests for source-based endpoints + * + * @param Endpoint $endpoint The endpoint configuration + * @param IRequest $request The incoming request + * @return JSONResponse + */ + private function handleSourceRequest(Endpoint $endpoint, IRequest $request): JSONResponse + { + $headers = $this->getHeaders($request->server); + + // Proxy the request to the source via CallService + $response = $this->callService->call( + source: $endpoint->getSource(), + endpoint: $endpoint->getPath(), + method: $request->getMethod(), + config: [ + 'query' => $request->getParams(), + 'headers' => $headers, + 'body' => $this->getRawContent(), + ] + ); + + return new JSONResponse( + $response->getResponse(), + $response->getStatusCode() + ); + } +} diff --git a/lib/Service/ObjectService.php b/lib/Service/ObjectService.php index eb5f2c00..c1d8e6a5 100644 --- a/lib/Service/ObjectService.php +++ b/lib/Service/ObjectService.php @@ -8,6 +8,7 @@ use GuzzleHttp\Exception\ClientException; use GuzzleHttp\Exception\GuzzleException; use OCP\App\IAppManager; +use OCP\AppFramework\Db\QBMapper; use Psr\Container\ContainerExceptionInterface; use Psr\Container\ContainerInterface; use Psr\Container\NotFoundExceptionInterface; @@ -251,4 +252,25 @@ public function getOpenRegisters(): ?\OCA\OpenRegister\Service\ObjectService return null; } + /** + * Get the mapper for the given objecttype (usually the proper instantiation of the objectService of OpenRegister. + * + * @param string|null $objecttype The objecttype as string + * @param int|null $schema The openregister schema + * @param int|null $register The openregister register + * + * @return QBMapper|\OCA\OpenRegister\Service\ObjectService|null The resulting mapper + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function getMapper(?string $objecttype = null, ?int $schema = null, ?int $register = null): QBMapper|\OCA\OpenRegister\Service\ObjectService|null + { + if ($register !== null && $schema !== null && $objecttype === null) { + return $this->getOpenRegisters()->getMapper(register: $register, schema: $schema); + } + + return null; + + } + }