From f0251c65682667ecd42559afbfab0ea5cf8acc9a Mon Sep 17 00:00:00 2001 From: Carl Schwan Date: Tue, 24 Feb 2026 14:59:41 +0100 Subject: [PATCH 1/2] feat: frankenphp To test: 1. Install frankenphp https://frankenphp.dev/docs/#rpm-packages 2. Install some extensions: sudo dnf in php-zts-intl php-zts-apcu php-zts-pdo_sqlite php-zts-pdo php-zts-gd php-zts-zip 3. Make /var/lib/php-zts/session writable for your user 4. `frankenphp run` This is only for the classic mode for now Signed-off-by: Carl Schwan --- Caddyfile | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 Caddyfile diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000000000..5813be26a4999 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,48 @@ +localhost { + php_server { + + } + + log { + output stderr + } + + encode gzip + + redir /.well-known/carddav /remote.php/dav 301 + redir /.well-known/caldav /remote.php/dav 301 + + # Rule: Maps most RFC 8615 compliant well-known URIs to our main frontend controller (/index.php) by default + @wellKnown { + path "/.well-known/" + not { + path /.well-known/acme-challenge + path /.well-known/pki-validation + } + } + rewrite @wellKnown /index.php + + rewrite /ocm-provider/ /index.php + + @forbidden { + path /.htaccess + path /data/* + path /config/* + path /db_structure + path /.xml + path /README + path /3rdparty/* + path /lib/* + path /templates/* + path /occ + path /build + path /tests + path /console.php + path /autotest + path /issue + path /indi + path /db_ + path /console + } + respond @forbidden 404 +} From f182ec78715545882974719c988e6fade4c1c7ed Mon Sep 17 00:00:00 2001 From: Carl Schwan Date: Tue, 24 Feb 2026 16:40:33 +0100 Subject: [PATCH 2/2] feat: enabled frankenphp worker mode Signed-off-by: Carl Schwan --- Caddyfile | 3 +- cron.php | 2 + index.php | 165 +++++++++++++++++++---------------- lib/base.php | 72 ++++++++------- lib/private/Route/Router.php | 2 + lib/private/Share/Share.php | 10 ++- ocs/v1.php | 2 + remote.php | 2 + 8 files changed, 149 insertions(+), 109 deletions(-) diff --git a/Caddyfile b/Caddyfile index 5813be26a4999..b18b31297838c 100644 --- a/Caddyfile +++ b/Caddyfile @@ -1,9 +1,10 @@ localhost { php_server { - + worker index.php } log { + level ERROR output stderr } diff --git a/cron.php b/cron.php index f86d93b9a739e..4ccb8b7c63294 100644 --- a/cron.php +++ b/cron.php @@ -17,6 +17,8 @@ try { require_once __DIR__ . '/lib/base.php'; + OC::init(); + if (isset($argv[1]) && ($argv[1] === '-h' || $argv[1] === '--help')) { echo 'Description: Run the background job routine diff --git a/index.php b/index.php index b368462371d40..6dc316d32e750 100644 --- a/index.php +++ b/index.php @@ -19,90 +19,109 @@ use OCP\Template\ITemplateManager; use Psr\Log\LoggerInterface; -try { - require_once __DIR__ . '/lib/base.php'; +require_once __DIR__ . '/lib/base.php'; - OC::handleRequest(); -} catch (ServiceUnavailableException $ex) { - Server::get(LoggerInterface::class)->error($ex->getMessage(), [ - 'app' => 'index', - 'exception' => $ex, - ]); - - //show the user a detailed error page - Server::get(ITemplateManager::class)->printExceptionErrorPage($ex, 503); -} catch (HintException $ex) { +$handler = static function () { try { - Server::get(ITemplateManager::class)->printErrorPage($ex->getMessage(), $ex->getHint(), 503); - } catch (Exception $ex2) { + OC::init(); + OC::handleRequest(); + } catch (ServiceUnavailableException $ex) { + Server::get(LoggerInterface::class)->error($ex->getMessage(), [ + 'app' => 'index', + 'exception' => $ex, + ]); + + //show the user a detailed error page + Server::get(ITemplateManager::class)->printExceptionErrorPage($ex, 503); + } catch (HintException $ex) { + try { + Server::get(ITemplateManager::class)->printErrorPage($ex->getMessage(), $ex->getHint(), 503); + } catch (Exception $ex2) { + try { + Server::get(LoggerInterface::class)->error($ex->getMessage(), [ + 'app' => 'index', + 'exception' => $ex, + ]); + Server::get(LoggerInterface::class)->error($ex2->getMessage(), [ + 'app' => 'index', + 'exception' => $ex2, + ]); + } catch (Throwable $e) { + // no way to log it properly - but to avoid a white page of death we try harder and ignore this one here + } + + //show the user a detailed error page + Server::get(ITemplateManager::class)->printExceptionErrorPage($ex, 500); + } + } catch (LoginException $ex) { + $request = Server::get(IRequest::class); + /** + * Routes with the @CORS annotation and other API endpoints should + * not return a webpage, so we only print the error page when html is accepted, + * otherwise we reply with a JSON array like the SecurityMiddleware would do. + */ + if (stripos($request->getHeader('Accept'), 'html') === false) { + http_response_code(401); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['message' => $ex->getMessage()]); + exit(); + } + Server::get(ITemplateManager::class)->printErrorPage($ex->getMessage(), $ex->getMessage(), 401); + } catch (MaxDelayReached $ex) { + $request = Server::get(IRequest::class); + /** + * Routes with the @CORS annotation and other API endpoints should + * not return a webpage, so we only print the error page when html is accepted, + * otherwise we reply with a JSON array like the BruteForceMiddleware would do. + */ + if (stripos($request->getHeader('Accept'), 'html') === false) { + http_response_code(429); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['message' => $ex->getMessage()]); + exit(); + } + http_response_code(429); + Server::get(ITemplateManager::class)->printGuestPage('core', '429'); + } catch (Exception $ex) { + Server::get(LoggerInterface::class)->error($ex->getMessage(), [ + 'app' => 'index', + 'exception' => $ex, + ]); + + //show the user a detailed error page + Server::get(ITemplateManager::class)->printExceptionErrorPage($ex, 500); + } catch (Error $ex) { try { Server::get(LoggerInterface::class)->error($ex->getMessage(), [ 'app' => 'index', 'exception' => $ex, ]); - Server::get(LoggerInterface::class)->error($ex2->getMessage(), [ - 'app' => 'index', - 'exception' => $ex2, - ]); - } catch (Throwable $e) { - // no way to log it properly - but to avoid a white page of death we try harder and ignore this one here - } + } catch (Error $e) { + http_response_code(500); + header('Content-Type: text/plain; charset=utf-8'); + print("Internal Server Error\n\n"); + print("The server encountered an internal error and was unable to complete your request.\n"); + print("Please contact the server administrator if this error reappears multiple times, please include the technical details below in your report.\n"); + print("More details can be found in the webserver log.\n"); - //show the user a detailed error page + throw $ex; + } Server::get(ITemplateManager::class)->printExceptionErrorPage($ex, 500); } -} catch (LoginException $ex) { - $request = Server::get(IRequest::class); - /** - * Routes with the @CORS annotation and other API endpoints should - * not return a webpage, so we only print the error page when html is accepted, - * otherwise we reply with a JSON array like the SecurityMiddleware would do. - */ - if (stripos($request->getHeader('Accept'), 'html') === false) { - http_response_code(401); - header('Content-Type: application/json; charset=utf-8'); - echo json_encode(['message' => $ex->getMessage()]); - exit(); - } - Server::get(ITemplateManager::class)->printErrorPage($ex->getMessage(), $ex->getMessage(), 401); -} catch (MaxDelayReached $ex) { - $request = Server::get(IRequest::class); - /** - * Routes with the @CORS annotation and other API endpoints should - * not return a webpage, so we only print the error page when html is accepted, - * otherwise we reply with a JSON array like the BruteForceMiddleware would do. - */ - if (stripos($request->getHeader('Accept'), 'html') === false) { - http_response_code(429); - header('Content-Type: application/json; charset=utf-8'); - echo json_encode(['message' => $ex->getMessage()]); - exit(); - } - http_response_code(429); - Server::get(ITemplateManager::class)->printGuestPage('core', '429'); -} catch (Exception $ex) { - Server::get(LoggerInterface::class)->error($ex->getMessage(), [ - 'app' => 'index', - 'exception' => $ex, - ]); +}; - //show the user a detailed error page - Server::get(ITemplateManager::class)->printExceptionErrorPage($ex, 500); -} catch (Error $ex) { - try { - Server::get(LoggerInterface::class)->error($ex->getMessage(), [ - 'app' => 'index', - 'exception' => $ex, - ]); - } catch (Error $e) { - http_response_code(500); - header('Content-Type: text/plain; charset=utf-8'); - print("Internal Server Error\n\n"); - print("The server encountered an internal error and was unable to complete your request.\n"); - print("Please contact the server administrator if this error reappears multiple times, please include the technical details below in your report.\n"); - print("More details can be found in the webserver log.\n"); +if (function_exists('frankenphp_handle_request')) { + $maxRequests = (int)($_SERVER['MAX_REQUESTS'] ?? 0); + for ($nbRequests = 0; !$maxRequests || $nbRequests < $maxRequests; ++$nbRequests) { + $keepRunning = \frankenphp_handle_request($handler); - throw $ex; + // Call the garbage collector to reduce the chances of it being triggered in the middle of a page generation + gc_collect_cycles(); + + if (!$keepRunning) { + break; + } } - Server::get(ITemplateManager::class)->printExceptionErrorPage($ex, 500); +} else { + $handler(); } diff --git a/lib/base.php b/lib/base.php index 2884aaea9d539..d3d9d7a74c686 100644 --- a/lib/base.php +++ b/lib/base.php @@ -83,17 +83,6 @@ class OC { * the app path list is empty or contains an invalid path */ public static function initPaths(): void { - if (defined('PHPUNIT_CONFIG_DIR')) { - self::$configDir = OC::$SERVERROOT . '/' . PHPUNIT_CONFIG_DIR . '/'; - } elseif (defined('PHPUNIT_RUN') && PHPUNIT_RUN && is_dir(OC::$SERVERROOT . '/tests/config/')) { - self::$configDir = OC::$SERVERROOT . '/tests/config/'; - } elseif ($dir = getenv('NEXTCLOUD_CONFIG_DIR')) { - self::$configDir = rtrim($dir, '/') . '/'; - } else { - self::$configDir = OC::$SERVERROOT . '/config/'; - } - self::$config = new \OC\Config(self::$configDir); - OC::$SUBURI = str_replace('\\', '/', substr(realpath($_SERVER['SCRIPT_FILENAME'] ?? ''), strlen(OC::$SERVERROOT))); /** * FIXME: The following lines are required because we can't yet instantiate @@ -145,7 +134,7 @@ public static function initPaths(): void { // Resolve /nextcloud to /nextcloud/ to ensure to always have a trailing // slash which is required by URL generation. if (isset($_SERVER['REQUEST_URI']) && $_SERVER['REQUEST_URI'] === \OC::$WEBROOT - && substr($_SERVER['REQUEST_URI'], -1) !== '/') { + && substr($_SERVER['REQUEST_URI'], -1) !== '/') { header('Location: ' . \OC::$WEBROOT . '/'); exit(); } @@ -165,6 +154,7 @@ public static function initPaths(): void { OC::$APPSROOTS[] = ['path' => OC::$SERVERROOT . '/apps', 'url' => '/apps', 'writable' => true]; } + if (empty(OC::$APPSROOTS)) { throw new \RuntimeException('apps directory not found! Please put the Nextcloud apps folder in the Nextcloud folder' . '. You can also configure the location in the config.php file.'); @@ -643,12 +633,7 @@ private static function addSecurityHeaders(): void { } } - public static function init(): void { - // First handle PHP configuration and copy auth headers to the expected - // $_SERVER variable before doing anything Server object related - self::setRequiredIniValues(); - self::handleAuthHeaders(); - + public static function boot(): void { // prevent any XML processing from loading external entities libxml_set_external_entity_loader(static function () { return null; @@ -671,15 +656,32 @@ public static function init(): void { self::$composerAutoloader = require_once OC::$SERVERROOT . '/lib/composer/autoload.php'; self::$composerAutoloader->setApcuPrefix(null); + // setup 3rdparty autoloader + $vendorAutoLoad = OC::$SERVERROOT . '/3rdparty/autoload.php'; + if (!file_exists($vendorAutoLoad)) { + throw new \RuntimeException('Composer autoloader not found, unable to continue. Check the folder "3rdparty". Running "git submodule update --init" will initialize the git submodule that handles the subfolder "3rdparty".'); + } + require_once $vendorAutoLoad; + + $loaderEnd = microtime(true); + + // load configs + if (defined('PHPUNIT_CONFIG_DIR')) { + self::$configDir = OC::$SERVERROOT . '/' . PHPUNIT_CONFIG_DIR . '/'; + } elseif (defined('PHPUNIT_RUN') && PHPUNIT_RUN && is_dir(OC::$SERVERROOT . '/tests/config/')) { + self::$configDir = OC::$SERVERROOT . '/tests/config/'; + } elseif ($dir = getenv('NEXTCLOUD_CONFIG_DIR')) { + self::$configDir = rtrim($dir, '/') . '/'; + } else { + self::$configDir = OC::$SERVERROOT . '/config/'; + } + self::$config = new \OC\Config(self::$configDir); + + // Enable lazy loading if activated + \OC\AppFramework\Utility\SimpleContainer::$useLazyObjects = (bool)self::$config->getValue('enable_lazy_objects', true); try { self::initPaths(); - // setup 3rdparty autoloader - $vendorAutoLoad = OC::$SERVERROOT . '/3rdparty/autoload.php'; - if (!file_exists($vendorAutoLoad)) { - throw new \RuntimeException('Composer autoloader not found, unable to continue. Check the folder "3rdparty". Running "git submodule update --init" will initialize the git submodule that handles the subfolder "3rdparty".'); - } - require_once $vendorAutoLoad; } catch (\RuntimeException $e) { if (!self::$CLI) { http_response_code(503); @@ -689,15 +691,24 @@ public static function init(): void { print($e->getMessage()); exit(); } - $loaderEnd = microtime(true); - // Enable lazy loading if activated - \OC\AppFramework\Utility\SimpleContainer::$useLazyObjects = (bool)self::$config->getValue('enable_lazy_objects', true); + //$eventLogger = Server::get(\OCP\Diagnostics\IEventLogger::class); + //$eventLogger->log('autoloader', 'Autoloader', $loaderStart, $loaderEnd); + //$eventLogger->start('init', 'Initialize'); + } + + public static function init(): void { + // First handle PHP configuration and copy auth headers to the expected + // $_SERVER variable before doing anything Server object related + self::setRequiredIniValues(); + self::handleAuthHeaders(); - // setup the basic server + // set up the basic server self::$server = new \OC\Server(\OC::$WEBROOT, self::$config); self::$server->boot(); + $loaderStart = microtime(true); + try { $profiler = new BuiltInProfiler( Server::get(IConfig::class), @@ -713,8 +724,7 @@ public static function init(): void { } $eventLogger = Server::get(\OCP\Diagnostics\IEventLogger::class); - $eventLogger->log('autoloader', 'Autoloader', $loaderStart, $loaderEnd); - $eventLogger->start('boot', 'Initialize'); + $eventLogger->start('init', 'Initialize'); // Override php.ini and log everything if we're troubleshooting if (self::$config->getValue('loglevel') === ILogger::DEBUG) { @@ -1285,4 +1295,4 @@ protected static function tryAppAPILogin(OCP\IRequest $request): bool { } } -OC::init(); +OC::boot(); diff --git a/lib/private/Route/Router.php b/lib/private/Route/Router.php index c0170b7dd8383..d654b5559f25b 100644 --- a/lib/private/Route/Router.php +++ b/lib/private/Route/Router.php @@ -250,6 +250,7 @@ public function create($name, * @return array */ public function findMatchingRoute(string $url): array { + $url = str_replace('index.php', '', $url); $this->eventLogger->start('route:match', 'Match route'); if (str_starts_with($url, '/apps/')) { // empty string / 'apps' / $app / rest of the route @@ -277,6 +278,7 @@ public function findMatchingRoute(string $url): array { $this->loadRoutes(); } + $this->eventLogger->start('route:url:match', 'Symfony url matcher call'); $matcher = new UrlMatcher($this->root, $this->context); try { diff --git a/lib/private/Share/Share.php b/lib/private/Share/Share.php index 6f4c373dcf9d5..5682dc692585d 100644 --- a/lib/private/Share/Share.php +++ b/lib/private/Share/Share.php @@ -57,10 +57,12 @@ public static function registerBackend($itemType, $class, $collectionOf = null, ]; return true; } - Server::get(LoggerInterface::class)->warning( - 'Sharing backend ' . $class . ' not registered, ' . self::$backendTypes[$itemType]['class'] - . ' is already registered for ' . $itemType, - ['app' => 'files_sharing']); + if (self::$backendTypes[$itemType]['class'] !== $class) { + Server::get(LoggerInterface::class)->warning( + 'Sharing backend ' . $class . ' not registered, ' . self::$backendTypes[$itemType]['class'] + . ' is already registered for ' . $itemType, + ['app' => 'files_sharing']); + } } return false; } diff --git a/ocs/v1.php b/ocs/v1.php index e12cd6ddc1147..677fc4e7c382c 100644 --- a/ocs/v1.php +++ b/ocs/v1.php @@ -28,6 +28,8 @@ use Symfony\Component\Routing\Exception\MethodNotAllowedException; use Symfony\Component\Routing\Exception\ResourceNotFoundException; +OC::init(); + if (Util::needUpgrade() || Server::get(IConfig::class)->getSystemValueBool('maintenance')) { // since the behavior of apps or remotes are unpredictable during diff --git a/remote.php b/remote.php index 2cfd9d818c82f..63135678bbc6f 100644 --- a/remote.php +++ b/remote.php @@ -96,6 +96,8 @@ function resolveService($service) { try { require_once __DIR__ . '/lib/base.php'; + OC::init(); + // All resources served via the DAV endpoint should have the strictest possible // policy. Exempted from this is the SabreDAV browser plugin which overwrites // this policy with a softer one if debug mode is enabled.