From 812f12ecc434618d96fc33234e60c2c3c4f06701 Mon Sep 17 00:00:00 2001 From: ernolf Date: Tue, 30 Sep 2025 14:38:45 +0200 Subject: [PATCH 1/4] perf(client): enable HTTP/2 and brotli support in internal HTTP client - Prefer HTTP/2 by setting RequestOptions::VERSION => "2.0" so clients that respect PSR-7 request version will prefer HTTP/2. - Add a curl hint (CURLOPT_HTTP_VERSION) to prefer HTTP/2 via ALPN (CURL_HTTP_VERSION_2TLS or CURL_HTTP_VERSION_2_0 fallback) while allowing automatic fallback to HTTP/1.1. - Advertise Brotli ("br") in Accept-Encoding when the php-brotli extension is available (detected via function_exists('brotli_uncompress')), otherwise fall back to gzip. Notes: - The PSR-7 request version is used as a hint for HTTP client libraries; setting the version to "2.0" signals a preference for HTTP/2 at the request abstraction level. - The curl option is defensive: it prefers HTTP/2 where libcurl supports it (via ALPN), but will not break on older libcurl/builds (uses defined()). Compatibility: - Fully backwards compatible: if the php-brotli extension is not present, no Brotli usage will occur and behaviour remains equivalent to previous (gzip). Signed-off-by: ernolf --- lib/private/Http/Client/Client.php | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/lib/private/Http/Client/Client.php b/lib/private/Http/Client/Client.php index 553a8921a8012..ba4176b137155 100644 --- a/lib/private/Http/Client/Client.php +++ b/lib/private/Http/Client/Client.php @@ -54,7 +54,14 @@ private function buildRequestOptions(array $options): array { $defaults = [ RequestOptions::VERIFY => $this->getCertBundle(), RequestOptions::TIMEOUT => IClient::DEFAULT_REQUEST_TIMEOUT, + // Prefer HTTP/2 globally (PSR-7 request version) + RequestOptions::VERSION => '2.0', ]; + // cURL hint: Prefer HTTP/2 (with ALPN); automatically falls back to 1.1. + $defaults['curl'][\CURLOPT_HTTP_VERSION] + = \defined('CURL_HTTP_VERSION_2TLS') ? \CURL_HTTP_VERSION_2TLS + : (\defined('CURL_HTTP_VERSION_2_0') ? \CURL_HTTP_VERSION_2_0 + : \CURL_HTTP_VERSION_NONE); $options['nextcloud']['allow_local_address'] = $this->isLocalAddressAllowed($options); if ($options['nextcloud']['allow_local_address'] === false) { @@ -84,8 +91,15 @@ private function buildRequestOptions(array $options): array { $options[RequestOptions::HEADERS]['User-Agent'] = 'Nextcloud Server Crawler'; } - if (!isset($options[RequestOptions::HEADERS]['Accept-Encoding'])) { - $options[RequestOptions::HEADERS]['Accept-Encoding'] = 'gzip'; + // Ensure headers array exists and set Accept-Encoding only if not present + $headers = $options[RequestOptions::HEADERS] ?? []; + if (!isset($headers['Accept-Encoding'])) { + $acceptEnc = 'gzip'; + if (function_exists('brotli_uncompress')) { + $acceptEnc = 'br, ' . $acceptEnc; + } + $options[RequestOptions::HEADERS] = $headers; // ensure headers are present + $options[RequestOptions::HEADERS]['Accept-Encoding'] = $acceptEnc; } // Fallback for save_to From 65aa731ef3d677518f4a97e6211a904f8d16880a Mon Sep 17 00:00:00 2001 From: ernolf Date: Tue, 30 Sep 2025 16:27:02 +0200 Subject: [PATCH 2/4] test: add unit test for Accept-Encoding with Brotli support Signed-off-by: ernolf --- tests/lib/Http/Client/ClientTest.php | 52 +++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/tests/lib/Http/Client/ClientTest.php b/tests/lib/Http/Client/ClientTest.php index e76b66b52d78d..795168bb8de01 100644 --- a/tests/lib/Http/Client/ClientTest.php +++ b/tests/lib/Http/Client/ClientTest.php @@ -276,6 +276,13 @@ private function setUpDefaultRequestOptions(): void { ->with() ->willReturn('/my/path.crt'); + $acceptEnc = function_exists('brotli_uncompress') ? 'br, gzip' : 'gzip'; + + // compute curl http version hint like in production code + $curlVersion = \defined('CURL_HTTP_VERSION_2TLS') + ? \CURL_HTTP_VERSION_2TLS + : (\defined('CURL_HTTP_VERSION_2_0') ? \CURL_HTTP_VERSION_2_0 : \CURL_HTTP_VERSION_NONE); + $this->defaultRequestOptions = [ 'verify' => '/my/path.crt', 'proxy' => [ @@ -284,12 +291,16 @@ private function setUpDefaultRequestOptions(): void { ], 'headers' => [ 'User-Agent' => 'Nextcloud Server Crawler', - 'Accept-Encoding' => 'gzip', + 'Accept-Encoding' => $acceptEnc, ], 'timeout' => 30, 'nextcloud' => [ 'allow_local_address' => true, ], + 'version' => '2.0', + 'curl' => [ + \CURLOPT_HTTP_VERSION => $curlVersion, + ], ]; } @@ -466,11 +477,18 @@ public function testSetDefaultOptionsWithNotInstalled(): void { ->expects($this->never()) ->method('listCertificates'); + $acceptEnc = function_exists('brotli_uncompress') ? 'br, gzip' : 'gzip'; + + // compute curl http version hint like in production code + $curlVersion = \defined('CURL_HTTP_VERSION_2TLS') + ? \CURL_HTTP_VERSION_2TLS + : (\defined('CURL_HTTP_VERSION_2_0') ? \CURL_HTTP_VERSION_2_0 : \CURL_HTTP_VERSION_NONE); + $this->assertEquals([ 'verify' => \OC::$SERVERROOT . '/resources/config/ca-bundle.crt', 'headers' => [ 'User-Agent' => 'Nextcloud Server Crawler', - 'Accept-Encoding' => 'gzip', + 'Accept-Encoding' => $acceptEnc, ], 'timeout' => 30, 'nextcloud' => [ @@ -484,6 +502,10 @@ public function testSetDefaultOptionsWithNotInstalled(): void { ): void { }, ], + 'version' => '2.0', + 'curl' => [ + \CURLOPT_HTTP_VERSION => $curlVersion, + ], ], self::invokePrivate($this->client, 'buildRequestOptions', [[]])); } @@ -513,6 +535,13 @@ public function testSetDefaultOptionsWithProxy(): void { ->with() ->willReturn('/my/path.crt'); + $acceptEnc = function_exists('brotli_uncompress') ? 'br, gzip' : 'gzip'; + + // compute curl http version hint like in production code + $curlVersion = \defined('CURL_HTTP_VERSION_2TLS') + ? \CURL_HTTP_VERSION_2TLS + : (\defined('CURL_HTTP_VERSION_2_0') ? \CURL_HTTP_VERSION_2_0 : \CURL_HTTP_VERSION_NONE); + $this->assertEquals([ 'verify' => '/my/path.crt', 'proxy' => [ @@ -521,7 +550,7 @@ public function testSetDefaultOptionsWithProxy(): void { ], 'headers' => [ 'User-Agent' => 'Nextcloud Server Crawler', - 'Accept-Encoding' => 'gzip', + 'Accept-Encoding' => $acceptEnc, ], 'timeout' => 30, 'nextcloud' => [ @@ -535,6 +564,10 @@ public function testSetDefaultOptionsWithProxy(): void { ): void { }, ], + 'version' => '2.0', + 'curl' => [ + \CURLOPT_HTTP_VERSION => $curlVersion, + ], ], self::invokePrivate($this->client, 'buildRequestOptions', [[]])); } @@ -564,6 +597,13 @@ public function testSetDefaultOptionsWithProxyAndExclude(): void { ->with() ->willReturn('/my/path.crt'); + $acceptEnc = function_exists('brotli_uncompress') ? 'br, gzip' : 'gzip'; + + // compute curl http version hint like in production code + $curlVersion = \defined('CURL_HTTP_VERSION_2TLS') + ? \CURL_HTTP_VERSION_2TLS + : (\defined('CURL_HTTP_VERSION_2_0') ? \CURL_HTTP_VERSION_2_0 : \CURL_HTTP_VERSION_NONE); + $this->assertEquals([ 'verify' => '/my/path.crt', 'proxy' => [ @@ -573,7 +613,7 @@ public function testSetDefaultOptionsWithProxyAndExclude(): void { ], 'headers' => [ 'User-Agent' => 'Nextcloud Server Crawler', - 'Accept-Encoding' => 'gzip', + 'Accept-Encoding' => $acceptEnc, ], 'timeout' => 30, 'nextcloud' => [ @@ -587,6 +627,10 @@ public function testSetDefaultOptionsWithProxyAndExclude(): void { ): void { }, ], + 'version' => '2.0', + 'curl' => [ + \CURLOPT_HTTP_VERSION => $curlVersion, + ], ], self::invokePrivate($this->client, 'buildRequestOptions', [[]])); } } From b6ea2bc0f521d6d0957198a1b37fb46d54e17e86 Mon Sep 17 00:00:00 2001 From: ernolf Date: Wed, 22 Oct 2025 12:17:17 +0200 Subject: [PATCH 3/4] refactor(http-client): use direct HTTP/2 cURL hint and align tests Signed-off-by: ernolf --- lib/private/Http/Client/Client.php | 6 +----- tests/lib/Http/Client/ClientTest.php | 28 ++++------------------------ 2 files changed, 5 insertions(+), 29 deletions(-) diff --git a/lib/private/Http/Client/Client.php b/lib/private/Http/Client/Client.php index ba4176b137155..78ffe928de693 100644 --- a/lib/private/Http/Client/Client.php +++ b/lib/private/Http/Client/Client.php @@ -57,11 +57,7 @@ private function buildRequestOptions(array $options): array { // Prefer HTTP/2 globally (PSR-7 request version) RequestOptions::VERSION => '2.0', ]; - // cURL hint: Prefer HTTP/2 (with ALPN); automatically falls back to 1.1. - $defaults['curl'][\CURLOPT_HTTP_VERSION] - = \defined('CURL_HTTP_VERSION_2TLS') ? \CURL_HTTP_VERSION_2TLS - : (\defined('CURL_HTTP_VERSION_2_0') ? \CURL_HTTP_VERSION_2_0 - : \CURL_HTTP_VERSION_NONE); + $defaults['curl'][\CURLOPT_HTTP_VERSION] = \CURL_HTTP_VERSION_2TLS; $options['nextcloud']['allow_local_address'] = $this->isLocalAddressAllowed($options); if ($options['nextcloud']['allow_local_address'] === false) { diff --git a/tests/lib/Http/Client/ClientTest.php b/tests/lib/Http/Client/ClientTest.php index 795168bb8de01..69668e7da5120 100644 --- a/tests/lib/Http/Client/ClientTest.php +++ b/tests/lib/Http/Client/ClientTest.php @@ -278,11 +278,6 @@ private function setUpDefaultRequestOptions(): void { $acceptEnc = function_exists('brotli_uncompress') ? 'br, gzip' : 'gzip'; - // compute curl http version hint like in production code - $curlVersion = \defined('CURL_HTTP_VERSION_2TLS') - ? \CURL_HTTP_VERSION_2TLS - : (\defined('CURL_HTTP_VERSION_2_0') ? \CURL_HTTP_VERSION_2_0 : \CURL_HTTP_VERSION_NONE); - $this->defaultRequestOptions = [ 'verify' => '/my/path.crt', 'proxy' => [ @@ -299,7 +294,7 @@ private function setUpDefaultRequestOptions(): void { ], 'version' => '2.0', 'curl' => [ - \CURLOPT_HTTP_VERSION => $curlVersion, + \CURLOPT_HTTP_VERSION => \CURL_HTTP_VERSION_2TLS, ], ]; } @@ -479,11 +474,6 @@ public function testSetDefaultOptionsWithNotInstalled(): void { $acceptEnc = function_exists('brotli_uncompress') ? 'br, gzip' : 'gzip'; - // compute curl http version hint like in production code - $curlVersion = \defined('CURL_HTTP_VERSION_2TLS') - ? \CURL_HTTP_VERSION_2TLS - : (\defined('CURL_HTTP_VERSION_2_0') ? \CURL_HTTP_VERSION_2_0 : \CURL_HTTP_VERSION_NONE); - $this->assertEquals([ 'verify' => \OC::$SERVERROOT . '/resources/config/ca-bundle.crt', 'headers' => [ @@ -504,7 +494,7 @@ public function testSetDefaultOptionsWithNotInstalled(): void { ], 'version' => '2.0', 'curl' => [ - \CURLOPT_HTTP_VERSION => $curlVersion, + \CURLOPT_HTTP_VERSION => \CURL_HTTP_VERSION_2TLS, ], ], self::invokePrivate($this->client, 'buildRequestOptions', [[]])); } @@ -537,11 +527,6 @@ public function testSetDefaultOptionsWithProxy(): void { $acceptEnc = function_exists('brotli_uncompress') ? 'br, gzip' : 'gzip'; - // compute curl http version hint like in production code - $curlVersion = \defined('CURL_HTTP_VERSION_2TLS') - ? \CURL_HTTP_VERSION_2TLS - : (\defined('CURL_HTTP_VERSION_2_0') ? \CURL_HTTP_VERSION_2_0 : \CURL_HTTP_VERSION_NONE); - $this->assertEquals([ 'verify' => '/my/path.crt', 'proxy' => [ @@ -566,7 +551,7 @@ public function testSetDefaultOptionsWithProxy(): void { ], 'version' => '2.0', 'curl' => [ - \CURLOPT_HTTP_VERSION => $curlVersion, + \CURLOPT_HTTP_VERSION => \CURL_HTTP_VERSION_2TLS, ], ], self::invokePrivate($this->client, 'buildRequestOptions', [[]])); } @@ -599,11 +584,6 @@ public function testSetDefaultOptionsWithProxyAndExclude(): void { $acceptEnc = function_exists('brotli_uncompress') ? 'br, gzip' : 'gzip'; - // compute curl http version hint like in production code - $curlVersion = \defined('CURL_HTTP_VERSION_2TLS') - ? \CURL_HTTP_VERSION_2TLS - : (\defined('CURL_HTTP_VERSION_2_0') ? \CURL_HTTP_VERSION_2_0 : \CURL_HTTP_VERSION_NONE); - $this->assertEquals([ 'verify' => '/my/path.crt', 'proxy' => [ @@ -629,7 +609,7 @@ public function testSetDefaultOptionsWithProxyAndExclude(): void { ], 'version' => '2.0', 'curl' => [ - \CURLOPT_HTTP_VERSION => $curlVersion, + \CURLOPT_HTTP_VERSION => \CURL_HTTP_VERSION_2TLS, ], ], self::invokePrivate($this->client, 'buildRequestOptions', [[]])); } From 932523e844fee0ab33bb7429d70e3ac207f8f6de Mon Sep 17 00:00:00 2001 From: ernolf Date: Fri, 9 Jan 2026 01:29:59 +0100 Subject: [PATCH 4/4] style(tests): apply cs-fixer formatting to ClientTest Signed-off-by: ernolf --- tests/lib/Http/Client/ClientTest.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/lib/Http/Client/ClientTest.php b/tests/lib/Http/Client/ClientTest.php index 508ecb7fdea46..c4d1ecc4da944 100644 --- a/tests/lib/Http/Client/ClientTest.php +++ b/tests/lib/Http/Client/ClientTest.php @@ -293,10 +293,10 @@ private function setUpDefaultRequestOptions(): void { ], 'headers' => [ - 'User-Agent' => 'Nextcloud-Server-Crawler/123.45.6', + 'User-Agent' => 'Nextcloud-Server-Crawler/123.45.6', 'Accept-Encoding' => $acceptEnc, - ], + ], 'timeout' => 30, 'nextcloud' => [ 'allow_local_address' => true, @@ -544,7 +544,7 @@ public function testSetDefaultOptionsWithProxy(): void { $this->serverVersion->method('getVersionString') ->willReturn('123.45.6'); - $acceptEnc = function_exists('brotli_uncompress') ? 'br, gzip' : 'gzip'; + $acceptEnc = function_exists('brotli_uncompress') ? 'br, gzip' : 'gzip'; $this->assertEquals([ 'verify' => '/my/path.crt', @@ -604,7 +604,7 @@ public function testSetDefaultOptionsWithProxyAndExclude(): void { $this->serverVersion->method('getVersionString') ->willReturn('123.45.6'); - $acceptEnc = function_exists('brotli_uncompress') ? 'br, gzip' : 'gzip'; + $acceptEnc = function_exists('brotli_uncompress') ? 'br, gzip' : 'gzip'; $this->assertEquals([ 'verify' => '/my/path.crt',