From 6995223b1ed202c7f8e920e83cb5b53efd7ce761 Mon Sep 17 00:00:00 2001 From: Christoph Wurst Date: Mon, 14 Dec 2020 15:56:07 +0100 Subject: [PATCH] Add well known handlers API Signed-off-by: Christoph Wurst --- .htaccess | 4 - apps/settings/js/admin.js | 8 +- apps/settings/lib/AppInfo/Application.php | 21 --- core/Controller/WellKnownController.php | 67 +++++++ core/js/setupchecks.js | 7 +- core/js/tests/specs/setupchecksSpec.js | 8 +- core/routes.php | 3 + lib/composer/composer/autoload_classmap.php | 7 + lib/composer/composer/autoload_static.php | 7 + .../Bootstrap/RegistrationContext.php | 24 +++ lib/private/Http/WellKnown/RequestManager.php | 124 ++++++++++++ .../Bootstrap/IRegistrationContext.php | 14 ++ lib/public/Http/WellKnown/GenericResponse.php | 51 +++++ lib/public/Http/WellKnown/IHandler.php | 48 +++++ lib/public/Http/WellKnown/IRequestContext.php | 46 +++++ lib/public/Http/WellKnown/IResponse.php | 39 ++++ lib/public/Http/WellKnown/JrdResponse.php | 170 +++++++++++++++++ .../Controller/WellKnownControllerTest.php | 86 +++++++++ .../Http/WellKnown/GenericResponseTest.php | 40 ++++ tests/lib/Http/WellKnown/JrdResponseTest.php | 108 +++++++++++ .../lib/Http/WellKnown/RequestManagerTest.php | 176 ++++++++++++++++++ 21 files changed, 1022 insertions(+), 36 deletions(-) create mode 100644 core/Controller/WellKnownController.php create mode 100644 lib/private/Http/WellKnown/RequestManager.php create mode 100644 lib/public/Http/WellKnown/GenericResponse.php create mode 100644 lib/public/Http/WellKnown/IHandler.php create mode 100644 lib/public/Http/WellKnown/IRequestContext.php create mode 100644 lib/public/Http/WellKnown/IResponse.php create mode 100644 lib/public/Http/WellKnown/JrdResponse.php create mode 100644 tests/Core/Controller/WellKnownControllerTest.php create mode 100644 tests/lib/Http/WellKnown/GenericResponseTest.php create mode 100644 tests/lib/Http/WellKnown/JrdResponseTest.php create mode 100644 tests/lib/Http/WellKnown/RequestManagerTest.php diff --git a/.htaccess b/.htaccess index 87280cc9e01f7..8f13d0509754f 100644 --- a/.htaccess +++ b/.htaccess @@ -61,10 +61,6 @@ RewriteCond %{HTTP_USER_AGENT} DavClnt RewriteRule ^$ /remote.php/webdav/ [L,R=302] RewriteRule .* - [env=HTTP_AUTHORIZATION:%{HTTP:Authorization}] - RewriteRule ^\.well-known/host-meta /public.php?service=host-meta [QSA,L] - RewriteRule ^\.well-known/host-meta\.json /public.php?service=host-meta-json [QSA,L] - RewriteRule ^\.well-known/webfinger /public.php?service=webfinger [QSA,L] - RewriteRule ^\.well-known/nodeinfo /public.php?service=nodeinfo [QSA,L] RewriteRule ^\.well-known/carddav /remote.php/dav/ [R=301,L] RewriteRule ^\.well-known/caldav /remote.php/dav/ [R=301,L] RewriteRule ^remote/(.*) remote.php [QSA,L] diff --git a/apps/settings/js/admin.js b/apps/settings/js/admin.js index 9252125de1224..cffaefa3821b3 100644 --- a/apps/settings/js/admin.js +++ b/apps/settings/js/admin.js @@ -256,10 +256,10 @@ window.addEventListener('DOMContentLoaded', function(){ // run setup checks then gather error messages $.when( OC.SetupChecks.checkWebDAV(), - OC.SetupChecks.checkWellKnownUrl('/.well-known/webfinger', OC.theme.docPlaceholderUrl, $('#postsetupchecks').data('check-wellknown') === true && !!OC.appConfig.core.public_webfinger, [200, 404]), - OC.SetupChecks.checkWellKnownUrl('/.well-known/nodeinfo', OC.theme.docPlaceholderUrl, $('#postsetupchecks').data('check-wellknown') === true && !!OC.appConfig.core.public_nodeinfo, [200, 404]), - OC.SetupChecks.checkWellKnownUrl('/.well-known/caldav', OC.theme.docPlaceholderUrl, $('#postsetupchecks').data('check-wellknown') === true), - OC.SetupChecks.checkWellKnownUrl('/.well-known/carddav', OC.theme.docPlaceholderUrl, $('#postsetupchecks').data('check-wellknown') === true), + OC.SetupChecks.checkWellKnownUrl('GET', '/.well-known/webfinger', OC.theme.docPlaceholderUrl, $('#postsetupchecks').data('check-wellknown') === true, [200, 404], true), + OC.SetupChecks.checkWellKnownUrl('GET', '/.well-known/nodeinfo', OC.theme.docPlaceholderUrl, $('#postsetupchecks').data('check-wellknown') === true, [200, 404], true), + OC.SetupChecks.checkWellKnownUrl('PROPFIND', '/.well-known/caldav', OC.theme.docPlaceholderUrl, $('#postsetupchecks').data('check-wellknown') === true), + OC.SetupChecks.checkWellKnownUrl('PROPFIND', '/.well-known/carddav', OC.theme.docPlaceholderUrl, $('#postsetupchecks').data('check-wellknown') === true), OC.SetupChecks.checkProviderUrl(OC.getRootPath() + '/ocm-provider/', OC.theme.docPlaceholderUrl, $('#postsetupchecks').data('check-wellknown') === true), OC.SetupChecks.checkProviderUrl(OC.getRootPath() + '/ocs-provider/', OC.theme.docPlaceholderUrl, $('#postsetupchecks').data('check-wellknown') === true), OC.SetupChecks.checkSetup(), diff --git a/apps/settings/lib/AppInfo/Application.php b/apps/settings/lib/AppInfo/Application.php index fe9ba63b01238..af45c56e517e8 100644 --- a/apps/settings/lib/AppInfo/Application.php +++ b/apps/settings/lib/AppInfo/Application.php @@ -163,8 +163,6 @@ public function boot(IBootContext $context): void { $groupManager->listen('\OC\Group', 'postRemoveUser', [$this, 'removeUserFromGroup']); $groupManager->listen('\OC\Group', 'postAddUser', [$this, 'addUserToGroup']); }); - - Util::connectHook('\OCP\Config', 'js', $this, 'extendJsConfig'); } public function addUserToGroup(IGroup $group, IUser $user): void { @@ -209,23 +207,4 @@ public function onChangeInfo(array $parameters) { $hooks = $this->getContainer()->query(Hooks::class); $hooks->onChangeEmail($parameters['user'], $parameters['old_value']); } - - /** - * @param array $settings - */ - public function extendJsConfig(array $settings) { - $appConfig = json_decode($settings['array']['oc_appconfig'], true); - - $publicWebFinger = \OC::$server->getConfig()->getAppValue('core', 'public_webfinger', ''); - if (!empty($publicWebFinger)) { - $appConfig['core']['public_webfinger'] = $publicWebFinger; - } - - $publicNodeInfo = \OC::$server->getConfig()->getAppValue('core', 'public_nodeinfo', ''); - if (!empty($publicNodeInfo)) { - $appConfig['core']['public_nodeinfo'] = $publicNodeInfo; - } - - $settings['array']['oc_appconfig'] = json_encode($appConfig); - } } diff --git a/core/Controller/WellKnownController.php b/core/Controller/WellKnownController.php new file mode 100644 index 0000000000000..1d45e87569b27 --- /dev/null +++ b/core/Controller/WellKnownController.php @@ -0,0 +1,67 @@ + + * + * @author 2020 Christoph Wurst + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OC\Core\Controller; + +use OC\Http\WellKnown\RequestManager; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; +use OCP\AppFramework\Http\Response; +use OCP\IRequest; + +class WellKnownController extends Controller { + + /** @var RequestManager */ + private $requestManager; + + public function __construct(IRequest $request, + RequestManager $wellKnownManager) { + parent::__construct('core', $request); + $this->requestManager = $wellKnownManager; + } + + /** + * @PublicPage + * @NoCSRFRequired + * + * @return Response + */ + public function handle(string $service): Response { + $response = $this->requestManager->process( + $service, + $this->request + ); + + if ($response === null) { + $httpResponse = new JSONResponse(["message" => "$service not supported"], Http::STATUS_NOT_FOUND); + } else { + $httpResponse = $response->toHttpResponse(); + } + + // We add a custom header so that setup checks can detect if their requests are answered by this controller + return $httpResponse->addHeader('X-NEXTCLOUD-WELL-KNOWN', '1'); + } +} diff --git a/core/js/setupchecks.js b/core/js/setupchecks.js index 22c8589f73b7a..fb01a91b30e56 100644 --- a/core/js/setupchecks.js +++ b/core/js/setupchecks.js @@ -56,7 +56,7 @@ * @param {int|int[]} expectedStatus the expected HTTP status to be returned by the URL, 207 by default * @return $.Deferred object resolved with an array of error messages */ - checkWellKnownUrl: function(url, placeholderUrl, runCheck, expectedStatus) { + checkWellKnownUrl: function(verb, url, placeholderUrl, runCheck, expectedStatus, checkCustomHeader) { if (expectedStatus === undefined) { expectedStatus = [207]; } @@ -73,7 +73,8 @@ } var afterCall = function(xhr) { var messages = []; - if (expectedStatus.indexOf(xhr.status) === -1) { + var customWellKnown = xhr.getResponseHeader('X-NEXTCLOUD-WELL-KNOWN') + if (expectedStatus.indexOf(xhr.status) === -1 || (checkCustomHeader && !customWellKnown)) { var docUrl = placeholderUrl.replace('PLACEHOLDER', 'admin-setup-well-known-URL'); messages.push({ msg: t('core', 'Your web server is not properly set up to resolve "{url}". Further information can be found in the documentation.', { docLink: docUrl, url: url }), @@ -84,7 +85,7 @@ }; $.ajax({ - type: 'PROPFIND', + type: verb, url: url, complete: afterCall, allowAuthErrors: true diff --git a/core/js/tests/specs/setupchecksSpec.js b/core/js/tests/specs/setupchecksSpec.js index c3cddb88a9d75..3f02302ee8070 100644 --- a/core/js/tests/specs/setupchecksSpec.js +++ b/core/js/tests/specs/setupchecksSpec.js @@ -62,7 +62,7 @@ describe('OC.SetupChecks tests', function() { describe('checkWellKnownUrl', function() { it('should fail with another response status code than the expected one', function(done) { - var async = OC.SetupChecks.checkWellKnownUrl('/.well-known/caldav', 'http://example.org/PLACEHOLDER', true, 207); + var async = OC.SetupChecks.checkWellKnownUrl('PROPFIND', '/.well-known/caldav', 'http://example.org/PLACEHOLDER', true, 207); suite.server.requests[0].respond(200); @@ -76,7 +76,7 @@ describe('OC.SetupChecks tests', function() { }); it('should return no error with the expected response status code', function(done) { - var async = OC.SetupChecks.checkWellKnownUrl('/.well-known/caldav', 'http://example.org/PLACEHOLDER', true, 207); + var async = OC.SetupChecks.checkWellKnownUrl('PROPFIND', '/.well-known/caldav', 'http://example.org/PLACEHOLDER', true, 207); suite.server.requests[0].respond(207); @@ -87,7 +87,7 @@ describe('OC.SetupChecks tests', function() { }); it('should return no error with the default expected response status code', function(done) { - var async = OC.SetupChecks.checkWellKnownUrl('/.well-known/caldav', 'http://example.org/PLACEHOLDER', true); + var async = OC.SetupChecks.checkWellKnownUrl('PROPFIND', '/.well-known/caldav', 'http://example.org/PLACEHOLDER', true); suite.server.requests[0].respond(207); @@ -98,7 +98,7 @@ describe('OC.SetupChecks tests', function() { }); it('should return no error when no check should be run', function(done) { - var async = OC.SetupChecks.checkWellKnownUrl('/.well-known/caldav', 'http://example.org/PLACEHOLDER', false); + var async = OC.SetupChecks.checkWellKnownUrl('PROPFIND', '/.well-known/caldav', 'http://example.org/PLACEHOLDER', false); async.done(function( data, s, x ){ expect(data).toEqual([]); diff --git a/core/routes.php b/core/routes.php index 9fa378dc1d8ed..5bc4075315a00 100644 --- a/core/routes.php +++ b/core/routes.php @@ -89,6 +89,9 @@ // Logins for passwordless auth ['name' => 'WebAuthn#startAuthentication', 'url' => 'login/webauthn/start', 'verb' => 'POST'], ['name' => 'WebAuthn#finishAuthentication', 'url' => 'login/webauthn/finish', 'verb' => 'POST'], + + // Well known requests https://tools.ietf.org/html/rfc5785 + ['name' => 'WellKnown#handle', 'url' => '.well-known/{service}'], ], 'ocs' => [ ['root' => '/cloud', 'name' => 'OCS#getCapabilities', 'url' => '/capabilities', 'verb' => 'GET'], diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index c033a237ca4fe..282d8f402a9f3 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -359,6 +359,11 @@ 'OCP\\Http\\Client\\IClientService' => $baseDir . '/lib/public/Http/Client/IClientService.php', 'OCP\\Http\\Client\\IResponse' => $baseDir . '/lib/public/Http/Client/IResponse.php', 'OCP\\Http\\Client\\LocalServerException' => $baseDir . '/lib/public/Http/Client/LocalServerException.php', + 'OCP\\Http\\WellKnown\\GenericResponse' => $baseDir . '/lib/public/Http/WellKnown/GenericResponse.php', + 'OCP\\Http\\WellKnown\\IHandler' => $baseDir . '/lib/public/Http/WellKnown/IHandler.php', + 'OCP\\Http\\WellKnown\\IRequestContext' => $baseDir . '/lib/public/Http/WellKnown/IRequestContext.php', + 'OCP\\Http\\WellKnown\\IResponse' => $baseDir . '/lib/public/Http/WellKnown/IResponse.php', + 'OCP\\Http\\WellKnown\\JrdResponse' => $baseDir . '/lib/public/Http/WellKnown/JrdResponse.php', 'OCP\\IAddressBook' => $baseDir . '/lib/public/IAddressBook.php', 'OCP\\IAppConfig' => $baseDir . '/lib/public/IAppConfig.php', 'OCP\\IAvatar' => $baseDir . '/lib/public/IAvatar.php', @@ -892,6 +897,7 @@ 'OC\\Core\\Controller\\UserController' => $baseDir . '/core/Controller/UserController.php', 'OC\\Core\\Controller\\WalledGardenController' => $baseDir . '/core/Controller/WalledGardenController.php', 'OC\\Core\\Controller\\WebAuthnController' => $baseDir . '/core/Controller/WebAuthnController.php', + 'OC\\Core\\Controller\\WellKnownController' => $baseDir . '/core/Controller/WellKnownController.php', 'OC\\Core\\Controller\\WhatsNewController' => $baseDir . '/core/Controller/WhatsNewController.php', 'OC\\Core\\Controller\\WipeController' => $baseDir . '/core/Controller/WipeController.php', 'OC\\Core\\Data\\LoginFlowV2Credentials' => $baseDir . '/core/Data/LoginFlowV2Credentials.php', @@ -1136,6 +1142,7 @@ 'OC\\Http\\Client\\ClientService' => $baseDir . '/lib/private/Http/Client/ClientService.php', 'OC\\Http\\Client\\Response' => $baseDir . '/lib/private/Http/Client/Response.php', 'OC\\Http\\CookieHelper' => $baseDir . '/lib/private/Http/CookieHelper.php', + 'OC\\Http\\WellKnown\\RequestManager' => $baseDir . '/lib/private/Http/WellKnown/RequestManager.php', 'OC\\InitialStateService' => $baseDir . '/lib/private/InitialStateService.php', 'OC\\Installer' => $baseDir . '/lib/private/Installer.php', 'OC\\IntegrityCheck\\Checker' => $baseDir . '/lib/private/IntegrityCheck/Checker.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 9afd8d98377a9..bb11975d089de 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -388,6 +388,11 @@ class ComposerStaticInit53792487c5a8370acc0b06b1a864ff4c 'OCP\\Http\\Client\\IClientService' => __DIR__ . '/../../..' . '/lib/public/Http/Client/IClientService.php', 'OCP\\Http\\Client\\IResponse' => __DIR__ . '/../../..' . '/lib/public/Http/Client/IResponse.php', 'OCP\\Http\\Client\\LocalServerException' => __DIR__ . '/../../..' . '/lib/public/Http/Client/LocalServerException.php', + 'OCP\\Http\\WellKnown\\GenericResponse' => __DIR__ . '/../../..' . '/lib/public/Http/WellKnown/GenericResponse.php', + 'OCP\\Http\\WellKnown\\IHandler' => __DIR__ . '/../../..' . '/lib/public/Http/WellKnown/IHandler.php', + 'OCP\\Http\\WellKnown\\IRequestContext' => __DIR__ . '/../../..' . '/lib/public/Http/WellKnown/IRequestContext.php', + 'OCP\\Http\\WellKnown\\IResponse' => __DIR__ . '/../../..' . '/lib/public/Http/WellKnown/IResponse.php', + 'OCP\\Http\\WellKnown\\JrdResponse' => __DIR__ . '/../../..' . '/lib/public/Http/WellKnown/JrdResponse.php', 'OCP\\IAddressBook' => __DIR__ . '/../../..' . '/lib/public/IAddressBook.php', 'OCP\\IAppConfig' => __DIR__ . '/../../..' . '/lib/public/IAppConfig.php', 'OCP\\IAvatar' => __DIR__ . '/../../..' . '/lib/public/IAvatar.php', @@ -921,6 +926,7 @@ class ComposerStaticInit53792487c5a8370acc0b06b1a864ff4c 'OC\\Core\\Controller\\UserController' => __DIR__ . '/../../..' . '/core/Controller/UserController.php', 'OC\\Core\\Controller\\WalledGardenController' => __DIR__ . '/../../..' . '/core/Controller/WalledGardenController.php', 'OC\\Core\\Controller\\WebAuthnController' => __DIR__ . '/../../..' . '/core/Controller/WebAuthnController.php', + 'OC\\Core\\Controller\\WellKnownController' => __DIR__ . '/../../..' . '/core/Controller/WellKnownController.php', 'OC\\Core\\Controller\\WhatsNewController' => __DIR__ . '/../../..' . '/core/Controller/WhatsNewController.php', 'OC\\Core\\Controller\\WipeController' => __DIR__ . '/../../..' . '/core/Controller/WipeController.php', 'OC\\Core\\Data\\LoginFlowV2Credentials' => __DIR__ . '/../../..' . '/core/Data/LoginFlowV2Credentials.php', @@ -1165,6 +1171,7 @@ class ComposerStaticInit53792487c5a8370acc0b06b1a864ff4c 'OC\\Http\\Client\\ClientService' => __DIR__ . '/../../..' . '/lib/private/Http/Client/ClientService.php', 'OC\\Http\\Client\\Response' => __DIR__ . '/../../..' . '/lib/private/Http/Client/Response.php', 'OC\\Http\\CookieHelper' => __DIR__ . '/../../..' . '/lib/private/Http/CookieHelper.php', + 'OC\\Http\\WellKnown\\RequestManager' => __DIR__ . '/../../..' . '/lib/private/Http/WellKnown/RequestManager.php', 'OC\\InitialStateService' => __DIR__ . '/../../..' . '/lib/private/InitialStateService.php', 'OC\\Installer' => __DIR__ . '/../../..' . '/lib/private/Installer.php', 'OC\\IntegrityCheck\\Checker' => __DIR__ . '/../../..' . '/lib/private/IntegrityCheck/Checker.php', diff --git a/lib/private/AppFramework/Bootstrap/RegistrationContext.php b/lib/private/AppFramework/Bootstrap/RegistrationContext.php index 12fca23c51fd8..3e5d70d6f1c22 100644 --- a/lib/private/AppFramework/Bootstrap/RegistrationContext.php +++ b/lib/private/AppFramework/Bootstrap/RegistrationContext.php @@ -72,6 +72,9 @@ class RegistrationContext { /** @var array[] */ private $initialStates = []; + /** @var array[] */ + private $wellKnownHandlers = []; + /** @var ILogger */ private $logger; @@ -174,6 +177,13 @@ public function registerInitialStateProvider(string $class): void { $class ); } + + public function registerWellKnownHandler(string $class): void { + $this->context->registerWellKnown( + $this->appId, + $class + ); + } }; } @@ -260,6 +270,13 @@ public function registerInitialState(string $appId, string $class): void { ]; } + public function registerWellKnown(string $appId, string $class): void { + $this->wellKnownHandlers[] = [ + 'appId' => $appId, + 'class' => $class, + ]; + } + /** * @param App[] $apps */ @@ -437,4 +454,11 @@ public function getAlternativeLogins(): array { public function getInitialStates(): array { return $this->initialStates; } + + /** + * @return array[] + */ + public function getWellKnownHandlers(): array { + return $this->wellKnownHandlers; + } } diff --git a/lib/private/Http/WellKnown/RequestManager.php b/lib/private/Http/WellKnown/RequestManager.php new file mode 100644 index 0000000000000..d17ea5c671b95 --- /dev/null +++ b/lib/private/Http/WellKnown/RequestManager.php @@ -0,0 +1,124 @@ + + * + * @author 2020 Christoph Wurst + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OC\Http\WellKnown; + +use OC\AppFramework\Bootstrap\Coordinator; +use OCP\AppFramework\QueryException; +use OCP\Http\WellKnown\IHandler; +use OCP\Http\WellKnown\IRequestContext; +use OCP\Http\WellKnown\IResponse; +use OCP\Http\WellKnown\JrdResponse; +use OCP\IRequest; +use OCP\IServerContainer; +use Psr\Log\LoggerInterface; +use RuntimeException; +use function array_reduce; + +class RequestManager { + + /** @var Coordinator */ + private $coordinator; + + /** @var IServerContainer */ + private $container; + + /** @var LoggerInterface */ + private $logger; + + public function __construct(Coordinator $coordinator, + IServerContainer $container, + LoggerInterface $logger) { + $this->coordinator = $coordinator; + $this->container = $container; + $this->logger = $logger; + } + + public function process(string $service, IRequest $request): ?IResponse { + $handlers = $this->loadHandlers(); + $context = new class($request) implements IRequestContext { + /** @var IRequest */ + private $request; + + public function __construct(IRequest $request) { + $this->request = $request; + } + + public function getHttpRequest(): IRequest { + return $this->request; + } + }; + + $subject = $request->getParam('resource'); + $initialResponse = new JrdResponse($subject ?? ''); + $finalResponse = array_reduce($handlers, function (?IResponse $previousResponse, IHandler $handler) use ($context, $service) { + return $handler->handle($service, $context, $previousResponse); + }, $initialResponse); + + if ($finalResponse instanceof JrdResponse && $finalResponse->isEmpty()) { + return null; + } + + return $finalResponse; + } + + /** + * @return IHandler[] + */ + private function loadHandlers(): array { + $context = $this->coordinator->getRegistrationContext(); + + if ($context === null) { + throw new RuntimeException("Well known handlers requested before the apps had been fully registered"); + } + + $registrations = $context->getWellKnownHandlers(); + $this->logger->debug(count($registrations) . " well known handlers registered"); + + return array_filter( + array_map(function (array $registration) { + $class = $registration['class']; + + try { + $handler = $this->container->get($class); + + if (!($handler) instanceof IHandler) { + $this->logger->error("Well known handler $class is invalid"); + + return null; + } + + return $handler; + } catch (QueryException $e) { + $this->logger->error("Could not load well known handler $class", [ + 'exception' => $e, + ]); + + return null; + } + }, $registrations) + ); + } +} diff --git a/lib/public/AppFramework/Bootstrap/IRegistrationContext.php b/lib/public/AppFramework/Bootstrap/IRegistrationContext.php index aaf5ef00bfaf7..9966d19965a5f 100644 --- a/lib/public/AppFramework/Bootstrap/IRegistrationContext.php +++ b/lib/public/AppFramework/Bootstrap/IRegistrationContext.php @@ -180,4 +180,18 @@ public function registerAlternativeLogin(string $class): void; * @since 21.0.0 */ public function registerInitialStateProvider(string $class): void; + + /** + * Register a well known protocol handler + * + * It is allowed to register more than one handler per app. + * + * @param string $class + * @psalm-param class-string<\OCP\Http\WellKnown\IHandler> $class + * + * @return void + * + * @since 21.0.0 + */ + public function registerWellKnownHandler(string $class): void; } diff --git a/lib/public/Http/WellKnown/GenericResponse.php b/lib/public/Http/WellKnown/GenericResponse.php new file mode 100644 index 0000000000000..6efacc41c0092 --- /dev/null +++ b/lib/public/Http/WellKnown/GenericResponse.php @@ -0,0 +1,51 @@ + + * + * @author 2020 Christoph Wurst + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCP\Http\WellKnown; + +use OCP\AppFramework\Http\Response; + +/** + * @since 21.0.0 + */ +final class GenericResponse implements IResponse { + + /** @var Response */ + private $response; + + /** + * @since 21.0.0 + */ + public function __construct(Response $response) { + $this->response = $response; + } + + /** + * @since 21.0.0 + */ + public function toHttpResponse(): Response { + return $this->response; + } +} diff --git a/lib/public/Http/WellKnown/IHandler.php b/lib/public/Http/WellKnown/IHandler.php new file mode 100644 index 0000000000000..30fca0da5a2a5 --- /dev/null +++ b/lib/public/Http/WellKnown/IHandler.php @@ -0,0 +1,48 @@ + + * + * @author 2020 Christoph Wurst + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCP\Http\WellKnown; + +/** + * Interface for an app handler that reacts to requests to Nextcloud's well + * known URLs, e.g. to a WebFinger + * + * @ref https://tools.ietf.org/html/rfc5785 + * + * @since 21.0.0 + */ +interface IHandler { + + /** + * @param string $service the name of the well known service, e.g. 'webfinger' + * @param IRequestContext $context + * @param IResponse|null $previousResponse the response of the previous handler, if any + * + * @return IResponse|null a response object if the request could be handled, null otherwise + * + * @since 21.0.0 + */ + public function handle(string $service, IRequestContext $context, ?IResponse $previousResponse): ?IResponse; +} diff --git a/lib/public/Http/WellKnown/IRequestContext.php b/lib/public/Http/WellKnown/IRequestContext.php new file mode 100644 index 0000000000000..d064c467d68e2 --- /dev/null +++ b/lib/public/Http/WellKnown/IRequestContext.php @@ -0,0 +1,46 @@ + + * + * @author 2020 Christoph Wurst + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCP\Http\WellKnown; + +use OCP\IRequest; + +/** + * The context object for \OCP\Http\IWellKnownHandler::handle + * + * Objects of this type will transport any optional information, e.g. the request + * object through which the app well known handler can obtain URL parameters + * + * @since 21.0.0 + */ +interface IRequestContext { + + /** + * @return IRequest + * + * @since 21.0.0 + */ + public function getHttpRequest(): IRequest; +} diff --git a/lib/public/Http/WellKnown/IResponse.php b/lib/public/Http/WellKnown/IResponse.php new file mode 100644 index 0000000000000..8d7bc30725229 --- /dev/null +++ b/lib/public/Http/WellKnown/IResponse.php @@ -0,0 +1,39 @@ + + * + * @author 2020 Christoph Wurst + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCP\Http\WellKnown; + +use OCP\AppFramework\Http\Response; + +/** + * @since 21.0.0 + */ +interface IResponse { + + /** + * @since 21.0.0 + */ + public function toHttpResponse(): Response; +} diff --git a/lib/public/Http/WellKnown/JrdResponse.php b/lib/public/Http/WellKnown/JrdResponse.php new file mode 100644 index 0000000000000..3a387107e8e93 --- /dev/null +++ b/lib/public/Http/WellKnown/JrdResponse.php @@ -0,0 +1,170 @@ + + * + * @author 2020 Christoph Wurst + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCP\Http\WellKnown; + +use OCP\AppFramework\Http\JSONResponse; +use OCP\AppFramework\Http\Response; +use function array_filter; + +/** + * A JSON Document Format (JDF) response to a well-known request + * + * @ref https://tools.ietf.org/html/rfc6415#appendix-A + * @ref https://tools.ietf.org/html/rfc7033#section-4.4 + * + * @since 21.0.0 + */ +final class JrdResponse implements IResponse { + + /** @var string */ + private $subject; + + /** @var string|null */ + private $expires; + + /** @var string[] */ + private $aliases = []; + + /** @var (string|null)[] */ + private $properties = []; + + /** @var mixed[] */ + private $links; + + /** + * @param string $subject https://tools.ietf.org/html/rfc7033#section-4.4.1 + * + * @since 21.0.0 + */ + public function __construct(string $subject) { + $this->subject = $subject; + } + + /** + * @param string $expires + * + * @return $this + * + * @since 21.0.0 + */ + public function setExpires(string $expires): self { + $this->expires = $expires; + + return $this; + } + + /** + * Add an alias + * + * @ref https://tools.ietf.org/html/rfc7033#section-4.4.2 + * + * @param string $alias + * + * @return $this + * + * @since 21.0.0 + */ + public function addAlias(string $alias): self { + $this->aliases[] = $alias; + + return $this; + } + + /** + * Add a property + * + * @ref https://tools.ietf.org/html/rfc7033#section-4.4.3 + * + * @param string $property + * @param string|null $value + * + * @return $this + * + * @since 21.0.0 + */ + public function addProperty(string $property, ?string $value): self { + $this->properties[$property] = $value; + + return $this; + } + + /** + * Add a link + * + * @ref https://tools.ietf.org/html/rfc7033#section-8.4 + * + * @param string $rel https://tools.ietf.org/html/rfc7033#section-4.4.4.1 + * @param string|null $type https://tools.ietf.org/html/rfc7033#section-4.4.4.2 + * @param string|null $href https://tools.ietf.org/html/rfc7033#section-4.4.4.3 + * @param string[]|null $titles https://tools.ietf.org/html/rfc7033#section-4.4.4.4 + * @param string|null $properties https://tools.ietf.org/html/rfc7033#section-4.4.4.5 + * + * @psalm-param array|null $properties https://tools.ietf.org/html/rfc7033#section-4.4.4.5 + * + * @return JrdResponse + * @since 21.0.0 + */ + public function addLink(string $rel, + ?string $type, + ?string $href, + ?array $titles = [], + ?array $properties = []): self { + $this->links[] = array_filter([ + 'rel' => $rel, + 'type' => $type, + 'href' => $href, + 'titles' => $titles, + 'properties' => $properties, + ]); + + return $this; + } + + /** + * @since 21.0.0 + */ + public function toHttpResponse(): Response { + return new JSONResponse(array_filter([ + 'subject' => $this->subject, + 'expires' => $this->expires, + 'aliases' => $this->aliases, + 'properties' => $this->properties, + 'links' => $this->links, + ])); + } + + /** + * Does this response have any data attached to it? + * + * @since 21.0.0 + */ + public function isEmpty(): bool { + return $this->expires === null + && empty($this->aliases) + && empty($this->properties) + && empty($this->links); + } +} diff --git a/tests/Core/Controller/WellKnownControllerTest.php b/tests/Core/Controller/WellKnownControllerTest.php new file mode 100644 index 0000000000000..2a35ed5ac85a4 --- /dev/null +++ b/tests/Core/Controller/WellKnownControllerTest.php @@ -0,0 +1,86 @@ + + * + * @author 2020 Christoph Wurst + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Tests\Core\Controller; + +use OC\Core\Controller\WellKnownController; +use OC\Http\WellKnown\RequestManager; +use OCP\AppFramework\Http\JSONResponse; +use OCP\Http\WellKnown\IResponse; +use OCP\IRequest; +use PHPUnit\Framework\MockObject\MockObject; +use Test\TestCase; + +class WellKnownControllerTest extends TestCase { + + /** @var IRequest|MockObject */ + private $request; + + /** @var RequestManager|MockObject */ + private $manager; + + /** @var WellKnownController */ + private $controller; + + protected function setUp(): void { + parent::setUp(); + + $this->request = $this->createMock(IRequest::class); + $this->manager = $this->createMock(RequestManager::class); + + $this->controller = new WellKnownController( + $this->request, + $this->manager, + ); + } + + public function testHandleNotProcessed(): void { + $httpResponse = $this->controller->handle("nodeinfo"); + + self::assertInstanceOf(JSONResponse::class, $httpResponse); + self::assertArrayHasKey('X-NEXTCLOUD-WELL-KNOWN', $httpResponse->getHeaders()); + } + + public function testHandle(): void { + $response = $this->createMock(IResponse::class); + $jsonResponse = $this->createMock(JSONResponse::class); + $response->expects(self::once()) + ->method('toHttpResponse') + ->willReturn($jsonResponse); + $this->manager->expects(self::once()) + ->method('process') + ->with( + "nodeinfo", + $this->request + )->willReturn($response); + $jsonResponse->expects(self::once()) + ->method('addHeader') + ->willReturnSelf(); + + $httpResponse = $this->controller->handle("nodeinfo"); + + self::assertInstanceOf(JSONResponse::class, $httpResponse); + } +} diff --git a/tests/lib/Http/WellKnown/GenericResponseTest.php b/tests/lib/Http/WellKnown/GenericResponseTest.php new file mode 100644 index 0000000000000..1e84a509c1b04 --- /dev/null +++ b/tests/lib/Http/WellKnown/GenericResponseTest.php @@ -0,0 +1,40 @@ + + * + * @author 2020 Christoph Wurst + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Tests\Http\WellKnown; + +use OCP\AppFramework\Http\JSONResponse; +use OCP\Http\WellKnown\GenericResponse; +use Test\TestCase; + +class GenericResponseTest extends TestCase { + public function testToHttpResponse(): void { + $httpResponse = $this->createMock(JSONResponse::class); + + $response = new GenericResponse($httpResponse); + + self::assertSame($httpResponse, $response->toHttpResponse()); + } +} diff --git a/tests/lib/Http/WellKnown/JrdResponseTest.php b/tests/lib/Http/WellKnown/JrdResponseTest.php new file mode 100644 index 0000000000000..f540ac1862ea2 --- /dev/null +++ b/tests/lib/Http/WellKnown/JrdResponseTest.php @@ -0,0 +1,108 @@ + + * + * @author 2020 Christoph Wurst + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Test\Http\WellKnown; + +use OCP\AppFramework\Http\JSONResponse; +use OCP\Http\WellKnown\JrdResponse; +use Test\TestCase; + +class JrdResponseTest extends TestCase { + public function testEmptyToHttpResponse(): void { + $response = new JrdResponse("subject"); + $httpResponse = $response->toHttpResponse(); + + self::assertTrue($response->isEmpty()); + self::assertInstanceOf(JSONResponse::class, $httpResponse); + /** @var JSONResponse $httpResponse */ + self::assertEquals( + [ + 'subject' => 'subject', + ], + $httpResponse->getData() + ); + } + + public function testComplexToHttpResponse(): void { + $response = new JrdResponse("subject"); + $response->addAlias('alias'); + $response->addAlias('blias'); + $response->addProperty('propa', 'a'); + $response->addProperty('propb', null); + $response->setExpires('tomorrow'); + $response->addLink('rel', null, null); + $response->addLink('rel', 'type', null); + $response->addLink('rel', 'type', 'href', ['title' => 'titlevalue']); + $response->addLink('rel', 'type', 'href', ['title' => 'titlevalue'], ['propx' => 'valx']); + $httpResponse = $response->toHttpResponse(); + + self::assertFalse($response->isEmpty()); + self::assertInstanceOf(JSONResponse::class, $httpResponse); + /** @var JSONResponse $httpResponse */ + self::assertEquals( + [ + 'subject' => 'subject', + 'aliases' => [ + 'alias', + 'blias', + ], + 'properties' => [ + 'propa' => 'a', + 'propb' => null, + ], + 'expires' => 'tomorrow', + 'links' => [ + [ + 'rel' => 'rel', + ], + [ + 'rel' => 'rel', + 'type' => 'type', + ], + [ + 'rel' => 'rel', + 'type' => 'type', + 'href' => 'href', + 'titles' => [ + 'title' => 'titlevalue', + ], + ], + [ + 'rel' => 'rel', + 'type' => 'type', + 'href' => 'href', + 'titles' => [ + 'title' => 'titlevalue', + ], + 'properties' => [ + 'propx' => 'valx', + ], + ], + ] + ], + $httpResponse->getData() + ); + } +} diff --git a/tests/lib/Http/WellKnown/RequestManagerTest.php b/tests/lib/Http/WellKnown/RequestManagerTest.php new file mode 100644 index 0000000000000..b947df494967b --- /dev/null +++ b/tests/lib/Http/WellKnown/RequestManagerTest.php @@ -0,0 +1,176 @@ + + * + * @author 2020 Christoph Wurst + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Test\Http\WellKnown; + +use OC\AppFramework\Bootstrap\Coordinator; +use OC\AppFramework\Bootstrap\RegistrationContext; +use OC\Http\WellKnown\RequestManager; +use OCP\AppFramework\QueryException; +use OCP\Http\WellKnown\IHandler; +use OCP\Http\WellKnown\IRequestContext; +use OCP\Http\WellKnown\IResponse; +use OCP\Http\WellKnown\JrdResponse; +use OCP\IRequest; +use OCP\IServerContainer; +use PHPUnit\Framework\MockObject\MockObject; +use Psr\Log\LoggerInterface; +use RuntimeException; +use Test\TestCase; +use function get_class; + +class RequestManagerTest extends TestCase { + + /** @var Coordinator|MockObject */ + private $coordinator; + + /** @var IServerContainer|MockObject */ + private $container; + + /** @var MockObject|LoggerInterface */ + private $logger; + + /** @var RequestManager */ + private $manager; + + protected function setUp(): void { + parent::setUp(); + + $this->coordinator = $this->createMock(Coordinator::class); + $this->container = $this->createMock(IServerContainer::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->manager = new RequestManager( + $this->coordinator, + $this->container, + $this->logger, + ); + } + + public function testProcessAppsNotRegistered(): void { + $request = $this->createMock(IRequest::class); + $this->expectException(RuntimeException::class); + + $this->manager->process("webfinger", $request); + } + + public function testProcessNoHandlersRegistered(): void { + $request = $this->createMock(IRequest::class); + $registrationContext = $this->createMock(RegistrationContext::class); + $this->coordinator->expects(self::once()) + ->method('getRegistrationContext') + ->willReturn($registrationContext); + $registrationContext->expects(self::once()) + ->method('getWellKnownHandlers') + ->willReturn([]); + + $response = $this->manager->process("webfinger", $request); + + self::assertNull($response); + } + + public function testProcessHandlerNotLoadable(): void { + $request = $this->createMock(IRequest::class); + $registrationContext = $this->createMock(RegistrationContext::class); + $this->coordinator->expects(self::once()) + ->method('getRegistrationContext') + ->willReturn($registrationContext); + $handler = new class { + }; + $registrationContext->expects(self::once()) + ->method('getWellKnownHandlers') + ->willReturn([ + [ + 'class' => get_class($handler), + ], + ]); + $this->container->expects(self::once()) + ->method('get') + ->with(get_class($handler)) + ->willThrowException(new QueryException("")); + $this->logger->expects(self::once()) + ->method('error'); + + $response = $this->manager->process("webfinger", $request); + + self::assertNull($response); + } + + public function testProcessHandlerOfWrongType(): void { + $request = $this->createMock(IRequest::class); + $registrationContext = $this->createMock(RegistrationContext::class); + $this->coordinator->expects(self::once()) + ->method('getRegistrationContext') + ->willReturn($registrationContext); + $handler = new class { + }; + $registrationContext->expects(self::once()) + ->method('getWellKnownHandlers') + ->willReturn([ + [ + 'class' => get_class($handler), + ], + ]); + $this->container->expects(self::once()) + ->method('get') + ->with(get_class($handler)) + ->willReturn($handler); + $this->logger->expects(self::once()) + ->method('error'); + + $response = $this->manager->process("webfinger", $request); + + self::assertNull($response); + } + + public function testProcess(): void { + $request = $this->createMock(IRequest::class); + $registrationContext = $this->createMock(RegistrationContext::class); + $this->coordinator->expects(self::once()) + ->method('getRegistrationContext') + ->willReturn($registrationContext); + $handler = new class implements IHandler { + public function handle(string $service, IRequestContext $context, ?IResponse $previousResponse): ?IResponse { + return (new JrdResponse($service))->addAlias('alias'); + } + }; + $registrationContext->expects(self::once()) + ->method('getWellKnownHandlers') + ->willReturn([ + [ + 'class' => get_class($handler), + ], + ]); + $this->container->expects(self::once()) + ->method('get') + ->with(get_class($handler)) + ->willReturn($handler); + + $response = $this->manager->process("webfinger", $request); + + self::assertNotNull($response); + self::assertInstanceOf(JrdResponse::class, $response); + } +}