diff options
Diffstat (limited to 'lib/private/AppFramework')
60 files changed, 7234 insertions, 0 deletions
diff --git a/lib/private/AppFramework/App.php b/lib/private/AppFramework/App.php new file mode 100644 index 00000000000..7bf32852209 --- /dev/null +++ b/lib/private/AppFramework/App.php @@ -0,0 +1,217 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\AppFramework; + +use OC\AppFramework\DependencyInjection\DIContainer; +use OC\AppFramework\Http\Dispatcher; +use OC\AppFramework\Http\Request; +use OC\Profiler\RoutingDataCollector; +use OCP\App\IAppManager; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\ICallbackResponse; +use OCP\AppFramework\Http\IOutput; +use OCP\AppFramework\QueryException; +use OCP\Diagnostics\IEventLogger; +use OCP\HintException; +use OCP\IRequest; +use OCP\Profiler\IProfiler; + +/** + * Entry point for every request in your app. You can consider this as your + * public static void main() method + * + * Handles all the dependency injection, controllers and output flow + */ +class App { + /** @var string[] */ + private static $nameSpaceCache = []; + + /** + * Turns an app id into a namespace by either reading the appinfo.xml's + * namespace tag or uppercasing the appid's first letter + * @param string $appId the app id + * @param string $topNamespace the namespace which should be prepended to + * the transformed app id, defaults to OCA\ + * @return string the starting namespace for the app + */ + public static function buildAppNamespace(string $appId, string $topNamespace = 'OCA\\'): string { + // Hit the cache! + if (isset(self::$nameSpaceCache[$appId])) { + return $topNamespace . self::$nameSpaceCache[$appId]; + } + + $appInfo = \OCP\Server::get(IAppManager::class)->getAppInfo($appId); + if (isset($appInfo['namespace'])) { + self::$nameSpaceCache[$appId] = trim($appInfo['namespace']); + } else { + // if the tag is not found, fall back to uppercasing the first letter + self::$nameSpaceCache[$appId] = ucfirst($appId); + } + + return $topNamespace . self::$nameSpaceCache[$appId]; + } + + public static function getAppIdForClass(string $className, string $topNamespace = 'OCA\\'): ?string { + if (!str_starts_with($className, $topNamespace)) { + return null; + } + + foreach (self::$nameSpaceCache as $appId => $namespace) { + if (str_starts_with($className, $topNamespace . $namespace . '\\')) { + return $appId; + } + } + + return null; + } + + /** + * Shortcut for calling a controller method and printing the result + * + * @param string $controllerName the name of the controller under which it is + * stored in the DI container + * @param string $methodName the method that you want to call + * @param DIContainer $container an instance of a pimple container. + * @param array $urlParams list of URL parameters (optional) + * @throws HintException + */ + public static function main( + string $controllerName, + string $methodName, + DIContainer $container, + ?array $urlParams = null, + ): void { + /** @var IProfiler $profiler */ + $profiler = $container->get(IProfiler::class); + $eventLogger = $container->get(IEventLogger::class); + // Disable profiler on the profiler UI + $profiler->setEnabled($profiler->isEnabled() && !is_null($urlParams) && isset($urlParams['_route']) && !str_starts_with($urlParams['_route'], 'profiler.')); + if ($profiler->isEnabled()) { + \OC::$server->get(IEventLogger::class)->activate(); + $profiler->add(new RoutingDataCollector($container['appName'], $controllerName, $methodName)); + } + + $eventLogger->start('app:controller:params', 'Gather controller parameters'); + + if (!is_null($urlParams)) { + /** @var Request $request */ + $request = $container->get(IRequest::class); + $request->setUrlParameters($urlParams); + } elseif (isset($container['urlParams']) && !is_null($container['urlParams'])) { + /** @var Request $request */ + $request = $container->get(IRequest::class); + $request->setUrlParameters($container['urlParams']); + } + $appName = $container['appName']; + + $eventLogger->end('app:controller:params'); + + $eventLogger->start('app:controller:load', 'Load app controller'); + + // first try $controllerName then go for \OCA\AppName\Controller\$controllerName + try { + $controller = $container->get($controllerName); + } catch (QueryException $e) { + if (str_contains($controllerName, '\\Controller\\')) { + // This is from a global registered app route that is not enabled. + [/*OC(A)*/, $app, /* Controller/Name*/] = explode('\\', $controllerName, 3); + throw new HintException('App ' . strtolower($app) . ' is not enabled'); + } + + if ($appName === 'core') { + $appNameSpace = 'OC\\Core'; + } else { + $appNameSpace = self::buildAppNamespace($appName); + } + $controllerName = $appNameSpace . '\\Controller\\' . $controllerName; + $controller = $container->query($controllerName); + } + + $eventLogger->end('app:controller:load'); + + $eventLogger->start('app:controller:dispatcher', 'Initialize dispatcher and pre-middleware'); + + // initialize the dispatcher and run all the middleware before the controller + $dispatcher = $container->get(Dispatcher::class); + + $eventLogger->end('app:controller:dispatcher'); + + $eventLogger->start('app:controller:run', 'Run app controller'); + + [ + $httpHeaders, + $responseHeaders, + $responseCookies, + $output, + $response + ] = $dispatcher->dispatch($controller, $methodName); + + $eventLogger->end('app:controller:run'); + + $io = $container[IOutput::class]; + + if ($profiler->isEnabled()) { + $eventLogger->end('runtime'); + $profile = $profiler->collect($container->get(IRequest::class), $response); + $profiler->saveProfile($profile); + $io->setHeader('X-Debug-Token:' . $profile->getToken()); + $io->setHeader('Server-Timing: token;desc="' . $profile->getToken() . '"'); + } + + if (!is_null($httpHeaders)) { + $io->setHeader($httpHeaders); + } + + foreach ($responseHeaders as $name => $value) { + $io->setHeader($name . ': ' . $value); + } + + foreach ($responseCookies as $name => $value) { + $expireDate = null; + if ($value['expireDate'] instanceof \DateTime) { + $expireDate = $value['expireDate']->getTimestamp(); + } + $sameSite = $value['sameSite'] ?? 'Lax'; + + $io->setCookie( + $name, + $value['value'], + $expireDate, + $container->getServer()->getWebRoot(), + null, + $container->getServer()->getRequest()->getServerProtocol() === 'https', + true, + $sameSite + ); + } + + /* + * Status 204 does not have a body and no Content Length + * Status 304 does not have a body and does not need a Content Length + * https://tools.ietf.org/html/rfc7230#section-3.3 + * https://tools.ietf.org/html/rfc7230#section-3.3.2 + */ + $emptyResponse = false; + if (preg_match('/^HTTP\/\d\.\d (\d{3}) .*$/', $httpHeaders, $matches)) { + $status = (int)$matches[1]; + if ($status === Http::STATUS_NO_CONTENT || $status === Http::STATUS_NOT_MODIFIED) { + $emptyResponse = true; + } + } + + if (!$emptyResponse) { + if ($response instanceof ICallbackResponse) { + $response->callback($io); + } elseif (!is_null($output)) { + $io->setHeader('Content-Length: ' . strlen($output)); + $io->setOutput($output); + } + } + } +} diff --git a/lib/private/AppFramework/Bootstrap/ARegistration.php b/lib/private/AppFramework/Bootstrap/ARegistration.php new file mode 100644 index 00000000000..37984667727 --- /dev/null +++ b/lib/private/AppFramework/Bootstrap/ARegistration.php @@ -0,0 +1,28 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\AppFramework\Bootstrap; + +/** + * @psalm-immutable + */ +abstract class ARegistration { + /** @var string */ + private $appId; + + public function __construct(string $appId) { + $this->appId = $appId; + } + + /** + * @return string + */ + public function getAppId(): string { + return $this->appId; + } +} diff --git a/lib/private/AppFramework/Bootstrap/BootContext.php b/lib/private/AppFramework/Bootstrap/BootContext.php new file mode 100644 index 00000000000..b3da08adb07 --- /dev/null +++ b/lib/private/AppFramework/Bootstrap/BootContext.php @@ -0,0 +1,34 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\AppFramework\Bootstrap; + +use OCP\AppFramework\Bootstrap\IBootContext; +use OCP\AppFramework\IAppContainer; +use OCP\IServerContainer; + +class BootContext implements IBootContext { + /** @var IAppContainer */ + private $appContainer; + + public function __construct(IAppContainer $appContainer) { + $this->appContainer = $appContainer; + } + + public function getAppContainer(): IAppContainer { + return $this->appContainer; + } + + public function getServerContainer(): IServerContainer { + return $this->appContainer->get(IServerContainer::class); + } + + public function injectFn(callable $fn) { + return (new FunctionInjector($this->appContainer))->injectFn($fn); + } +} diff --git a/lib/private/AppFramework/Bootstrap/Coordinator.php b/lib/private/AppFramework/Bootstrap/Coordinator.php new file mode 100644 index 00000000000..a31dd6a05e1 --- /dev/null +++ b/lib/private/AppFramework/Bootstrap/Coordinator.php @@ -0,0 +1,190 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\AppFramework\Bootstrap; + +use OC\Support\CrashReport\Registry; +use OC_App; +use OCP\App\AppPathNotFoundException; +use OCP\App\IAppManager; +use OCP\AppFramework\App; +use OCP\AppFramework\Bootstrap\IBootstrap; +use OCP\AppFramework\QueryException; +use OCP\Dashboard\IManager; +use OCP\Diagnostics\IEventLogger; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IServerContainer; +use Psr\Container\ContainerExceptionInterface; +use Psr\Log\LoggerInterface; +use Throwable; +use function class_exists; +use function class_implements; +use function in_array; + +class Coordinator { + /** @var RegistrationContext|null */ + private $registrationContext; + + /** @var array<string,true> */ + private array $bootedApps = []; + + public function __construct( + private IServerContainer $serverContainer, + private Registry $registry, + private IManager $dashboardManager, + private IEventDispatcher $eventDispatcher, + private IEventLogger $eventLogger, + private IAppManager $appManager, + private LoggerInterface $logger, + ) { + } + + public function runInitialRegistration(): void { + $apps = OC_App::getEnabledApps(); + if (!empty($apps)) { + // make sure to also register the core app + $apps = ['core', ...$apps]; + } + + $this->registerApps($apps); + } + + public function runLazyRegistration(string $appId): void { + $this->registerApps([$appId]); + } + + /** + * @param string[] $appIds + */ + private function registerApps(array $appIds): void { + $this->eventLogger->start('bootstrap:register_apps', ''); + if ($this->registrationContext === null) { + $this->registrationContext = new RegistrationContext($this->logger); + } + $apps = []; + foreach ($appIds as $appId) { + $this->eventLogger->start("bootstrap:register_app:$appId", "Register $appId"); + $this->eventLogger->start("bootstrap:register_app:$appId:autoloader", "Setup autoloader for $appId"); + /* + * First, we have to enable the app's autoloader + */ + try { + $path = $this->appManager->getAppPath($appId); + OC_App::registerAutoloading($appId, $path); + } catch (AppPathNotFoundException) { + // Ignore + continue; + } + $this->eventLogger->end("bootstrap:register_app:$appId:autoloader"); + + /* + * Next we check if there is an application class, and it implements + * the \OCP\AppFramework\Bootstrap\IBootstrap interface + */ + if ($appId === 'core') { + $appNameSpace = 'OC\\Core'; + } else { + $appNameSpace = App::buildAppNamespace($appId); + } + $applicationClassName = $appNameSpace . '\\AppInfo\\Application'; + + try { + if (class_exists($applicationClassName) && is_a($applicationClassName, IBootstrap::class, true)) { + $this->eventLogger->start("bootstrap:register_app:$appId:application", "Load `Application` instance for $appId"); + try { + /** @var IBootstrap&App $application */ + $application = $this->serverContainer->query($applicationClassName); + $apps[$appId] = $application; + } catch (ContainerExceptionInterface $e) { + // Weird, but ok + $this->eventLogger->end("bootstrap:register_app:$appId"); + continue; + } + $this->eventLogger->end("bootstrap:register_app:$appId:application"); + + $this->eventLogger->start("bootstrap:register_app:$appId:register", "`Application::register` for $appId"); + $application->register($this->registrationContext->for($appId)); + $this->eventLogger->end("bootstrap:register_app:$appId:register"); + } + } catch (Throwable $e) { + $this->logger->emergency('Error during app service registration: ' . $e->getMessage(), [ + 'exception' => $e, + 'app' => $appId, + ]); + $this->eventLogger->end("bootstrap:register_app:$appId"); + continue; + } + $this->eventLogger->end("bootstrap:register_app:$appId"); + } + + $this->eventLogger->start('bootstrap:register_apps:apply', 'Apply all the registered service by apps'); + /** + * Now that all register methods have been called, we can delegate the registrations + * to the actual services + */ + $this->registrationContext->delegateCapabilityRegistrations($apps); + $this->registrationContext->delegateCrashReporterRegistrations($apps, $this->registry); + $this->registrationContext->delegateDashboardPanelRegistrations($this->dashboardManager); + $this->registrationContext->delegateEventListenerRegistrations($this->eventDispatcher); + $this->registrationContext->delegateContainerRegistrations($apps); + $this->eventLogger->end('bootstrap:register_apps:apply'); + $this->eventLogger->end('bootstrap:register_apps'); + } + + public function getRegistrationContext(): ?RegistrationContext { + return $this->registrationContext; + } + + public function bootApp(string $appId): void { + if (isset($this->bootedApps[$appId])) { + return; + } + $this->bootedApps[$appId] = true; + + $appNameSpace = App::buildAppNamespace($appId); + $applicationClassName = $appNameSpace . '\\AppInfo\\Application'; + if (!class_exists($applicationClassName)) { + // Nothing to boot + return; + } + + /* + * Now it is time to fetch an instance of the App class. For classes + * that implement \OCP\AppFramework\Bootstrap\IBootstrap this means + * the instance was already created for register, but any other + * (legacy) code will now do their magic via the constructor. + */ + $this->eventLogger->start('bootstrap:boot_app:' . $appId, "Call `Application::boot` for $appId"); + try { + /** @var App $application */ + $application = $this->serverContainer->query($applicationClassName); + if ($application instanceof IBootstrap) { + /** @var BootContext $context */ + $context = new BootContext($application->getContainer()); + $application->boot($context); + } + } catch (QueryException $e) { + $this->logger->error("Could not boot $appId: " . $e->getMessage(), [ + 'exception' => $e, + ]); + } catch (Throwable $e) { + $this->logger->emergency("Could not boot $appId: " . $e->getMessage(), [ + 'exception' => $e, + ]); + } + $this->eventLogger->end('bootstrap:boot_app:' . $appId); + } + + public function isBootable(string $appId) { + $appNameSpace = App::buildAppNamespace($appId); + $applicationClassName = $appNameSpace . '\\AppInfo\\Application'; + return class_exists($applicationClassName) + && in_array(IBootstrap::class, class_implements($applicationClassName), true); + } +} diff --git a/lib/private/AppFramework/Bootstrap/EventListenerRegistration.php b/lib/private/AppFramework/Bootstrap/EventListenerRegistration.php new file mode 100644 index 00000000000..92955fc4123 --- /dev/null +++ b/lib/private/AppFramework/Bootstrap/EventListenerRegistration.php @@ -0,0 +1,44 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\AppFramework\Bootstrap; + +/** + * @psalm-immutable + * @template-extends ServiceRegistration<\OCP\EventDispatcher\IEventListener> + */ +class EventListenerRegistration extends ServiceRegistration { + /** @var string */ + private $event; + + /** @var int */ + private $priority; + + public function __construct(string $appId, + string $event, + string $service, + int $priority) { + parent::__construct($appId, $service); + $this->event = $event; + $this->priority = $priority; + } + + /** + * @return string + */ + public function getEvent(): string { + return $this->event; + } + + /** + * @return int + */ + public function getPriority(): int { + return $this->priority; + } +} diff --git a/lib/private/AppFramework/Bootstrap/FunctionInjector.php b/lib/private/AppFramework/Bootstrap/FunctionInjector.php new file mode 100644 index 00000000000..973fc13aa2c --- /dev/null +++ b/lib/private/AppFramework/Bootstrap/FunctionInjector.php @@ -0,0 +1,51 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\AppFramework\Bootstrap; + +use Closure; +use OCP\AppFramework\QueryException; +use Psr\Container\ContainerInterface; +use ReflectionFunction; +use ReflectionParameter; +use function array_map; + +class FunctionInjector { + /** @var ContainerInterface */ + private $container; + + public function __construct(ContainerInterface $container) { + $this->container = $container; + } + + public function injectFn(callable $fn) { + $reflected = new ReflectionFunction(Closure::fromCallable($fn)); + return $fn(...array_map(function (ReflectionParameter $param) { + // First we try by type (more likely these days) + if (($type = $param->getType()) !== null) { + try { + return $this->container->get($type->getName()); + } catch (QueryException $ex) { + // Ignore and try name as well + } + } + // Second we try by name (mostly for primitives) + try { + return $this->container->get($param->getName()); + } catch (QueryException $ex) { + // As a last resort we pass `null` if allowed + if ($type !== null && $type->allowsNull()) { + return null; + } + + // Nothing worked, time to bail out + throw $ex; + } + }, $reflected->getParameters())); + } +} diff --git a/lib/private/AppFramework/Bootstrap/MiddlewareRegistration.php b/lib/private/AppFramework/Bootstrap/MiddlewareRegistration.php new file mode 100644 index 00000000000..d2ad6bbf0f6 --- /dev/null +++ b/lib/private/AppFramework/Bootstrap/MiddlewareRegistration.php @@ -0,0 +1,29 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\AppFramework\Bootstrap; + +use OCP\AppFramework\Middleware; + +/** + * @psalm-immutable + * @template-extends ServiceRegistration<Middleware> + */ +class MiddlewareRegistration extends ServiceRegistration { + private bool $global; + + public function __construct(string $appId, string $service, bool $global) { + parent::__construct($appId, $service); + $this->global = $global; + } + + public function isGlobal(): bool { + return $this->global; + } +} diff --git a/lib/private/AppFramework/Bootstrap/ParameterRegistration.php b/lib/private/AppFramework/Bootstrap/ParameterRegistration.php new file mode 100644 index 00000000000..cc9a4875e9a --- /dev/null +++ b/lib/private/AppFramework/Bootstrap/ParameterRegistration.php @@ -0,0 +1,39 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\AppFramework\Bootstrap; + +/** + * @psalm-immutable + */ +final class ParameterRegistration extends ARegistration { + /** @var string */ + private $name; + + /** @var mixed */ + private $value; + + public function __construct(string $appId, + string $name, + $value) { + parent::__construct($appId); + $this->name = $name; + $this->value = $value; + } + + public function getName(): string { + return $this->name; + } + + /** + * @return mixed + */ + public function getValue() { + return $this->value; + } +} diff --git a/lib/private/AppFramework/Bootstrap/PreviewProviderRegistration.php b/lib/private/AppFramework/Bootstrap/PreviewProviderRegistration.php new file mode 100644 index 00000000000..7ecc4aac7f2 --- /dev/null +++ b/lib/private/AppFramework/Bootstrap/PreviewProviderRegistration.php @@ -0,0 +1,30 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\AppFramework\Bootstrap; + +/** + * @psalm-immutable + * @template-extends ServiceRegistration<\OCP\Preview\IProviderV2> + */ +class PreviewProviderRegistration extends ServiceRegistration { + /** @var string */ + private $mimeTypeRegex; + + public function __construct(string $appId, + string $service, + string $mimeTypeRegex) { + parent::__construct($appId, $service); + $this->mimeTypeRegex = $mimeTypeRegex; + } + + public function getMimeTypeRegex(): string { + return $this->mimeTypeRegex; + } +} diff --git a/lib/private/AppFramework/Bootstrap/RegistrationContext.php b/lib/private/AppFramework/Bootstrap/RegistrationContext.php new file mode 100644 index 00000000000..8bd1ff35610 --- /dev/null +++ b/lib/private/AppFramework/Bootstrap/RegistrationContext.php @@ -0,0 +1,1034 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\AppFramework\Bootstrap; + +use Closure; +use OC\Support\CrashReport\Registry; +use OCP\AppFramework\App; +use OCP\AppFramework\Bootstrap\IRegistrationContext; +use OCP\AppFramework\Middleware; +use OCP\AppFramework\Services\InitialStateProvider; +use OCP\Authentication\IAlternativeLogin; +use OCP\Calendar\ICalendarProvider; +use OCP\Calendar\Resource\IBackend as IResourceBackend; +use OCP\Calendar\Room\IBackend as IRoomBackend; +use OCP\Capabilities\ICapability; +use OCP\Collaboration\Reference\IReferenceProvider; +use OCP\Config\Lexicon\ILexicon; +use OCP\Dashboard\IManager; +use OCP\Dashboard\IWidget; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\Files\Template\ICustomTemplateProvider; +use OCP\Http\WellKnown\IHandler; +use OCP\Mail\Provider\IProvider as IMailProvider; +use OCP\Notification\INotifier; +use OCP\Profile\ILinkAction; +use OCP\Search\IProvider; +use OCP\Settings\IDeclarativeSettingsForm; +use OCP\SetupCheck\ISetupCheck; +use OCP\Share\IPublicShareTemplateProvider; +use OCP\SpeechToText\ISpeechToTextProvider; +use OCP\Support\CrashReport\IReporter; +use OCP\Talk\ITalkBackend; +use OCP\Teams\ITeamResourceProvider; +use OCP\TextProcessing\IProvider as ITextProcessingProvider; +use OCP\Translation\ITranslationProvider; +use OCP\UserMigration\IMigrator as IUserMigrator; +use Psr\Log\LoggerInterface; +use RuntimeException; +use Throwable; +use function array_shift; + +class RegistrationContext { + /** @var ServiceRegistration<ICapability>[] */ + private $capabilities = []; + + /** @var ServiceRegistration<IReporter>[] */ + private $crashReporters = []; + + /** @var ServiceRegistration<IWidget>[] */ + private $dashboardPanels = []; + + /** @var ServiceRegistration<ILinkAction>[] */ + private $profileLinkActions = []; + + /** @var null|ServiceRegistration<ITalkBackend> */ + private $talkBackendRegistration = null; + + /** @var ServiceRegistration<IResourceBackend>[] */ + private $calendarResourceBackendRegistrations = []; + + /** @var ServiceRegistration<IRoomBackend>[] */ + private $calendarRoomBackendRegistrations = []; + + /** @var ServiceRegistration<IUserMigrator>[] */ + private $userMigrators = []; + + /** @var ServiceFactoryRegistration[] */ + private $services = []; + + /** @var ServiceAliasRegistration[] */ + private $aliases = []; + + /** @var ParameterRegistration[] */ + private $parameters = []; + + /** @var EventListenerRegistration[] */ + private $eventListeners = []; + + /** @var MiddlewareRegistration[] */ + private $middlewares = []; + + /** @var ServiceRegistration<IProvider>[] */ + private $searchProviders = []; + + /** @var ServiceRegistration<IAlternativeLogin>[] */ + private $alternativeLogins = []; + + /** @var ServiceRegistration<InitialStateProvider>[] */ + private $initialStates = []; + + /** @var ServiceRegistration<IHandler>[] */ + private $wellKnownHandlers = []; + + /** @var ServiceRegistration<ISpeechToTextProvider>[] */ + private $speechToTextProviders = []; + + /** @var ServiceRegistration<ITextProcessingProvider>[] */ + private $textProcessingProviders = []; + + /** @var ServiceRegistration<ICustomTemplateProvider>[] */ + private $templateProviders = []; + + /** @var ServiceRegistration<ITranslationProvider>[] */ + private $translationProviders = []; + + /** @var ServiceRegistration<INotifier>[] */ + private $notifierServices = []; + + /** @var ServiceRegistration<\OCP\Authentication\TwoFactorAuth\IProvider>[] */ + private $twoFactorProviders = []; + + /** @var ServiceRegistration<ICalendarProvider>[] */ + private $calendarProviders = []; + + /** @var ServiceRegistration<IReferenceProvider>[] */ + private array $referenceProviders = []; + + /** @var ServiceRegistration<\OCP\TextToImage\IProvider>[] */ + private $textToImageProviders = []; + + /** @var ParameterRegistration[] */ + private $sensitiveMethods = []; + + /** @var ServiceRegistration<IPublicShareTemplateProvider>[] */ + private $publicShareTemplateProviders = []; + + private LoggerInterface $logger; + + /** @var ServiceRegistration<ISetupCheck>[] */ + private array $setupChecks = []; + + /** @var PreviewProviderRegistration[] */ + private array $previewProviders = []; + + /** @var ServiceRegistration<IDeclarativeSettingsForm>[] */ + private array $declarativeSettings = []; + + /** @var array<array-key, string> */ + private array $configLexiconClasses = []; + + /** @var ServiceRegistration<ITeamResourceProvider>[] */ + private array $teamResourceProviders = []; + + /** @var ServiceRegistration<\OCP\TaskProcessing\IProvider>[] */ + private array $taskProcessingProviders = []; + + /** @var ServiceRegistration<\OCP\TaskProcessing\ITaskType>[] */ + private array $taskProcessingTaskTypes = []; + + /** @var ServiceRegistration<\OCP\Files\Conversion\IConversionProvider>[] */ + private array $fileConversionProviders = []; + + /** @var ServiceRegistration<IMailProvider>[] */ + private $mailProviders = []; + + public function __construct(LoggerInterface $logger) { + $this->logger = $logger; + } + + public function for(string $appId): IRegistrationContext { + return new class($appId, $this) implements IRegistrationContext { + /** @var string */ + private $appId; + + /** @var RegistrationContext */ + private $context; + + public function __construct(string $appId, RegistrationContext $context) { + $this->appId = $appId; + $this->context = $context; + } + + public function registerCapability(string $capability): void { + $this->context->registerCapability( + $this->appId, + $capability + ); + } + + public function registerCrashReporter(string $reporterClass): void { + $this->context->registerCrashReporter( + $this->appId, + $reporterClass + ); + } + + public function registerDashboardWidget(string $widgetClass): void { + $this->context->registerDashboardPanel( + $this->appId, + $widgetClass + ); + } + + public function registerService(string $name, callable $factory, bool $shared = true): void { + $this->context->registerService( + $this->appId, + $name, + $factory, + $shared + ); + } + + public function registerServiceAlias(string $alias, string $target): void { + $this->context->registerServiceAlias( + $this->appId, + $alias, + $target + ); + } + + public function registerParameter(string $name, $value): void { + $this->context->registerParameter( + $this->appId, + $name, + $value + ); + } + + public function registerEventListener(string $event, string $listener, int $priority = 0): void { + $this->context->registerEventListener( + $this->appId, + $event, + $listener, + $priority + ); + } + + public function registerMiddleware(string $class, bool $global = false): void { + $this->context->registerMiddleware( + $this->appId, + $class, + $global, + ); + } + + public function registerSearchProvider(string $class): void { + $this->context->registerSearchProvider( + $this->appId, + $class + ); + } + + public function registerAlternativeLogin(string $class): void { + $this->context->registerAlternativeLogin( + $this->appId, + $class + ); + } + + public function registerInitialStateProvider(string $class): void { + $this->context->registerInitialState( + $this->appId, + $class + ); + } + + public function registerWellKnownHandler(string $class): void { + $this->context->registerWellKnown( + $this->appId, + $class + ); + } + + public function registerSpeechToTextProvider(string $providerClass): void { + $this->context->registerSpeechToTextProvider( + $this->appId, + $providerClass + ); + } + public function registerTextProcessingProvider(string $providerClass): void { + $this->context->registerTextProcessingProvider( + $this->appId, + $providerClass + ); + } + + public function registerTextToImageProvider(string $providerClass): void { + $this->context->registerTextToImageProvider( + $this->appId, + $providerClass + ); + } + + public function registerTemplateProvider(string $providerClass): void { + $this->context->registerTemplateProvider( + $this->appId, + $providerClass + ); + } + + public function registerTranslationProvider(string $providerClass): void { + $this->context->registerTranslationProvider( + $this->appId, + $providerClass + ); + } + + public function registerNotifierService(string $notifierClass): void { + $this->context->registerNotifierService( + $this->appId, + $notifierClass + ); + } + + public function registerTwoFactorProvider(string $twoFactorProviderClass): void { + $this->context->registerTwoFactorProvider( + $this->appId, + $twoFactorProviderClass + ); + } + + public function registerPreviewProvider(string $previewProviderClass, string $mimeTypeRegex): void { + $this->context->registerPreviewProvider( + $this->appId, + $previewProviderClass, + $mimeTypeRegex + ); + } + + public function registerCalendarProvider(string $class): void { + $this->context->registerCalendarProvider( + $this->appId, + $class + ); + } + + public function registerReferenceProvider(string $class): void { + $this->context->registerReferenceProvider( + $this->appId, + $class + ); + } + + public function registerProfileLinkAction(string $actionClass): void { + $this->context->registerProfileLinkAction( + $this->appId, + $actionClass + ); + } + + public function registerTalkBackend(string $backend): void { + $this->context->registerTalkBackend( + $this->appId, + $backend + ); + } + + public function registerCalendarResourceBackend(string $class): void { + $this->context->registerCalendarResourceBackend( + $this->appId, + $class + ); + } + + public function registerTeamResourceProvider(string $class) : void { + $this->context->registerTeamResourceProvider( + $this->appId, + $class + ); + } + + public function registerCalendarRoomBackend(string $class): void { + $this->context->registerCalendarRoomBackend( + $this->appId, + $class + ); + } + + public function registerUserMigrator(string $migratorClass): void { + $this->context->registerUserMigrator( + $this->appId, + $migratorClass + ); + } + + public function registerSensitiveMethods(string $class, array $methods): void { + $this->context->registerSensitiveMethods( + $this->appId, + $class, + $methods + ); + } + + public function registerPublicShareTemplateProvider(string $class): void { + $this->context->registerPublicShareTemplateProvider( + $this->appId, + $class + ); + } + + public function registerSetupCheck(string $setupCheckClass): void { + $this->context->registerSetupCheck( + $this->appId, + $setupCheckClass + ); + } + + public function registerDeclarativeSettings(string $declarativeSettingsClass): void { + $this->context->registerDeclarativeSettings( + $this->appId, + $declarativeSettingsClass + ); + } + + public function registerTaskProcessingProvider(string $taskProcessingProviderClass): void { + $this->context->registerTaskProcessingProvider( + $this->appId, + $taskProcessingProviderClass + ); + } + + public function registerTaskProcessingTaskType(string $taskProcessingTaskTypeClass): void { + $this->context->registerTaskProcessingTaskType( + $this->appId, + $taskProcessingTaskTypeClass + ); + } + + public function registerFileConversionProvider(string $class): void { + $this->context->registerFileConversionProvider( + $this->appId, + $class + ); + } + + public function registerMailProvider(string $class): void { + $this->context->registerMailProvider( + $this->appId, + $class + ); + } + + public function registerConfigLexicon(string $configLexiconClass): void { + $this->context->registerConfigLexicon( + $this->appId, + $configLexiconClass + ); + } + }; + } + + /** + * @psalm-param class-string<ICapability> $capability + */ + public function registerCapability(string $appId, string $capability): void { + $this->capabilities[] = new ServiceRegistration($appId, $capability); + } + + /** + * @psalm-param class-string<IReporter> $reporterClass + */ + public function registerCrashReporter(string $appId, string $reporterClass): void { + $this->crashReporters[] = new ServiceRegistration($appId, $reporterClass); + } + + /** + * @psalm-param class-string<IWidget> $panelClass + */ + public function registerDashboardPanel(string $appId, string $panelClass): void { + $this->dashboardPanels[] = new ServiceRegistration($appId, $panelClass); + } + + public function registerService(string $appId, string $name, callable $factory, bool $shared = true): void { + $this->services[] = new ServiceFactoryRegistration($appId, $name, $factory, $shared); + } + + public function registerServiceAlias(string $appId, string $alias, string $target): void { + $this->aliases[] = new ServiceAliasRegistration($appId, $alias, $target); + } + + public function registerParameter(string $appId, string $name, $value): void { + $this->parameters[] = new ParameterRegistration($appId, $name, $value); + } + + public function registerEventListener(string $appId, string $event, string $listener, int $priority = 0): void { + $this->eventListeners[] = new EventListenerRegistration($appId, $event, $listener, $priority); + } + + /** + * @psalm-param class-string<Middleware> $class + */ + public function registerMiddleware(string $appId, string $class, bool $global): void { + $this->middlewares[] = new MiddlewareRegistration($appId, $class, $global); + } + + public function registerSearchProvider(string $appId, string $class) { + $this->searchProviders[] = new ServiceRegistration($appId, $class); + } + + public function registerAlternativeLogin(string $appId, string $class): void { + $this->alternativeLogins[] = new ServiceRegistration($appId, $class); + } + + public function registerInitialState(string $appId, string $class): void { + $this->initialStates[] = new ServiceRegistration($appId, $class); + } + + public function registerWellKnown(string $appId, string $class): void { + $this->wellKnownHandlers[] = new ServiceRegistration($appId, $class); + } + + public function registerSpeechToTextProvider(string $appId, string $class): void { + $this->speechToTextProviders[] = new ServiceRegistration($appId, $class); + } + + public function registerTextProcessingProvider(string $appId, string $class): void { + $this->textProcessingProviders[] = new ServiceRegistration($appId, $class); + } + + public function registerTextToImageProvider(string $appId, string $class): void { + $this->textToImageProviders[] = new ServiceRegistration($appId, $class); + } + + public function registerTemplateProvider(string $appId, string $class): void { + $this->templateProviders[] = new ServiceRegistration($appId, $class); + } + + public function registerTranslationProvider(string $appId, string $class): void { + $this->translationProviders[] = new ServiceRegistration($appId, $class); + } + + public function registerNotifierService(string $appId, string $class): void { + $this->notifierServices[] = new ServiceRegistration($appId, $class); + } + + public function registerTwoFactorProvider(string $appId, string $class): void { + $this->twoFactorProviders[] = new ServiceRegistration($appId, $class); + } + + public function registerPreviewProvider(string $appId, string $class, string $mimeTypeRegex): void { + $this->previewProviders[] = new PreviewProviderRegistration($appId, $class, $mimeTypeRegex); + } + + public function registerCalendarProvider(string $appId, string $class): void { + $this->calendarProviders[] = new ServiceRegistration($appId, $class); + } + + public function registerReferenceProvider(string $appId, string $class): void { + $this->referenceProviders[] = new ServiceRegistration($appId, $class); + } + + /** + * @psalm-param class-string<ILinkAction> $actionClass + */ + public function registerProfileLinkAction(string $appId, string $actionClass): void { + $this->profileLinkActions[] = new ServiceRegistration($appId, $actionClass); + } + + /** + * @psalm-param class-string<ITalkBackend> $backend + */ + public function registerTalkBackend(string $appId, string $backend) { + // Some safeguards for invalid registrations + if ($appId !== 'spreed') { + throw new RuntimeException('Only the Talk app is allowed to register a Talk backend'); + } + if ($this->talkBackendRegistration !== null) { + throw new RuntimeException('There can only be one Talk backend'); + } + + $this->talkBackendRegistration = new ServiceRegistration($appId, $backend); + } + + public function registerCalendarResourceBackend(string $appId, string $class) { + $this->calendarResourceBackendRegistrations[] = new ServiceRegistration( + $appId, + $class, + ); + } + + public function registerCalendarRoomBackend(string $appId, string $class) { + $this->calendarRoomBackendRegistrations[] = new ServiceRegistration( + $appId, + $class, + ); + } + + /** + * @psalm-param class-string<ITeamResourceProvider> $class + */ + public function registerTeamResourceProvider(string $appId, string $class) { + $this->teamResourceProviders[] = new ServiceRegistration( + $appId, + $class + ); + } + + /** + * @psalm-param class-string<IUserMigrator> $migratorClass + */ + public function registerUserMigrator(string $appId, string $migratorClass): void { + $this->userMigrators[] = new ServiceRegistration($appId, $migratorClass); + } + + public function registerSensitiveMethods(string $appId, string $class, array $methods): void { + $methods = array_filter($methods, 'is_string'); + $this->sensitiveMethods[] = new ParameterRegistration($appId, $class, $methods); + } + + public function registerPublicShareTemplateProvider(string $appId, string $class): void { + $this->publicShareTemplateProviders[] = new ServiceRegistration($appId, $class); + } + + /** + * @psalm-param class-string<ISetupCheck> $setupCheckClass + */ + public function registerSetupCheck(string $appId, string $setupCheckClass): void { + $this->setupChecks[] = new ServiceRegistration($appId, $setupCheckClass); + } + + /** + * @psalm-param class-string<IDeclarativeSettingsForm> $declarativeSettingsClass + */ + public function registerDeclarativeSettings(string $appId, string $declarativeSettingsClass): void { + $this->declarativeSettings[] = new ServiceRegistration($appId, $declarativeSettingsClass); + } + + /** + * @psalm-param class-string<\OCP\TaskProcessing\IProvider> $declarativeSettingsClass + */ + public function registerTaskProcessingProvider(string $appId, string $taskProcessingProviderClass): void { + $this->taskProcessingProviders[] = new ServiceRegistration($appId, $taskProcessingProviderClass); + } + + /** + * @psalm-param class-string<\OCP\TaskProcessing\ITaskType> $declarativeSettingsClass + */ + public function registerTaskProcessingTaskType(string $appId, string $taskProcessingTaskTypeClass) { + $this->taskProcessingTaskTypes[] = new ServiceRegistration($appId, $taskProcessingTaskTypeClass); + } + + /** + * @psalm-param class-string<\OCP\Files\Conversion\IConversionProvider> $class + */ + public function registerFileConversionProvider(string $appId, string $class): void { + $this->fileConversionProviders[] = new ServiceRegistration($appId, $class); + } + + /** + * @psalm-param class-string<IMailProvider> $migratorClass + */ + public function registerMailProvider(string $appId, string $class): void { + $this->mailProviders[] = new ServiceRegistration($appId, $class); + } + + /** + * @psalm-param class-string<ILexicon> $configLexiconClass + */ + public function registerConfigLexicon(string $appId, string $configLexiconClass): void { + $this->configLexiconClasses[$appId] = $configLexiconClass; + } + + /** + * @param App[] $apps + */ + public function delegateCapabilityRegistrations(array $apps): void { + while (($registration = array_shift($this->capabilities)) !== null) { + $appId = $registration->getAppId(); + if (!isset($apps[$appId])) { + // If we land here something really isn't right. But at least we caught the + // notice that is otherwise emitted for the undefined index + $this->logger->error("App $appId not loaded for the capability registration"); + + continue; + } + + try { + $apps[$appId] + ->getContainer() + ->registerCapability($registration->getService()); + } catch (Throwable $e) { + $this->logger->error("Error during capability registration of $appId: " . $e->getMessage(), [ + 'exception' => $e, + ]); + } + } + } + + /** + * @param App[] $apps + */ + public function delegateCrashReporterRegistrations(array $apps, Registry $registry): void { + while (($registration = array_shift($this->crashReporters)) !== null) { + try { + $registry->registerLazy($registration->getService()); + } catch (Throwable $e) { + $appId = $registration->getAppId(); + $this->logger->error("Error during crash reporter registration of $appId: " . $e->getMessage(), [ + 'exception' => $e, + ]); + } + } + } + + public function delegateDashboardPanelRegistrations(IManager $dashboardManager): void { + while (($panel = array_shift($this->dashboardPanels)) !== null) { + try { + $dashboardManager->lazyRegisterWidget($panel->getService(), $panel->getAppId()); + } catch (Throwable $e) { + $appId = $panel->getAppId(); + $this->logger->error("Error during dashboard registration of $appId: " . $e->getMessage(), [ + 'exception' => $e, + ]); + } + } + } + + public function delegateEventListenerRegistrations(IEventDispatcher $eventDispatcher): void { + while (($registration = array_shift($this->eventListeners)) !== null) { + try { + $eventDispatcher->addServiceListener( + $registration->getEvent(), + $registration->getService(), + $registration->getPriority() + ); + } catch (Throwable $e) { + $appId = $registration->getAppId(); + $this->logger->error("Error during event listener registration of $appId: " . $e->getMessage(), [ + 'exception' => $e, + ]); + } + } + } + + /** + * @param App[] $apps + */ + public function delegateContainerRegistrations(array $apps): void { + while (($registration = array_shift($this->services)) !== null) { + $appId = $registration->getAppId(); + if (!isset($apps[$appId])) { + // If we land here something really isn't right. But at least we caught the + // notice that is otherwise emitted for the undefined index + $this->logger->error("App $appId not loaded for the container service registration"); + + continue; + } + + try { + /** + * Register the service and convert the callable into a \Closure if necessary + */ + $apps[$appId] + ->getContainer() + ->registerService( + $registration->getName(), + Closure::fromCallable($registration->getFactory()), + $registration->isShared() + ); + } catch (Throwable $e) { + $this->logger->error("Error during service registration of $appId: " . $e->getMessage(), [ + 'exception' => $e, + ]); + } + } + + while (($registration = array_shift($this->aliases)) !== null) { + $appId = $registration->getAppId(); + if (!isset($apps[$appId])) { + // If we land here something really isn't right. But at least we caught the + // notice that is otherwise emitted for the undefined index + $this->logger->error("App $appId not loaded for the container alias registration"); + + continue; + } + + try { + $apps[$appId] + ->getContainer() + ->registerAlias( + $registration->getAlias(), + $registration->getTarget() + ); + } catch (Throwable $e) { + $this->logger->error("Error during service alias registration of $appId: " . $e->getMessage(), [ + 'exception' => $e, + ]); + } + } + + while (($registration = array_shift($this->parameters)) !== null) { + $appId = $registration->getAppId(); + if (!isset($apps[$appId])) { + // If we land here something really isn't right. But at least we caught the + // notice that is otherwise emitted for the undefined index + $this->logger->error("App $appId not loaded for the container parameter registration"); + + continue; + } + + try { + $apps[$appId] + ->getContainer() + ->registerParameter( + $registration->getName(), + $registration->getValue() + ); + } catch (Throwable $e) { + $this->logger->error("Error during service parameter registration of $appId: " . $e->getMessage(), [ + 'exception' => $e, + ]); + } + } + } + + /** + * @return MiddlewareRegistration[] + */ + public function getMiddlewareRegistrations(): array { + return $this->middlewares; + } + + /** + * @return ServiceRegistration<IProvider>[] + */ + public function getSearchProviders(): array { + return $this->searchProviders; + } + + /** + * @return ServiceRegistration<IAlternativeLogin>[] + */ + public function getAlternativeLogins(): array { + return $this->alternativeLogins; + } + + /** + * @return ServiceRegistration<InitialStateProvider>[] + */ + public function getInitialStates(): array { + return $this->initialStates; + } + + /** + * @return ServiceRegistration<IHandler>[] + */ + public function getWellKnownHandlers(): array { + return $this->wellKnownHandlers; + } + + /** + * @return ServiceRegistration<ISpeechToTextProvider>[] + */ + public function getSpeechToTextProviders(): array { + return $this->speechToTextProviders; + } + + /** + * @return ServiceRegistration<ITextProcessingProvider>[] + */ + public function getTextProcessingProviders(): array { + return $this->textProcessingProviders; + } + + /** + * @return ServiceRegistration<\OCP\TextToImage\IProvider>[] + */ + public function getTextToImageProviders(): array { + return $this->textToImageProviders; + } + + /** + * @return ServiceRegistration<ICustomTemplateProvider>[] + */ + public function getTemplateProviders(): array { + return $this->templateProviders; + } + + /** + * @return ServiceRegistration<ITranslationProvider>[] + */ + public function getTranslationProviders(): array { + return $this->translationProviders; + } + + /** + * @return ServiceRegistration<INotifier>[] + */ + public function getNotifierServices(): array { + return $this->notifierServices; + } + + /** + * @return ServiceRegistration<\OCP\Authentication\TwoFactorAuth\IProvider>[] + */ + public function getTwoFactorProviders(): array { + return $this->twoFactorProviders; + } + + /** + * @return PreviewProviderRegistration[] + */ + public function getPreviewProviders(): array { + return $this->previewProviders; + } + + /** + * @return ServiceRegistration<ICalendarProvider>[] + */ + public function getCalendarProviders(): array { + return $this->calendarProviders; + } + + /** + * @return ServiceRegistration<IReferenceProvider>[] + */ + public function getReferenceProviders(): array { + return $this->referenceProviders; + } + + /** + * @return ServiceRegistration<ILinkAction>[] + */ + public function getProfileLinkActions(): array { + return $this->profileLinkActions; + } + + /** + * @return ServiceRegistration|null + * @psalm-return ServiceRegistration<ITalkBackend>|null + */ + public function getTalkBackendRegistration(): ?ServiceRegistration { + return $this->talkBackendRegistration; + } + + /** + * @return ServiceRegistration[] + * @psalm-return ServiceRegistration<IResourceBackend>[] + */ + public function getCalendarResourceBackendRegistrations(): array { + return $this->calendarResourceBackendRegistrations; + } + + /** + * @return ServiceRegistration[] + * @psalm-return ServiceRegistration<IRoomBackend>[] + */ + public function getCalendarRoomBackendRegistrations(): array { + return $this->calendarRoomBackendRegistrations; + } + + /** + * @return ServiceRegistration<IUserMigrator>[] + */ + public function getUserMigrators(): array { + return $this->userMigrators; + } + + /** + * @return ParameterRegistration[] + */ + public function getSensitiveMethods(): array { + return $this->sensitiveMethods; + } + + /** + * @return ServiceRegistration<IPublicShareTemplateProvider>[] + */ + public function getPublicShareTemplateProviders(): array { + return $this->publicShareTemplateProviders; + } + + /** + * @return ServiceRegistration<ISetupCheck>[] + */ + public function getSetupChecks(): array { + return $this->setupChecks; + } + + /** + * @return ServiceRegistration<ITeamResourceProvider>[] + */ + public function getTeamResourceProviders(): array { + return $this->teamResourceProviders; + } + + /** + * @return ServiceRegistration<IDeclarativeSettingsForm>[] + */ + public function getDeclarativeSettings(): array { + return $this->declarativeSettings; + } + + /** + * @return ServiceRegistration<\OCP\TaskProcessing\IProvider>[] + */ + public function getTaskProcessingProviders(): array { + return $this->taskProcessingProviders; + } + + /** + * @return ServiceRegistration<\OCP\TaskProcessing\ITaskType>[] + */ + public function getTaskProcessingTaskTypes(): array { + return $this->taskProcessingTaskTypes; + } + + /** + * @return ServiceRegistration<\OCP\Files\Conversion\IConversionProvider>[] + */ + public function getFileConversionProviders(): array { + return $this->fileConversionProviders; + } + + /** + * @return ServiceRegistration<IMailProvider>[] + */ + public function getMailProviders(): array { + return $this->mailProviders; + } + + /** + * returns IConfigLexicon registered by the app. + * null if none registered. + * + * @param string $appId + * + * @return ILexicon|null + */ + public function getConfigLexicon(string $appId): ?ILexicon { + if (!array_key_exists($appId, $this->configLexiconClasses)) { + return null; + } + + return \OCP\Server::get($this->configLexiconClasses[$appId]); + } +} diff --git a/lib/private/AppFramework/Bootstrap/ServiceAliasRegistration.php b/lib/private/AppFramework/Bootstrap/ServiceAliasRegistration.php new file mode 100644 index 00000000000..aa3e38e4c46 --- /dev/null +++ b/lib/private/AppFramework/Bootstrap/ServiceAliasRegistration.php @@ -0,0 +1,52 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\AppFramework\Bootstrap; + +/** + * @psalm-immutable + */ +class ServiceAliasRegistration extends ARegistration { + /** + * @var string + * @psalm-var string|class-string + */ + private $alias; + + /** + * @var string + * @psalm-var string|class-string + */ + private $target; + + /** + * @psalm-param string|class-string $alias + * @paslm-param string|class-string $target + */ + public function __construct(string $appId, + string $alias, + string $target) { + parent::__construct($appId); + $this->alias = $alias; + $this->target = $target; + } + + /** + * @psalm-return string|class-string + */ + public function getAlias(): string { + return $this->alias; + } + + /** + * @psalm-return string|class-string + */ + public function getTarget(): string { + return $this->target; + } +} diff --git a/lib/private/AppFramework/Bootstrap/ServiceFactoryRegistration.php b/lib/private/AppFramework/Bootstrap/ServiceFactoryRegistration.php new file mode 100644 index 00000000000..63e73410b5a --- /dev/null +++ b/lib/private/AppFramework/Bootstrap/ServiceFactoryRegistration.php @@ -0,0 +1,54 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\AppFramework\Bootstrap; + +/** + * @psalm-immutable + */ +class ServiceFactoryRegistration extends ARegistration { + /** + * @var string + * @psalm-var string|class-string + */ + private $name; + + /** + * @var callable + * @psalm-var callable(\Psr\Container\ContainerInterface): mixed + */ + private $factory; + + /** @var bool */ + private $shared; + + public function __construct(string $appId, + string $alias, + callable $target, + bool $shared) { + parent::__construct($appId); + $this->name = $alias; + $this->factory = $target; + $this->shared = $shared; + } + + public function getName(): string { + return $this->name; + } + + /** + * @psalm-return callable(\Psr\Container\ContainerInterface): mixed + */ + public function getFactory(): callable { + return $this->factory; + } + + public function isShared(): bool { + return $this->shared; + } +} diff --git a/lib/private/AppFramework/Bootstrap/ServiceRegistration.php b/lib/private/AppFramework/Bootstrap/ServiceRegistration.php new file mode 100644 index 00000000000..6eda5e0196f --- /dev/null +++ b/lib/private/AppFramework/Bootstrap/ServiceRegistration.php @@ -0,0 +1,36 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\AppFramework\Bootstrap; + +/** + * @psalm-immutable + * @template T + */ +class ServiceRegistration extends ARegistration { + /** + * @var string + * @psalm-var class-string<T> + */ + private $service; + + /** + * @psalm-param class-string<T> $service + */ + public function __construct(string $appId, string $service) { + parent::__construct($appId); + $this->service = $service; + } + + /** + * @psalm-return class-string<T> + */ + public function getService(): string { + return $this->service; + } +} diff --git a/lib/private/AppFramework/DependencyInjection/DIContainer.php b/lib/private/AppFramework/DependencyInjection/DIContainer.php new file mode 100644 index 00000000000..76261fe6b92 --- /dev/null +++ b/lib/private/AppFramework/DependencyInjection/DIContainer.php @@ -0,0 +1,364 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ + +namespace OC\AppFramework\DependencyInjection; + +use OC\AppFramework\Http; +use OC\AppFramework\Http\Dispatcher; +use OC\AppFramework\Http\Output; +use OC\AppFramework\Middleware\AdditionalScriptsMiddleware; +use OC\AppFramework\Middleware\CompressionMiddleware; +use OC\AppFramework\Middleware\FlowV2EphemeralSessionsMiddleware; +use OC\AppFramework\Middleware\MiddlewareDispatcher; +use OC\AppFramework\Middleware\NotModifiedMiddleware; +use OC\AppFramework\Middleware\OCSMiddleware; +use OC\AppFramework\Middleware\PublicShare\PublicShareMiddleware; +use OC\AppFramework\Middleware\Security\BruteForceMiddleware; +use OC\AppFramework\Middleware\Security\CORSMiddleware; +use OC\AppFramework\Middleware\Security\CSPMiddleware; +use OC\AppFramework\Middleware\Security\FeaturePolicyMiddleware; +use OC\AppFramework\Middleware\Security\PasswordConfirmationMiddleware; +use OC\AppFramework\Middleware\Security\RateLimitingMiddleware; +use OC\AppFramework\Middleware\Security\ReloadExecutionMiddleware; +use OC\AppFramework\Middleware\Security\SameSiteCookieMiddleware; +use OC\AppFramework\Middleware\Security\SecurityMiddleware; +use OC\AppFramework\Middleware\SessionMiddleware; +use OC\AppFramework\ScopedPsrLogger; +use OC\AppFramework\Utility\SimpleContainer; +use OC\Core\Middleware\TwoFactorMiddleware; +use OC\Diagnostics\EventLogger; +use OC\Log\PsrLoggerAdapter; +use OC\ServerContainer; +use OC\Settings\AuthorizedGroupMapper; +use OCA\WorkflowEngine\Manager; +use OCP\App\IAppManager; +use OCP\AppFramework\Http\IOutput; +use OCP\AppFramework\IAppContainer; +use OCP\AppFramework\QueryException; +use OCP\AppFramework\Services\IAppConfig; +use OCP\AppFramework\Services\IInitialState; +use OCP\AppFramework\Utility\IControllerMethodReflector; +use OCP\Files\AppData\IAppDataFactory; +use OCP\Files\Folder; +use OCP\Files\IAppData; +use OCP\Group\ISubAdmin; +use OCP\IConfig; +use OCP\IDBConnection; +use OCP\IGroupManager; +use OCP\IL10N; +use OCP\INavigationManager; +use OCP\IRequest; +use OCP\IServerContainer; +use OCP\ISession; +use OCP\IURLGenerator; +use OCP\IUserSession; +use OCP\Security\Ip\IRemoteAddress; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +class DIContainer extends SimpleContainer implements IAppContainer { + protected string $appName; + private array $middleWares = []; + private ServerContainer $server; + + public function __construct(string $appName, array $urlParams = [], ?ServerContainer $server = null) { + parent::__construct(); + $this->appName = $appName; + $this->registerParameter('appName', $appName); + $this->registerParameter('urlParams', $urlParams); + + /** @deprecated 32.0.0 */ + $this->registerDeprecatedAlias('Request', IRequest::class); + + if ($server === null) { + $server = \OC::$server; + } + $this->server = $server; + $this->server->registerAppContainer($appName, $this); + + // aliases + /** @deprecated 26.0.0 inject $appName */ + $this->registerDeprecatedAlias('AppName', 'appName'); + /** @deprecated 26.0.0 inject $webRoot*/ + $this->registerDeprecatedAlias('WebRoot', 'webRoot'); + /** @deprecated 26.0.0 inject $userId */ + $this->registerDeprecatedAlias('UserId', 'userId'); + + /** + * Core services + */ + /* Cannot be an alias because Output is not in OCA */ + $this->registerService(IOutput::class, fn (ContainerInterface $c): IOutput => new Output($c->get('webRoot'))); + + $this->registerService(Folder::class, function () { + return $this->getServer()->getUserFolder(); + }); + + $this->registerService(IAppData::class, function (ContainerInterface $c): IAppData { + return $c->get(IAppDataFactory::class)->get($c->get('appName')); + }); + + $this->registerService(IL10N::class, function (ContainerInterface $c) { + return $this->getServer()->getL10N($c->get('appName')); + }); + + // Log wrappers + $this->registerService(LoggerInterface::class, function (ContainerInterface $c) { + /* Cannot be an alias because it uses LoggerInterface so it would infinite loop */ + return new ScopedPsrLogger( + $c->get(PsrLoggerAdapter::class), + $c->get('appName') + ); + }); + + $this->registerService(IServerContainer::class, function () { + return $this->getServer(); + }); + /** @deprecated 32.0.0 */ + $this->registerDeprecatedAlias('ServerContainer', IServerContainer::class); + + $this->registerAlias(\OCP\WorkflowEngine\IManager::class, Manager::class); + + $this->registerService(ContainerInterface::class, fn (ContainerInterface $c) => $c); + $this->registerDeprecatedAlias(IAppContainer::class, ContainerInterface::class); + + // commonly used attributes + $this->registerService('userId', function (ContainerInterface $c): ?string { + return $c->get(ISession::class)->get('user_id'); + }); + + $this->registerService('webRoot', function (ContainerInterface $c): string { + return $c->get(IServerContainer::class)->getWebRoot(); + }); + + $this->registerService('OC_Defaults', function (ContainerInterface $c): object { + return $c->get(IServerContainer::class)->get('ThemingDefaults'); + }); + + /** @deprecated 32.0.0 */ + $this->registerDeprecatedAlias('Protocol', Http::class); + $this->registerService(Http::class, function (ContainerInterface $c) { + $protocol = $c->get(IRequest::class)->getHttpProtocol(); + return new Http($_SERVER, $protocol); + }); + + /** @deprecated 32.0.0 */ + $this->registerDeprecatedAlias('Dispatcher', Dispatcher::class); + $this->registerService(Dispatcher::class, function (ContainerInterface $c) { + return new Dispatcher( + $c->get(Http::class), + $c->get(MiddlewareDispatcher::class), + $c->get(IControllerMethodReflector::class), + $c->get(IRequest::class), + $c->get(IConfig::class), + $c->get(IDBConnection::class), + $c->get(LoggerInterface::class), + $c->get(EventLogger::class), + $c, + ); + }); + + /** + * App Framework default arguments + */ + $this->registerParameter('corsMethods', 'PUT, POST, GET, DELETE, PATCH'); + $this->registerParameter('corsAllowedHeaders', 'Authorization, Content-Type, Accept'); + $this->registerParameter('corsMaxAge', 1728000); + + /** + * Middleware + */ + /** @deprecated 32.0.0 */ + $this->registerDeprecatedAlias('MiddlewareDispatcher', MiddlewareDispatcher::class); + $this->registerService(MiddlewareDispatcher::class, function (ContainerInterface $c) { + $server = $this->getServer(); + + $dispatcher = new MiddlewareDispatcher(); + + $dispatcher->registerMiddleware($c->get(CompressionMiddleware::class)); + $dispatcher->registerMiddleware($c->get(NotModifiedMiddleware::class)); + $dispatcher->registerMiddleware($c->get(ReloadExecutionMiddleware::class)); + $dispatcher->registerMiddleware($c->get(SameSiteCookieMiddleware::class)); + $dispatcher->registerMiddleware($c->get(CORSMiddleware::class)); + $dispatcher->registerMiddleware($c->get(OCSMiddleware::class)); + + $dispatcher->registerMiddleware($c->get(FlowV2EphemeralSessionsMiddleware::class)); + + $securityMiddleware = new SecurityMiddleware( + $c->get(IRequest::class), + $c->get(IControllerMethodReflector::class), + $c->get(INavigationManager::class), + $c->get(IURLGenerator::class), + $c->get(LoggerInterface::class), + $c->get('appName'), + $server->getUserSession()->isLoggedIn(), + $c->get(IGroupManager::class), + $c->get(ISubAdmin::class), + $c->get(IAppManager::class), + $server->getL10N('lib'), + $c->get(AuthorizedGroupMapper::class), + $c->get(IUserSession::class), + $c->get(IRemoteAddress::class), + ); + $dispatcher->registerMiddleware($securityMiddleware); + $dispatcher->registerMiddleware($c->get(CSPMiddleware::class)); + $dispatcher->registerMiddleware($c->get(FeaturePolicyMiddleware::class)); + $dispatcher->registerMiddleware($c->get(PasswordConfirmationMiddleware::class)); + $dispatcher->registerMiddleware($c->get(TwoFactorMiddleware::class)); + $dispatcher->registerMiddleware($c->get(BruteForceMiddleware::class)); + $dispatcher->registerMiddleware($c->get(RateLimitingMiddleware::class)); + $dispatcher->registerMiddleware($c->get(PublicShareMiddleware::class)); + $dispatcher->registerMiddleware($c->get(AdditionalScriptsMiddleware::class)); + + $coordinator = $c->get(\OC\AppFramework\Bootstrap\Coordinator::class); + $registrationContext = $coordinator->getRegistrationContext(); + if ($registrationContext !== null) { + $appId = $this->get('appName'); + foreach ($registrationContext->getMiddlewareRegistrations() as $middlewareRegistration) { + if ($middlewareRegistration->getAppId() === $appId + || $middlewareRegistration->isGlobal()) { + $dispatcher->registerMiddleware($c->get($middlewareRegistration->getService())); + } + } + } + foreach ($this->middleWares as $middleWare) { + $dispatcher->registerMiddleware($c->get($middleWare)); + } + + $dispatcher->registerMiddleware($c->get(SessionMiddleware::class)); + return $dispatcher; + }); + + $this->registerAlias(IAppConfig::class, \OC\AppFramework\Services\AppConfig::class); + $this->registerAlias(IInitialState::class, \OC\AppFramework\Services\InitialState::class); + } + + /** + * @return \OCP\IServerContainer + */ + public function getServer() { + return $this->server; + } + + /** + * @param string $middleWare + */ + public function registerMiddleWare($middleWare): bool { + if (in_array($middleWare, $this->middleWares, true) !== false) { + return false; + } + $this->middleWares[] = $middleWare; + return true; + } + + /** + * used to return the appname of the set application + * @return string the name of your application + */ + public function getAppName() { + return $this->query('appName'); + } + + /** + * @deprecated 12.0.0 use IUserSession->isLoggedIn() + * @return boolean + */ + public function isLoggedIn() { + return \OC::$server->getUserSession()->isLoggedIn(); + } + + /** + * @deprecated 12.0.0 use IGroupManager->isAdmin($userId) + * @return boolean + */ + public function isAdminUser() { + $uid = $this->getUserId(); + return \OC_User::isAdminUser($uid); + } + + private function getUserId(): string { + return $this->getServer()->getSession()->get('user_id'); + } + + /** + * Register a capability + * + * @param string $serviceName e.g. 'OCA\Files\Capabilities' + */ + public function registerCapability($serviceName) { + $this->query('OC\CapabilitiesManager')->registerCapability(function () use ($serviceName) { + return $this->query($serviceName); + }); + } + + public function has($id): bool { + if (parent::has($id)) { + return true; + } + + if ($this->server->has($id, true)) { + return true; + } + + return false; + } + + public function query(string $name, bool $autoload = true) { + if ($name === 'AppName' || $name === 'appName') { + return $this->appName; + } + + $isServerClass = str_starts_with($name, 'OCP\\') || str_starts_with($name, 'OC\\'); + if ($isServerClass && !$this->has($name)) { + return $this->getServer()->query($name, $autoload); + } + + try { + return $this->queryNoFallback($name); + } catch (QueryException $firstException) { + try { + return $this->getServer()->query($name, $autoload); + } catch (QueryException $secondException) { + if ($firstException->getCode() === 1) { + throw $secondException; + } + throw $firstException; + } + } + } + + /** + * @param string $name + * @return mixed + * @throws QueryException if the query could not be resolved + */ + public function queryNoFallback($name) { + $name = $this->sanitizeName($name); + + if ($this->offsetExists($name)) { + return parent::query($name); + } elseif ($this->appName === 'settings' && str_starts_with($name, 'OC\\Settings\\')) { + return parent::query($name); + } elseif ($this->appName === 'core' && str_starts_with($name, 'OC\\Core\\')) { + return parent::query($name); + } elseif (str_starts_with($name, \OC\AppFramework\App::buildAppNamespace($this->appName) . '\\')) { + return parent::query($name); + } elseif ( + str_starts_with($name, 'OC\\AppFramework\\Services\\') + || str_starts_with($name, 'OC\\AppFramework\\Middleware\\') + ) { + /* AppFramework services are scoped to the application */ + return parent::query($name); + } + + throw new QueryException('Could not resolve ' . $name . '!' + . ' Class can not be instantiated', 1); + } +} diff --git a/lib/private/AppFramework/Http.php b/lib/private/AppFramework/Http.php new file mode 100644 index 00000000000..08d6259c2a2 --- /dev/null +++ b/lib/private/AppFramework/Http.php @@ -0,0 +1,108 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\AppFramework; + +use OCP\AppFramework\Http as BaseHttp; + +class Http extends BaseHttp { + private $server; + private $protocolVersion; + protected $headers; + + /** + * @param array $server $_SERVER + * @param string $protocolVersion the http version to use defaults to HTTP/1.1 + */ + public function __construct($server, $protocolVersion = 'HTTP/1.1') { + $this->server = $server; + $this->protocolVersion = $protocolVersion; + + $this->headers = [ + self::STATUS_CONTINUE => 'Continue', + self::STATUS_SWITCHING_PROTOCOLS => 'Switching Protocols', + self::STATUS_PROCESSING => 'Processing', + self::STATUS_OK => 'OK', + self::STATUS_CREATED => 'Created', + self::STATUS_ACCEPTED => 'Accepted', + self::STATUS_NON_AUTHORATIVE_INFORMATION => 'Non-Authorative Information', + self::STATUS_NO_CONTENT => 'No Content', + self::STATUS_RESET_CONTENT => 'Reset Content', + self::STATUS_PARTIAL_CONTENT => 'Partial Content', + self::STATUS_MULTI_STATUS => 'Multi-Status', // RFC 4918 + self::STATUS_ALREADY_REPORTED => 'Already Reported', // RFC 5842 + self::STATUS_IM_USED => 'IM Used', // RFC 3229 + self::STATUS_MULTIPLE_CHOICES => 'Multiple Choices', + self::STATUS_MOVED_PERMANENTLY => 'Moved Permanently', + self::STATUS_FOUND => 'Found', + self::STATUS_SEE_OTHER => 'See Other', + self::STATUS_NOT_MODIFIED => 'Not Modified', + self::STATUS_USE_PROXY => 'Use Proxy', + self::STATUS_RESERVED => 'Reserved', + self::STATUS_TEMPORARY_REDIRECT => 'Temporary Redirect', + self::STATUS_BAD_REQUEST => 'Bad request', + self::STATUS_UNAUTHORIZED => 'Unauthorized', + self::STATUS_PAYMENT_REQUIRED => 'Payment Required', + self::STATUS_FORBIDDEN => 'Forbidden', + self::STATUS_NOT_FOUND => 'Not Found', + self::STATUS_METHOD_NOT_ALLOWED => 'Method Not Allowed', + self::STATUS_NOT_ACCEPTABLE => 'Not Acceptable', + self::STATUS_PROXY_AUTHENTICATION_REQUIRED => 'Proxy Authentication Required', + self::STATUS_REQUEST_TIMEOUT => 'Request Timeout', + self::STATUS_CONFLICT => 'Conflict', + self::STATUS_GONE => 'Gone', + self::STATUS_LENGTH_REQUIRED => 'Length Required', + self::STATUS_PRECONDITION_FAILED => 'Precondition failed', + self::STATUS_REQUEST_ENTITY_TOO_LARGE => 'Request Entity Too Large', + self::STATUS_REQUEST_URI_TOO_LONG => 'Request-URI Too Long', + self::STATUS_UNSUPPORTED_MEDIA_TYPE => 'Unsupported Media Type', + self::STATUS_REQUEST_RANGE_NOT_SATISFIABLE => 'Requested Range Not Satisfiable', + self::STATUS_EXPECTATION_FAILED => 'Expectation Failed', + self::STATUS_IM_A_TEAPOT => 'I\'m a teapot', // RFC 2324 + self::STATUS_UNPROCESSABLE_ENTITY => 'Unprocessable Entity', // RFC 4918 + self::STATUS_LOCKED => 'Locked', // RFC 4918 + self::STATUS_FAILED_DEPENDENCY => 'Failed Dependency', // RFC 4918 + self::STATUS_UPGRADE_REQUIRED => 'Upgrade required', + self::STATUS_PRECONDITION_REQUIRED => 'Precondition required', // draft-nottingham-http-new-status + self::STATUS_TOO_MANY_REQUESTS => 'Too Many Requests', // draft-nottingham-http-new-status + self::STATUS_REQUEST_HEADER_FIELDS_TOO_LARGE => 'Request Header Fields Too Large', // draft-nottingham-http-new-status + self::STATUS_INTERNAL_SERVER_ERROR => 'Internal Server Error', + self::STATUS_NOT_IMPLEMENTED => 'Not Implemented', + self::STATUS_BAD_GATEWAY => 'Bad Gateway', + self::STATUS_SERVICE_UNAVAILABLE => 'Service Unavailable', + self::STATUS_GATEWAY_TIMEOUT => 'Gateway Timeout', + self::STATUS_HTTP_VERSION_NOT_SUPPORTED => 'HTTP Version not supported', + self::STATUS_VARIANT_ALSO_NEGOTIATES => 'Variant Also Negotiates', + self::STATUS_INSUFFICIENT_STORAGE => 'Insufficient Storage', // RFC 4918 + self::STATUS_LOOP_DETECTED => 'Loop Detected', // RFC 5842 + self::STATUS_BANDWIDTH_LIMIT_EXCEEDED => 'Bandwidth Limit Exceeded', // non-standard + self::STATUS_NOT_EXTENDED => 'Not extended', + self::STATUS_NETWORK_AUTHENTICATION_REQUIRED => 'Network Authentication Required', // draft-nottingham-http-new-status + ]; + } + + + /** + * Gets the correct header + * @param int Http::CONSTANT $status the constant from the Http class + * @param \DateTime $lastModified formatted last modified date + * @param string $ETag the etag + * @return string + */ + public function getStatusHeader($status) { + // we have one change currently for the http 1.0 header that differs + // from 1.1: STATUS_TEMPORARY_REDIRECT should be STATUS_FOUND + // if this differs any more, we want to create childclasses for this + if ($status === self::STATUS_TEMPORARY_REDIRECT + && $this->protocolVersion === 'HTTP/1.0') { + $status = self::STATUS_FOUND; + } + + return $this->protocolVersion . ' ' . $status . ' ' + . $this->headers[$status]; + } +} diff --git a/lib/private/AppFramework/Http/Dispatcher.php b/lib/private/AppFramework/Http/Dispatcher.php new file mode 100644 index 00000000000..8d91ddf7502 --- /dev/null +++ b/lib/private/AppFramework/Http/Dispatcher.php @@ -0,0 +1,249 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\AppFramework\Http; + +use OC\AppFramework\Http; +use OC\AppFramework\Middleware\MiddlewareDispatcher; +use OC\AppFramework\Utility\ControllerMethodReflector; +use OC\DB\ConnectionAdapter; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\Http\ParameterOutOfRangeException; +use OCP\AppFramework\Http\Response; +use OCP\Diagnostics\IEventLogger; +use OCP\IConfig; +use OCP\IRequest; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** + * Class to dispatch the request to the middleware dispatcher + */ +class Dispatcher { + /** @var MiddlewareDispatcher */ + private $middlewareDispatcher; + + /** @var Http */ + private $protocol; + + /** @var ControllerMethodReflector */ + private $reflector; + + /** @var IRequest */ + private $request; + + /** @var IConfig */ + private $config; + + /** @var ConnectionAdapter */ + private $connection; + + /** @var LoggerInterface */ + private $logger; + + /** @var IEventLogger */ + private $eventLogger; + + private ContainerInterface $appContainer; + + /** + * @param Http $protocol the http protocol with contains all status headers + * @param MiddlewareDispatcher $middlewareDispatcher the dispatcher which + * runs the middleware + * @param ControllerMethodReflector $reflector the reflector that is used to inject + * the arguments for the controller + * @param IRequest $request the incoming request + * @param IConfig $config + * @param ConnectionAdapter $connection + * @param LoggerInterface $logger + * @param IEventLogger $eventLogger + */ + public function __construct( + Http $protocol, + MiddlewareDispatcher $middlewareDispatcher, + ControllerMethodReflector $reflector, + IRequest $request, + IConfig $config, + ConnectionAdapter $connection, + LoggerInterface $logger, + IEventLogger $eventLogger, + ContainerInterface $appContainer, + ) { + $this->protocol = $protocol; + $this->middlewareDispatcher = $middlewareDispatcher; + $this->reflector = $reflector; + $this->request = $request; + $this->config = $config; + $this->connection = $connection; + $this->logger = $logger; + $this->eventLogger = $eventLogger; + $this->appContainer = $appContainer; + } + + + /** + * Handles a request and calls the dispatcher on the controller + * @param Controller $controller the controller which will be called + * @param string $methodName the method name which will be called on + * the controller + * @return array $array[0] contains the http status header as a string, + * $array[1] contains response headers as an array, + * $array[2] contains response cookies as an array, + * $array[3] contains the response output as a string, + * $array[4] contains the response object + * @throws \Exception + */ + public function dispatch(Controller $controller, string $methodName): array { + $out = [null, [], null]; + + try { + // prefill reflector with everything that's needed for the + // middlewares + $this->reflector->reflect($controller, $methodName); + + $this->middlewareDispatcher->beforeController($controller, + $methodName); + + $databaseStatsBefore = []; + if ($this->config->getSystemValueBool('debug', false)) { + $databaseStatsBefore = $this->connection->getInner()->getStats(); + } + + $response = $this->executeController($controller, $methodName); + + if (!empty($databaseStatsBefore)) { + $databaseStatsAfter = $this->connection->getInner()->getStats(); + $numBuilt = $databaseStatsAfter['built'] - $databaseStatsBefore['built']; + $numExecuted = $databaseStatsAfter['executed'] - $databaseStatsBefore['executed']; + + if ($numBuilt > 50) { + $this->logger->debug('Controller {class}::{method} created {count} QueryBuilder objects, please check if they are created inside a loop by accident.', [ + 'class' => get_class($controller), + 'method' => $methodName, + 'count' => $numBuilt, + ]); + } + + if ($numExecuted > 100) { + $this->logger->warning('Controller {class}::{method} executed {count} queries.', [ + 'class' => get_class($controller), + 'method' => $methodName, + 'count' => $numExecuted, + ]); + } + } + + // if an exception appears, the middleware checks if it can handle the + // exception and creates a response. If no response is created, it is + // assumed that there's no middleware who can handle it and the error is + // thrown again + } catch (\Exception $exception) { + $response = $this->middlewareDispatcher->afterException( + $controller, $methodName, $exception); + } catch (\Throwable $throwable) { + $exception = new \Exception($throwable->getMessage() . ' in file \'' . $throwable->getFile() . '\' line ' . $throwable->getLine(), $throwable->getCode(), $throwable); + $response = $this->middlewareDispatcher->afterException( + $controller, $methodName, $exception); + } + + $response = $this->middlewareDispatcher->afterController( + $controller, $methodName, $response); + + // depending on the cache object the headers need to be changed + $out[0] = $this->protocol->getStatusHeader($response->getStatus()); + $out[1] = array_merge($response->getHeaders()); + $out[2] = $response->getCookies(); + $out[3] = $this->middlewareDispatcher->beforeOutput( + $controller, $methodName, $response->render() + ); + $out[4] = $response; + + return $out; + } + + + /** + * Uses the reflected parameters, types and request parameters to execute + * the controller + * @param Controller $controller the controller to be executed + * @param string $methodName the method on the controller that should be executed + * @return Response + */ + private function executeController(Controller $controller, string $methodName): Response { + $arguments = []; + + // valid types that will be cast + $types = ['int', 'integer', 'bool', 'boolean', 'float', 'double']; + + foreach ($this->reflector->getParameters() as $param => $default) { + // try to get the parameter from the request object and cast + // it to the type annotated in the @param annotation + $value = $this->request->getParam($param, $default); + $type = $this->reflector->getType($param); + + // Converted the string `'false'` to false when the controller wants a boolean + if ($value === 'false' && ($type === 'bool' || $type === 'boolean')) { + $value = false; + } elseif ($value !== null && \in_array($type, $types, true)) { + settype($value, $type); + $this->ensureParameterValueSatisfiesRange($param, $value); + } elseif ($value === null && $type !== null && $this->appContainer->has($type)) { + $value = $this->appContainer->get($type); + } + + $arguments[] = $value; + } + + $this->eventLogger->start('controller:' . get_class($controller) . '::' . $methodName, 'App framework controller execution'); + $response = \call_user_func_array([$controller, $methodName], $arguments); + $this->eventLogger->end('controller:' . get_class($controller) . '::' . $methodName); + + if (!($response instanceof Response)) { + $this->logger->debug($controller::class . '::' . $methodName . ' returned raw data. Please wrap it in a Response or one of it\'s inheritors.'); + } + + // format response + if ($response instanceof DataResponse || !($response instanceof Response)) { + // get format from the url format or request format parameter + $format = $this->request->getParam('format'); + + // if none is given try the first Accept header + if ($format === null) { + $headers = $this->request->getHeader('Accept'); + $format = $controller->getResponderByHTTPHeader($headers, null); + } + + if ($format !== null) { + $response = $controller->buildResponse($response, $format); + } else { + $response = $controller->buildResponse($response); + } + } + + return $response; + } + + /** + * @psalm-param mixed $value + * @throws ParameterOutOfRangeException + */ + private function ensureParameterValueSatisfiesRange(string $param, $value): void { + $rangeInfo = $this->reflector->getRange($param); + if ($rangeInfo) { + if ($value < $rangeInfo['min'] || $value > $rangeInfo['max']) { + throw new ParameterOutOfRangeException( + $param, + $value, + $rangeInfo['min'], + $rangeInfo['max'], + ); + } + } + } +} diff --git a/lib/private/AppFramework/Http/Output.php b/lib/private/AppFramework/Http/Output.php new file mode 100644 index 00000000000..b4a8672fdc7 --- /dev/null +++ b/lib/private/AppFramework/Http/Output.php @@ -0,0 +1,84 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\AppFramework\Http; + +use OCP\AppFramework\Http\IOutput; + +/** + * Very thin wrapper class to make output testable + */ +class Output implements IOutput { + public function __construct( + private string $webRoot, + ) { + } + + /** + * @param string $out + */ + public function setOutput($out) { + print($out); + } + + /** + * @param string|resource $path or file handle + * + * @return bool false if an error occurred + */ + public function setReadfile($path) { + if (is_resource($path)) { + $output = fopen('php://output', 'w'); + return stream_copy_to_stream($path, $output) > 0; + } else { + return @readfile($path); + } + } + + /** + * @param string $header + */ + public function setHeader($header) { + header($header); + } + + /** + * @param int $code sets the http status code + */ + public function setHttpResponseCode($code) { + http_response_code($code); + } + + /** + * @return int returns the current http response code + */ + public function getHttpResponseCode() { + return http_response_code(); + } + + /** + * @param string $name + * @param string $value + * @param int $expire + * @param string $path + * @param string $domain + * @param bool $secure + * @param bool $httpOnly + */ + public function setCookie($name, $value, $expire, $path, $domain, $secure, $httpOnly, $sameSite = 'Lax') { + $path = $this->webRoot ? : '/'; + + setcookie($name, $value, [ + 'expires' => $expire, + 'path' => $path, + 'domain' => $domain, + 'secure' => $secure, + 'httponly' => $httpOnly, + 'samesite' => $sameSite + ]); + } +} diff --git a/lib/private/AppFramework/Http/Request.php b/lib/private/AppFramework/Http/Request.php new file mode 100644 index 00000000000..7cc7467675c --- /dev/null +++ b/lib/private/AppFramework/Http/Request.php @@ -0,0 +1,880 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\AppFramework\Http; + +use OC\Security\CSRF\CsrfToken; +use OC\Security\CSRF\CsrfTokenManager; +use OC\Security\TrustedDomainHelper; +use OCP\IConfig; +use OCP\IRequest; +use OCP\IRequestId; +use Psr\Log\LoggerInterface; +use Symfony\Component\HttpFoundation\IpUtils; + +/** + * Class for accessing variables in the request. + * This class provides an immutable object with request variables. + * + * @property mixed[] $cookies + * @property mixed[] $env + * @property mixed[] $files + * @property string $method + * @property mixed[] $parameters + * @property mixed[] $server + * @template-implements \ArrayAccess<string,mixed> + */ +class Request implements \ArrayAccess, \Countable, IRequest { + public const USER_AGENT_IE = '/(MSIE)|(Trident)/'; + // Microsoft Edge User Agent from https://msdn.microsoft.com/en-us/library/hh869301(v=vs.85).aspx + public const USER_AGENT_MS_EDGE = '/^Mozilla\/5\.0 \([^)]+\) AppleWebKit\/[0-9.]+ \(KHTML, like Gecko\) Chrome\/[0-9.]+ (Mobile Safari|Safari)\/[0-9.]+ Edge?\/[0-9.]+$/'; + // Firefox User Agent from https://developer.mozilla.org/en-US/docs/Web/HTTP/Gecko_user_agent_string_reference + public const USER_AGENT_FIREFOX = '/^Mozilla\/5\.0 \([^)]+\) Gecko\/[0-9.]+ Firefox\/[0-9.]+$/'; + // Chrome User Agent from https://developer.chrome.com/multidevice/user-agent + public const USER_AGENT_CHROME = '/^Mozilla\/5\.0 \([^)]+\) AppleWebKit\/[0-9.]+ \(KHTML, like Gecko\)( Ubuntu Chromium\/[0-9.]+|) Chrome\/[0-9.]+ (Mobile Safari|Safari)\/[0-9.]+( (Vivaldi|Brave|OPR)\/[0-9.]+|)$/'; + // Safari User Agent from http://www.useragentstring.com/pages/Safari/ + public const USER_AGENT_SAFARI = '/^Mozilla\/5\.0 \([^)]+\) AppleWebKit\/[0-9.]+ \(KHTML, like Gecko\) Version\/[0-9.]+ Safari\/[0-9.A-Z]+$/'; + public const USER_AGENT_SAFARI_MOBILE = '/^Mozilla\/5\.0 \([^)]+\) AppleWebKit\/[0-9.]+ \(KHTML, like Gecko\) Version\/[0-9.]+ (Mobile\/[0-9.A-Z]+) Safari\/[0-9.A-Z]+$/'; + // Android Chrome user agent: https://developers.google.com/chrome/mobile/docs/user-agent + public const USER_AGENT_ANDROID_MOBILE_CHROME = '#Android.*Chrome/[.0-9]*#'; + public const USER_AGENT_FREEBOX = '#^Mozilla/5\.0$#'; + public const REGEX_LOCALHOST = '/^(127\.0\.0\.1|localhost|\[::1\])$/'; + + protected string $inputStream; + private bool $isPutStreamContentAlreadySent = false; + protected array $items = []; + protected array $allowedKeys = [ + 'get', + 'post', + 'files', + 'server', + 'env', + 'cookies', + 'urlParams', + 'parameters', + 'method', + 'requesttoken', + ]; + protected IRequestId $requestId; + protected IConfig $config; + protected ?CsrfTokenManager $csrfTokenManager; + + protected bool $contentDecoded = false; + private ?\JsonException $decodingException = null; + + /** + * @param array $vars An associative array with the following optional values: + * - array 'urlParams' the parameters which were matched from the URL + * - array 'get' the $_GET array + * - array|string 'post' the $_POST array or JSON string + * - array 'files' the $_FILES array + * - array 'server' the $_SERVER array + * - array 'env' the $_ENV array + * - array 'cookies' the $_COOKIE array + * - string 'method' the request method (GET, POST etc) + * - string|false 'requesttoken' the requesttoken or false when not available + * @param IRequestId $requestId + * @param IConfig $config + * @param CsrfTokenManager|null $csrfTokenManager + * @param string $stream + * @see https://www.php.net/manual/en/reserved.variables.php + */ + public function __construct(array $vars, + IRequestId $requestId, + IConfig $config, + ?CsrfTokenManager $csrfTokenManager = null, + string $stream = 'php://input') { + $this->inputStream = $stream; + $this->items['params'] = []; + $this->requestId = $requestId; + $this->config = $config; + $this->csrfTokenManager = $csrfTokenManager; + + if (!array_key_exists('method', $vars)) { + $vars['method'] = 'GET'; + } + + foreach ($this->allowedKeys as $name) { + $this->items[$name] = $vars[$name] ?? []; + } + + $this->items['parameters'] = array_merge( + $this->items['get'], + $this->items['post'], + $this->items['urlParams'], + $this->items['params'] + ); + } + /** + * @param array $parameters + */ + public function setUrlParameters(array $parameters) { + $this->items['urlParams'] = $parameters; + $this->items['parameters'] = array_merge( + $this->items['parameters'], + $this->items['urlParams'] + ); + } + + /** + * Countable method + * @return int + */ + public function count(): int { + return \count($this->items['parameters']); + } + + /** + * ArrayAccess methods + * + * Gives access to the combined GET, POST and urlParams arrays + * + * Examples: + * + * $var = $request['myvar']; + * + * or + * + * if(!isset($request['myvar']) { + * // Do something + * } + * + * $request['myvar'] = 'something'; // This throws an exception. + * + * @param string $offset The key to lookup + * @return boolean + */ + public function offsetExists($offset): bool { + return isset($this->items['parameters'][$offset]); + } + + /** + * @see offsetExists + * @param string $offset + * @return mixed + */ + #[\ReturnTypeWillChange] + public function offsetGet($offset) { + return $this->items['parameters'][$offset] ?? null; + } + + /** + * @see offsetExists + * @param string $offset + * @param mixed $value + */ + public function offsetSet($offset, $value): void { + throw new \RuntimeException('You cannot change the contents of the request object'); + } + + /** + * @see offsetExists + * @param string $offset + */ + public function offsetUnset($offset): void { + throw new \RuntimeException('You cannot change the contents of the request object'); + } + + /** + * Magic property accessors + * @param string $name + * @param mixed $value + */ + public function __set($name, $value) { + throw new \RuntimeException('You cannot change the contents of the request object'); + } + + /** + * Access request variables by method and name. + * Examples: + * + * $request->post['myvar']; // Only look for POST variables + * $request->myvar; or $request->{'myvar'}; or $request->{$myvar} + * Looks in the combined GET, POST and urlParams array. + * + * If you access e.g. ->post but the current HTTP request method + * is GET a \LogicException will be thrown. + * + * @param string $name The key to look for. + * @throws \LogicException + * @return mixed|null + */ + public function __get($name) { + switch ($name) { + case 'put': + case 'patch': + case 'get': + case 'post': + if ($this->method !== strtoupper($name)) { + throw new \LogicException(sprintf('%s cannot be accessed in a %s request.', $name, $this->method)); + } + return $this->getContent(); + case 'files': + case 'server': + case 'env': + case 'cookies': + case 'urlParams': + case 'method': + return $this->items[$name] ?? null; + case 'parameters': + case 'params': + if ($this->isPutStreamContent()) { + return $this->items['parameters']; + } + return $this->getContent(); + default: + return isset($this[$name]) + ? $this[$name] + : null; + } + } + + /** + * @param string $name + * @return bool + */ + public function __isset($name) { + if (\in_array($name, $this->allowedKeys, true)) { + return true; + } + return isset($this->items['parameters'][$name]); + } + + /** + * @param string $id + */ + public function __unset($id) { + throw new \RuntimeException('You cannot change the contents of the request object'); + } + + /** + * Returns the value for a specific http header. + * + * This method returns an empty string if the header did not exist. + * + * @param string $name + * @return string + */ + public function getHeader(string $name): string { + $name = strtoupper(str_replace('-', '_', $name)); + if (isset($this->server['HTTP_' . $name])) { + return $this->server['HTTP_' . $name]; + } + + // There's a few headers that seem to end up in the top-level + // server array. + switch ($name) { + case 'CONTENT_TYPE': + case 'CONTENT_LENGTH': + case 'REMOTE_ADDR': + if (isset($this->server[$name])) { + return $this->server[$name]; + } + break; + } + + return ''; + } + + /** + * Lets you access post and get parameters by the index + * In case of json requests the encoded json body is accessed + * + * @param string $key the key which you want to access in the URL Parameter + * placeholder, $_POST or $_GET array. + * The priority how they're returned is the following: + * 1. URL parameters + * 2. POST parameters + * 3. GET parameters + * @param mixed $default If the key is not found, this value will be returned + * @return mixed the content of the array + */ + public function getParam(string $key, $default = null) { + return isset($this->parameters[$key]) + ? $this->parameters[$key] + : $default; + } + + /** + * Returns all params that were received, be it from the request + * (as GET or POST) or through the URL by the route + * @return array the array with all parameters + */ + public function getParams(): array { + return is_array($this->parameters) ? $this->parameters : []; + } + + /** + * Returns the method of the request + * @return string the method of the request (POST, GET, etc) + */ + public function getMethod(): string { + return $this->method; + } + + /** + * Shortcut for accessing an uploaded file through the $_FILES array + * @param string $key the key that will be taken from the $_FILES array + * @return array the file in the $_FILES element + */ + public function getUploadedFile(string $key) { + return isset($this->files[$key]) ? $this->files[$key] : null; + } + + /** + * Shortcut for getting env variables + * @param string $key the key that will be taken from the $_ENV array + * @return array the value in the $_ENV element + */ + public function getEnv(string $key) { + return isset($this->env[$key]) ? $this->env[$key] : null; + } + + /** + * Shortcut for getting cookie variables + * @param string $key the key that will be taken from the $_COOKIE array + * @return string the value in the $_COOKIE element + */ + public function getCookie(string $key) { + return isset($this->cookies[$key]) ? $this->cookies[$key] : null; + } + + /** + * Returns the request body content. + * + * If the HTTP request method is PUT and the body + * not application/x-www-form-urlencoded or application/json a stream + * resource is returned, otherwise an array. + * + * @return array|string|resource The request body content or a resource to read the body stream. + * + * @throws \LogicException + */ + protected function getContent() { + // If the content can't be parsed into an array then return a stream resource. + if ($this->isPutStreamContent()) { + if ($this->isPutStreamContentAlreadySent) { + throw new \LogicException( + '"put" can only be accessed once if not ' + . 'application/x-www-form-urlencoded or application/json.' + ); + } + $this->isPutStreamContentAlreadySent = true; + return fopen($this->inputStream, 'rb'); + } else { + $this->decodeContent(); + return $this->items['parameters']; + } + } + + private function isPutStreamContent(): bool { + return $this->method === 'PUT' + && $this->getHeader('Content-Length') !== '0' + && $this->getHeader('Content-Length') !== '' + && !str_contains($this->getHeader('Content-Type'), 'application/x-www-form-urlencoded') + && !str_contains($this->getHeader('Content-Type'), 'application/json'); + } + + /** + * Attempt to decode the content and populate parameters + */ + protected function decodeContent() { + if ($this->contentDecoded) { + return; + } + $params = []; + + // 'application/json' and other JSON-related content types must be decoded manually. + if (preg_match(self::JSON_CONTENT_TYPE_REGEX, $this->getHeader('Content-Type')) === 1) { + $content = file_get_contents($this->inputStream); + if ($content !== '') { + try { + $params = json_decode($content, true, flags:JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + $this->decodingException = $e; + } + } + if (\is_array($params) && \count($params) > 0) { + $this->items['params'] = $params; + if ($this->method === 'POST') { + $this->items['post'] = $params; + } + } + // Handle application/x-www-form-urlencoded for methods other than GET + // or post correctly + } elseif ($this->method !== 'GET' + && $this->method !== 'POST' + && str_contains($this->getHeader('Content-Type'), 'application/x-www-form-urlencoded')) { + parse_str(file_get_contents($this->inputStream), $params); + if (\is_array($params)) { + $this->items['params'] = $params; + } + } + + if (\is_array($params)) { + $this->items['parameters'] = array_merge($this->items['parameters'], $params); + } + $this->contentDecoded = true; + } + + public function throwDecodingExceptionIfAny(): void { + if ($this->decodingException !== null) { + throw $this->decodingException; + } + } + + + /** + * Checks if the CSRF check was correct + * @return bool true if CSRF check passed + */ + public function passesCSRFCheck(): bool { + if ($this->csrfTokenManager === null) { + return false; + } + + if (!$this->passesStrictCookieCheck()) { + return false; + } + + if ($this->getHeader('OCS-APIRequest') !== '') { + return true; + } + + if (isset($this->items['get']['requesttoken'])) { + $token = $this->items['get']['requesttoken']; + } elseif (isset($this->items['post']['requesttoken'])) { + $token = $this->items['post']['requesttoken']; + } elseif (isset($this->items['server']['HTTP_REQUESTTOKEN'])) { + $token = $this->items['server']['HTTP_REQUESTTOKEN']; + } else { + //no token found. + return false; + } + $token = new CsrfToken($token); + + return $this->csrfTokenManager->isTokenValid($token); + } + + /** + * Whether the cookie checks are required + * + * @return bool + */ + private function cookieCheckRequired(): bool { + if ($this->getHeader('OCS-APIREQUEST')) { + return false; + } + if ($this->getCookie(session_name()) === null && $this->getCookie('nc_token') === null) { + return false; + } + + return true; + } + + /** + * Wrapper around session_get_cookie_params + * + * @return array + */ + public function getCookieParams(): array { + return session_get_cookie_params(); + } + + /** + * Appends the __Host- prefix to the cookie if applicable + * + * @param string $name + * @return string + */ + protected function getProtectedCookieName(string $name): string { + $cookieParams = $this->getCookieParams(); + $prefix = ''; + if ($cookieParams['secure'] === true && $cookieParams['path'] === '/') { + $prefix = '__Host-'; + } + + return $prefix . $name; + } + + /** + * Checks if the strict cookie has been sent with the request if the request + * is including any cookies. + * + * @return bool + * @since 9.1.0 + */ + public function passesStrictCookieCheck(): bool { + if (!$this->cookieCheckRequired()) { + return true; + } + + $cookieName = $this->getProtectedCookieName('nc_sameSiteCookiestrict'); + if ($this->getCookie($cookieName) === 'true' + && $this->passesLaxCookieCheck()) { + return true; + } + return false; + } + + /** + * Checks if the lax cookie has been sent with the request if the request + * is including any cookies. + * + * @return bool + * @since 9.1.0 + */ + public function passesLaxCookieCheck(): bool { + if (!$this->cookieCheckRequired()) { + return true; + } + + $cookieName = $this->getProtectedCookieName('nc_sameSiteCookielax'); + if ($this->getCookie($cookieName) === 'true') { + return true; + } + return false; + } + + + /** + * Returns an ID for the request, value is not guaranteed to be unique and is mostly meant for logging + * If `mod_unique_id` is installed this value will be taken. + * @return string + */ + public function getId(): string { + return $this->requestId->getId(); + } + + /** + * Checks if given $remoteAddress matches any entry in the given array $trustedProxies. + * For details regarding what "match" means, refer to `matchesTrustedProxy`. + * @return boolean true if $remoteAddress matches any entry in $trustedProxies, false otherwise + */ + protected function isTrustedProxy($trustedProxies, $remoteAddress) { + try { + return IpUtils::checkIp($remoteAddress, $trustedProxies); + } catch (\Throwable) { + // We can not log to our log here as the logger is using `getRemoteAddress` which uses the function, so we would have a cyclic dependency + // Reaching this line means `trustedProxies` is in invalid format. + error_log('Nextcloud trustedProxies has malformed entries'); + return false; + } + } + + /** + * Returns the remote address, if the connection came from a trusted proxy + * and `forwarded_for_headers` has been configured then the IP address + * specified in this header will be returned instead. + * Do always use this instead of $_SERVER['REMOTE_ADDR'] + * @return string IP address + */ + public function getRemoteAddress(): string { + $remoteAddress = isset($this->server['REMOTE_ADDR']) ? $this->server['REMOTE_ADDR'] : ''; + $trustedProxies = $this->config->getSystemValue('trusted_proxies', []); + + if (\is_array($trustedProxies) && $this->isTrustedProxy($trustedProxies, $remoteAddress)) { + $forwardedForHeaders = $this->config->getSystemValue('forwarded_for_headers', [ + 'HTTP_X_FORWARDED_FOR' + // only have one default, so we cannot ship an insecure product out of the box + ]); + + // Read the x-forwarded-for headers and values in reverse order as per + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For#selecting_an_ip_address + foreach (array_reverse($forwardedForHeaders) as $header) { + if (isset($this->server[$header])) { + foreach (array_reverse(explode(',', $this->server[$header])) as $IP) { + $IP = trim($IP); + $colons = substr_count($IP, ':'); + if ($colons > 1) { + // Extract IP from string with brackets and optional port + if (preg_match('/^\[(.+?)\](?::\d+)?$/', $IP, $matches) && isset($matches[1])) { + $IP = $matches[1]; + } + } elseif ($colons === 1) { + // IPv4 with port + $IP = substr($IP, 0, strpos($IP, ':')); + } + + if ($this->isTrustedProxy($trustedProxies, $IP)) { + continue; + } + + if (filter_var($IP, FILTER_VALIDATE_IP) !== false) { + return $IP; + } + } + } + } + } + + return $remoteAddress; + } + + /** + * Check overwrite condition + * @return bool + */ + private function isOverwriteCondition(): bool { + $regex = '/' . $this->config->getSystemValueString('overwritecondaddr', '') . '/'; + $remoteAddr = isset($this->server['REMOTE_ADDR']) ? $this->server['REMOTE_ADDR'] : ''; + return $regex === '//' || preg_match($regex, $remoteAddr) === 1; + } + + /** + * Returns the server protocol. It respects one or more reverse proxies servers + * and load balancers. Precedence: + * 1. `overwriteprotocol` config value + * 2. `X-Forwarded-Proto` header value + * 3. $_SERVER['HTTPS'] value + * If an invalid protocol is provided, defaults to http, continues, but logs as an error. + * + * @return string Server protocol (http or https) + */ + public function getServerProtocol(): string { + $proto = 'http'; + + if ($this->config->getSystemValueString('overwriteprotocol') !== '' + && $this->isOverwriteCondition() + ) { + $proto = strtolower($this->config->getSystemValueString('overwriteprotocol')); + } elseif ($this->fromTrustedProxy() + && isset($this->server['HTTP_X_FORWARDED_PROTO']) + ) { + if (str_contains($this->server['HTTP_X_FORWARDED_PROTO'], ',')) { + $parts = explode(',', $this->server['HTTP_X_FORWARDED_PROTO']); + $proto = strtolower(trim($parts[0])); + } else { + $proto = strtolower($this->server['HTTP_X_FORWARDED_PROTO']); + } + } elseif (!empty($this->server['HTTPS']) + && $this->server['HTTPS'] !== 'off' + ) { + $proto = 'https'; + } + + if ($proto !== 'https' && $proto !== 'http') { + // log unrecognized value so admin has a chance to fix it + \OCP\Server::get(LoggerInterface::class)->critical( + 'Server protocol is malformed [falling back to http] (check overwriteprotocol and/or X-Forwarded-Proto to remedy): ' . $proto, + ['app' => 'core'] + ); + } + + // default to http if provided an invalid value + return $proto === 'https' ? 'https' : 'http'; + } + + /** + * Returns the used HTTP protocol. + * + * @return string HTTP protocol. HTTP/2, HTTP/1.1 or HTTP/1.0. + */ + public function getHttpProtocol(): string { + $claimedProtocol = $this->server['SERVER_PROTOCOL']; + + if (\is_string($claimedProtocol)) { + $claimedProtocol = strtoupper($claimedProtocol); + } + + $validProtocols = [ + 'HTTP/1.0', + 'HTTP/1.1', + 'HTTP/2', + ]; + + if (\in_array($claimedProtocol, $validProtocols, true)) { + return $claimedProtocol; + } + + return 'HTTP/1.1'; + } + + /** + * Returns the request uri, even if the website uses one or more + * reverse proxies + * @return string + */ + public function getRequestUri(): string { + $uri = isset($this->server['REQUEST_URI']) ? $this->server['REQUEST_URI'] : ''; + if ($this->config->getSystemValueString('overwritewebroot') !== '' && $this->isOverwriteCondition()) { + $uri = $this->getScriptName() . substr($uri, \strlen($this->server['SCRIPT_NAME'])); + } + return $uri; + } + + /** + * Get raw PathInfo from request (not urldecoded) + * @throws \Exception + * @return string Path info + */ + public function getRawPathInfo(): string { + $requestUri = isset($this->server['REQUEST_URI']) ? $this->server['REQUEST_URI'] : ''; + // remove too many slashes - can be caused by reverse proxy configuration + $requestUri = preg_replace('%/{2,}%', '/', $requestUri); + + // Remove the query string from REQUEST_URI + if ($pos = strpos($requestUri, '?')) { + $requestUri = substr($requestUri, 0, $pos); + } + + $scriptName = $this->server['SCRIPT_NAME']; + $pathInfo = $requestUri; + + // strip off the script name's dir and file name + // FIXME: Sabre does not really belong here + [$path, $name] = \Sabre\Uri\split($scriptName); + if (!empty($path)) { + if ($path === $pathInfo || str_starts_with($pathInfo, $path . '/')) { + $pathInfo = substr($pathInfo, \strlen($path)); + } else { + throw new \Exception("The requested uri($requestUri) cannot be processed by the script '$scriptName')"); + } + } + if ($name === null) { + $name = ''; + } + + if (str_starts_with($pathInfo, '/' . $name)) { + $pathInfo = substr($pathInfo, \strlen($name) + 1); + } + if ($name !== '' && str_starts_with($pathInfo, $name)) { + $pathInfo = substr($pathInfo, \strlen($name)); + } + if ($pathInfo === false || $pathInfo === '/') { + return ''; + } else { + return $pathInfo; + } + } + + /** + * Get PathInfo from request (rawurldecoded) + * @throws \Exception + * @return string|false Path info or false when not found + */ + public function getPathInfo(): string|false { + $pathInfo = $this->getRawPathInfo(); + return \Sabre\HTTP\decodePath($pathInfo); + } + + /** + * Returns the script name, even if the website uses one or more + * reverse proxies + * @return string the script name + */ + public function getScriptName(): string { + $name = $this->server['SCRIPT_NAME']; + $overwriteWebRoot = $this->config->getSystemValueString('overwritewebroot'); + if ($overwriteWebRoot !== '' && $this->isOverwriteCondition()) { + // FIXME: This code is untestable due to __DIR__, also that hardcoded path is really dangerous + $serverRoot = str_replace('\\', '/', substr(__DIR__, 0, -\strlen('lib/private/appframework/http/'))); + $suburi = str_replace('\\', '/', substr(realpath($this->server['SCRIPT_FILENAME']), \strlen($serverRoot))); + $name = '/' . ltrim($overwriteWebRoot . $suburi, '/'); + } + return $name; + } + + /** + * Checks whether the user agent matches a given regex + * @param array $agent array of agent names + * @return bool true if at least one of the given agent matches, false otherwise + */ + public function isUserAgent(array $agent): bool { + if (!isset($this->server['HTTP_USER_AGENT'])) { + return false; + } + foreach ($agent as $regex) { + if (preg_match($regex, $this->server['HTTP_USER_AGENT'])) { + return true; + } + } + return false; + } + + /** + * Returns the unverified server host from the headers without checking + * whether it is a trusted domain + * @return string Server host + */ + public function getInsecureServerHost(): string { + if ($this->fromTrustedProxy() && $this->getOverwriteHost() !== null) { + return $this->getOverwriteHost(); + } + + $host = 'localhost'; + if ($this->fromTrustedProxy() && isset($this->server['HTTP_X_FORWARDED_HOST'])) { + if (str_contains($this->server['HTTP_X_FORWARDED_HOST'], ',')) { + $parts = explode(',', $this->server['HTTP_X_FORWARDED_HOST']); + $host = trim(current($parts)); + } else { + $host = $this->server['HTTP_X_FORWARDED_HOST']; + } + } else { + if (isset($this->server['HTTP_HOST'])) { + $host = $this->server['HTTP_HOST']; + } elseif (isset($this->server['SERVER_NAME'])) { + $host = $this->server['SERVER_NAME']; + } + } + + return $host; + } + + + /** + * Returns the server host from the headers, or the first configured + * trusted domain if the host isn't in the trusted list + * @return string Server host + */ + public function getServerHost(): string { + // overwritehost is always trusted + $host = $this->getOverwriteHost(); + if ($host !== null) { + return $host; + } + + // get the host from the headers + $host = $this->getInsecureServerHost(); + + // Verify that the host is a trusted domain if the trusted domains + // are defined + // If no trusted domain is provided the first trusted domain is returned + $trustedDomainHelper = new TrustedDomainHelper($this->config); + if ($trustedDomainHelper->isTrustedDomain($host)) { + return $host; + } + + $trustedList = (array)$this->config->getSystemValue('trusted_domains', []); + if (count($trustedList) > 0) { + return reset($trustedList); + } + + return ''; + } + + /** + * Returns the overwritehost setting from the config if set and + * if the overwrite condition is met + * @return string|null overwritehost value or null if not defined or the defined condition + * isn't met + */ + private function getOverwriteHost() { + if ($this->config->getSystemValueString('overwritehost') !== '' && $this->isOverwriteCondition()) { + return $this->config->getSystemValueString('overwritehost'); + } + return null; + } + + private function fromTrustedProxy(): bool { + $remoteAddress = isset($this->server['REMOTE_ADDR']) ? $this->server['REMOTE_ADDR'] : ''; + $trustedProxies = $this->config->getSystemValue('trusted_proxies', []); + + return \is_array($trustedProxies) && $this->isTrustedProxy($trustedProxies, $remoteAddress); + } +} diff --git a/lib/private/AppFramework/Http/RequestId.php b/lib/private/AppFramework/Http/RequestId.php new file mode 100644 index 00000000000..c3a99c93591 --- /dev/null +++ b/lib/private/AppFramework/Http/RequestId.php @@ -0,0 +1,36 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\AppFramework\Http; + +use OCP\IRequestId; +use OCP\Security\ISecureRandom; + +class RequestId implements IRequestId { + protected ISecureRandom $secureRandom; + protected string $requestId; + + public function __construct(string $uniqueId, + ISecureRandom $secureRandom) { + $this->requestId = $uniqueId; + $this->secureRandom = $secureRandom; + } + + /** + * Returns an ID for the request, value is not guaranteed to be unique and is mostly meant for logging + * If `mod_unique_id` is installed this value will be taken. + * @return string + */ + public function getId(): string { + if (empty($this->requestId)) { + $validChars = ISecureRandom::CHAR_ALPHANUMERIC; + $this->requestId = $this->secureRandom->generate(20, $validChars); + } + + return $this->requestId; + } +} diff --git a/lib/private/AppFramework/Middleware/AdditionalScriptsMiddleware.php b/lib/private/AppFramework/Middleware/AdditionalScriptsMiddleware.php new file mode 100644 index 00000000000..4f1c69b104f --- /dev/null +++ b/lib/private/AppFramework/Middleware/AdditionalScriptsMiddleware.php @@ -0,0 +1,40 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\AppFramework\Middleware; + +use OC\Core\Controller\LoginController; +use OCP\AppFramework\Http\Events\BeforeLoginTemplateRenderedEvent; +use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent; +use OCP\AppFramework\Http\Response; +use OCP\AppFramework\Http\StandaloneTemplateResponse; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\AppFramework\Middleware; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IUserSession; + +class AdditionalScriptsMiddleware extends Middleware { + public function __construct( + private IUserSession $userSession, + private IEventDispatcher $dispatcher, + ) { + } + + public function afterController($controller, $methodName, Response $response): Response { + if ($response instanceof TemplateResponse) { + if ($controller instanceof LoginController) { + $this->dispatcher->dispatchTyped(new BeforeLoginTemplateRenderedEvent($response)); + } else { + $isLoggedIn = !($response instanceof StandaloneTemplateResponse) && $this->userSession->isLoggedIn(); + $this->dispatcher->dispatchTyped(new BeforeTemplateRenderedEvent($isLoggedIn, $response)); + } + } + + return $response; + } +} diff --git a/lib/private/AppFramework/Middleware/CompressionMiddleware.php b/lib/private/AppFramework/Middleware/CompressionMiddleware.php new file mode 100644 index 00000000000..8bc56beb62e --- /dev/null +++ b/lib/private/AppFramework/Middleware/CompressionMiddleware.php @@ -0,0 +1,72 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\AppFramework\Middleware; + +use OC\AppFramework\OCS\BaseResponse; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; +use OCP\AppFramework\Http\Response; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\AppFramework\Middleware; +use OCP\IRequest; + +class CompressionMiddleware extends Middleware { + /** @var bool */ + private $useGZip; + + /** @var IRequest */ + private $request; + + public function __construct(IRequest $request) { + $this->request = $request; + $this->useGZip = false; + } + + public function afterController($controller, $methodName, Response $response) { + // By default we do not gzip + $allowGzip = false; + + // Only return gzipped content for 200 responses + if ($response->getStatus() !== Http::STATUS_OK) { + return $response; + } + + // Check if we are even asked for gzip + $header = $this->request->getHeader('Accept-Encoding'); + if (!str_contains($header, 'gzip')) { + return $response; + } + + // We only allow gzip in some cases + if ($response instanceof BaseResponse) { + $allowGzip = true; + } + if ($response instanceof JSONResponse) { + $allowGzip = true; + } + if ($response instanceof TemplateResponse) { + $allowGzip = true; + } + + if ($allowGzip) { + $this->useGZip = true; + $response->addHeader('Content-Encoding', 'gzip'); + } + + return $response; + } + + public function beforeOutput($controller, $methodName, $output) { + if (!$this->useGZip) { + return $output; + } + + return gzencode($output); + } +} diff --git a/lib/private/AppFramework/Middleware/FlowV2EphemeralSessionsMiddleware.php b/lib/private/AppFramework/Middleware/FlowV2EphemeralSessionsMiddleware.php new file mode 100644 index 00000000000..b69b129f798 --- /dev/null +++ b/lib/private/AppFramework/Middleware/FlowV2EphemeralSessionsMiddleware.php @@ -0,0 +1,64 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\AppFramework\Middleware; + +use OC\AppFramework\Utility\ControllerMethodReflector; +use OC\Core\Controller\ClientFlowLoginV2Controller; +use OC\Core\Controller\TwoFactorChallengeController; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\Attribute\PublicPage; +use OCP\AppFramework\Middleware; +use OCP\ISession; +use OCP\IUserSession; +use Psr\Log\LoggerInterface; +use ReflectionMethod; + +// Will close the session if the user session is ephemeral. +// Happens when the user logs in via the login flow v2. +class FlowV2EphemeralSessionsMiddleware extends Middleware { + public function __construct( + private ISession $session, + private IUserSession $userSession, + private ControllerMethodReflector $reflector, + private LoggerInterface $logger, + ) { + } + + public function beforeController(Controller $controller, string $methodName) { + if (!$this->session->get(ClientFlowLoginV2Controller::EPHEMERAL_NAME)) { + return; + } + + if ( + $controller instanceof ClientFlowLoginV2Controller + && ($methodName === 'grantPage' || $methodName === 'generateAppPassword') + ) { + return; + } + + if ($controller instanceof TwoFactorChallengeController) { + return; + } + + $reflectionMethod = new ReflectionMethod($controller, $methodName); + if (!empty($reflectionMethod->getAttributes(PublicPage::class))) { + return; + } + + if ($this->reflector->hasAnnotation('PublicPage')) { + return; + } + + $this->logger->info('Closing user and PHP session for ephemeral session', [ + 'controller' => $controller::class, + 'method' => $methodName, + ]); + $this->userSession->logout(); + $this->session->close(); + } +} diff --git a/lib/private/AppFramework/Middleware/MiddlewareDispatcher.php b/lib/private/AppFramework/Middleware/MiddlewareDispatcher.php new file mode 100644 index 00000000000..c9b51f26f34 --- /dev/null +++ b/lib/private/AppFramework/Middleware/MiddlewareDispatcher.php @@ -0,0 +1,142 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\AppFramework\Middleware; + +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\Response; +use OCP\AppFramework\Middleware; + +/** + * This class is used to store and run all the middleware in correct order + */ +class MiddlewareDispatcher { + /** + * @var Middleware[] array containing all the middlewares + */ + private array $middlewares; + + /** + * @var int counter which tells us what middleware was executed once an + * exception occurs + */ + private int $middlewareCounter; + + + /** + * Constructor + */ + public function __construct() { + $this->middlewares = []; + $this->middlewareCounter = 0; + } + + + /** + * Adds a new middleware + * @param Middleware $middleWare the middleware which will be added + */ + public function registerMiddleware(Middleware $middleWare): void { + $this->middlewares[] = $middleWare; + } + + + /** + * returns an array with all middleware elements + * @return Middleware[] the middlewares + */ + public function getMiddlewares(): array { + return $this->middlewares; + } + + + /** + * This is being run in normal order before the controller is being + * called which allows several modifications and checks + * + * @param Controller $controller the controller that is being called + * @param string $methodName the name of the method that will be called on + * the controller + */ + public function beforeController(Controller $controller, string $methodName): void { + // we need to count so that we know which middlewares we have to ask in + // case there is an exception + $middlewareCount = \count($this->middlewares); + for ($i = 0; $i < $middlewareCount; $i++) { + $this->middlewareCounter++; + $middleware = $this->middlewares[$i]; + $middleware->beforeController($controller, $methodName); + } + } + + + /** + * This is being run when either the beforeController method or the + * controller method itself is throwing an exception. The middleware is asked + * in reverse order to handle the exception and to return a response. + * If the response is null, it is assumed that the exception could not be + * handled and the error will be thrown again + * + * @param Controller $controller the controller that is being called + * @param string $methodName the name of the method that will be called on + * the controller + * @param \Exception $exception the thrown exception + * @return Response a Response object if the middleware can handle the + * exception + * @throws \Exception the passed in exception if it can't handle it + */ + public function afterException(Controller $controller, string $methodName, \Exception $exception): Response { + for ($i = $this->middlewareCounter - 1; $i >= 0; $i--) { + $middleware = $this->middlewares[$i]; + try { + return $middleware->afterException($controller, $methodName, $exception); + } catch (\Exception $exception) { + continue; + } + } + throw $exception; + } + + + /** + * This is being run after a successful controllermethod call and allows + * the manipulation of a Response object. The middleware is run in reverse order + * + * @param Controller $controller the controller that is being called + * @param string $methodName the name of the method that will be called on + * the controller + * @param Response $response the generated response from the controller + * @return Response a Response object + */ + public function afterController(Controller $controller, string $methodName, Response $response): Response { + for ($i = \count($this->middlewares) - 1; $i >= 0; $i--) { + $middleware = $this->middlewares[$i]; + $response = $middleware->afterController($controller, $methodName, $response); + } + return $response; + } + + + /** + * This is being run after the response object has been rendered and + * allows the manipulation of the output. The middleware is run in reverse order + * + * @param Controller $controller the controller that is being called + * @param string $methodName the name of the method that will be called on + * the controller + * @param string $output the generated output from a response + * @return string the output that should be printed + */ + public function beforeOutput(Controller $controller, string $methodName, string $output): string { + for ($i = \count($this->middlewares) - 1; $i >= 0; $i--) { + $middleware = $this->middlewares[$i]; + $output = $middleware->beforeOutput($controller, $methodName, $output); + } + return $output; + } +} diff --git a/lib/private/AppFramework/Middleware/NotModifiedMiddleware.php b/lib/private/AppFramework/Middleware/NotModifiedMiddleware.php new file mode 100644 index 00000000000..08b30092155 --- /dev/null +++ b/lib/private/AppFramework/Middleware/NotModifiedMiddleware.php @@ -0,0 +1,39 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\AppFramework\Middleware; + +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Response; +use OCP\AppFramework\Middleware; +use OCP\IRequest; + +class NotModifiedMiddleware extends Middleware { + /** @var IRequest */ + private $request; + + public function __construct(IRequest $request) { + $this->request = $request; + } + + public function afterController($controller, $methodName, Response $response) { + $etagHeader = $this->request->getHeader('IF_NONE_MATCH'); + if ($etagHeader !== '' && $response->getETag() !== null && trim($etagHeader) === '"' . $response->getETag() . '"') { + $response->setStatus(Http::STATUS_NOT_MODIFIED); + return $response; + } + + $modifiedSinceHeader = $this->request->getHeader('IF_MODIFIED_SINCE'); + if ($modifiedSinceHeader !== '' && $response->getLastModified() !== null && trim($modifiedSinceHeader) === $response->getLastModified()->format(\DateTimeInterface::RFC7231)) { + $response->setStatus(Http::STATUS_NOT_MODIFIED); + return $response; + } + + return $response; + } +} diff --git a/lib/private/AppFramework/Middleware/OCSMiddleware.php b/lib/private/AppFramework/Middleware/OCSMiddleware.php new file mode 100644 index 00000000000..64f4b0054de --- /dev/null +++ b/lib/private/AppFramework/Middleware/OCSMiddleware.php @@ -0,0 +1,142 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\AppFramework\Middleware; + +use OC\AppFramework\Http; +use OC\AppFramework\OCS\BaseResponse; +use OC\AppFramework\OCS\V1Response; +use OC\AppFramework\OCS\V2Response; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\Http\JSONResponse; +use OCP\AppFramework\Http\Response; +use OCP\AppFramework\Middleware; +use OCP\AppFramework\OCS\OCSException; +use OCP\AppFramework\OCSController; +use OCP\IRequest; + +class OCSMiddleware extends Middleware { + /** @var IRequest */ + private $request; + + /** @var int */ + private $ocsVersion; + + /** + * @param IRequest $request + */ + public function __construct(IRequest $request) { + $this->request = $request; + } + + /** + * @param Controller $controller + * @param string $methodName + */ + public function beforeController($controller, $methodName) { + if ($controller instanceof OCSController) { + if (substr_compare($this->request->getScriptName(), '/ocs/v2.php', -strlen('/ocs/v2.php')) === 0) { + $this->ocsVersion = 2; + } else { + $this->ocsVersion = 1; + } + $controller->setOCSVersion($this->ocsVersion); + } + } + + /** + * @param Controller $controller + * @param string $methodName + * @param \Exception $exception + * @throws \Exception + * @return BaseResponse + */ + public function afterException($controller, $methodName, \Exception $exception) { + if ($controller instanceof OCSController && $exception instanceof OCSException) { + $code = $exception->getCode(); + if ($code === 0) { + $code = \OCP\AppFramework\OCSController::RESPOND_UNKNOWN_ERROR; + } + + return $this->buildNewResponse($controller, $code, $exception->getMessage()); + } + + throw $exception; + } + + /** + * @param Controller $controller + * @param string $methodName + * @param Response $response + * @return \OCP\AppFramework\Http\Response + */ + public function afterController($controller, $methodName, Response $response) { + /* + * If a different middleware has detected that a request unauthorized or forbidden + * we need to catch the response and convert it to a proper OCS response. + */ + if ($controller instanceof OCSController && !($response instanceof BaseResponse)) { + if ($response->getStatus() === Http::STATUS_UNAUTHORIZED) { + $message = ''; + if ($response instanceof JSONResponse) { + /** @var DataResponse $response */ + $message = $response->getData()['message']; + } + + return $this->buildNewResponse($controller, OCSController::RESPOND_UNAUTHORISED, $message); + } + if ($response->getStatus() === Http::STATUS_FORBIDDEN) { + $message = ''; + if ($response instanceof JSONResponse) { + /** @var DataResponse $response */ + $message = $response->getData()['message']; + } + + return $this->buildNewResponse($controller, Http::STATUS_FORBIDDEN, $message); + } + } + + return $response; + } + + /** + * @param Controller $controller + * @param int $code + * @param string $message + * @return V1Response|V2Response + */ + private function buildNewResponse(Controller $controller, $code, $message) { + $format = $this->getFormat($controller); + + $data = new DataResponse(); + $data->setStatus($code); + if ($this->ocsVersion === 1) { + $response = new V1Response($data, $format, $message); + } else { + $response = new V2Response($data, $format, $message); + } + + return $response; + } + + /** + * @param Controller $controller + * @return string + */ + private function getFormat(Controller $controller) { + // get format from the url format or request format parameter + $format = $this->request->getParam('format'); + + // if none is given try the first Accept header + if ($format === null) { + $headers = $this->request->getHeader('Accept'); + $format = $controller->getResponderByHTTPHeader($headers, 'xml'); + } + + return $format; + } +} diff --git a/lib/private/AppFramework/Middleware/PublicShare/Exceptions/NeedAuthenticationException.php b/lib/private/AppFramework/Middleware/PublicShare/Exceptions/NeedAuthenticationException.php new file mode 100644 index 00000000000..5df4009b094 --- /dev/null +++ b/lib/private/AppFramework/Middleware/PublicShare/Exceptions/NeedAuthenticationException.php @@ -0,0 +1,10 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\AppFramework\Middleware\PublicShare\Exceptions; + +class NeedAuthenticationException extends \Exception { +} diff --git a/lib/private/AppFramework/Middleware/PublicShare/PublicShareMiddleware.php b/lib/private/AppFramework/Middleware/PublicShare/PublicShareMiddleware.php new file mode 100644 index 00000000000..83e799e3d3b --- /dev/null +++ b/lib/private/AppFramework/Middleware/PublicShare/PublicShareMiddleware.php @@ -0,0 +1,127 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\AppFramework\Middleware\PublicShare; + +use OC\AppFramework\Middleware\PublicShare\Exceptions\NeedAuthenticationException; +use OCA\Files_Sharing\AppInfo\Application; +use OCP\AppFramework\AuthPublicShareController; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\AppFramework\Middleware; +use OCP\AppFramework\PublicShareController; +use OCP\Files\NotFoundException; +use OCP\IConfig; +use OCP\IRequest; +use OCP\ISession; +use OCP\Security\Bruteforce\IThrottler; + +class PublicShareMiddleware extends Middleware { + + public function __construct( + private IRequest $request, + private ISession $session, + private IConfig $config, + private IThrottler $throttler, + ) { + } + + public function beforeController($controller, $methodName) { + if (!($controller instanceof PublicShareController)) { + return; + } + + $controllerClassPath = explode('\\', get_class($controller)); + $controllerShortClass = end($controllerClassPath); + $bruteforceProtectionAction = $controllerShortClass . '::' . $methodName; + $this->throttler->sleepDelayOrThrowOnMax($this->request->getRemoteAddress(), $bruteforceProtectionAction); + + if (!$this->isLinkSharingEnabled()) { + throw new NotFoundException('Link sharing is disabled'); + } + + // We require the token parameter to be set + $token = $this->request->getParam('token'); + if ($token === null) { + throw new NotFoundException(); + } + + // Set the token + $controller->setToken($token); + + if (!$controller->isValidToken()) { + $this->throttle($bruteforceProtectionAction, $token); + + $controller->shareNotFound(); + throw new NotFoundException(); + } + + // No need to check for authentication when we try to authenticate + if ($methodName === 'authenticate' || $methodName === 'showAuthenticate') { + return; + } + + // If authentication succeeds just continue + if ($controller->isAuthenticated()) { + return; + } + + // If we can authenticate to this controller do it else we throw a 404 to not leak any info + if ($controller instanceof AuthPublicShareController) { + $this->session->set('public_link_authenticate_redirect', json_encode($this->request->getParams())); + throw new NeedAuthenticationException(); + } + + $this->throttle($bruteforceProtectionAction, $token); + throw new NotFoundException(); + } + + public function afterException($controller, $methodName, \Exception $exception) { + if (!($controller instanceof PublicShareController)) { + throw $exception; + } + + if ($exception instanceof NotFoundException) { + return new TemplateResponse(Application::APP_ID, 'sharenotfound', [ + 'message' => $exception->getMessage(), + ], 'guest', Http::STATUS_NOT_FOUND); + } + + if ($controller instanceof AuthPublicShareController && $exception instanceof NeedAuthenticationException) { + return $controller->getAuthenticationRedirect($this->getFunctionForRoute($this->request->getParam('_route'))); + } + + throw $exception; + } + + private function getFunctionForRoute(string $route): string { + $tmp = explode('.', $route); + return array_pop($tmp); + } + + /** + * Check if link sharing is allowed + */ + private function isLinkSharingEnabled(): bool { + // Check if the shareAPI is enabled + if ($this->config->getAppValue('core', 'shareapi_enabled', 'yes') !== 'yes') { + return false; + } + + // Check whether public sharing is enabled + if ($this->config->getAppValue('core', 'shareapi_allow_links', 'yes') !== 'yes') { + return false; + } + + return true; + } + + private function throttle($bruteforceProtectionAction, $token): void { + $ip = $this->request->getRemoteAddress(); + $this->throttler->sleepDelayOrThrowOnMax($ip, $bruteforceProtectionAction); + $this->throttler->registerAttempt($bruteforceProtectionAction, $ip, ['token' => $token]); + } +} diff --git a/lib/private/AppFramework/Middleware/Security/BruteForceMiddleware.php b/lib/private/AppFramework/Middleware/Security/BruteForceMiddleware.php new file mode 100644 index 00000000000..4b4425517e0 --- /dev/null +++ b/lib/private/AppFramework/Middleware/Security/BruteForceMiddleware.php @@ -0,0 +1,137 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\AppFramework\Middleware\Security; + +use OC\AppFramework\Utility\ControllerMethodReflector; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\BruteForceProtection; +use OCP\AppFramework\Http\Response; +use OCP\AppFramework\Http\TooManyRequestsResponse; +use OCP\AppFramework\Middleware; +use OCP\AppFramework\OCS\OCSException; +use OCP\AppFramework\OCSController; +use OCP\IRequest; +use OCP\Security\Bruteforce\IThrottler; +use OCP\Security\Bruteforce\MaxDelayReached; +use Psr\Log\LoggerInterface; +use ReflectionMethod; + +/** + * Class BruteForceMiddleware performs the bruteforce protection for controllers + * that are annotated with @BruteForceProtection(action=$action) whereas $action + * is the action that should be logged within the database. + * + * @package OC\AppFramework\Middleware\Security + */ +class BruteForceMiddleware extends Middleware { + private int $delaySlept = 0; + + public function __construct( + protected ControllerMethodReflector $reflector, + protected IThrottler $throttler, + protected IRequest $request, + protected LoggerInterface $logger, + ) { + } + + /** + * {@inheritDoc} + */ + public function beforeController($controller, $methodName) { + parent::beforeController($controller, $methodName); + + if ($this->reflector->hasAnnotation('BruteForceProtection')) { + $action = $this->reflector->getAnnotationParameter('BruteForceProtection', 'action'); + $this->delaySlept += $this->throttler->sleepDelayOrThrowOnMax($this->request->getRemoteAddress(), $action); + } else { + $reflectionMethod = new ReflectionMethod($controller, $methodName); + $attributes = $reflectionMethod->getAttributes(BruteForceProtection::class); + + if (!empty($attributes)) { + $remoteAddress = $this->request->getRemoteAddress(); + + foreach ($attributes as $attribute) { + /** @var BruteForceProtection $protection */ + $protection = $attribute->newInstance(); + $action = $protection->getAction(); + $this->delaySlept += $this->throttler->sleepDelayOrThrowOnMax($remoteAddress, $action); + } + } + } + } + + /** + * {@inheritDoc} + */ + public function afterController($controller, $methodName, Response $response) { + if ($response->isThrottled()) { + try { + if ($this->reflector->hasAnnotation('BruteForceProtection')) { + $action = $this->reflector->getAnnotationParameter('BruteForceProtection', 'action'); + $ip = $this->request->getRemoteAddress(); + $this->throttler->registerAttempt($action, $ip, $response->getThrottleMetadata()); + $this->delaySlept += $this->throttler->sleepDelayOrThrowOnMax($ip, $action); + } else { + $reflectionMethod = new ReflectionMethod($controller, $methodName); + $attributes = $reflectionMethod->getAttributes(BruteForceProtection::class); + + if (!empty($attributes)) { + $ip = $this->request->getRemoteAddress(); + $metaData = $response->getThrottleMetadata(); + + foreach ($attributes as $attribute) { + /** @var BruteForceProtection $protection */ + $protection = $attribute->newInstance(); + $action = $protection->getAction(); + + if (!isset($metaData['action']) || $metaData['action'] === $action) { + $this->throttler->registerAttempt($action, $ip, $metaData); + $this->delaySlept += $this->throttler->sleepDelayOrThrowOnMax($ip, $action); + } + } + } else { + $this->logger->debug('Response for ' . get_class($controller) . '::' . $methodName . ' got bruteforce throttled but has no annotation nor attribute defined.'); + } + } + } catch (MaxDelayReached $e) { + if ($controller instanceof OCSController) { + throw new OCSException($e->getMessage(), Http::STATUS_TOO_MANY_REQUESTS); + } + + return new TooManyRequestsResponse(); + } + } + + if ($this->delaySlept) { + $response->addHeader('X-Nextcloud-Bruteforce-Throttled', $this->delaySlept . 'ms'); + } + + return parent::afterController($controller, $methodName, $response); + } + + /** + * @param Controller $controller + * @param string $methodName + * @param \Exception $exception + * @throws \Exception + * @return Response + */ + public function afterException($controller, $methodName, \Exception $exception): Response { + if ($exception instanceof MaxDelayReached) { + if ($controller instanceof OCSController) { + throw new OCSException($exception->getMessage(), Http::STATUS_TOO_MANY_REQUESTS); + } + + return new TooManyRequestsResponse(); + } + + throw $exception; + } +} diff --git a/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php b/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php new file mode 100644 index 00000000000..4453f5a7d4b --- /dev/null +++ b/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php @@ -0,0 +1,175 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\AppFramework\Middleware\Security; + +use OC\AppFramework\Middleware\Security\Exceptions\SecurityException; +use OC\AppFramework\Utility\ControllerMethodReflector; +use OC\Authentication\Exceptions\PasswordLoginForbiddenException; +use OC\User\Session; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\CORS; +use OCP\AppFramework\Http\Attribute\PublicPage; +use OCP\AppFramework\Http\JSONResponse; +use OCP\AppFramework\Http\Response; +use OCP\AppFramework\Middleware; +use OCP\IRequest; +use OCP\ISession; +use OCP\Security\Bruteforce\IThrottler; +use Psr\Log\LoggerInterface; +use ReflectionMethod; + +/** + * This middleware sets the correct CORS headers on a response if the + * controller has the @CORS annotation. This is needed for webapps that want + * to access an API and don't run on the same domain, see + * https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS + */ +class CORSMiddleware extends Middleware { + /** @var IRequest */ + private $request; + /** @var ControllerMethodReflector */ + private $reflector; + /** @var Session */ + private $session; + /** @var IThrottler */ + private $throttler; + + public function __construct( + IRequest $request, + ControllerMethodReflector $reflector, + Session $session, + IThrottler $throttler, + private readonly LoggerInterface $logger, + ) { + $this->request = $request; + $this->reflector = $reflector; + $this->session = $session; + $this->throttler = $throttler; + } + + /** + * This is being run in normal order before the controller is being + * called which allows several modifications and checks + * + * @param Controller $controller the controller that is being called + * @param string $methodName the name of the method that will be called on + * the controller + * @throws SecurityException + * @since 6.0.0 + */ + public function beforeController($controller, $methodName) { + $reflectionMethod = new ReflectionMethod($controller, $methodName); + + // ensure that @CORS annotated API routes are not used in conjunction + // with session authentication since this enables CSRF attack vectors + if ($this->hasAnnotationOrAttribute($reflectionMethod, 'CORS', CORS::class) + && (!$this->hasAnnotationOrAttribute($reflectionMethod, 'PublicPage', PublicPage::class) || $this->session->isLoggedIn())) { + $user = array_key_exists('PHP_AUTH_USER', $this->request->server) ? $this->request->server['PHP_AUTH_USER'] : null; + $pass = array_key_exists('PHP_AUTH_PW', $this->request->server) ? $this->request->server['PHP_AUTH_PW'] : null; + + // Allow to use the current session if a CSRF token is provided + if ($this->request->passesCSRFCheck()) { + return; + } + // Skip CORS check for requests with AppAPI auth. + if ($this->session->getSession() instanceof ISession && $this->session->getSession()->get('app_api') === true) { + return; + } + $this->session->logout(); + try { + if ($user === null || $pass === null || !$this->session->logClientIn($user, $pass, $this->request, $this->throttler)) { + throw new SecurityException('CORS requires basic auth', Http::STATUS_UNAUTHORIZED); + } + } catch (PasswordLoginForbiddenException $ex) { + throw new SecurityException('Password login forbidden, use token instead', Http::STATUS_UNAUTHORIZED); + } + } + } + + /** + * @template T + * + * @param ReflectionMethod $reflectionMethod + * @param string $annotationName + * @param class-string<T> $attributeClass + * @return boolean + */ + protected function hasAnnotationOrAttribute(ReflectionMethod $reflectionMethod, string $annotationName, string $attributeClass): bool { + if ($this->reflector->hasAnnotation($annotationName)) { + $this->logger->debug($reflectionMethod->getDeclaringClass()->getName() . '::' . $reflectionMethod->getName() . ' uses the @' . $annotationName . ' annotation and should use the #[' . $attributeClass . '] attribute instead'); + return true; + } + + + if (!empty($reflectionMethod->getAttributes($attributeClass))) { + return true; + } + + return false; + } + + /** + * This is being run after a successful controller method call and allows + * the manipulation of a Response object. The middleware is run in reverse order + * + * @param Controller $controller the controller that is being called + * @param string $methodName the name of the method that will be called on + * the controller + * @param Response $response the generated response from the controller + * @return Response a Response object + * @throws SecurityException + */ + public function afterController($controller, $methodName, Response $response) { + // only react if it's a CORS request and if the request sends origin and + + if (isset($this->request->server['HTTP_ORIGIN'])) { + $reflectionMethod = new ReflectionMethod($controller, $methodName); + if ($this->hasAnnotationOrAttribute($reflectionMethod, 'CORS', CORS::class)) { + // allow credentials headers must not be true or CSRF is possible + // otherwise + foreach ($response->getHeaders() as $header => $value) { + if (strtolower($header) === 'access-control-allow-credentials' + && strtolower(trim($value)) === 'true') { + $msg = 'Access-Control-Allow-Credentials must not be ' + . 'set to true in order to prevent CSRF'; + throw new SecurityException($msg); + } + } + + $origin = $this->request->server['HTTP_ORIGIN']; + $response->addHeader('Access-Control-Allow-Origin', $origin); + } + } + return $response; + } + + /** + * If an SecurityException is being caught return a JSON error response + * + * @param Controller $controller the controller that is being called + * @param string $methodName the name of the method that will be called on + * the controller + * @param \Exception $exception the thrown exception + * @throws \Exception the passed in exception if it can't handle it + * @return Response a Response object or null in case that the exception could not be handled + */ + public function afterException($controller, $methodName, \Exception $exception) { + if ($exception instanceof SecurityException) { + $response = new JSONResponse(['message' => $exception->getMessage()]); + if ($exception->getCode() !== 0) { + $response->setStatus($exception->getCode()); + } else { + $response->setStatus(Http::STATUS_INTERNAL_SERVER_ERROR); + } + return $response; + } + + throw $exception; + } +} diff --git a/lib/private/AppFramework/Middleware/Security/CSPMiddleware.php b/lib/private/AppFramework/Middleware/Security/CSPMiddleware.php new file mode 100644 index 00000000000..e88c9563c00 --- /dev/null +++ b/lib/private/AppFramework/Middleware/Security/CSPMiddleware.php @@ -0,0 +1,54 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\AppFramework\Middleware\Security; + +use OC\Security\CSP\ContentSecurityPolicyManager; +use OC\Security\CSP\ContentSecurityPolicyNonceManager; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\ContentSecurityPolicy; +use OCP\AppFramework\Http\EmptyContentSecurityPolicy; +use OCP\AppFramework\Http\Response; +use OCP\AppFramework\Middleware; + +class CSPMiddleware extends Middleware { + + public function __construct( + private ContentSecurityPolicyManager $policyManager, + private ContentSecurityPolicyNonceManager $cspNonceManager, + ) { + } + + /** + * Performs the default CSP modifications that may be injected by other + * applications + * + * @param Controller $controller + * @param string $methodName + * @param Response $response + * @return Response + */ + public function afterController($controller, $methodName, Response $response): Response { + $policy = !is_null($response->getContentSecurityPolicy()) ? $response->getContentSecurityPolicy() : new ContentSecurityPolicy(); + + if (get_class($policy) === EmptyContentSecurityPolicy::class) { + return $response; + } + + $defaultPolicy = $this->policyManager->getDefaultPolicy(); + $defaultPolicy = $this->policyManager->mergePolicies($defaultPolicy, $policy); + + if ($this->cspNonceManager->browserSupportsCspV3()) { + $defaultPolicy->useJsNonce($this->cspNonceManager->getNonce()); + } + + $response->setContentSecurityPolicy($defaultPolicy); + + return $response; + } +} diff --git a/lib/private/AppFramework/Middleware/Security/Exceptions/AdminIpNotAllowedException.php b/lib/private/AppFramework/Middleware/Security/Exceptions/AdminIpNotAllowedException.php new file mode 100644 index 00000000000..36eb8f18928 --- /dev/null +++ b/lib/private/AppFramework/Middleware/Security/Exceptions/AdminIpNotAllowedException.php @@ -0,0 +1,23 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\AppFramework\Middleware\Security\Exceptions; + +use OCP\AppFramework\Http; + +/** + * Class AdminIpNotAllowed is thrown when a resource has been requested by a + * an admin user connecting from an unauthorized IP address + * See configuration `allowed_admin_ranges` + * + * @package OC\AppFramework\Middleware\Security\Exceptions + */ +class AdminIpNotAllowedException extends SecurityException { + public function __construct(string $message) { + parent::__construct($message, Http::STATUS_FORBIDDEN); + } +} diff --git a/lib/private/AppFramework/Middleware/Security/Exceptions/AppNotEnabledException.php b/lib/private/AppFramework/Middleware/Security/Exceptions/AppNotEnabledException.php new file mode 100644 index 00000000000..53fbaaf5ed2 --- /dev/null +++ b/lib/private/AppFramework/Middleware/Security/Exceptions/AppNotEnabledException.php @@ -0,0 +1,22 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\AppFramework\Middleware\Security\Exceptions; + +use OCP\AppFramework\Http; + +/** + * Class AppNotEnabledException is thrown when a resource for an application is + * requested that is not enabled. + * + * @package OC\AppFramework\Middleware\Security\Exceptions + */ +class AppNotEnabledException extends SecurityException { + public function __construct() { + parent::__construct('App is not enabled', Http::STATUS_PRECONDITION_FAILED); + } +} diff --git a/lib/private/AppFramework/Middleware/Security/Exceptions/CrossSiteRequestForgeryException.php b/lib/private/AppFramework/Middleware/Security/Exceptions/CrossSiteRequestForgeryException.php new file mode 100644 index 00000000000..0c6a28134ca --- /dev/null +++ b/lib/private/AppFramework/Middleware/Security/Exceptions/CrossSiteRequestForgeryException.php @@ -0,0 +1,22 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\AppFramework\Middleware\Security\Exceptions; + +use OCP\AppFramework\Http; + +/** + * Class CrossSiteRequestForgeryException is thrown when a CSRF exception has + * been encountered. + * + * @package OC\AppFramework\Middleware\Security\Exceptions + */ +class CrossSiteRequestForgeryException extends SecurityException { + public function __construct() { + parent::__construct('CSRF check failed', Http::STATUS_PRECONDITION_FAILED); + } +} diff --git a/lib/private/AppFramework/Middleware/Security/Exceptions/ExAppRequiredException.php b/lib/private/AppFramework/Middleware/Security/Exceptions/ExAppRequiredException.php new file mode 100644 index 00000000000..77bc7efebac --- /dev/null +++ b/lib/private/AppFramework/Middleware/Security/Exceptions/ExAppRequiredException.php @@ -0,0 +1,18 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\AppFramework\Middleware\Security\Exceptions; + +use OCP\AppFramework\Http; + +/** + * Class ExAppRequiredException is thrown when an endpoint can only be called by an ExApp but the caller is not an ExApp. + */ +class ExAppRequiredException extends SecurityException { + public function __construct() { + parent::__construct('ExApp required', Http::STATUS_PRECONDITION_FAILED); + } +} diff --git a/lib/private/AppFramework/Middleware/Security/Exceptions/LaxSameSiteCookieFailedException.php b/lib/private/AppFramework/Middleware/Security/Exceptions/LaxSameSiteCookieFailedException.php new file mode 100644 index 00000000000..0380c6781aa --- /dev/null +++ b/lib/private/AppFramework/Middleware/Security/Exceptions/LaxSameSiteCookieFailedException.php @@ -0,0 +1,21 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\AppFramework\Middleware\Security\Exceptions; + +use OCP\AppFramework\Http; + +/** + * Class LaxSameSiteCookieFailedException is thrown when a request doesn't pass + * the required LaxCookie check on index.php + * + * @package OC\AppFramework\Middleware\Security\Exceptions + */ +class LaxSameSiteCookieFailedException extends SecurityException { + public function __construct() { + parent::__construct('Lax Same Site Cookie is invalid in request.', Http::STATUS_PRECONDITION_FAILED); + } +} diff --git a/lib/private/AppFramework/Middleware/Security/Exceptions/NotAdminException.php b/lib/private/AppFramework/Middleware/Security/Exceptions/NotAdminException.php new file mode 100644 index 00000000000..6252c914ac1 --- /dev/null +++ b/lib/private/AppFramework/Middleware/Security/Exceptions/NotAdminException.php @@ -0,0 +1,23 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\AppFramework\Middleware\Security\Exceptions; + +use OCP\AppFramework\Http; + +/** + * Class NotAdminException is thrown when a resource has been requested by a + * non-admin user that is not accessible to non-admin users. + * + * @package OC\AppFramework\Middleware\Security\Exceptions + */ +class NotAdminException extends SecurityException { + public function __construct(string $message) { + parent::__construct($message, Http::STATUS_FORBIDDEN); + } +} diff --git a/lib/private/AppFramework/Middleware/Security/Exceptions/NotConfirmedException.php b/lib/private/AppFramework/Middleware/Security/Exceptions/NotConfirmedException.php new file mode 100644 index 00000000000..ca30f736fbc --- /dev/null +++ b/lib/private/AppFramework/Middleware/Security/Exceptions/NotConfirmedException.php @@ -0,0 +1,21 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\AppFramework\Middleware\Security\Exceptions; + +use OCP\AppFramework\Http; + +/** + * Class NotConfirmedException is thrown when a resource has been requested by a + * user that has not confirmed their password in the last 30 minutes. + * + * @package OC\AppFramework\Middleware\Security\Exceptions + */ +class NotConfirmedException extends SecurityException { + public function __construct(string $message = 'Password confirmation is required') { + parent::__construct($message, Http::STATUS_FORBIDDEN); + } +} diff --git a/lib/private/AppFramework/Middleware/Security/Exceptions/NotLoggedInException.php b/lib/private/AppFramework/Middleware/Security/Exceptions/NotLoggedInException.php new file mode 100644 index 00000000000..e5a7853a64b --- /dev/null +++ b/lib/private/AppFramework/Middleware/Security/Exceptions/NotLoggedInException.php @@ -0,0 +1,22 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\AppFramework\Middleware\Security\Exceptions; + +use OCP\AppFramework\Http; + +/** + * Class NotLoggedInException is thrown when a resource has been requested by a + * guest user that is not accessible to the public. + * + * @package OC\AppFramework\Middleware\Security\Exceptions + */ +class NotLoggedInException extends SecurityException { + public function __construct() { + parent::__construct('Current user is not logged in', Http::STATUS_UNAUTHORIZED); + } +} diff --git a/lib/private/AppFramework/Middleware/Security/Exceptions/ReloadExecutionException.php b/lib/private/AppFramework/Middleware/Security/Exceptions/ReloadExecutionException.php new file mode 100644 index 00000000000..d12ee96292e --- /dev/null +++ b/lib/private/AppFramework/Middleware/Security/Exceptions/ReloadExecutionException.php @@ -0,0 +1,12 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\AppFramework\Middleware\Security\Exceptions; + +class ReloadExecutionException extends SecurityException { +} diff --git a/lib/private/AppFramework/Middleware/Security/Exceptions/SecurityException.php b/lib/private/AppFramework/Middleware/Security/Exceptions/SecurityException.php new file mode 100644 index 00000000000..c8d70ad4f2b --- /dev/null +++ b/lib/private/AppFramework/Middleware/Security/Exceptions/SecurityException.php @@ -0,0 +1,18 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\AppFramework\Middleware\Security\Exceptions; + +/** + * Class SecurityException is the base class for security exceptions thrown by + * the security middleware. + * + * @package OC\AppFramework\Middleware\Security\Exceptions + */ +class SecurityException extends \Exception { +} diff --git a/lib/private/AppFramework/Middleware/Security/Exceptions/StrictCookieMissingException.php b/lib/private/AppFramework/Middleware/Security/Exceptions/StrictCookieMissingException.php new file mode 100644 index 00000000000..8ae20ab4e70 --- /dev/null +++ b/lib/private/AppFramework/Middleware/Security/Exceptions/StrictCookieMissingException.php @@ -0,0 +1,23 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\AppFramework\Middleware\Security\Exceptions; + +use OCP\AppFramework\Http; + +/** + * Class StrictCookieMissingException is thrown when the strict cookie has not + * been sent with the request but is required. + * + * @package OC\AppFramework\Middleware\Security\Exceptions + */ +class StrictCookieMissingException extends SecurityException { + public function __construct() { + parent::__construct('Strict Cookie has not been found in request.', Http::STATUS_PRECONDITION_FAILED); + } +} diff --git a/lib/private/AppFramework/Middleware/Security/FeaturePolicyMiddleware.php b/lib/private/AppFramework/Middleware/Security/FeaturePolicyMiddleware.php new file mode 100644 index 00000000000..921630e6326 --- /dev/null +++ b/lib/private/AppFramework/Middleware/Security/FeaturePolicyMiddleware.php @@ -0,0 +1,48 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\AppFramework\Middleware\Security; + +use OC\Security\FeaturePolicy\FeaturePolicy; +use OC\Security\FeaturePolicy\FeaturePolicyManager; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\EmptyFeaturePolicy; +use OCP\AppFramework\Http\Response; +use OCP\AppFramework\Middleware; + +class FeaturePolicyMiddleware extends Middleware { + /** @var FeaturePolicyManager */ + private $policyManager; + + public function __construct(FeaturePolicyManager $policyManager) { + $this->policyManager = $policyManager; + } + + /** + * Performs the default FeaturePolicy modifications that may be injected by other + * applications + * + * @param Controller $controller + * @param string $methodName + * @param Response $response + * @return Response + */ + public function afterController($controller, $methodName, Response $response): Response { + $policy = !is_null($response->getFeaturePolicy()) ? $response->getFeaturePolicy() : new FeaturePolicy(); + + if (get_class($policy) === EmptyFeaturePolicy::class) { + return $response; + } + + $defaultPolicy = $this->policyManager->getDefaultPolicy(); + $defaultPolicy = $this->policyManager->mergePolicies($defaultPolicy, $policy); + $response->setFeaturePolicy($defaultPolicy); + + return $response; + } +} diff --git a/lib/private/AppFramework/Middleware/Security/PasswordConfirmationMiddleware.php b/lib/private/AppFramework/Middleware/Security/PasswordConfirmationMiddleware.php new file mode 100644 index 00000000000..0facbffe504 --- /dev/null +++ b/lib/private/AppFramework/Middleware/Security/PasswordConfirmationMiddleware.php @@ -0,0 +1,121 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\AppFramework\Middleware\Security; + +use OC\AppFramework\Middleware\Security\Exceptions\NotConfirmedException; +use OC\AppFramework\Utility\ControllerMethodReflector; +use OC\Authentication\Token\IProvider; +use OC\User\Manager; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired; +use OCP\AppFramework\Middleware; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Authentication\Exceptions\ExpiredTokenException; +use OCP\Authentication\Exceptions\InvalidTokenException; +use OCP\Authentication\Exceptions\WipeTokenException; +use OCP\Authentication\Token\IToken; +use OCP\IRequest; +use OCP\ISession; +use OCP\IUserSession; +use OCP\Session\Exceptions\SessionNotAvailableException; +use OCP\User\Backend\IPasswordConfirmationBackend; +use Psr\Log\LoggerInterface; +use ReflectionMethod; + +class PasswordConfirmationMiddleware extends Middleware { + private array $excludedUserBackEnds = ['user_saml' => true, 'user_globalsiteselector' => true]; + + public function __construct( + private ControllerMethodReflector $reflector, + private ISession $session, + private IUserSession $userSession, + private ITimeFactory $timeFactory, + private IProvider $tokenProvider, + private readonly LoggerInterface $logger, + private readonly IRequest $request, + private readonly Manager $userManager, + ) { + } + + /** + * @throws NotConfirmedException + */ + public function beforeController(Controller $controller, string $methodName) { + $reflectionMethod = new ReflectionMethod($controller, $methodName); + + if (!$this->needsPasswordConfirmation($reflectionMethod)) { + return; + } + + $user = $this->userSession->getUser(); + $backendClassName = ''; + if ($user !== null) { + $backend = $user->getBackend(); + if ($backend instanceof IPasswordConfirmationBackend) { + if (!$backend->canConfirmPassword($user->getUID())) { + return; + } + } + + $backendClassName = $user->getBackendClassName(); + } + + try { + $sessionId = $this->session->getId(); + $token = $this->tokenProvider->getToken($sessionId); + } catch (SessionNotAvailableException|InvalidTokenException|WipeTokenException|ExpiredTokenException) { + // States we do not deal with here. + return; + } + + $scope = $token->getScopeAsArray(); + if (isset($scope[IToken::SCOPE_SKIP_PASSWORD_VALIDATION]) && $scope[IToken::SCOPE_SKIP_PASSWORD_VALIDATION] === true) { + // Users logging in from SSO backends cannot confirm their password by design + return; + } + + if ($this->isPasswordConfirmationStrict($reflectionMethod)) { + $authHeader = $this->request->getHeader('Authorization'); + if (!str_starts_with(strtolower($authHeader), 'basic ')) { + throw new NotConfirmedException('Required authorization header missing'); + } + [, $password] = explode(':', base64_decode(substr($authHeader, 6)), 2); + $loginName = $this->session->get('loginname'); + $loginResult = $this->userManager->checkPassword($loginName, $password); + if ($loginResult === false) { + throw new NotConfirmedException(); + } + + $this->session->set('last-password-confirm', $this->timeFactory->getTime()); + } else { + $lastConfirm = (int)$this->session->get('last-password-confirm'); + // TODO: confirm excludedUserBackEnds can go away and remove it + if (!isset($this->excludedUserBackEnds[$backendClassName]) && $lastConfirm < ($this->timeFactory->getTime() - (30 * 60 + 15))) { // allow 15 seconds delay + throw new NotConfirmedException(); + } + } + } + + private function needsPasswordConfirmation(ReflectionMethod $reflectionMethod): bool { + $attributes = $reflectionMethod->getAttributes(PasswordConfirmationRequired::class); + if (!empty($attributes)) { + return true; + } + + if ($this->reflector->hasAnnotation('PasswordConfirmationRequired')) { + $this->logger->debug($reflectionMethod->getDeclaringClass()->getName() . '::' . $reflectionMethod->getName() . ' uses the @' . 'PasswordConfirmationRequired' . ' annotation and should use the #[PasswordConfirmationRequired] attribute instead'); + return true; + } + + return false; + } + + private function isPasswordConfirmationStrict(ReflectionMethod $reflectionMethod): bool { + $attributes = $reflectionMethod->getAttributes(PasswordConfirmationRequired::class); + return !empty($attributes) && ($attributes[0]->newInstance()->getStrict()); + } +} diff --git a/lib/private/AppFramework/Middleware/Security/RateLimitingMiddleware.php b/lib/private/AppFramework/Middleware/Security/RateLimitingMiddleware.php new file mode 100644 index 00000000000..2d19be97993 --- /dev/null +++ b/lib/private/AppFramework/Middleware/Security/RateLimitingMiddleware.php @@ -0,0 +1,162 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\AppFramework\Middleware\Security; + +use OC\AppFramework\Utility\ControllerMethodReflector; +use OC\Security\Ip\BruteforceAllowList; +use OC\Security\RateLimiting\Exception\RateLimitExceededException; +use OC\Security\RateLimiting\Limiter; +use OC\User\Session; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\Attribute\AnonRateLimit; +use OCP\AppFramework\Http\Attribute\ARateLimit; +use OCP\AppFramework\Http\Attribute\UserRateLimit; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\Http\Response; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\AppFramework\Middleware; +use OCP\IAppConfig; +use OCP\IRequest; +use OCP\ISession; +use OCP\IUserSession; +use ReflectionMethod; + +/** + * Class RateLimitingMiddleware is the middleware responsible for implementing the + * ratelimiting in Nextcloud. + * + * It parses annotations such as: + * + * @UserRateThrottle(limit=5, period=100) + * @AnonRateThrottle(limit=1, period=100) + * + * Or attributes such as: + * + * #[UserRateLimit(limit: 5, period: 100)] + * #[AnonRateLimit(limit: 1, period: 100)] + * + * Both sets would mean that logged-in users can access the page 5 + * times within 100 seconds, and anonymous users 1 time within 100 seconds. If + * only an AnonRateThrottle is specified that one will also be applied to logged-in + * users. + * + * @package OC\AppFramework\Middleware\Security + */ +class RateLimitingMiddleware extends Middleware { + public function __construct( + protected IRequest $request, + protected IUserSession $userSession, + protected ControllerMethodReflector $reflector, + protected Limiter $limiter, + protected ISession $session, + protected IAppConfig $appConfig, + protected BruteforceAllowList $bruteForceAllowList, + ) { + } + + /** + * {@inheritDoc} + * @throws RateLimitExceededException + */ + public function beforeController(Controller $controller, string $methodName): void { + parent::beforeController($controller, $methodName); + $rateLimitIdentifier = get_class($controller) . '::' . $methodName; + + if ($this->userSession instanceof Session && $this->userSession->getSession()->get('app_api') === true && $this->userSession->getUser() === null) { + // if userId is not specified and the request is authenticated by AppAPI, we skip the rate limit + return; + } + + if ($this->userSession->isLoggedIn()) { + $rateLimit = $this->readLimitFromAnnotationOrAttribute($controller, $methodName, 'UserRateThrottle', UserRateLimit::class); + + if ($rateLimit !== null) { + if ($this->appConfig->getValueBool('bruteforcesettings', 'apply_allowlist_to_ratelimit') + && $this->bruteForceAllowList->isBypassListed($this->request->getRemoteAddress())) { + return; + } + + $this->limiter->registerUserRequest( + $rateLimitIdentifier, + $rateLimit->getLimit(), + $rateLimit->getPeriod(), + $this->userSession->getUser() + ); + return; + } + + // If not user specific rate limit is found the Anon rate limit applies! + } + + $rateLimit = $this->readLimitFromAnnotationOrAttribute($controller, $methodName, 'AnonRateThrottle', AnonRateLimit::class); + + if ($rateLimit !== null) { + $this->limiter->registerAnonRequest( + $rateLimitIdentifier, + $rateLimit->getLimit(), + $rateLimit->getPeriod(), + $this->request->getRemoteAddress() + ); + } + } + + /** + * @template T of ARateLimit + * + * @param Controller $controller + * @param string $methodName + * @param string $annotationName + * @param class-string<T> $attributeClass + * @return ?ARateLimit + */ + protected function readLimitFromAnnotationOrAttribute(Controller $controller, string $methodName, string $annotationName, string $attributeClass): ?ARateLimit { + $annotationLimit = $this->reflector->getAnnotationParameter($annotationName, 'limit'); + $annotationPeriod = $this->reflector->getAnnotationParameter($annotationName, 'period'); + + if ($annotationLimit !== '' && $annotationPeriod !== '') { + return new $attributeClass( + (int)$annotationLimit, + (int)$annotationPeriod, + ); + } + + $reflectionMethod = new ReflectionMethod($controller, $methodName); + $attributes = $reflectionMethod->getAttributes($attributeClass); + $attribute = current($attributes); + + if ($attribute !== false) { + return $attribute->newInstance(); + } + + return null; + } + + /** + * {@inheritDoc} + */ + public function afterException(Controller $controller, string $methodName, \Exception $exception): Response { + if ($exception instanceof RateLimitExceededException) { + if (stripos($this->request->getHeader('Accept'), 'html') === false) { + $response = new DataResponse([], $exception->getCode()); + } else { + $response = new TemplateResponse( + 'core', + '429', + [], + TemplateResponse::RENDER_AS_GUEST + ); + $response->setStatus($exception->getCode()); + } + + return $response; + } + + throw $exception; + } +} diff --git a/lib/private/AppFramework/Middleware/Security/ReloadExecutionMiddleware.php b/lib/private/AppFramework/Middleware/Security/ReloadExecutionMiddleware.php new file mode 100644 index 00000000000..e770fa4cbff --- /dev/null +++ b/lib/private/AppFramework/Middleware/Security/ReloadExecutionMiddleware.php @@ -0,0 +1,50 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\AppFramework\Middleware\Security; + +use OC\AppFramework\Middleware\Security\Exceptions\ReloadExecutionException; +use OCP\AppFramework\Http\RedirectResponse; +use OCP\AppFramework\Middleware; +use OCP\ISession; +use OCP\IURLGenerator; + +/** + * Simple middleware to handle the clearing of the execution context. This will trigger + * a reload but if the session variable is set we properly redirect to the login page. + */ +class ReloadExecutionMiddleware extends Middleware { + /** @var ISession */ + private $session; + /** @var IURLGenerator */ + private $urlGenerator; + + public function __construct(ISession $session, IURLGenerator $urlGenerator) { + $this->session = $session; + $this->urlGenerator = $urlGenerator; + } + + public function beforeController($controller, $methodName) { + if ($this->session->exists('clearingExecutionContexts')) { + throw new ReloadExecutionException(); + } + } + + public function afterException($controller, $methodName, \Exception $exception) { + if ($exception instanceof ReloadExecutionException) { + $this->session->remove('clearingExecutionContexts'); + + return new RedirectResponse($this->urlGenerator->linkToRouteAbsolute( + 'core.login.showLoginForm', + ['clear' => true] // this param the code in login.js may be removed when the "Clear-Site-Data" is working in the browsers + )); + } + + return parent::afterException($controller, $methodName, $exception); + } +} diff --git a/lib/private/AppFramework/Middleware/Security/SameSiteCookieMiddleware.php b/lib/private/AppFramework/Middleware/Security/SameSiteCookieMiddleware.php new file mode 100644 index 00000000000..097ed1b2b8f --- /dev/null +++ b/lib/private/AppFramework/Middleware/Security/SameSiteCookieMiddleware.php @@ -0,0 +1,83 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\AppFramework\Middleware\Security; + +use OC\AppFramework\Http\Request; +use OC\AppFramework\Middleware\Security\Exceptions\LaxSameSiteCookieFailedException; +use OC\AppFramework\Utility\ControllerMethodReflector; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Response; +use OCP\AppFramework\Middleware; + +class SameSiteCookieMiddleware extends Middleware { + public function __construct( + private Request $request, + private ControllerMethodReflector $reflector, + ) { + } + + public function beforeController($controller, $methodName) { + $requestUri = $this->request->getScriptName(); + $processingScript = explode('/', $requestUri); + $processingScript = $processingScript[count($processingScript) - 1]; + + if ($processingScript !== 'index.php') { + return; + } + + $noSSC = $this->reflector->hasAnnotation('NoSameSiteCookieRequired'); + if ($noSSC) { + return; + } + + if (!$this->request->passesLaxCookieCheck()) { + throw new LaxSameSiteCookieFailedException(); + } + } + + public function afterException($controller, $methodName, \Exception $exception) { + if ($exception instanceof LaxSameSiteCookieFailedException) { + $response = new Response(); + $response->setStatus(Http::STATUS_FOUND); + $response->addHeader('Location', $this->request->getRequestUri()); + + $this->setSameSiteCookie(); + + return $response; + } + + throw $exception; + } + + protected function setSameSiteCookie(): void { + $cookieParams = $this->request->getCookieParams(); + $secureCookie = ($cookieParams['secure'] === true) ? 'secure; ' : ''; + $policies = [ + 'lax', + 'strict', + ]; + + // Append __Host to the cookie if it meets the requirements + $cookiePrefix = ''; + if ($cookieParams['secure'] === true && $cookieParams['path'] === '/') { + $cookiePrefix = '__Host-'; + } + + foreach ($policies as $policy) { + header( + sprintf( + 'Set-Cookie: %snc_sameSiteCookie%s=true; path=%s; httponly;' . $secureCookie . 'expires=Fri, 31-Dec-2100 23:59:59 GMT; SameSite=%s', + $cookiePrefix, + $policy, + $cookieParams['path'], + $policy + ), + false + ); + } + } +} diff --git a/lib/private/AppFramework/Middleware/Security/SecurityMiddleware.php b/lib/private/AppFramework/Middleware/Security/SecurityMiddleware.php new file mode 100644 index 00000000000..e3a293e0fd9 --- /dev/null +++ b/lib/private/AppFramework/Middleware/Security/SecurityMiddleware.php @@ -0,0 +1,332 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\AppFramework\Middleware\Security; + +use OC\AppFramework\Middleware\Security\Exceptions\AdminIpNotAllowedException; +use OC\AppFramework\Middleware\Security\Exceptions\AppNotEnabledException; +use OC\AppFramework\Middleware\Security\Exceptions\CrossSiteRequestForgeryException; +use OC\AppFramework\Middleware\Security\Exceptions\ExAppRequiredException; +use OC\AppFramework\Middleware\Security\Exceptions\NotAdminException; +use OC\AppFramework\Middleware\Security\Exceptions\NotLoggedInException; +use OC\AppFramework\Middleware\Security\Exceptions\SecurityException; +use OC\AppFramework\Middleware\Security\Exceptions\StrictCookieMissingException; +use OC\AppFramework\Utility\ControllerMethodReflector; +use OC\Settings\AuthorizedGroupMapper; +use OC\User\Session; +use OCP\App\AppPathNotFoundException; +use OCP\App\IAppManager; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\Attribute\AppApiAdminAccessWithoutUser; +use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; +use OCP\AppFramework\Http\Attribute\ExAppRequired; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\PublicPage; +use OCP\AppFramework\Http\Attribute\StrictCookiesRequired; +use OCP\AppFramework\Http\Attribute\SubAdminRequired; +use OCP\AppFramework\Http\JSONResponse; +use OCP\AppFramework\Http\RedirectResponse; +use OCP\AppFramework\Http\Response; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\AppFramework\Middleware; +use OCP\AppFramework\OCSController; +use OCP\Group\ISubAdmin; +use OCP\IGroupManager; +use OCP\IL10N; +use OCP\INavigationManager; +use OCP\IRequest; +use OCP\IURLGenerator; +use OCP\IUserSession; +use OCP\Security\Ip\IRemoteAddress; +use OCP\Util; +use Psr\Log\LoggerInterface; +use ReflectionMethod; + +/** + * Used to do all the authentication and checking stuff for a controller method + * It reads out the annotations of a controller method and checks which if + * security things should be checked and also handles errors in case a security + * check fails + */ +class SecurityMiddleware extends Middleware { + private ?bool $isAdminUser = null; + private ?bool $isSubAdmin = null; + + public function __construct( + private IRequest $request, + private ControllerMethodReflector $reflector, + private INavigationManager $navigationManager, + private IURLGenerator $urlGenerator, + private LoggerInterface $logger, + private string $appName, + private bool $isLoggedIn, + private IGroupManager $groupManager, + private ISubAdmin $subAdminManager, + private IAppManager $appManager, + private IL10N $l10n, + private AuthorizedGroupMapper $groupAuthorizationMapper, + private IUserSession $userSession, + private IRemoteAddress $remoteAddress, + ) { + } + + private function isAdminUser(): bool { + if ($this->isAdminUser === null) { + $user = $this->userSession->getUser(); + $this->isAdminUser = $user && $this->groupManager->isAdmin($user->getUID()); + } + return $this->isAdminUser; + } + + private function isSubAdmin(): bool { + if ($this->isSubAdmin === null) { + $user = $this->userSession->getUser(); + $this->isSubAdmin = $user && $this->subAdminManager->isSubAdmin($user); + } + return $this->isSubAdmin; + } + + /** + * This runs all the security checks before a method call. The + * security checks are determined by inspecting the controller method + * annotations + * + * @param Controller $controller the controller + * @param string $methodName the name of the method + * @throws SecurityException when a security check fails + * + * @suppress PhanUndeclaredClassConstant + */ + public function beforeController($controller, $methodName) { + // this will set the current navigation entry of the app, use this only + // for normal HTML requests and not for AJAX requests + $this->navigationManager->setActiveEntry($this->appName); + + if (get_class($controller) === \OCA\Talk\Controller\PageController::class && $methodName === 'showCall') { + $this->navigationManager->setActiveEntry('spreed'); + } + + $reflectionMethod = new ReflectionMethod($controller, $methodName); + + // security checks + $isPublicPage = $this->hasAnnotationOrAttribute($reflectionMethod, 'PublicPage', PublicPage::class); + + if ($this->hasAnnotationOrAttribute($reflectionMethod, 'ExAppRequired', ExAppRequired::class)) { + if (!$this->userSession instanceof Session || $this->userSession->getSession()->get('app_api') !== true) { + throw new ExAppRequiredException(); + } + } elseif (!$isPublicPage) { + $authorized = false; + if ($this->hasAnnotationOrAttribute($reflectionMethod, null, AppApiAdminAccessWithoutUser::class)) { + // this attribute allows ExApp to access admin endpoints only if "userId" is "null" + if ($this->userSession instanceof Session && $this->userSession->getSession()->get('app_api') === true && $this->userSession->getUser() === null) { + $authorized = true; + } + } + + if (!$authorized && !$this->isLoggedIn) { + throw new NotLoggedInException(); + } + + if (!$authorized && $this->hasAnnotationOrAttribute($reflectionMethod, 'AuthorizedAdminSetting', AuthorizedAdminSetting::class)) { + $authorized = $this->isAdminUser(); + + if (!$authorized && $this->hasAnnotationOrAttribute($reflectionMethod, 'SubAdminRequired', SubAdminRequired::class)) { + $authorized = $this->isSubAdmin(); + } + + if (!$authorized) { + $settingClasses = $this->getAuthorizedAdminSettingClasses($reflectionMethod); + $authorizedClasses = $this->groupAuthorizationMapper->findAllClassesForUser($this->userSession->getUser()); + foreach ($settingClasses as $settingClass) { + $authorized = in_array($settingClass, $authorizedClasses, true); + + if ($authorized) { + break; + } + } + } + if (!$authorized) { + throw new NotAdminException($this->l10n->t('Logged in account must be an admin, a sub admin or gotten special right to access this setting')); + } + if (!$this->remoteAddress->allowsAdminActions()) { + throw new AdminIpNotAllowedException($this->l10n->t('Your current IP address doesn\'t allow you to perform admin actions')); + } + } + if ($this->hasAnnotationOrAttribute($reflectionMethod, 'SubAdminRequired', SubAdminRequired::class) + && !$this->isSubAdmin() + && !$this->isAdminUser() + && !$authorized) { + throw new NotAdminException($this->l10n->t('Logged in account must be an admin or sub admin')); + } + if (!$this->hasAnnotationOrAttribute($reflectionMethod, 'SubAdminRequired', SubAdminRequired::class) + && !$this->hasAnnotationOrAttribute($reflectionMethod, 'NoAdminRequired', NoAdminRequired::class) + && !$this->isAdminUser() + && !$authorized) { + throw new NotAdminException($this->l10n->t('Logged in account must be an admin')); + } + if ($this->hasAnnotationOrAttribute($reflectionMethod, 'SubAdminRequired', SubAdminRequired::class) + && !$this->remoteAddress->allowsAdminActions()) { + throw new AdminIpNotAllowedException($this->l10n->t('Your current IP address doesn\'t allow you to perform admin actions')); + } + if (!$this->hasAnnotationOrAttribute($reflectionMethod, 'SubAdminRequired', SubAdminRequired::class) + && !$this->hasAnnotationOrAttribute($reflectionMethod, 'NoAdminRequired', NoAdminRequired::class) + && !$this->remoteAddress->allowsAdminActions()) { + throw new AdminIpNotAllowedException($this->l10n->t('Your current IP address doesn\'t allow you to perform admin actions')); + } + + } + + // Check for strict cookie requirement + if ($this->hasAnnotationOrAttribute($reflectionMethod, 'StrictCookieRequired', StrictCookiesRequired::class) + || !$this->hasAnnotationOrAttribute($reflectionMethod, 'NoCSRFRequired', NoCSRFRequired::class)) { + if (!$this->request->passesStrictCookieCheck()) { + throw new StrictCookieMissingException(); + } + } + // CSRF check - also registers the CSRF token since the session may be closed later + Util::callRegister(); + if ($this->isInvalidCSRFRequired($reflectionMethod)) { + /* + * Only allow the CSRF check to fail on OCS Requests. This kind of + * hacks around that we have no full token auth in place yet and we + * do want to offer CSRF checks for web requests. + * + * Additionally we allow Bearer authenticated requests to pass on OCS routes. + * This allows oauth apps (e.g. moodle) to use the OCS endpoints + */ + if (!$controller instanceof OCSController || !$this->isValidOCSRequest()) { + throw new CrossSiteRequestForgeryException(); + } + } + + /** + * Checks if app is enabled (also includes a check whether user is allowed to access the resource) + * The getAppPath() check is here since components such as settings also use the AppFramework and + * therefore won't pass this check. + * If page is public, app does not need to be enabled for current user/visitor + */ + try { + $appPath = $this->appManager->getAppPath($this->appName); + } catch (AppPathNotFoundException $e) { + $appPath = false; + } + + if ($appPath !== false && !$isPublicPage && !$this->appManager->isEnabledForUser($this->appName)) { + throw new AppNotEnabledException(); + } + } + + private function isInvalidCSRFRequired(ReflectionMethod $reflectionMethod): bool { + if ($this->hasAnnotationOrAttribute($reflectionMethod, 'NoCSRFRequired', NoCSRFRequired::class)) { + return false; + } + + return !$this->request->passesCSRFCheck(); + } + + private function isValidOCSRequest(): bool { + return $this->request->getHeader('OCS-APIREQUEST') === 'true' + || str_starts_with($this->request->getHeader('Authorization'), 'Bearer '); + } + + /** + * @template T + * + * @param ReflectionMethod $reflectionMethod + * @param ?string $annotationName + * @param class-string<T> $attributeClass + * @return boolean + */ + protected function hasAnnotationOrAttribute(ReflectionMethod $reflectionMethod, ?string $annotationName, string $attributeClass): bool { + if (!empty($reflectionMethod->getAttributes($attributeClass))) { + return true; + } + + if ($annotationName && $this->reflector->hasAnnotation($annotationName)) { + $this->logger->debug($reflectionMethod->getDeclaringClass()->getName() . '::' . $reflectionMethod->getName() . ' uses the @' . $annotationName . ' annotation and should use the #[' . $attributeClass . '] attribute instead'); + return true; + } + + return false; + } + + /** + * @param ReflectionMethod $reflectionMethod + * @return string[] + */ + protected function getAuthorizedAdminSettingClasses(ReflectionMethod $reflectionMethod): array { + $classes = []; + if ($this->reflector->hasAnnotation('AuthorizedAdminSetting')) { + $classes = explode(';', $this->reflector->getAnnotationParameter('AuthorizedAdminSetting', 'settings')); + } + + $attributes = $reflectionMethod->getAttributes(AuthorizedAdminSetting::class); + if (!empty($attributes)) { + foreach ($attributes as $attribute) { + /** @var AuthorizedAdminSetting $setting */ + $setting = $attribute->newInstance(); + $classes[] = $setting->getSettings(); + } + } + + return $classes; + } + + /** + * If an SecurityException is being caught, ajax requests return a JSON error + * response and non ajax requests redirect to the index + * + * @param Controller $controller the controller that is being called + * @param string $methodName the name of the method that will be called on + * the controller + * @param \Exception $exception the thrown exception + * @return Response a Response object or null in case that the exception could not be handled + * @throws \Exception the passed in exception if it can't handle it + */ + public function afterException($controller, $methodName, \Exception $exception): Response { + if ($exception instanceof SecurityException) { + if ($exception instanceof StrictCookieMissingException) { + return new RedirectResponse(\OC::$WEBROOT . '/'); + } + if (stripos($this->request->getHeader('Accept'), 'html') === false) { + $response = new JSONResponse( + ['message' => $exception->getMessage()], + $exception->getCode() + ); + } else { + if ($exception instanceof NotLoggedInException) { + $params = []; + if (isset($this->request->server['REQUEST_URI'])) { + $params['redirect_url'] = $this->request->server['REQUEST_URI']; + } + $usernamePrefill = $this->request->getParam('user', ''); + if ($usernamePrefill !== '') { + $params['user'] = $usernamePrefill; + } + if ($this->request->getParam('direct')) { + $params['direct'] = 1; + } + $url = $this->urlGenerator->linkToRoute('core.login.showLoginForm', $params); + $response = new RedirectResponse($url); + } else { + $response = new TemplateResponse('core', '403', ['message' => $exception->getMessage()], 'guest'); + $response->setStatus($exception->getCode()); + } + } + + $this->logger->debug($exception->getMessage(), [ + 'exception' => $exception, + ]); + return $response; + } + + throw $exception; + } +} diff --git a/lib/private/AppFramework/Middleware/SessionMiddleware.php b/lib/private/AppFramework/Middleware/SessionMiddleware.php new file mode 100644 index 00000000000..b7b0fb118c2 --- /dev/null +++ b/lib/private/AppFramework/Middleware/SessionMiddleware.php @@ -0,0 +1,77 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\AppFramework\Middleware; + +use OC\AppFramework\Utility\ControllerMethodReflector; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\Attribute\UseSession; +use OCP\AppFramework\Http\Response; +use OCP\AppFramework\Middleware; +use OCP\ISession; +use ReflectionMethod; + +class SessionMiddleware extends Middleware { + /** @var ControllerMethodReflector */ + private $reflector; + + /** @var ISession */ + private $session; + + public function __construct(ControllerMethodReflector $reflector, + ISession $session) { + $this->reflector = $reflector; + $this->session = $session; + } + + /** + * @param Controller $controller + * @param string $methodName + */ + public function beforeController($controller, $methodName) { + /** + * Annotation deprecated with Nextcloud 26 + */ + $hasAnnotation = $this->reflector->hasAnnotation('UseSession'); + if ($hasAnnotation) { + $this->session->reopen(); + return; + } + + $reflectionMethod = new ReflectionMethod($controller, $methodName); + $hasAttribute = !empty($reflectionMethod->getAttributes(UseSession::class)); + if ($hasAttribute) { + $this->session->reopen(); + } + } + + /** + * @param Controller $controller + * @param string $methodName + * @param Response $response + * @return Response + */ + public function afterController($controller, $methodName, Response $response) { + /** + * Annotation deprecated with Nextcloud 26 + */ + $hasAnnotation = $this->reflector->hasAnnotation('UseSession'); + if ($hasAnnotation) { + $this->session->close(); + return $response; + } + + $reflectionMethod = new ReflectionMethod($controller, $methodName); + $hasAttribute = !empty($reflectionMethod->getAttributes(UseSession::class)); + if ($hasAttribute) { + $this->session->close(); + } + + return $response; + } +} diff --git a/lib/private/AppFramework/OCS/BaseResponse.php b/lib/private/AppFramework/OCS/BaseResponse.php new file mode 100644 index 00000000000..05ce133db24 --- /dev/null +++ b/lib/private/AppFramework/OCS/BaseResponse.php @@ -0,0 +1,166 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\AppFramework\OCS; + +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\Http\Response; + +/** + * @psalm-import-type DataResponseType from DataResponse + * @template S of Http::STATUS_* + * @template-covariant T of DataResponseType + * @template H of array<string, mixed> + * @template-extends Response<Http::STATUS_*, array<string, mixed>> + */ +abstract class BaseResponse extends Response { + /** @var array */ + protected $data; + + /** @var string */ + protected $format; + + /** @var ?string */ + protected $statusMessage; + + /** @var ?int */ + protected $itemsCount; + + /** @var ?int */ + protected $itemsPerPage; + + /** + * BaseResponse constructor. + * + * @param DataResponse<S, T, H> $dataResponse + * @param string $format + * @param string|null $statusMessage + * @param int|null $itemsCount + * @param int|null $itemsPerPage + */ + public function __construct(DataResponse $dataResponse, + $format = 'xml', + $statusMessage = null, + $itemsCount = null, + $itemsPerPage = null) { + parent::__construct(); + + $this->format = $format; + $this->statusMessage = $statusMessage; + $this->itemsCount = $itemsCount; + $this->itemsPerPage = $itemsPerPage; + + $this->data = $dataResponse->getData(); + + $this->setHeaders($dataResponse->getHeaders()); + $this->setStatus($dataResponse->getStatus()); + $this->setETag($dataResponse->getETag()); + $this->setLastModified($dataResponse->getLastModified()); + $this->setCookies($dataResponse->getCookies()); + + if ($dataResponse->isThrottled()) { + $throttleMetadata = $dataResponse->getThrottleMetadata(); + $this->throttle($throttleMetadata); + } + + if ($format === 'json') { + $this->addHeader( + 'Content-Type', 'application/json; charset=utf-8' + ); + } else { + $this->addHeader( + 'Content-Type', 'application/xml; charset=utf-8' + ); + } + } + + /** + * @param array<string,string|int> $meta + * @return string + */ + protected function renderResult(array $meta): string { + $status = $this->getStatus(); + if ($status === Http::STATUS_NO_CONTENT + || $status === Http::STATUS_NOT_MODIFIED + || ($status >= 100 && $status <= 199)) { + // Those status codes are not supposed to have a body: + // https://stackoverflow.com/q/8628725 + return ''; + } + + $response = [ + 'ocs' => [ + 'meta' => $meta, + 'data' => $this->data, + ], + ]; + + if ($this->format === 'json') { + return $this->toJson($response); + } + + $writer = new \XMLWriter(); + $writer->openMemory(); + $writer->setIndent(true); + $writer->startDocument(); + $this->toXML($response, $writer); + $writer->endDocument(); + return $writer->outputMemory(true); + } + + /** + * @psalm-taint-escape has_quotes + * @psalm-taint-escape html + */ + protected function toJson(array $array): string { + return \json_encode($array, \JSON_HEX_TAG); + } + + protected function toXML(array $array, \XMLWriter $writer): void { + foreach ($array as $k => $v) { + if ($k === '@attributes' && is_array($v)) { + foreach ($v as $k2 => $v2) { + $writer->writeAttribute($k2, $v2); + } + continue; + } + + if (\is_string($k) && str_starts_with($k, '@')) { + $writer->writeAttribute(substr($k, 1), $v); + continue; + } + + if (\is_numeric($k)) { + $k = 'element'; + } + + if ($v instanceof \stdClass) { + $v = []; + } + + if ($k === '$comment') { + $writer->writeComment($v); + } elseif (\is_array($v)) { + $writer->startElement($k); + $this->toXML($v, $writer); + $writer->endElement(); + } elseif ($v instanceof \JsonSerializable) { + $writer->startElement($k); + $this->toXML($v->jsonSerialize(), $writer); + $writer->endElement(); + } elseif ($v === null) { + $writer->writeElement($k); + } else { + $writer->writeElement($k, (string)$v); + } + } + } + + public function getOCSStatus() { + return parent::getStatus(); + } +} diff --git a/lib/private/AppFramework/OCS/V1Response.php b/lib/private/AppFramework/OCS/V1Response.php new file mode 100644 index 00000000000..1c2c25f5cb0 --- /dev/null +++ b/lib/private/AppFramework/OCS/V1Response.php @@ -0,0 +1,68 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\AppFramework\OCS; + +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCSController; + +/** + * @psalm-import-type DataResponseType from DataResponse + * @template S of Http::STATUS_* + * @template-covariant T of DataResponseType + * @template H of array<string, mixed> + * @template-extends BaseResponse<Http::STATUS_*, DataResponseType, array<string, mixed>> + */ +class V1Response extends BaseResponse { + /** + * The V1 endpoint has very limited http status codes basically everything + * is status 200 except 401 + * + * @return Http::STATUS_* + */ + public function getStatus() { + $status = parent::getStatus(); + if ($status === OCSController::RESPOND_UNAUTHORISED) { + return Http::STATUS_UNAUTHORIZED; + } + + return Http::STATUS_OK; + } + + /** + * In v1 all OK is 100 + * + * @return int + */ + public function getOCSStatus() { + $status = parent::getOCSStatus(); + + if ($status === Http::STATUS_OK) { + return 100; + } + + return $status; + } + + /** + * Construct the meta part of the response + * And then late the base class render + * + * @return string + */ + public function render() { + $meta = [ + 'status' => $this->getOCSStatus() === 100 ? 'ok' : 'failure', + 'statuscode' => $this->getOCSStatus(), + 'message' => $this->getOCSStatus() === 100 ? 'OK' : $this->statusMessage ?? '', + 'totalitems' => (string)($this->itemsCount ?? ''), + 'itemsperpage' => (string)($this->itemsPerPage ?? ''), + ]; + + return $this->renderResult($meta); + } +} diff --git a/lib/private/AppFramework/OCS/V2Response.php b/lib/private/AppFramework/OCS/V2Response.php new file mode 100644 index 00000000000..efc9348eb37 --- /dev/null +++ b/lib/private/AppFramework/OCS/V2Response.php @@ -0,0 +1,66 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\AppFramework\OCS; + +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCSController; + +/** + * @psalm-import-type DataResponseType from DataResponse + * @template S of Http::STATUS_* + * @template-covariant T of DataResponseType + * @template H of array<string, mixed> + * @template-extends BaseResponse<Http::STATUS_*, DataResponseType, array<string, mixed>> + */ +class V2Response extends BaseResponse { + /** + * The V2 endpoint just passes on status codes. + * Of course we have to map the OCS specific codes to proper HTTP status codes + * + * @return Http::STATUS_* + */ + public function getStatus() { + $status = parent::getStatus(); + if ($status === OCSController::RESPOND_UNAUTHORISED) { + return Http::STATUS_UNAUTHORIZED; + } elseif ($status === OCSController::RESPOND_NOT_FOUND) { + return Http::STATUS_NOT_FOUND; + } elseif ($status === OCSController::RESPOND_SERVER_ERROR || $status === OCSController::RESPOND_UNKNOWN_ERROR) { + return Http::STATUS_INTERNAL_SERVER_ERROR; + } elseif ($status < 200 || $status > 600) { + return Http::STATUS_BAD_REQUEST; + } + + return $status; + } + + /** + * Construct the meta part of the response + * And then late the base class render + * + * @return string + */ + public function render() { + $status = parent::getStatus(); + + $meta = [ + 'status' => $status >= 200 && $status < 300 ? 'ok' : 'failure', + 'statuscode' => $this->getOCSStatus(), + 'message' => $status >= 200 && $status < 300 ? 'OK' : $this->statusMessage ?? '', + ]; + + if ($this->itemsCount !== null) { + $meta['totalitems'] = $this->itemsCount; + } + if ($this->itemsPerPage !== null) { + $meta['itemsperpage'] = $this->itemsPerPage; + } + + return $this->renderResult($meta); + } +} diff --git a/lib/private/AppFramework/Routing/RouteActionHandler.php b/lib/private/AppFramework/Routing/RouteActionHandler.php new file mode 100644 index 00000000000..ec38105a5a0 --- /dev/null +++ b/lib/private/AppFramework/Routing/RouteActionHandler.php @@ -0,0 +1,31 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\AppFramework\Routing; + +use OC\AppFramework\App; +use OC\AppFramework\DependencyInjection\DIContainer; + +class RouteActionHandler { + private $controllerName; + private $actionName; + private $container; + + /** + * @param string $controllerName + * @param string $actionName + */ + public function __construct(DIContainer $container, $controllerName, $actionName) { + $this->controllerName = $controllerName; + $this->actionName = $actionName; + $this->container = $container; + } + + public function __invoke($params) { + App::main($this->controllerName, $this->actionName, $this->container, $params); + } +} diff --git a/lib/private/AppFramework/Routing/RouteParser.php b/lib/private/AppFramework/Routing/RouteParser.php new file mode 100644 index 00000000000..55e58234673 --- /dev/null +++ b/lib/private/AppFramework/Routing/RouteParser.php @@ -0,0 +1,253 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\AppFramework\Routing; + +use OC\Route\Route; +use Symfony\Component\Routing\RouteCollection; + +class RouteParser { + /** @var string[] */ + private $controllerNameCache = []; + + private const rootUrlApps = [ + 'cloud_federation_api', + 'core', + 'files_sharing', + 'files', + 'profile', + 'settings', + 'spreed', + ]; + + public function parseDefaultRoutes(array $routes, string $appName): RouteCollection { + $collection = $this->processIndexRoutes($routes, $appName); + $collection->addCollection($this->processIndexResources($routes, $appName)); + + return $collection; + } + + public function parseOCSRoutes(array $routes, string $appName): RouteCollection { + $collection = $this->processOCS($routes, $appName); + $collection->addCollection($this->processOCSResources($routes, $appName)); + + return $collection; + } + + private function processOCS(array $routes, string $appName): RouteCollection { + $collection = new RouteCollection(); + $ocsRoutes = $routes['ocs'] ?? []; + foreach ($ocsRoutes as $ocsRoute) { + $result = $this->processRoute($ocsRoute, $appName, 'ocs.'); + + $collection->add($result[0], $result[1]); + } + + return $collection; + } + + /** + * Creates one route base on the give configuration + * @param array $routes + * @throws \UnexpectedValueException + */ + private function processIndexRoutes(array $routes, string $appName): RouteCollection { + $collection = new RouteCollection(); + $simpleRoutes = $routes['routes'] ?? []; + foreach ($simpleRoutes as $simpleRoute) { + $result = $this->processRoute($simpleRoute, $appName); + + $collection->add($result[0], $result[1]); + } + + return $collection; + } + + private function processRoute(array $route, string $appName, string $routeNamePrefix = ''): array { + $name = $route['name']; + $postfix = $route['postfix'] ?? ''; + $root = $this->buildRootPrefix($route, $appName, $routeNamePrefix); + + $url = $root . '/' . ltrim($route['url'], '/'); + $verb = strtoupper($route['verb'] ?? 'GET'); + + $split = explode('#', $name, 3); + if (count($split) !== 2) { + throw new \UnexpectedValueException('Invalid route name: use the format foo#bar to reference FooController::bar'); + } + [$controller, $action] = $split; + + $controllerName = $this->buildControllerName($controller); + $actionName = $this->buildActionName($action); + + /* + * The route name has to be lowercase, for symfony to match it correctly. + * This is required because symfony allows mixed casing for controller names in the routes. + * To avoid breaking all the existing route names, registering and matching will only use the lowercase names. + * This is also safe on the PHP side because class and method names collide regardless of the casing. + */ + $routeName = strtolower($routeNamePrefix . $appName . '.' . $controller . '.' . $action . $postfix); + + $routeObject = new Route($url); + $routeObject->method($verb); + + // optionally register requirements for route. This is used to + // tell the route parser how url parameters should be matched + if (array_key_exists('requirements', $route)) { + $routeObject->requirements($route['requirements']); + } + + // optionally register defaults for route. This is used to + // tell the route parser how url parameters should be default valued + $defaults = []; + if (array_key_exists('defaults', $route)) { + $defaults = $route['defaults']; + } + + $defaults['caller'] = [$appName, $controllerName, $actionName]; + $routeObject->defaults($defaults); + + return [$routeName, $routeObject]; + } + + /** + * For a given name and url restful OCS routes are created: + * - index + * - show + * - create + * - update + * - destroy + * + * @param array $routes + */ + private function processOCSResources(array $routes, string $appName): RouteCollection { + return $this->processResources($routes['ocs-resources'] ?? [], $appName, 'ocs.'); + } + + /** + * For a given name and url restful routes are created: + * - index + * - show + * - create + * - update + * - destroy + * + * @param array $routes + */ + private function processIndexResources(array $routes, string $appName): RouteCollection { + return $this->processResources($routes['resources'] ?? [], $appName); + } + + /** + * For a given name and url restful routes are created: + * - index + * - show + * - create + * - update + * - destroy + * + * @param array $resources + * @param string $routeNamePrefix + */ + private function processResources(array $resources, string $appName, string $routeNamePrefix = ''): RouteCollection { + // declaration of all restful actions + $actions = [ + ['name' => 'index', 'verb' => 'GET', 'on-collection' => true], + ['name' => 'show', 'verb' => 'GET'], + ['name' => 'create', 'verb' => 'POST', 'on-collection' => true], + ['name' => 'update', 'verb' => 'PUT'], + ['name' => 'destroy', 'verb' => 'DELETE'], + ]; + + $collection = new RouteCollection(); + foreach ($resources as $resource => $config) { + $root = $this->buildRootPrefix($config, $appName, $routeNamePrefix); + + // the url parameter used as id to the resource + foreach ($actions as $action) { + $url = $root . '/' . ltrim($config['url'], '/'); + $method = $action['name']; + + $verb = strtoupper($action['verb'] ?? 'GET'); + $collectionAction = $action['on-collection'] ?? false; + if (!$collectionAction) { + $url .= '/{id}'; + } + + $controller = $resource; + + $controllerName = $this->buildControllerName($controller); + $actionName = $this->buildActionName($method); + + $routeName = $routeNamePrefix . $appName . '.' . strtolower($resource) . '.' . $method; + + $route = new Route($url); + $route->method($verb); + + $route->defaults(['caller' => [$appName, $controllerName, $actionName]]); + + $collection->add($routeName, $route); + } + } + + return $collection; + } + + private function buildRootPrefix(array $route, string $appName, string $routeNamePrefix): string { + $defaultRoot = $appName === 'core' ? '' : '/apps/' . $appName; + $root = $route['root'] ?? $defaultRoot; + + if ($routeNamePrefix !== '') { + // In OCS all apps are whitelisted + return $root; + } + + if (!\in_array($appName, self::rootUrlApps, true)) { + // Only allow root URLS for some apps + return $defaultRoot; + } + + return $root; + } + + /** + * Based on a given route name the controller name is generated + * @param string $controller + * @return string + */ + private function buildControllerName(string $controller): string { + if (!isset($this->controllerNameCache[$controller])) { + $this->controllerNameCache[$controller] = $this->underScoreToCamelCase(ucfirst($controller)) . 'Controller'; + } + return $this->controllerNameCache[$controller]; + } + + /** + * Based on the action part of the route name the controller method name is generated + * @param string $action + * @return string + */ + private function buildActionName(string $action): string { + return $this->underScoreToCamelCase($action); + } + + /** + * Underscored strings are converted to camel case strings + * @param string $str + * @return string + */ + private function underScoreToCamelCase(string $str): string { + $pattern = '/_[a-z]?/'; + return preg_replace_callback( + $pattern, + function ($matches) { + return strtoupper(ltrim($matches[0], '_')); + }, + $str); + } +} diff --git a/lib/private/AppFramework/ScopedPsrLogger.php b/lib/private/AppFramework/ScopedPsrLogger.php new file mode 100644 index 00000000000..0a8e2b0d303 --- /dev/null +++ b/lib/private/AppFramework/ScopedPsrLogger.php @@ -0,0 +1,135 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\AppFramework; + +use Psr\Log\LoggerInterface; +use function array_merge; + +class ScopedPsrLogger implements LoggerInterface { + /** @var LoggerInterface */ + private $inner; + + /** @var string */ + private $appId; + + public function __construct(LoggerInterface $inner, + string $appId) { + $this->inner = $inner; + $this->appId = $appId; + } + + public function emergency($message, array $context = []): void { + $this->inner->emergency( + $message, + array_merge( + [ + 'app' => $this->appId, + ], + $context + ) + ); + } + + public function alert($message, array $context = []): void { + $this->inner->alert( + $message, + array_merge( + [ + 'app' => $this->appId, + ], + $context + ) + ); + } + + public function critical($message, array $context = []): void { + $this->inner->critical( + $message, + array_merge( + [ + 'app' => $this->appId, + ], + $context + ) + ); + } + + public function error($message, array $context = []): void { + $this->inner->error( + $message, + array_merge( + [ + 'app' => $this->appId, + ], + $context + ) + ); + } + + public function warning($message, array $context = []): void { + $this->inner->warning( + $message, + array_merge( + [ + 'app' => $this->appId, + ], + $context + ) + ); + } + + public function notice($message, array $context = []): void { + $this->inner->notice( + $message, + array_merge( + [ + 'app' => $this->appId, + ], + $context + ) + ); + } + + public function info($message, array $context = []): void { + $this->inner->info( + $message, + array_merge( + [ + 'app' => $this->appId, + ], + $context + ) + ); + } + + public function debug($message, array $context = []): void { + $this->inner->debug( + $message, + array_merge( + [ + 'app' => $this->appId, + ], + $context + ) + ); + } + + public function log($level, $message, array $context = []): void { + $this->inner->log( + $level, + $message, + array_merge( + [ + 'app' => $this->appId, + ], + $context + ) + ); + } +} diff --git a/lib/private/AppFramework/Services/AppConfig.php b/lib/private/AppFramework/Services/AppConfig.php new file mode 100644 index 00000000000..04d97738483 --- /dev/null +++ b/lib/private/AppFramework/Services/AppConfig.php @@ -0,0 +1,349 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\AppFramework\Services; + +use InvalidArgumentException; +use JsonException; +use OCP\AppFramework\Services\IAppConfig; +use OCP\Exceptions\AppConfigTypeConflictException; +use OCP\Exceptions\AppConfigUnknownKeyException; +use OCP\IConfig; + +class AppConfig implements IAppConfig { + public function __construct( + private IConfig $config, + /** @var \OC\AppConfig */ + private \OCP\IAppConfig $appConfig, + private string $appName, + ) { + } + + /** + * @inheritDoc + * + * @return string[] list of stored config keys + * @since 20.0.0 + */ + public function getAppKeys(): array { + return $this->appConfig->getKeys($this->appName); + } + + /** + * @inheritDoc + * + * @param string $key config key + * @param bool|null $lazy TRUE to search within lazy loaded config, NULL to search within all config + * + * @return bool TRUE if key exists + * @since 29.0.0 + */ + public function hasAppKey(string $key, ?bool $lazy = false): bool { + return $this->appConfig->hasKey($this->appName, $key, $lazy); + } + + /** + * @param string $key config key + * @param bool|null $lazy TRUE to search within lazy loaded config, NULL to search within all config + * + * @return bool + * @throws AppConfigUnknownKeyException if config key is not known + * @since 29.0.0 + */ + public function isSensitive(string $key, ?bool $lazy = false): bool { + return $this->appConfig->isSensitive($this->appName, $key, $lazy); + } + + /** + * @inheritDoc + * + * @param string $key config key + * + * @return bool TRUE if config is lazy loaded + * @throws AppConfigUnknownKeyException if config key is not known + * @see \OCP\IAppConfig for details about lazy loading + * @since 29.0.0 + */ + public function isLazy(string $key): bool { + return $this->appConfig->isLazy($this->appName, $key); + } + + /** + * @inheritDoc + * + * @param string $key config keys prefix to search + * @param bool $filtered TRUE to hide sensitive config values. Value are replaced by {@see IConfig::SENSITIVE_VALUE} + * + * @return array<string, string|int|float|bool|array> [configKey => configValue] + * @since 29.0.0 + */ + public function getAllAppValues(string $key = '', bool $filtered = false): array { + return $this->appConfig->getAllValues($this->appName, $key, $filtered); + } + + /** + * @inheritDoc + * + * @param string $key the key of the value, under which will be saved + * @param string $value the value that should be stored + * @since 20.0.0 + * @deprecated 29.0.0 use {@see setAppValueString()} + */ + public function setAppValue(string $key, string $value): void { + /** @psalm-suppress InternalMethod */ + $this->appConfig->setValueMixed($this->appName, $key, $value); + } + + /** + * @inheritDoc + * + * @param string $key config key + * @param string $value config value + * @param bool $lazy set config as lazy loaded + * @param bool $sensitive if TRUE value will be hidden when listing config values. + * + * @return bool TRUE if value was different, therefor updated in database + * @throws AppConfigTypeConflictException if type from database is not VALUE_MIXED and different from the requested one + * @since 29.0.0 + * @see \OCP\IAppConfig for explanation about lazy loading + */ + public function setAppValueString( + string $key, + string $value, + bool $lazy = false, + bool $sensitive = false, + ): bool { + return $this->appConfig->setValueString($this->appName, $key, $value, $lazy, $sensitive); + } + + /** + * @inheritDoc + * + * @param string $key config key + * @param int $value config value + * @param bool $lazy set config as lazy loaded + * @param bool $sensitive if TRUE value will be hidden when listing config values. + * + * @return bool TRUE if value was different, therefor updated in database + * @throws AppConfigTypeConflictException if type from database is not VALUE_MIXED and different from the requested one + * @since 29.0.0 + * @see \OCP\IAppConfig for explanation about lazy loading + */ + public function setAppValueInt( + string $key, + int $value, + bool $lazy = false, + bool $sensitive = false, + ): bool { + return $this->appConfig->setValueInt($this->appName, $key, $value, $lazy, $sensitive); + } + + /** + * @inheritDoc + * + * @param string $key config key + * @param float $value config value + * @param bool $lazy set config as lazy loaded + * @param bool $sensitive if TRUE value will be hidden when listing config values. + * + * @return bool TRUE if value was different, therefor updated in database + * @throws AppConfigTypeConflictException if type from database is not VALUE_MIXED and different from the requested one + * @since 29.0.0 + * @see \OCP\IAppConfig for explanation about lazy loading + */ + public function setAppValueFloat( + string $key, + float $value, + bool $lazy = false, + bool $sensitive = false, + ): bool { + return $this->appConfig->setValueFloat($this->appName, $key, $value, $lazy, $sensitive); + } + + /** + * @inheritDoc + * + * @param string $key config key + * @param bool $value config value + * @param bool $lazy set config as lazy loaded + * + * @return bool TRUE if value was different, therefor updated in database + * @throws AppConfigTypeConflictException if type from database is not VALUE_MIXED and different from the requested one + * @since 29.0.0 + * @see \OCP\IAppConfig for explanation about lazy loading + */ + public function setAppValueBool( + string $key, + bool $value, + bool $lazy = false, + ): bool { + return $this->appConfig->setValueBool($this->appName, $key, $value, $lazy); + } + + /** + * @inheritDoc + * + * @param string $key config key + * @param array $value config value + * @param bool $lazy set config as lazy loaded + * @param bool $sensitive if TRUE value will be hidden when listing config values. + * + * @return bool TRUE if value was different, therefor updated in database + * @throws AppConfigTypeConflictException if type from database is not VALUE_MIXED and different from the requested one + * @throws JsonException + * @since 29.0.0 + * @see \OCP\IAppConfig for explanation about lazy loading + */ + public function setAppValueArray( + string $key, + array $value, + bool $lazy = false, + bool $sensitive = false, + ): bool { + return $this->appConfig->setValueArray($this->appName, $key, $value, $lazy, $sensitive); + } + + /** + * @param string $key + * @param string $default + * + * @since 20.0.0 + * @deprecated 29.0.0 use {@see getAppValueString()} + * @return string + */ + public function getAppValue(string $key, string $default = ''): string { + /** @psalm-suppress InternalMethod */ + /** @psalm-suppress UndefinedInterfaceMethod */ + return $this->appConfig->getValueMixed($this->appName, $key, $default); + } + + /** + * @inheritDoc + * + * @param string $key config key + * @param string $default default value + * @param bool $lazy search within lazy loaded config + * + * @return string stored config value or $default if not set in database + * @throws InvalidArgumentException if one of the argument format is invalid + * @throws AppConfigTypeConflictException in case of conflict with the value type set in database + * @since 29.0.0 + * @see \OCP\IAppConfig for explanation about lazy loading + */ + public function getAppValueString(string $key, string $default = '', bool $lazy = false): string { + return $this->appConfig->getValueString($this->appName, $key, $default, $lazy); + } + + /** + * @inheritDoc + * + * @param string $key config key + * @param int $default default value + * @param bool $lazy search within lazy loaded config + * + * @return int stored config value or $default if not set in database + * @throws InvalidArgumentException if one of the argument format is invalid + * @throws AppConfigTypeConflictException in case of conflict with the value type set in database + * @since 29.0.0 + * @see \OCP\IAppConfig for explanation about lazy loading + */ + public function getAppValueInt(string $key, int $default = 0, bool $lazy = false): int { + return $this->appConfig->getValueInt($this->appName, $key, $default, $lazy); + } + + /** + * @inheritDoc + * + * @param string $key config key + * @param float $default default value + * @param bool $lazy search within lazy loaded config + * + * @return float stored config value or $default if not set in database + * @throws InvalidArgumentException if one of the argument format is invalid + * @throws AppConfigTypeConflictException in case of conflict with the value type set in database + * @since 29.0.0 + * @see \OCP\IAppConfig for explanation about lazy loading + */ + public function getAppValueFloat(string $key, float $default = 0, bool $lazy = false): float { + return $this->appConfig->getValueFloat($this->appName, $key, $default, $lazy); + } + + /** + * @inheritDoc + * + * @param string $key config key + * @param bool $default default value + * @param bool $lazy search within lazy loaded config + * + * @return bool stored config value or $default if not set in database + * @throws InvalidArgumentException if one of the argument format is invalid + * @throws AppConfigTypeConflictException in case of conflict with the value type set in database + * @since 29.0.0 + * @see \OCP\IAppConfig for explanation about lazy loading + */ + public function getAppValueBool(string $key, bool $default = false, bool $lazy = false): bool { + return $this->appConfig->getValueBool($this->appName, $key, $default, $lazy); + } + + /** + * @inheritDoc + * + * @param string $key config key + * @param array $default default value + * @param bool $lazy search within lazy loaded config + * + * @return array stored config value or $default if not set in database + * @throws InvalidArgumentException if one of the argument format is invalid + * @throws AppConfigTypeConflictException in case of conflict with the value type set in database + * @since 29.0.0 + * @see \OCP\IAppConfig for explanation about lazy loading + */ + public function getAppValueArray(string $key, array $default = [], bool $lazy = false): array { + return $this->appConfig->getValueArray($this->appName, $key, $default, $lazy); + } + + /** + * @inheritDoc + * + * @param string $key the key of the value, under which it was saved + * @since 20.0.0 + */ + public function deleteAppValue(string $key): void { + $this->appConfig->deleteKey($this->appName, $key); + } + + /** + * @inheritDoc + * + * @since 20.0.0 + */ + public function deleteAppValues(): void { + $this->appConfig->deleteApp($this->appName); + } + + public function setUserValue(string $userId, string $key, string $value, ?string $preCondition = null): void { + $this->config->setUserValue($userId, $this->appName, $key, $value, $preCondition); + } + + public function getUserValue(string $userId, string $key, string $default = ''): string { + return $this->config->getUserValue($userId, $this->appName, $key, $default); + } + + public function deleteUserValue(string $userId, string $key): void { + $this->config->deleteUserValue($userId, $this->appName, $key); + } + + /** + * Returns the installed versions of all apps + * + * @return array<string, string> + */ + public function getAppInstalledVersions(bool $onlyEnabled = false): array { + return $this->appConfig->getAppInstalledVersions($onlyEnabled); + } +} diff --git a/lib/private/AppFramework/Services/InitialState.php b/lib/private/AppFramework/Services/InitialState.php new file mode 100644 index 00000000000..da225b612cf --- /dev/null +++ b/lib/private/AppFramework/Services/InitialState.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\AppFramework\Services; + +use OCP\AppFramework\Services\IInitialState; +use OCP\IInitialStateService; + +class InitialState implements IInitialState { + /** @var IInitialStateService */ + private $state; + + /** @var string */ + private $appName; + + public function __construct(IInitialStateService $state, string $appName) { + $this->state = $state; + $this->appName = $appName; + } + + public function provideInitialState(string $key, $data): void { + $this->state->provideInitialState($this->appName, $key, $data); + } + + public function provideLazyInitialState(string $key, \Closure $closure): void { + $this->state->provideLazyInitialState($this->appName, $key, $closure); + } +} diff --git a/lib/private/AppFramework/Utility/ControllerMethodReflector.php b/lib/private/AppFramework/Utility/ControllerMethodReflector.php new file mode 100644 index 00000000000..679e1788004 --- /dev/null +++ b/lib/private/AppFramework/Utility/ControllerMethodReflector.php @@ -0,0 +1,137 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\AppFramework\Utility; + +use OCP\AppFramework\Utility\IControllerMethodReflector; + +/** + * Reads and parses annotations from doc comments + */ +class ControllerMethodReflector implements IControllerMethodReflector { + public $annotations = []; + private $types = []; + private $parameters = []; + private array $ranges = []; + + /** + * @param object $object an object or classname + * @param string $method the method which we want to inspect + */ + public function reflect($object, string $method) { + $reflection = new \ReflectionMethod($object, $method); + $docs = $reflection->getDocComment(); + + if ($docs !== false) { + // extract everything prefixed by @ and first letter uppercase + preg_match_all('/^\h+\*\h+@(?P<annotation>[A-Z]\w+)((?P<parameter>.*))?$/m', $docs, $matches); + foreach ($matches['annotation'] as $key => $annotation) { + $annotation = strtolower($annotation); + $annotationValue = $matches['parameter'][$key]; + if (str_starts_with($annotationValue, '(') && str_ends_with($annotationValue, ')')) { + $cutString = substr($annotationValue, 1, -1); + $cutString = str_replace(' ', '', $cutString); + $splitArray = explode(',', $cutString); + foreach ($splitArray as $annotationValues) { + [$key, $value] = explode('=', $annotationValues); + $this->annotations[$annotation][$key] = $value; + } + continue; + } + + $this->annotations[$annotation] = [$annotationValue]; + } + + // extract type parameter information + preg_match_all('/@param\h+(?P<type>\w+)\h+\$(?P<var>\w+)/', $docs, $matches); + $this->types = array_combine($matches['var'], $matches['type']); + preg_match_all('/@psalm-param\h+(\?)?(?P<type>\w+)<(?P<rangeMin>(-?\d+|min)),\h*(?P<rangeMax>(-?\d+|max))>(\|null)?\h+\$(?P<var>\w+)/', $docs, $matches); + foreach ($matches['var'] as $index => $varName) { + if ($matches['type'][$index] !== 'int') { + // only int ranges are possible at the moment + // @see https://psalm.dev/docs/annotating_code/type_syntax/scalar_types + continue; + } + $this->ranges[$varName] = [ + 'min' => $matches['rangeMin'][$index] === 'min' ? PHP_INT_MIN : (int)$matches['rangeMin'][$index], + 'max' => $matches['rangeMax'][$index] === 'max' ? PHP_INT_MAX : (int)$matches['rangeMax'][$index], + ]; + } + } + + foreach ($reflection->getParameters() as $param) { + // extract type information from PHP 7 scalar types and prefer them over phpdoc annotations + $type = $param->getType(); + if ($type instanceof \ReflectionNamedType) { + $this->types[$param->getName()] = $type->getName(); + } + + $default = null; + if ($param->isOptional()) { + $default = $param->getDefaultValue(); + } + $this->parameters[$param->name] = $default; + } + } + + /** + * Inspects the PHPDoc parameters for types + * @param string $parameter the parameter whose type comments should be + * parsed + * @return string|null type in the type parameters (@param int $something) + * would return int or null if not existing + */ + public function getType(string $parameter) { + if (array_key_exists($parameter, $this->types)) { + return $this->types[$parameter]; + } + + return null; + } + + public function getRange(string $parameter): ?array { + if (array_key_exists($parameter, $this->ranges)) { + return $this->ranges[$parameter]; + } + + return null; + } + + /** + * @return array the arguments of the method with key => default value + */ + public function getParameters(): array { + return $this->parameters; + } + + /** + * Check if a method contains an annotation + * @param string $name the name of the annotation + * @return bool true if the annotation is found + */ + public function hasAnnotation(string $name): bool { + $name = strtolower($name); + return array_key_exists($name, $this->annotations); + } + + /** + * Get optional annotation parameter by key + * + * @param string $name the name of the annotation + * @param string $key the string of the annotation + * @return string + */ + public function getAnnotationParameter(string $name, string $key): string { + $name = strtolower($name); + if (isset($this->annotations[$name][$key])) { + return $this->annotations[$name][$key]; + } + + return ''; + } +} diff --git a/lib/private/AppFramework/Utility/QueryNotFoundException.php b/lib/private/AppFramework/Utility/QueryNotFoundException.php new file mode 100644 index 00000000000..4d2df14ebc8 --- /dev/null +++ b/lib/private/AppFramework/Utility/QueryNotFoundException.php @@ -0,0 +1,24 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\AppFramework\Utility; + +use OCP\AppFramework\QueryException; +use Psr\Container\NotFoundExceptionInterface; + +/** + * Private implementation of the `Psr\Container\NotFoundExceptionInterface` + * + * QueryNotFoundException is a simple wrapper over the `QueryException` + * to fulfill the PSR Container interface. + * + * You should not catch this class directly but the `NotFoundExceptionInterface`. + */ +class QueryNotFoundException extends QueryException implements NotFoundExceptionInterface { +} diff --git a/lib/private/AppFramework/Utility/SimpleContainer.php b/lib/private/AppFramework/Utility/SimpleContainer.php new file mode 100644 index 00000000000..0db3bfc1c77 --- /dev/null +++ b/lib/private/AppFramework/Utility/SimpleContainer.php @@ -0,0 +1,250 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\AppFramework\Utility; + +use ArrayAccess; +use Closure; +use OCP\AppFramework\QueryException; +use OCP\IContainer; +use Pimple\Container; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; +use ReflectionClass; +use ReflectionException; +use ReflectionNamedType; +use ReflectionParameter; +use function class_exists; + +/** + * SimpleContainer is a simple implementation of a container on basis of Pimple + */ +class SimpleContainer implements ArrayAccess, ContainerInterface, IContainer { + public static bool $useLazyObjects = false; + + private Container $container; + + public function __construct() { + $this->container = new Container(); + } + + /** + * @template T + * @param class-string<T>|string $id + * @return T|mixed + * @psalm-template S as class-string<T>|string + * @psalm-param S $id + * @psalm-return (S is class-string<T> ? T : mixed) + */ + public function get(string $id): mixed { + return $this->query($id); + } + + public function has(string $id): bool { + // If a service is no registered but is an existing class, we can probably load it + return isset($this->container[$id]) || class_exists($id); + } + + /** + * @param ReflectionClass $class the class to instantiate + * @return object the created class + * @suppress PhanUndeclaredClassInstanceof + */ + private function buildClass(ReflectionClass $class): object { + $constructor = $class->getConstructor(); + if ($constructor === null) { + /* No constructor, return a instance directly */ + return $class->newInstance(); + } + if (PHP_VERSION_ID >= 80400 && self::$useLazyObjects) { + /* For PHP>=8.4, use a lazy ghost to delay constructor and dependency resolving */ + /** @psalm-suppress UndefinedMethod */ + return $class->newLazyGhost(function (object $object) use ($constructor): void { + /** @psalm-suppress DirectConstructorCall For lazy ghosts we have to call the constructor directly */ + $object->__construct(...$this->buildClassConstructorParameters($constructor)); + }); + } else { + return $class->newInstanceArgs($this->buildClassConstructorParameters($constructor)); + } + } + + private function buildClassConstructorParameters(\ReflectionMethod $constructor): array { + return array_map(function (ReflectionParameter $parameter) { + $parameterType = $parameter->getType(); + + $resolveName = $parameter->getName(); + + // try to find out if it is a class or a simple parameter + if ($parameterType !== null && ($parameterType instanceof ReflectionNamedType) && !$parameterType->isBuiltin()) { + $resolveName = $parameterType->getName(); + } + + try { + $builtIn = $parameterType !== null && ($parameterType instanceof ReflectionNamedType) + && $parameterType->isBuiltin(); + return $this->query($resolveName, !$builtIn); + } catch (ContainerExceptionInterface $e) { + // Service not found, use the default value when available + if ($parameter->isDefaultValueAvailable()) { + return $parameter->getDefaultValue(); + } + + if ($parameterType !== null && ($parameterType instanceof ReflectionNamedType) && !$parameterType->isBuiltin()) { + $resolveName = $parameter->getName(); + try { + return $this->query($resolveName); + } catch (ContainerExceptionInterface $e2) { + // Pass null if typed and nullable + if ($parameter->allowsNull() && ($parameterType instanceof ReflectionNamedType)) { + return null; + } + + // don't lose the error we got while trying to query by type + throw new QueryException($e->getMessage(), (int)$e->getCode(), $e); + } + } + + throw $e; + } + }, $constructor->getParameters()); + } + + public function resolve($name) { + $baseMsg = 'Could not resolve ' . $name . '!'; + try { + $class = new ReflectionClass($name); + if ($class->isInstantiable()) { + return $this->buildClass($class); + } else { + throw new QueryException($baseMsg + . ' Class can not be instantiated'); + } + } catch (ReflectionException $e) { + // Class does not exist + throw new QueryNotFoundException($baseMsg . ' ' . $e->getMessage()); + } + } + + public function query(string $name, bool $autoload = true) { + $name = $this->sanitizeName($name); + if (isset($this->container[$name])) { + return $this->container[$name]; + } + + if ($autoload) { + $object = $this->resolve($name); + $this->registerService($name, function () use ($object) { + return $object; + }); + return $object; + } + + throw new QueryNotFoundException('Could not resolve ' . $name . '!'); + } + + /** + * @param string $name + * @param mixed $value + */ + public function registerParameter($name, $value) { + $this[$name] = $value; + } + + /** + * The given closure is call the first time the given service is queried. + * The closure has to return the instance for the given service. + * Created instance will be cached in case $shared is true. + * + * @param string $name name of the service to register another backend for + * @param Closure $closure the closure to be called on service creation + * @param bool $shared + */ + public function registerService($name, Closure $closure, $shared = true) { + $wrapped = function () use ($closure) { + return $closure($this); + }; + $name = $this->sanitizeName($name); + if (isset($this->container[$name])) { + unset($this->container[$name]); + } + if ($shared) { + $this->container[$name] = $wrapped; + } else { + $this->container[$name] = $this->container->factory($wrapped); + } + } + + /** + * Shortcut for returning a service from a service under a different key, + * e.g. to tell the container to return a class when queried for an + * interface + * @param string $alias the alias that should be registered + * @param string $target the target that should be resolved instead + */ + public function registerAlias($alias, $target): void { + $this->registerService($alias, function (ContainerInterface $container) use ($target): mixed { + return $container->get($target); + }, false); + } + + protected function registerDeprecatedAlias(string $alias, string $target): void { + $this->registerService($alias, function (ContainerInterface $container) use ($target, $alias): mixed { + try { + $logger = $container->get(LoggerInterface::class); + $logger->debug('The requested alias "' . $alias . '" is deprecated. Please request "' . $target . '" directly. This alias will be removed in a future Nextcloud version.', [ + 'app' => $this->appName ?? 'serverDI', + ]); + } catch (ContainerExceptionInterface $e) { + // Could not get logger. Continue + } + + return $container->get($target); + }, false); + } + + /** + * @param string $name + * @return string + */ + protected function sanitizeName($name) { + if (isset($name[0]) && $name[0] === '\\') { + return ltrim($name, '\\'); + } + return $name; + } + + /** + * @deprecated 20.0.0 use \Psr\Container\ContainerInterface::has + */ + public function offsetExists($id): bool { + return $this->container->offsetExists($id); + } + + /** + * @deprecated 20.0.0 use \Psr\Container\ContainerInterface::get + * @return mixed + */ + #[\ReturnTypeWillChange] + public function offsetGet($id) { + return $this->container->offsetGet($id); + } + + /** + * @deprecated 20.0.0 use \OCP\IContainer::registerService + */ + public function offsetSet($offset, $value): void { + $this->container->offsetSet($offset, $value); + } + + /** + * @deprecated 20.0.0 + */ + public function offsetUnset($offset): void { + $this->container->offsetUnset($offset); + } +} diff --git a/lib/private/AppFramework/Utility/TimeFactory.php b/lib/private/AppFramework/Utility/TimeFactory.php new file mode 100644 index 00000000000..0584fd05ef9 --- /dev/null +++ b/lib/private/AppFramework/Utility/TimeFactory.php @@ -0,0 +1,63 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\AppFramework\Utility; + +use OCP\AppFramework\Utility\ITimeFactory; + +/** + * Use this to get a timestamp or DateTime object in code to remain testable + * + * @since 8.0.0 + * @since 27.0.0 Implements the \Psr\Clock\ClockInterface interface + * @ref https://www.php-fig.org/psr/psr-20/#21-clockinterface + */ +class TimeFactory implements ITimeFactory { + protected \DateTimeZone $timezone; + + public function __construct() { + $this->timezone = new \DateTimeZone('UTC'); + } + + /** + * @return int the result of a call to time() + * @since 8.0.0 + * @deprecated 26.0.0 {@see ITimeFactory::now()} + */ + public function getTime(): int { + return time(); + } + + /** + * @param string $time + * @param \DateTimeZone $timezone + * @return \DateTime + * @since 15.0.0 + * @deprecated 26.0.0 {@see ITimeFactory::now()} + */ + public function getDateTime(string $time = 'now', ?\DateTimeZone $timezone = null): \DateTime { + return new \DateTime($time, $timezone); + } + + public function now(): \DateTimeImmutable { + return new \DateTimeImmutable('now', $this->timezone); + } + public function withTimeZone(\DateTimeZone $timezone): static { + $clone = clone $this; + $clone->timezone = $timezone; + + return $clone; + } + + public function getTimeZone(?string $timezone = null): \DateTimeZone { + if ($timezone !== null) { + return new \DateTimeZone($timezone); + } + return $this->timezone; + } +} |