From 7192786068242f857b198034d366429d77d8fbbe Mon Sep 17 00:00:00 2001 From: Fardel Pierre Date: Wed, 21 Oct 2026 10:46:13 +0200 Subject: [PATCH 1/2] fix(Themes): keep external stylesheets when CSS caching is enabled --- lib/Horde/Themes/Css/Cache.php | 26 +++++++++ lib/Horde/Themes/Css/Cache/File.php | 9 ++- lib/Horde/Themes/Css/Cache/HordeCache.php | 9 ++- test/Unit/Themes/Css/CacheTest.php | 67 +++++++++++++++++++++++ 4 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 test/Unit/Themes/Css/CacheTest.php diff --git a/lib/Horde/Themes/Css/Cache.php b/lib/Horde/Themes/Css/Cache.php index d1f033ba..a9337a22 100644 --- a/lib/Horde/Themes/Css/Cache.php +++ b/lib/Horde/Themes/Css/Cache.php @@ -54,6 +54,32 @@ public function __construct(array $params = []) */ abstract public function process($css, $cacheid); + /** + * Split off the stylesheets that are not local files (e.g. a CDN URL + * added by the cssfiles hook). These cannot be compressed, so they are + * linked as is, before the cached file. + * + * @param array $css See Horde_Themes_Css#getStylesheets(). + * + * @return array Two elements: the list of external URLs (Horde_Url + * objects) and the list of local stylesheets. + */ + protected function _splitExternal($css) + { + $external = $local = []; + + foreach ($css as $val) { + if (isset($val['uri']) + && preg_match('#^https?://#i', (string) $val['uri'])) { + $external[] = Horde::url($val['uri'], true, -1); + } else { + $local[] = $val; + } + } + + return [$external, $local]; + } + /** * Perform garbage collection. */ diff --git a/lib/Horde/Themes/Css/Cache/File.php b/lib/Horde/Themes/Css/Cache/File.php index 68bad5fa..e3906999 100644 --- a/lib/Horde/Themes/Css/Cache/File.php +++ b/lib/Horde/Themes/Css/Cache/File.php @@ -34,6 +34,11 @@ public function process($css, $cacheid) { global $registry, $injector; + [$external, $css] = $this->_splitExternal($css); + if (!count($css)) { + return $external; + } + if (!empty($this->_params['filemtime'])) { foreach ($css as &$val) { $val['mtime'] = @filemtime($val['fs']); @@ -71,9 +76,9 @@ public function process($css, $cacheid) } } - return [ + return array_merge($external, [ Horde::url(($registry->get('staticuri', 'horde') ?? '') . '/' . $filename, true, ['append_session' => -1]), - ]; + ]); } /** diff --git a/lib/Horde/Themes/Css/Cache/HordeCache.php b/lib/Horde/Themes/Css/Cache/HordeCache.php index 61eb90c9..91d04250 100644 --- a/lib/Horde/Themes/Css/Cache/HordeCache.php +++ b/lib/Horde/Themes/Css/Cache/HordeCache.php @@ -32,6 +32,11 @@ public function process($css, $cacheid) { global $injector; + [$external, $css] = $this->_splitExternal($css); + if (!count($css)) { + return $external; + } + if (!empty($this->_params['filemtime'])) { foreach ($css as &$val) { $val['mtime'] = @filemtime($val['fs']); @@ -62,9 +67,9 @@ public function process($css, $cacheid) $cache->set($sig, $compress->compress($css, $logger)); } - return [ + return array_merge($external, [ Horde::getCacheUrl('css', ['cid' => $sig]), - ]; + ]); } } diff --git a/test/Unit/Themes/Css/CacheTest.php b/test/Unit/Themes/Css/CacheTest.php new file mode 100644 index 00000000..6d207af0 --- /dev/null +++ b/test/Unit/Themes/Css/CacheTest.php @@ -0,0 +1,67 @@ +createStub(Horde_Registry::class); + $GLOBALS['registry']->method('get')->willReturn('/horde'); + } + + protected function tearDown(): void + { + unset($GLOBALS['registry']); + } + + private function getCache(): Horde_Themes_Css_Cache + { + return new class extends Horde_Themes_Css_Cache { + public function process($css, $cacheid) + { + return $this->_splitExternal($css); + } + }; + } + + public function testSplitExternal(): void + { + $cdn = 'https://cdn.example.org/styles/main.css'; + $local = ['app' => 'horde', 'fs' => '/srv/themes/x/screen.css', 'uri' => '/themes/x/screen.css']; + + [$external, $rest] = $this->getCache()->process([ + ['app' => 'horde', 'fs' => $cdn, 'uri' => $cdn], + $local, + ], ''); + + $this->assertCount(1, $external); + $this->assertSame($cdn, $external[0]->toString(false, true)); + $this->assertSame([$local], $rest); + } + + public function testSplitExternalWithoutExternal(): void + { + $local = ['app' => 'horde', 'fs' => '/srv/themes/x/screen.css', 'uri' => '/themes/x/screen.css']; + + [$external, $rest] = $this->getCache()->process([$local], ''); + + $this->assertSame([], $external); + $this->assertSame([$local], $rest); + } +} From aa55bc613801c11de25142b935e410ea06abeeb9 Mon Sep 17 00:00:00 2001 From: Fardel Pierre Date: Wed, 21 Oct 2026 11:32:53 +0200 Subject: [PATCH 2/2] fix(Themes): keep absolute URLs backed by a local file in the CSS cache --- lib/Horde/Themes/Css/Cache.php | 6 ++++-- test/Unit/Themes/Css/CacheTest.php | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/lib/Horde/Themes/Css/Cache.php b/lib/Horde/Themes/Css/Cache.php index a9337a22..3be984fd 100644 --- a/lib/Horde/Themes/Css/Cache.php +++ b/lib/Horde/Themes/Css/Cache.php @@ -57,7 +57,8 @@ abstract public function process($css, $cacheid); /** * Split off the stylesheets that are not local files (e.g. a CDN URL * added by the cssfiles hook). These cannot be compressed, so they are - * linked as is, before the cached file. + * linked as is, before the cached file. An absolute URL backed by a + * readable local file is kept in the cached file. * * @param array $css See Horde_Themes_Css#getStylesheets(). * @@ -70,7 +71,8 @@ protected function _splitExternal($css) foreach ($css as $val) { if (isset($val['uri']) - && preg_match('#^https?://#i', (string) $val['uri'])) { + && preg_match('#^https?://#i', (string) $val['uri']) + && !(isset($val['fs']) && is_readable($val['fs']))) { $external[] = Horde::url($val['uri'], true, -1); } else { $local[] = $val; diff --git a/test/Unit/Themes/Css/CacheTest.php b/test/Unit/Themes/Css/CacheTest.php index 6d207af0..db323e4c 100644 --- a/test/Unit/Themes/Css/CacheTest.php +++ b/test/Unit/Themes/Css/CacheTest.php @@ -55,6 +55,21 @@ public function testSplitExternal(): void $this->assertSame([$local], $rest); } + public function testSplitExternalKeepsAbsoluteUrlWithLocalFile(): void + { + $fs = tempnam(sys_get_temp_dir(), 'css'); + $local = ['app' => 'horde', 'fs' => $fs, 'uri' => 'https://static.example.org/themes/x/screen.css']; + + try { + [$external, $rest] = $this->getCache()->process([$local], ''); + } finally { + unlink($fs); + } + + $this->assertSame([], $external); + $this->assertSame([$local], $rest); + } + public function testSplitExternalWithoutExternal(): void { $local = ['app' => 'horde', 'fs' => '/srv/themes/x/screen.css', 'uri' => '/themes/x/screen.css'];