aboutsummaryrefslogtreecommitdiffstats
path: root/lib/private/AppFramework
diff options
context:
space:
mode:
Diffstat (limited to 'lib/private/AppFramework')
-rw-r--r--lib/private/AppFramework/App.php217
-rw-r--r--lib/private/AppFramework/Bootstrap/ARegistration.php28
-rw-r--r--lib/private/AppFramework/Bootstrap/BootContext.php34
-rw-r--r--lib/private/AppFramework/Bootstrap/Coordinator.php190
-rw-r--r--lib/private/AppFramework/Bootstrap/EventListenerRegistration.php44
-rw-r--r--lib/private/AppFramework/Bootstrap/FunctionInjector.php51
-rw-r--r--lib/private/AppFramework/Bootstrap/MiddlewareRegistration.php29
-rw-r--r--lib/private/AppFramework/Bootstrap/ParameterRegistration.php39
-rw-r--r--lib/private/AppFramework/Bootstrap/PreviewProviderRegistration.php30
-rw-r--r--lib/private/AppFramework/Bootstrap/RegistrationContext.php1034
-rw-r--r--lib/private/AppFramework/Bootstrap/ServiceAliasRegistration.php52
-rw-r--r--lib/private/AppFramework/Bootstrap/ServiceFactoryRegistration.php54
-rw-r--r--lib/private/AppFramework/Bootstrap/ServiceRegistration.php36
-rw-r--r--lib/private/AppFramework/DependencyInjection/DIContainer.php364
-rw-r--r--lib/private/AppFramework/Http.php108
-rw-r--r--lib/private/AppFramework/Http/Dispatcher.php249
-rw-r--r--lib/private/AppFramework/Http/Output.php84
-rw-r--r--lib/private/AppFramework/Http/Request.php880
-rw-r--r--lib/private/AppFramework/Http/RequestId.php36
-rw-r--r--lib/private/AppFramework/Middleware/AdditionalScriptsMiddleware.php40
-rw-r--r--lib/private/AppFramework/Middleware/CompressionMiddleware.php72
-rw-r--r--lib/private/AppFramework/Middleware/FlowV2EphemeralSessionsMiddleware.php64
-rw-r--r--lib/private/AppFramework/Middleware/MiddlewareDispatcher.php142
-rw-r--r--lib/private/AppFramework/Middleware/NotModifiedMiddleware.php39
-rw-r--r--lib/private/AppFramework/Middleware/OCSMiddleware.php142
-rw-r--r--lib/private/AppFramework/Middleware/PublicShare/Exceptions/NeedAuthenticationException.php10
-rw-r--r--lib/private/AppFramework/Middleware/PublicShare/PublicShareMiddleware.php127
-rw-r--r--lib/private/AppFramework/Middleware/Security/BruteForceMiddleware.php137
-rw-r--r--lib/private/AppFramework/Middleware/Security/CORSMiddleware.php175
-rw-r--r--lib/private/AppFramework/Middleware/Security/CSPMiddleware.php54
-rw-r--r--lib/private/AppFramework/Middleware/Security/Exceptions/AdminIpNotAllowedException.php23
-rw-r--r--lib/private/AppFramework/Middleware/Security/Exceptions/AppNotEnabledException.php22
-rw-r--r--lib/private/AppFramework/Middleware/Security/Exceptions/CrossSiteRequestForgeryException.php22
-rw-r--r--lib/private/AppFramework/Middleware/Security/Exceptions/ExAppRequiredException.php18
-rw-r--r--lib/private/AppFramework/Middleware/Security/Exceptions/LaxSameSiteCookieFailedException.php21
-rw-r--r--lib/private/AppFramework/Middleware/Security/Exceptions/NotAdminException.php23
-rw-r--r--lib/private/AppFramework/Middleware/Security/Exceptions/NotConfirmedException.php21
-rw-r--r--lib/private/AppFramework/Middleware/Security/Exceptions/NotLoggedInException.php22
-rw-r--r--lib/private/AppFramework/Middleware/Security/Exceptions/ReloadExecutionException.php12
-rw-r--r--lib/private/AppFramework/Middleware/Security/Exceptions/SecurityException.php18
-rw-r--r--lib/private/AppFramework/Middleware/Security/Exceptions/StrictCookieMissingException.php23
-rw-r--r--lib/private/AppFramework/Middleware/Security/FeaturePolicyMiddleware.php48
-rw-r--r--lib/private/AppFramework/Middleware/Security/PasswordConfirmationMiddleware.php121
-rw-r--r--lib/private/AppFramework/Middleware/Security/RateLimitingMiddleware.php162
-rw-r--r--lib/private/AppFramework/Middleware/Security/ReloadExecutionMiddleware.php50
-rw-r--r--lib/private/AppFramework/Middleware/Security/SameSiteCookieMiddleware.php83
-rw-r--r--lib/private/AppFramework/Middleware/Security/SecurityMiddleware.php332
-rw-r--r--lib/private/AppFramework/Middleware/SessionMiddleware.php77
-rw-r--r--lib/private/AppFramework/OCS/BaseResponse.php166
-rw-r--r--lib/private/AppFramework/OCS/V1Response.php68
-rw-r--r--lib/private/AppFramework/OCS/V2Response.php66
-rw-r--r--lib/private/AppFramework/Routing/RouteActionHandler.php31
-rw-r--r--lib/private/AppFramework/Routing/RouteParser.php253
-rw-r--r--lib/private/AppFramework/ScopedPsrLogger.php135
-rw-r--r--lib/private/AppFramework/Services/AppConfig.php349
-rw-r--r--lib/private/AppFramework/Services/InitialState.php33
-rw-r--r--lib/private/AppFramework/Utility/ControllerMethodReflector.php137
-rw-r--r--lib/private/AppFramework/Utility/QueryNotFoundException.php24
-rw-r--r--lib/private/AppFramework/Utility/SimpleContainer.php250
-rw-r--r--lib/private/AppFramework/Utility/TimeFactory.php63
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;
+ }
+}