From b9f45ee00962fa971842b37119f05c97d95d82bc 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): resolve URIs served outside the webroot in Element::fromUri() --- lib/Horde/Themes/Element.php | 35 ++++++- test/Unit/Themes/ElementTest.php | 156 +++++++++++++++++++++++++++++++ 2 files changed, 188 insertions(+), 3 deletions(-) create mode 100644 test/Unit/Themes/ElementTest.php diff --git a/lib/Horde/Themes/Element.php b/lib/Horde/Themes/Element.php index 17ec58b5..8a5f80f5 100644 --- a/lib/Horde/Themes/Element.php +++ b/lib/Horde/Themes/Element.php @@ -162,6 +162,12 @@ public function __get($name) /** * Convert a URI into a Horde_Themes_Element object. * + * The URI is first matched against the themes, javascript and static + * locations of the registered applications, since these may be served + * outside of the Horde webroot (e.g. /themes/horde in a composer + * installation). Otherwise, the URI is resolved against the Horde + * fileroot. + * * @param string $uri The URI to convert. * * @return Horde_Themes_Element A theme element object. @@ -170,12 +176,35 @@ public static function fromUri($uri) { global $registry; - $fileroot = $registry->get('fileroot', 'horde'); - $webroot = $registry->get('webroot', 'horde'); + $uri = (string) $uri; + $fs = null; + $match = 0; + + foreach ($registry->listAllApps() as $app) { + foreach (['themes', 'js', 'static'] as $type) { + $base_uri = rtrim((string) $registry->get($type . 'uri', $app), '/'); + $base_fs = $registry->get($type . 'fs', $app); + if (!strlen($base_uri) + || !strlen((string) $base_fs) + || (strlen($base_uri) <= $match) + || !str_starts_with($uri, $base_uri . '/')) { + continue; + } + $fs = rtrim($base_fs, '/') . substr($uri, strlen($base_uri)); + $match = strlen($base_uri); + } + } + + if (is_null($fs)) { + $fileroot = $registry->get('fileroot', 'horde'); + $webroot = rtrim((string) $registry->get('webroot', 'horde'), '/'); + $fs = realpath($fileroot ?? '') . '/' + . ltrim(preg_replace('/^' . preg_quote($webroot, '/') . '(?=\/)/', '', $uri), '/'); + } return new self('', [ 'data' => [ - 'fs' => realpath($fileroot ?? '') . preg_replace('/^' . preg_quote($webroot ?? '', '/') . '/', '', $uri), + 'fs' => $fs, 'uri' => $uri, ], ]); diff --git a/test/Unit/Themes/ElementTest.php b/test/Unit/Themes/ElementTest.php new file mode 100644 index 00000000..33d4101d --- /dev/null +++ b/test/Unit/Themes/ElementTest.php @@ -0,0 +1,156 @@ +fileroot = realpath(sys_get_temp_dir()); + } + + protected function tearDown(): void + { + unset($GLOBALS['registry']); + } + + /** + * Installs a registry stub answering get() from a per-app array. + */ + private function setRegistry(array $applications): void + { + $registry = $this->createStub(Horde_Registry::class); + $registry->method('listAllApps')->willReturn(array_keys($applications)); + $registry->method('get')->willReturnCallback( + fn($parameter, $app = null) => $applications[$app][$parameter] + ?? $applications['horde'][$parameter] + ?? null + ); + $GLOBALS['registry'] = $registry; + } + + /** + * Composer layout: themes are served outside of the Horde webroot. + */ + private function composerLayout(): array + { + return [ + 'horde' => [ + 'fileroot' => $this->fileroot, + 'webroot' => '/horde/', + 'jsfs' => '/var/www/horde/web/js/horde/', + 'jsuri' => '/js/horde', + 'staticfs' => '/var/www/horde/web/static/', + 'staticuri' => '/static/', + 'themesfs' => '/var/www/horde/web/themes/horde/', + 'themesuri' => '/themes/horde', + ], + 'imp' => [ + 'themesfs' => '/var/www/horde/web/themes/imp/', + 'themesuri' => '/themes/imp', + ], + ]; + } + + public function testThemesUriOutsideWebroot(): void + { + $this->setRegistry($this->composerLayout()); + + $ob = Horde_Themes_Element::fromUri('/themes/horde/mytheme/base.css'); + + $this->assertSame('/var/www/horde/web/themes/horde/mytheme/base.css', $ob->fs); + $this->assertSame('/themes/horde/mytheme/base.css', $ob->uri); + } + + public function testApplicationThemesUri(): void + { + $this->setRegistry($this->composerLayout()); + + $ob = Horde_Themes_Element::fromUri('/themes/imp/mytheme/screen.css'); + + $this->assertSame('/var/www/horde/web/themes/imp/mytheme/screen.css', $ob->fs); + } + + public function testStaticUri(): void + { + $this->setRegistry($this->composerLayout()); + + $ob = Horde_Themes_Element::fromUri('/static/abc.css'); + + $this->assertSame('/var/www/horde/web/static/abc.css', $ob->fs); + } + + public function testLongestPrefixWins(): void + { + $applications = $this->composerLayout(); + $applications['horde']['themesfs'] = '/srv/themes/'; + $applications['horde']['themesuri'] = '/themes'; + $this->setRegistry($applications); + + $ob = Horde_Themes_Element::fromUri('/themes/imp/mytheme/screen.css'); + + $this->assertSame('/var/www/horde/web/themes/imp/mytheme/screen.css', $ob->fs); + } + + public function testPrefixMatchesWholePathSegment(): void + { + $this->setRegistry($this->composerLayout()); + + $ob = Horde_Themes_Element::fromUri('/themes/horde-extra/base.css'); + + $this->assertSame($this->fileroot . '/themes/horde-extra/base.css', $ob->fs); + } + + public function testFallbackStripsWebrootWithTrailingSlash(): void + { + $this->setRegistry(['horde' => [ + 'fileroot' => $this->fileroot, + 'webroot' => '/horde/', + ]]); + + $ob = Horde_Themes_Element::fromUri('/horde/themes/default/screen.css'); + + $this->assertSame($this->fileroot . '/themes/default/screen.css', $ob->fs); + } + + public function testFallbackStripsWebrootWithoutTrailingSlash(): void + { + $this->setRegistry(['horde' => [ + 'fileroot' => $this->fileroot, + 'webroot' => '/horde', + ]]); + + $ob = Horde_Themes_Element::fromUri('/horde/themes/default/screen.css'); + + $this->assertSame($this->fileroot . '/themes/default/screen.css', $ob->fs); + } + + public function testFallbackWithRootWebroot(): void + { + $this->setRegistry(['horde' => [ + 'fileroot' => $this->fileroot, + 'webroot' => '/', + ]]); + + $ob = Horde_Themes_Element::fromUri('/themes/default/screen.css'); + + $this->assertSame($this->fileroot . '/themes/default/screen.css', $ob->fs); + } +} From 82d35a67814e379d04e6c718319df784624fb74c Mon Sep 17 00:00:00 2001 From: Fardel Pierre Date: Wed, 21 Oct 2026 11:32:17 +0200 Subject: [PATCH 2/2] test(Themes): stub listAllApps() in CompressTest --- test/Unit/Themes/Css/CompressTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/test/Unit/Themes/Css/CompressTest.php b/test/Unit/Themes/Css/CompressTest.php index 299c1a9a..4b8a4b82 100644 --- a/test/Unit/Themes/Css/CompressTest.php +++ b/test/Unit/Themes/Css/CompressTest.php @@ -31,6 +31,7 @@ protected function setUp(): void $GLOBALS['browser']->method('hasFeature')->willReturn(false); $GLOBALS['registry'] = $this->createStub(Horde_Registry::class); + $GLOBALS['registry']->method('listAllApps')->willReturn([]); // Use stubs for logger and injector $logger = $this->createStub(LoggerInterface::class);