From a4c2bbb9026291253451eded3beda3d6ac8e9cdc Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Wed, 21 Oct 2026 20:46:29 +0200 Subject: [PATCH 01/11] fix(tests): standalone scripts testing --- core/phpunit.xml | 2 ++ core/tests/Pest.php | 19 ++++++++++++++--- core/tests/Unit/StandaloneScriptsTest.php | 21 +++++++++++++++++++ core/tests/Unit/TestProcessHelpersTest.php | 6 ++++-- .../standalone/DatabaseBackupProgressTest.php | 2 ++ 5 files changed, 45 insertions(+), 5 deletions(-) create mode 100644 core/tests/Unit/StandaloneScriptsTest.php diff --git a/core/phpunit.xml b/core/phpunit.xml index b275b2d2ba..fa13c86954 100644 --- a/core/phpunit.xml +++ b/core/phpunit.xml @@ -7,6 +7,8 @@ ./tests + + ./tests/standalone diff --git a/core/tests/Pest.php b/core/tests/Pest.php index e402adb69d..ce811791af 100644 --- a/core/tests/Pest.php +++ b/core/tests/Pest.php @@ -63,17 +63,30 @@ function evoRunCommand(string $command, ?int &$exitCode = null): string /** * Runs the PHP script $script in a fresh PHP process, for what must not leak between tests - * (constants, globals, loaded classes). stderr joins the output unless $captureStderr is false. + * (constants, globals, loaded classes). stderr joins the output unless $captureStderr is false; + * then it goes to $stderr instead, never to the console of the test run. * * @param string[] $args passed to the script, each shell-escaped (on Windows escapeshellarg() * turns " % ! into spaces) */ -function evoRunPhp(string $script, array $args = [], ?int &$exitCode = null, bool $captureStderr = true): string +function evoRunPhp(string $script, array $args = [], ?int &$exitCode = null, bool $captureStderr = true, ?string &$stderr = null): string { $command = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($script); foreach ($args as $arg) { $command .= ' ' . escapeshellarg((string) $arg); } + $stderr = ''; + if ($captureStderr) { + return evoRunCommand($command . ' 2>&1', $exitCode); + } + + $stderrFile = tempnam(sys_get_temp_dir(), 'evo-stderr-'); + try { + $output = evoRunCommand($command . ' 2>' . escapeshellarg($stderrFile), $exitCode); + $stderr = (string) file_get_contents($stderrFile); + } finally { + @unlink($stderrFile); + } - return evoRunCommand($command . ($captureStderr ? ' 2>&1' : ''), $exitCode); + return $output; } diff --git a/core/tests/Unit/StandaloneScriptsTest.php b/core/tests/Unit/StandaloneScriptsTest.php new file mode 100644 index 0000000000..e0031b039b --- /dev/null +++ b/core/tests/Unit/StandaloneScriptsTest.php @@ -0,0 +1,21 @@ +not->toBeEmpty(); +}); + +test('standalone script passes cleanly', function (string $script) { + $output = evoRunPhp($script, [], $exitCode); + + expect($exitCode)->toBe(0, $output) + ->and($output)->toContain('PASS:') + ->and($output)->not->toMatch('/\b(Fatal error|Warning|Notice|Deprecated):/'); +})->with(array_combine(array_map('basename', $standaloneScripts), array_map(fn ($script) => [$script], $standaloneScripts))); diff --git a/core/tests/Unit/TestProcessHelpersTest.php b/core/tests/Unit/TestProcessHelpersTest.php index 189b014f4a..9a6d3d8f27 100644 --- a/core/tests/Unit/TestProcessHelpersTest.php +++ b/core/tests/Unit/TestProcessHelpersTest.php @@ -99,13 +99,15 @@ function system_status() {} $script = processHelperScript('fwrite(STDERR, "to-stderr"); echo "to-stdout";'); try { $withStderr = evoRunPhp($script); - $withoutStderr = evoRunPhp($script, [], $exitCode, false); + $withoutStderr = evoRunPhp($script, [], $exitCode, false, $stderr); } finally { unlink($script); } + // kept apart, stderr is handed back rather than left to reach the console of the test run expect($withStderr)->toContain('to-stdout')->toContain('to-stderr') - ->and($withoutStderr)->toBe('to-stdout'); + ->and($withoutStderr)->toBe('to-stdout') + ->and($stderr)->toBe('to-stderr'); }); it('leaves no direct shell_exec or exec calls in the tests', function () { diff --git a/core/tests/standalone/DatabaseBackupProgressTest.php b/core/tests/standalone/DatabaseBackupProgressTest.php index fa3dbf32c9..4e5667935a 100644 --- a/core/tests/standalone/DatabaseBackupProgressTest.php +++ b/core/tests/standalone/DatabaseBackupProgressTest.php @@ -1,6 +1,8 @@ progress = $values['progress']; } From 5b4a7f04f44d2bf67e0c2b6adaf245287dd76663 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Thu, 22 Oct 2026 03:31:36 +0200 Subject: [PATCH 02/11] fix(tests): without bundled composer on PATH --- .../Unit/Console/SiteUpdateCommandTest.php | 37 ++++++++++++++++--- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/core/tests/Unit/Console/SiteUpdateCommandTest.php b/core/tests/Unit/Console/SiteUpdateCommandTest.php index 64d16bba91..2a53d17807 100644 --- a/core/tests/Unit/Console/SiteUpdateCommandTest.php +++ b/core/tests/Unit/Console/SiteUpdateCommandTest.php @@ -12,6 +12,29 @@ function invokeSiteUpdateMethod(SiteUpdateCommand $command, string $method, arra return $instanceMethod->invokeArgs($command, $args); } +/** + * Runs a test without core/vendor/bin on the PATH, as make:site runs. + * + * "composer test" puts that directory first on the PATH of its scripts, where a + * bare "composer" is then the copy of core/vendor. + */ +function withoutBundledComposerOnPath(callable $test): mixed +{ + $path = getenv('PATH'); + $bin = realpath(dirname(__DIR__, 3) . '/vendor/bin'); + $directories = array_filter( + explode(PATH_SEPARATOR, (string) $path), + fn (string $directory) => $bin === false || realpath($directory) !== $bin + ); + putenv('PATH=' . implode(PATH_SEPARATOR, $directories)); + + try { + return $test(); + } finally { + $path === false ? putenv('PATH') : putenv('PATH=' . $path); + } +} + test('buildArchiveUrl uses tag archive path for semantic versions', function () { $url = invokeSiteUpdateMethod($this->command, 'buildArchiveUrl', ['evolution-cms/evolution', '3.5.5']); @@ -111,10 +134,12 @@ function invokeSiteUpdateMethod(SiteUpdateCommand $command, string $method, arra expect(strpos($source, 'buildCustomComposerUpdateCommand'))->toBeLessThan(strpos($source, '$this->composerInstallCommand()')); expect(strpos($source, '$this->composerInstallCommand()'))->toBeLessThan(strpos($source, '$this->composerDumpAutoloadCommand()')); expect(strpos($source, '$this->composerDumpAutoloadCommand()'))->toBeLessThan(strpos($source, "runCoreShellCommand('php artisan package:discover')")); - expect(invokeSiteUpdateMethod($this->command, 'composerInstallCommand')) - ->toBe('composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader --classmap-authoritative --no-scripts'); - expect(invokeSiteUpdateMethod($this->command, 'composerDumpAutoloadCommand')) - ->toBe('composer dump-autoload -o --no-dev --classmap-authoritative --no-scripts'); + withoutBundledComposerOnPath(function () { + expect(invokeSiteUpdateMethod($this->command, 'composerInstallCommand')) + ->toBe('composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader --classmap-authoritative --no-scripts'); + expect(invokeSiteUpdateMethod($this->command, 'composerDumpAutoloadCommand')) + ->toBe('composer dump-autoload -o --no-dev --classmap-authoritative --no-scripts'); + }); $composerBinary === false ? putenv('COMPOSER_BINARY') : putenv('COMPOSER_BINARY=' . $composerBinary); $composerBin === false ? putenv('COMPOSER_BIN') : putenv('COMPOSER_BIN=' . $composerBin); @@ -154,7 +179,7 @@ function invokeSiteUpdateMethod(SiteUpdateCommand $command, string $method, arra 'bad package' => '', 'seiger/scommerce' => '', ], true); - $command = invokeSiteUpdateMethod($this->command, 'buildCustomComposerUpdateCommand', [$arguments]); + $command = withoutBundledComposerOnPath(fn () => invokeSiteUpdateMethod($this->command, 'buildCustomComposerUpdateCommand', [$arguments])); // The builder quotes each package with escapeshellarg(), whose quoting style // is platform specific, so the expectation is quoted the same way. @@ -989,7 +1014,7 @@ public function error($string, $verbosity = null) try { $standalone = preflightCommand($workspace . '/custom-composer.json', $drift); - expect(invokeSiteUpdateMethod($standalone, 'preflightComposer', [$target, [], []]))->toBe('') + expect(withoutBundledComposerOnPath(fn () => invokeSiteUpdateMethod($standalone, 'preflightComposer', [$target, [], []])))->toBe('') ->and($standalone->commands)->toBe([]); $vendorCopy = preflightCommand($workspace . '/custom-composer.json', $drift, false, $bundled); From 99d91410588a5556c3a9edf91e4d0153484f704e Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Thu, 22 Oct 2026 04:45:34 +0200 Subject: [PATCH 03/11] fix(manager): admins own active user record --- core/src/Services/ManagerActivity.php | 3 ++- .../Unit/Services/ManagerActivityTest.php | 25 +++++++++++++++++++ manager/views/page/2.blade.php | 2 ++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/core/src/Services/ManagerActivity.php b/core/src/Services/ManagerActivity.php index 5466d8b425..0d3e66df16 100644 --- a/core/src/Services/ManagerActivity.php +++ b/core/src/Services/ManagerActivity.php @@ -15,7 +15,8 @@ * Each of these used to be its own autocommit statement inside the request - six or seven * fsyncs on every manager page, about 55 ms of the ~80 ms bootstrap on an fsync-per-commit * MySQL. Nothing in the same request reads its own bookkeeping back (the lock and action - * queries exclude the current user), so nothing observable moves. + * queries exclude the current user), so nothing observable moves - except the dashboard's + * online users widget, which lists the viewer too and flushes before it reads. * @since 3.5.9 */ final class ManagerActivity diff --git a/core/tests/Unit/Services/ManagerActivityTest.php b/core/tests/Unit/Services/ManagerActivityTest.php index ea659b7671..1004aab4de 100644 --- a/core/tests/Unit/Services/ManagerActivityTest.php +++ b/core/tests/Unit/Services/ManagerActivityTest.php @@ -136,6 +136,31 @@ function bootActivityDatabase(): Capsule ->and($activity->hasPending())->toBeFalse(); }); +test('the dashboard writes the viewer\'s own activity before the online users widget reads it', function () { + $view = file_get_contents(dirname(__DIR__, 4) . '/manager/views/page/2.blade.php'); + $flush = strpos($view, 'ManagerActivity::instance()->flush()'); + + expect($flush)->toBeInt() + ->and($flush)->toBeGreaterThan(strpos($view, "hasPermission('widget_online_info')")) + ->and($flush)->toBeLessThan(strpos($view, "->join('active_users'")); +}); + +test('a flush mid-request makes the queued session and action visible, later writes queue again', function () { + $activity = ManagerActivity::instance(); + $activity->touchSession(7, 'sid-a', 1000, '10.0.0.1'); + $activity->setAction(7, 'sid-a', 'admin', 1000, 2, 0); + + $activity->flush(); + + expect(Capsule::table('active_user_sessions') + ->join('active_users', 'active_users.sid', '=', 'active_user_sessions.sid') + ->pluck('username')->all())->toBe(['admin']); + + $activity->lock(7, 42, 7, 'sid-a', 1000); + + expect($activity->hasPending())->toBeTrue(); +}); + test('the manager hands its bookkeeping to the service and flushes after the response is sent', function () { $core = file_get_contents(dirname(__DIR__, 3) . '/src/Core.php'); $theme = file_get_contents(dirname(__DIR__, 3) . '/src/ManagerTheme.php'); diff --git a/manager/views/page/2.blade.php b/manager/views/page/2.blade.php index 270cd77dd2..03dbef6c9c 100644 --- a/manager/views/page/2.blade.php +++ b/manager/views/page/2.blade.php @@ -163,6 +163,8 @@ 'logincount' => $_SESSION['mgrLogincount'] + 1, ]); if (evo()->hasPermission('widget_online_info')) { + // The widget reads the viewer's own session and action, queued for after the response: write them first. + \EvolutionCMS\Services\ManagerActivity::instance()->flush(); $activeUsers = \EvolutionCMS\Models\ActiveUserSession::query() ->join('active_users', 'active_users.sid', '=', 'active_user_sessions.sid') ->where('active_users.action', '<>', 8) From b20d08c47b12e0fb25eaa41753a9ca4e690ea975 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Fri, 23 Oct 2026 02:34:31 +0200 Subject: [PATCH 04/11] fix(core): thumbnails default server rules and thumbnails generation security --- core/src/HelperProcessor.php | 107 +++++++- core/tests/Unit/PhpThumbCacheImagesTest.php | 272 ++++++++++++++++++++ ht.access | 6 + ng.inx | 9 +- 4 files changed, 385 insertions(+), 9 deletions(-) create mode 100644 core/tests/Unit/PhpThumbCacheImagesTest.php diff --git a/core/src/HelperProcessor.php b/core/src/HelperProcessor.php index a4adf99e02..4d27eb285a 100644 --- a/core/src/HelperProcessor.php +++ b/core/src/HelperProcessor.php @@ -1,9 +1,29 @@ \n Require all granted\n\n" + . "\n Order deny,allow\n Allow from all\n\n"; + + /** + * Set for a day once no WebP converter works here (a GD without WebP and no cwebp, imagick, ...), + * so the stack is not tried, and logged, again for every image; clearing the cache retries. + */ + const WEBP_UNAVAILABLE_KEY = 'evo.phpthumb.webp_unavailable'; + + const ALLOWED_PARAMS = ['w', 'h', 'wp', 'hp', 'wl', 'hl', 'ws', 'hs', 'q', 'sx', 'sy', 'sw', 'sh', + 'zc', 'ica', 'bc', 'bg', 'bgt', 'fltr', 'xto', 'ra', 'ar', 'aoe', 'far', 'iar', 'maxb', 'dpi']; + + const ALLOWED_FORMATS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'bmp', 'ico', 'wbmp']; + /** * @var Interfaces\CoreInterface */ @@ -61,15 +81,17 @@ public function phpThumb($input = '', $options = '', $webp = true) $cacheFolder !== $defaultCacheFolder && strpos($cacheFolder, $defaultCacheFolder) === 0 ) { - file_put_contents(EVO_BASE_PATH . $cacheFolder . '/.htaccess', "order deny,allow\nallow from all\n"); + file_put_contents(EVO_BASE_PATH . $cacheFolder . '/.htaccess', self::CACHE_FOLDER_HTACCESS); } $path_parts = pathinfo($input); $ext = strtolower($path_parts['extension']); - $options = 'f=' . (in_array($ext, ['png', 'gif', 'jpeg']) ? $ext : 'jpg&q=85') . '&' . + $defaultFormat = in_array($ext, ['png', 'gif', 'jpeg'], true) ? $ext : 'jpg'; + $options = 'f=' . ($defaultFormat === 'jpg' ? 'jpg&q=85' : $defaultFormat) . '&' . strtr($options, [',' => '&', '_' => '=', '{' => '[', '}' => ']']); parse_str($options, $params); + $params = $this->sanitizeParams($params, $defaultFormat); $fName = $this->makeFilename($path_parts, $params); @@ -77,7 +99,7 @@ public function phpThumb($input = '', $options = '', $webp = true) $tmpImagesFolder = explode('/', $tmpImagesFolder); foreach ($tmpImagesFolder as $folder) { - if (!empty($folder)) { + if (!empty($folder) && $folder !== '.' && $folder !== '..') { $cacheFolder .= '/' . $folder; $path = EVO_BASE_PATH . $cacheFolder; if (!file_exists($path) && mkdir($path) && is_dir($path)) { @@ -116,12 +138,9 @@ public function phpThumb($input = '', $options = '', $webp = true) } } - if (isset($webp) && class_exists('\WebPConvert\WebPConvert')) { + if ($webp && class_exists('\WebPConvert\WebPConvert')) { if( isset( $_SERVER['HTTP_ACCEPT'] ) && strpos( $_SERVER['HTTP_ACCEPT'], 'image/webp' ) !== false && pathinfo($outputFilename, PATHINFO_EXTENSION) != 'gif') { - if (file_exists($outputFilename . '.webp')) { - $fNameSuf .= '.webp'; - } else { - WebPConvert::convert($outputFilename, $outputFilename . '.webp'); + if ($this->makeWebp($outputFilename)) { $fNameSuf .= '.webp'; } } @@ -129,4 +148,76 @@ public function phpThumb($input = '', $options = '', $webp = true) return $fNamePref . rawurlencode($fName) . $fNameSuf; } + + /** + * @param array $params + * @return array + */ + protected function sanitizeParams(array $params, string $defaultFormat): array + { + $format = isset($params['f']) && is_string($params['f']) ? strtolower($params['f']) : $defaultFormat; + if (!in_array($format, self::ALLOWED_FORMATS, true)) { + $format = $defaultFormat; + } + + $safe = []; + foreach (self::ALLOWED_PARAMS as $key) { + if (array_key_exists($key, $params)) { + $safe[$key] = $params[$key]; + } + } + $safe['f'] = $format; + + return $safe; + } + + /** + * Writes $filename.webp next to the thumbnail unless it is there already. + * + * @return bool false when there is no WebP copy to link: the caller keeps the thumbnail itself + */ + protected function makeWebp(string $filename): bool + { + if (file_exists($filename . '.webp')) { + return true; + } + if (!file_exists($filename) || $this->rememberedWebpUnavailable()) { + return false; + } + try { + $this->convertToWebp($filename, $filename . '.webp'); + } catch (\Throwable $e) { + if ($e instanceof ConverterNotOperationalException) { + $this->rememberWebpUnavailable(); + } + $this->core->logEvent(0, 2, 'WebP copy of ' . e(basename($filename)) . ' not made, the thumbnail is served as it is: ' + . e($e->getMessage()), 'phpthumb'); + + return false; + } + + return file_exists($filename . '.webp'); + } + + protected function convertToWebp(string $source, string $destination): void + { + WebPConvert::convert($source, $destination); + } + + protected function rememberedWebpUnavailable(): bool + { + try { + return (bool) Cache::get(self::WEBP_UNAVAILABLE_KEY, false); + } catch (\Throwable $e) { + return false; // no cache store here: try every time + } + } + + protected function rememberWebpUnavailable(): void + { + try { + Cache::put(self::WEBP_UNAVAILABLE_KEY, true, 86400); + } catch (\Throwable $e) { + } + } } diff --git a/core/tests/Unit/PhpThumbCacheImagesTest.php b/core/tests/Unit/PhpThumbCacheImagesTest.php new file mode 100644 index 0000000000..b2160d3669 --- /dev/null +++ b/core/tests/Unit/PhpThumbCacheImagesTest.php @@ -0,0 +1,272 @@ + ['/assets/cache/images/demo/photo-320x200-fd5.jpg', true], + 'jpeg thumbnail' => ['/assets/cache/images/photo-x200-fd5.jpeg', true], + 'png thumbnail' => ['/assets/cache/images/a/b/logo-200x-402.PNG', true], + 'gif thumbnail' => ['/assets/cache/images/logo-120x-4b8.gif', true], + 'avif thumbnail' => ['/assets/cache/images/photo-100x-abc.avif', true], + 'bmp thumbnail' => ['/assets/cache/images/photo-100x-abc.bmp', true], + 'ico thumbnail' => ['/assets/cache/images/photo-16x-abc.ico', true], + 'wbmp thumbnail' => ['/assets/cache/images/photo-100x-abc.wbmp', true], + 'webp copy' => ['/assets/cache/images/demo/photo-320x200-fd5.jpg.webp', true], + 'image straight in the cache' => ['/assets/cache/probe.png', false], + 'image in another cache folder' => ['/assets/cache/store/probe.png', false], + 'php in images' => ['/assets/cache/images/probe.php', false], + 'php with an image suffix' => ['/assets/cache/images/probe.png.php', false], + 'svg' => ['/assets/cache/images/probe.svg', false], + 'html' => ['/assets/cache/images/probe.html', false], + 'video is never a phpThumb output' => ['/assets/cache/images/clip.mp4', false], + 'webm is never a phpThumb output' => ['/assets/cache/images/clip.webm', false], + 'htaccess' => ['/assets/cache/images/.htaccess', false], + 'folder' => ['/assets/cache/images/', false], + 'site cache' => ['/assets/cache/siteCache.idx.php', false], +]); + +it('lets nginx serve phpThumb images in assets/cache/images and nothing else of the cache', function (string $uri, bool $served) { + $nginx = phpThumbRepoFile('ng.inx'); + expect(preg_match('/location ~\* (\^\/assets\/cache\/images\/\S+) \{/', $nginx, $allow))->toBe(1); + $deny = 'location ~ ^/assets/(cache|backup|export|import)(/|$)'; + + // nginx tries regex locations in order: the exception only counts above the deny + expect(strpos($nginx, $allow[0]))->toBeLessThan(strpos($nginx, $deny)); + expect(preg_match('~' . $allow[1] . '~i', $uri))->toBe($served ? 1 : 0); + expect(preg_match('~^/assets/(cache|backup|export|import)(/|$)~', $uri))->toBe(1); // the deny takes the rest +})->with('phpthumb cache paths'); + +it('lets apache serve phpThumb images in assets/cache/images and nothing else of the cache', function (string $uri, bool $served) { + $htaccess = str_replace("\r\n", "\n", phpThumbRepoFile('ht.access')); + expect(preg_match('/RewriteCond %\{REQUEST_FILENAME\} \\\\\.\(([a-z?|]+)\)\$ \[NC\]\n' + . 'RewriteCond %\{REQUEST_FILENAME\} -f\n' + . 'RewriteRule \^assets\/cache\/images\/ - \[L\]\n/', $htaccess, $allow))->toBe(1); + $deny = 'RewriteRule ^assets/(cache|backup|export|import)(/|$) - [F,L]'; + + // the image exception must sit directly above the deny that ends rewriting for the rest of the cache + expect(strpos($htaccess, 'RewriteRule ^assets/cache/images/ - [L]'))->toBeLessThan(strpos($htaccess, $deny)); + + // per-directory rules see the path without the leading slash; REQUEST_FILENAME is the file it resolves to + $path = ltrim($uri, '/'); + $passes = preg_match('~^assets/cache/images/~', $path) === 1 && preg_match('~\.(' . $allow[1] . ')$~i', $path) === 1; + expect($passes)->toBe($served); +})->with('phpthumb cache paths'); + +it('keeps the rest of assets/cache closed in the per-directory file', function () { + expect(phpThumbRepoFile('assets/cache/.htaccess')) + ->toContain('Require all denied') + ->not->toContain('FilesMatch') + ->not->toContain('granted'); +}); + +it('writes the same two-dialect .htaccess into the thumbnail folder as the one shipped there', function () { + expect(HelperProcessor::CACHE_FOLDER_HTACCESS) + ->toBe(str_replace("\r\n", "\n", phpThumbRepoFile('assets/cache/images/.htaccess'))); +}); + +it('honours $webp = false', function () { + expect(phpThumbRepoFile('core/src/HelperProcessor.php')) + ->toContain('if ($webp && class_exists(') + ->not->toContain('isset($webp)'); +}); + +/** + * A HelperProcessor exposing the protected option sanitiser, with no Core needed. + */ +function sanitisingHelper(): object +{ + $core = test()->getMockBuilder(Core::class)->disableOriginalConstructor()->getMock(); + + return new class ($core) extends HelperProcessor { + /** @param array $params */ + public function sanitise(array $params, string $default): array + { + return $this->sanitizeParams($params, $default); + } + }; +} + +it('pins the output format to a real image format, never an executable extension', function (string $requested, string $default, string $expected) { + $out = sanitisingHelper()->sanitise(['f' => $requested, 'w' => 100], $default); + expect($out['f'])->toBe($expected); +})->with([ + 'php is refused' => ['php', 'jpg', 'jpg'], + 'phtml is refused' => ['phtml', 'png', 'png'], + 'empty falls back' => ['', 'gif', 'gif'], + 'unknown falls back' => ['exe', 'jpg', 'jpg'], + 'webp kept' => ['webp', 'jpg', 'webp'], + 'avif kept' => ['avif', 'jpg', 'avif'], + 'uppercase normalised' => ['PNG', 'jpg', 'png'], +]); + +it('drops every option key the phpThumb library must not receive from a caller', function () { + $out = sanitisingHelper()->sanitise([ + 'w' => 120, 'h' => 80, 'zc' => 1, 'q' => 90, 'fltr' => ['flip|x'], // kept + 'config_cache_directory' => '/tmp/evil', // config_* injection + 'config_imagemagick_path' => 'touch /tmp/x;', + 'config_allow_src_above_docroot' => 1, + 'config_document_root' => '/', + 'src' => '/etc/passwd', 'new' => 'x', 'file' => 'y', // source repointing + 'nocache' => 1, 'phpThumbDebug' => 1, 'md5s' => 'x', // not geometry + ], 'jpg'); + + expect($out)->toHaveKeys(['w', 'h', 'zc', 'q', 'fltr', 'f']) + ->and(array_keys($out))->each->not->toStartWith('config') + ->and($out)->not->toHaveKey('src') + ->and($out)->not->toHaveKey('new') + ->and($out)->not->toHaveKey('file') + ->and($out)->not->toHaveKey('nocache') + ->and($out)->not->toHaveKey('phpThumbDebug') + ->and($out)->not->toHaveKey('md5s'); +}); + +it('keeps thumbnails inside the cache folder when input tries to climb out', function () { + expect(phpThumbRepoFile('core/src/HelperProcessor.php')) + ->toContain("\$folder !== '.' && \$folder !== '..'"); +}); + +/** + * A HelperProcessor whose WebP converter is $convert, with the event log collected in $log. + */ +function webpHelper(callable $convert, array &$log, int &$calls): HelperProcessor +{ + $core = test()->getMockBuilder(Core::class)->disableOriginalConstructor()->onlyMethods(['logEvent'])->getMock(); + $core->method('logEvent')->willReturnCallback(function ($id, $type, $msg, $source) use (&$log) { + $log[] = [$type, $source, $msg]; + }); + + return new class ($core, $convert, $calls) extends HelperProcessor { + private $convert; + private $calls; + + public function __construct($core, callable $convert, int &$calls) + { + parent::__construct($core); + $this->convert = $convert; + $this->calls = &$calls; + } + + public function webp(string $filename): bool + { + return $this->makeWebp($filename); + } + + protected function convertToWebp(string $source, string $destination): void + { + $this->calls++; + ($this->convert)($source, $destination); + } + }; +} + +beforeEach(function () { + Cache::swap(new Repository(new ArrayStore())); + $this->thumb = sys_get_temp_dir() . '/evo-phpthumb-' . bin2hex(random_bytes(6)) . '.jpg'; + file_put_contents($this->thumb, 'jpeg'); +}); + +afterEach(function () { + @unlink($this->thumb); + @unlink($this->thumb . '.webp'); + Facade::clearResolvedInstances(); +}); + +it('serves the thumbnail itself when no WebP converter works, and stops trying for a while', function () { + $log = []; + $calls = 0; + $helper = webpHelper(function () { + throw new ConverterNotOperationalException('None of the converters in the stack are operational'); + }, $log, $calls); + + expect($helper->webp($this->thumb))->toBeFalse() + ->and($helper->webp($this->thumb))->toBeFalse() + ->and($calls)->toBe(1) + ->and($log)->toHaveCount(1) + ->and($log[0][0])->toBe(2) + ->and($log[0][1])->toBe('phpthumb') + ->and($log[0][2])->toContain(basename($this->thumb))->toContain('None of the converters') + ->and(Cache::get(HelperProcessor::WEBP_UNAVAILABLE_KEY))->toBeTrue(); +}); + +it('serves the thumbnail itself when one conversion fails, and tries the next image again', function () { + $log = []; + $calls = 0; + $helper = webpHelper(function () { + throw new RuntimeException('broken image'); + }, $log, $calls); + + expect($helper->webp($this->thumb))->toBeFalse() + ->and($helper->webp($this->thumb))->toBeFalse() + ->and($calls)->toBe(2) + ->and($log)->toHaveCount(2) + ->and($log[0][2])->toContain('<b>broken</b>')->not->toContain('') + ->and(Cache::get(HelperProcessor::WEBP_UNAVAILABLE_KEY))->toBeNull(); +}); + +it('links the WebP copy once it is made, and makes it only once', function () { + $log = []; + $calls = 0; + $helper = webpHelper(function ($source, $destination) { + file_put_contents($destination, 'webp'); + }, $log, $calls); + + expect($helper->webp($this->thumb))->toBeTrue() + ->and($helper->webp($this->thumb))->toBeTrue() + ->and($calls)->toBe(1) + ->and($log)->toBe([]) + ->and(file_get_contents($this->thumb . '.webp'))->toBe('webp'); +}); + +it('does not try to convert a thumbnail phpThumb failed to write', function () { + $log = []; + $calls = 0; + $helper = webpHelper(fn () => null, $log, $calls); + @unlink($this->thumb); + + expect($helper->webp($this->thumb))->toBeFalse() + ->and($calls)->toBe(0) + ->and($log)->toBe([]); +}); + +it('works without a cache store, trying every time', function () { + Facade::clearResolvedInstances(); + $app = Facade::getFacadeApplication(); + Facade::setFacadeApplication(null); + try { + $log = []; + $calls = 0; + $helper = webpHelper(function () { + throw new ConverterNotOperationalException('none'); + }, $log, $calls); + + expect($helper->webp($this->thumb))->toBeFalse() + ->and($helper->webp($this->thumb))->toBeFalse() + ->and($calls)->toBe(2); + } finally { + Facade::setFacadeApplication($app); + } +}); diff --git a/ht.access b/ht.access index efd36111eb..9013b63c1f 100644 --- a/ht.access +++ b/ht.access @@ -90,7 +90,13 @@ RewriteRule .* - [F,L] # core, views, vendor, tmp - CMS core (config, .env, storage), Blade layouts, deps, scratch # assets/(cache|backup|export|import) - generated and writable areas; the per-directory # .htaccess files there only work with AllowOverride +# except assets/cache/images - phpThumb thumbnails (Helper::phpThumb()), linked from pages: +# existing image files only. REQUEST_FILENAME is the file Apache +# resolved, so a script with PATH_INFO (x.php/y.png) is no image. RewriteRule ^(core|views|vendor|tmp)(/|$) - [F,L] +RewriteCond %{REQUEST_FILENAME} \.(jpe?g|png|gif|webp|avif|bmp|ico|wbmp)$ [NC] +RewriteCond %{REQUEST_FILENAME} -f +RewriteRule ^assets/cache/images/ - [L] RewriteRule ^assets/(cache|backup|export|import)(/|$) - [F,L] # Root-level files that fingerprint the install or describe its layout. diff --git a/ng.inx b/ng.inx index e266edfae1..fe249cfe2a 100644 --- a/ng.inx +++ b/ng.inx @@ -150,7 +150,14 @@ server { # --------------------------------------------------------------------- location ~ ^/(core|views|vendor|tmp)(/|$) { access_log off; return 404; } - # Writable / generated areas under assets. + # Writable / generated areas under assets. The one exception, and it has to + # come first: phpThumb thumbnails (Helper::phpThumb()) in assets/cache/images, + # which pages link to - image files only, served as static files. + location ~* ^/assets/cache/images/.+\.(jpe?g|png|gif|webp|avif|bmp|ico|wbmp)$ { + access_log off; + log_not_found off; + expires max; + } location ~ ^/assets/(cache|backup|export|import)(/|$) { access_log off; return 404; } # Root-level files that fingerprint the install or describe its layout. From 9f197b1880c541e2fa5c2e247753daf3ce0000c4 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Sat, 24 Oct 2026 21:38:30 +0200 Subject: [PATCH 05/11] fix(manager): resource change indicator after save and continue --- manager/actions/mutate_content.dynamic.php | 1 + manager/media/script/document-save-helper.js | 14 +++++++ .../script/tests/document-save-helper.test.js | 13 ++++++ manager/media/style/default/js/evo.js | 40 ++++++++++++++----- 4 files changed, 58 insertions(+), 10 deletions(-) diff --git a/manager/actions/mutate_content.dynamic.php b/manager/actions/mutate_content.dynamic.php index 4b3e91ef79..6e58db9b07 100644 --- a/manager/actions/mutate_content.dynamic.php +++ b/manager/actions/mutate_content.dynamic.php @@ -212,6 +212,7 @@ function ajaxSave() { return; } ajaxSaveHelper.applyResult(form, answer.result, document.querySelector('#create_edit > h1'), tokenMeta); + ajaxSaveHelper.announceSaved(document); if (button) { button.classList.add('saved'); setTimeout(function () { button.classList.remove('saved'); }, 1500); diff --git a/manager/media/script/document-save-helper.js b/manager/media/script/document-save-helper.js index 5e4ea1ae7f..f82c68c043 100644 --- a/manager/media/script/document-save-helper.js +++ b/manager/media/script/document-save-helper.js @@ -10,6 +10,7 @@ 'use strict'; var TITLE_LENGTH = 50; + var SAVED_EVENT = 'evo:saved'; function fieldValue(form, name) { var field = form && form.elements ? form.elements[name] : null; @@ -114,7 +115,20 @@ return changed; } + // An in-place save does not reload the editor, so the manager tab showing it (evo.js) + // learns from this event that the changes are stored and drops its "changed" mark. + function announceSaved(doc) { + var view = doc && doc.defaultView; + if (!view || typeof view.Event !== 'function' || typeof doc.dispatchEvent !== 'function') { + return false; + } + + return doc.dispatchEvent(new view.Event(SAVED_EVENT)); + } + return { + savedEvent: SAVED_EVENT, + announceSaved: announceSaved, usesAjax: usesAjax, requestUrl: requestUrl, requestBody: requestBody, diff --git a/manager/media/script/tests/document-save-helper.test.js b/manager/media/script/tests/document-save-helper.test.js index d36626c31b..643f0f241c 100644 --- a/manager/media/script/tests/document-save-helper.test.js +++ b/manager/media/script/tests/document-save-helper.test.js @@ -98,3 +98,16 @@ test('shortTitle truncates like the editor heading does', () => { assert.equal(helper.shortTitle('x'.repeat(60)), 'x'.repeat(50) + '...'); assert.equal(helper.shortTitle(undefined), ''); }); + +test('announceSaved tells the manager tab that the in-place save went through', () => { + const doc = new EventTarget(); + doc.defaultView = { Event }; + const seen = []; + doc.addEventListener(helper.savedEvent, (e) => seen.push(e.type)); + + assert.equal(helper.announceSaved(doc), true); + assert.deepEqual(seen, ['evo:saved']); + // no window (detached frame) or no document: nothing to tell + assert.equal(helper.announceSaved({ dispatchEvent: () => true }), false); + assert.equal(helper.announceSaved(null), false); +}); diff --git a/manager/media/style/default/js/evo.js b/manager/media/style/default/js/evo.js index 1b039cf829..d2d8909fb4 100644 --- a/manager/media/style/default/js/evo.js +++ b/manager/media/style/default/js/evo.js @@ -2129,21 +2129,41 @@ } else if (prevMain) { w.main = prevMain; } - tabWindow.document.addEventListener('click', function a(e) { - if (typeof e.view.documentDirty !== 'undefined' && e.view.documentDirty && !s.tab.classList.contains('changed')) { - s.tab.classList.add('changed'); - this.removeEventListener(e.type, a, false); - } - }, false); - tabWindow.document.addEventListener('keyup', function a(e) { - if (e.view && e.view.documentDirty && !s.tab.classList.contains('changed')) { - s.tab.classList.add('changed'); - this.removeEventListener(e.type, a, false); + this.watchChanges(tabWindow); + // a save that keeps the editor open (no reload) clears the mark here + tabWindow.document.addEventListener('evo:saved', function () { + if (!tabWindow.documentDirty) { + s.tab.classList.remove('changed'); + s.watchChanges(tabWindow); } }, false); } } }, + watchChanges: function (tabWindow) { + var s = this; + var doc = tabWindow.document; + var events = ['click', 'keyup']; + var mark = function () { + if (!tabWindow.documentDirty) { + return; + } + s.tab.classList.add('changed'); + events.forEach(function (type) { + doc.removeEventListener(type, mark, false); + }); + }; + if (this.markChanged && this.markChangedDoc === doc) { + events.forEach(function (type) { + doc.removeEventListener(type, s.markChanged, false); + }); + } + this.markChanged = mark; + this.markChangedDoc = doc; + events.forEach(function (type) { + doc.addEventListener(type, mark, false); + }); + }, cleanTitle: function () { var titleEl = this.tab && this.tab.querySelector ? this.tab.querySelector('.tab-title') : null; if (!titleEl) { From 374a6f470bf101ec85bb1bc74423670232c9bc23 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Sun, 25 Oct 2026 19:45:19 +0100 Subject: [PATCH 06/11] fix(core): session proxy filename validation --- core/functions/session_proxy.php | 7 +- .../Store/PackageInstallFlowService.php | 2 +- .../Mocks/legacy_session_read_worker.php | 9 +++ .../Mocks/store_dependency_error_worker.php | 24 ++++++ .../LegacySessionIdValidationTest.php | 44 ++++++++++ .../Unit/Security/ManagerReflectedXssTest.php | 12 +++ .../StoreDependencyErrorEscapingTest.php | 10 +++ manager/actions/mutate_content.dynamic.php | 10 +-- .../script/tests/tab-changed-mark.test.js | 80 +++++++++++++++++++ 9 files changed, 191 insertions(+), 7 deletions(-) create mode 100644 core/tests/Mocks/legacy_session_read_worker.php create mode 100644 core/tests/Mocks/store_dependency_error_worker.php create mode 100644 core/tests/Unit/Security/LegacySessionIdValidationTest.php create mode 100644 core/tests/Unit/Security/StoreDependencyErrorEscapingTest.php create mode 100644 manager/media/script/tests/tab-changed-mark.test.js diff --git a/core/functions/session_proxy.php b/core/functions/session_proxy.php index 150cbe448e..0be92f67e3 100644 --- a/core/functions/session_proxy.php +++ b/core/functions/session_proxy.php @@ -270,6 +270,11 @@ private static function migrateLegacySessionIfNeeded($store): void */ private static function readLegacySessionPayload(string $sessionId): ?string { + // The cookie becomes part of a file name; Windows resolves "sess_\..\" even without a sess_ folder. + if (preg_match('/^[A-Za-z0-9,-]{1,256}$/', $sessionId) !== 1) { + return null; + } + $savePath = session_save_path(); if (!is_string($savePath) || $savePath === '') { $savePath = sys_get_temp_dir(); @@ -282,7 +287,7 @@ private static function readLegacySessionPayload(string $sessionId): ?string } $file = rtrim($path, "/\\") . DIRECTORY_SEPARATOR . 'sess_' . $sessionId; - if (!is_readable($file)) { + if (!is_file($file) || !is_readable($file)) { return null; } diff --git a/core/src/Services/Store/PackageInstallFlowService.php b/core/src/Services/Store/PackageInstallFlowService.php index d10c67c79b..f0d23bf3cf 100644 --- a/core/src/Services/Store/PackageInstallFlowService.php +++ b/core/src/Services/Store/PackageInstallFlowService.php @@ -239,7 +239,7 @@ private function buildDependencyErrorHtml(array $get): string

'; - $html .= '

Error installation



Before install ' . htmlspecialchars((string) ($get['name'] ?? '')) . '
Please install this packages:
' . implode('
', $arrDependencies) . '

'; + $html .= '

Error installation



Before install ' . htmlspecialchars((string) ($get['name'] ?? '')) . '
Please install this packages:
' . implode('
', array_map('htmlspecialchars', $arrDependencies)) . '

'; $html .= "

"; return $html; diff --git a/core/tests/Mocks/legacy_session_read_worker.php b/core/tests/Mocks/legacy_session_read_worker.php new file mode 100644 index 0000000000..f46a1ce70f --- /dev/null +++ b/core/tests/Mocks/legacy_session_read_worker.php @@ -0,0 +1,9 @@ + +[, $savePath, $hexId] = $argv; +require_once dirname(__DIR__, 2) . '/functions/session_proxy.php'; + +session_save_path($savePath); +$method = new ReflectionMethod(EvoSessionProxy::class, 'readLegacySessionPayload'); +echo json_encode($method->invoke(null, hex2bin($hexId))); diff --git a/core/tests/Mocks/store_dependency_error_worker.php b/core/tests/Mocks/store_dependency_error_worker.php new file mode 100644 index 0000000000..0a0f97034b --- /dev/null +++ b/core/tests/Mocks/store_dependency_error_worker.php @@ -0,0 +1,24 @@ + ['manager_theme_mode' => 0]]; } + + require dirname(__DIR__, 2) . '/src/Services/Store/PackageInstallFlowService.php'; + + $class = new ReflectionClass(\EvolutionCMS\Services\Store\PackageInstallFlowService::class); + $method = $class->getMethod('buildDependencyErrorHtml'); + echo $method->invoke($class->newInstanceWithoutConstructor(), ['dependencies' => $argv[1], 'name' => 'pkg']); +} diff --git a/core/tests/Unit/Security/LegacySessionIdValidationTest.php b/core/tests/Unit/Security/LegacySessionIdValidationTest.php new file mode 100644 index 0000000000..af9ad1c8f6 --- /dev/null +++ b/core/tests/Unit/Security/LegacySessionIdValidationTest.php @@ -0,0 +1,44 @@ +root = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'evo-legacy-session-' . bin2hex(random_bytes(4)); + $this->store = $this->root . DIRECTORY_SEPARATOR . 'store'; + mkdir($this->store, 0777, true); + file_put_contents($this->root . DIRECTORY_SEPARATOR . 'planted.txt', 'mgrValidated|b:1;'); + file_put_contents($this->store . DIRECTORY_SEPARATOR . 'sess_abc123,DEF-456', 'legacy|s:2:"ok";'); +}); + +afterEach(function () { + @unlink($this->root . DIRECTORY_SEPARATOR . 'planted.txt'); + @unlink($this->store . DIRECTORY_SEPARATOR . 'sess_abc123,DEF-456'); + @rmdir($this->store); + @rmdir($this->root); +}); + +it('reads a legacy session file named by a well-formed id', function () { + expect(legacySessionRead($this->store, 'abc123,DEF-456'))->toBe('legacy|s:2:"ok";'); +}); + +it('refuses ids that would leave the session directory', function (string $id) { + expect(legacySessionRead($this->store, $id))->toBeNull(); +})->with([ + 'slash traversal' => ['/../../planted.txt'], + 'backslash traversal' => ['\\..\\..\\planted.txt'], + 'dot segment' => ['..'], + 'null byte' => ["abc\0"], + 'too long' => [str_repeat('a', 257)], +]); diff --git a/core/tests/Unit/Security/ManagerReflectedXssTest.php b/core/tests/Unit/Security/ManagerReflectedXssTest.php index fdef55a81b..b9f26288c3 100644 --- a/core/tests/Unit/Security/ManagerReflectedXssTest.php +++ b/core/tests/Unit/Security/ManagerReflectedXssTest.php @@ -323,3 +323,15 @@ function paginateLinks(string $extargv): array ->and(html_entity_decode(strip_tags($html)))->toBe('Summer sale & more') ->not->toContain('onerror'); }); + +it('escapes the document fields restored from a posted form', function () { + // A template switch or a failed save re-renders the form from $_POST, not from the saved document. + $source = managerSource('manager/actions/mutate_content.dynamic.php'); + + expect($source) + ->toContain("value=\"\"") + ->toContain("value=\"\"") + ->toContain("name=\"content_dispo\" value=\"\"") + ->not->toContain(": \$content['parent']) ?>") + ->and(substr_count($source, ": (int) (\$content['parent'] ?? 0)) ?>"))->toBe(2); +}); diff --git a/core/tests/Unit/Security/StoreDependencyErrorEscapingTest.php b/core/tests/Unit/Security/StoreDependencyErrorEscapingTest.php new file mode 100644 index 0000000000..0d8d80d254 --- /dev/null +++ b/core/tests/Unit/Security/StoreDependencyErrorEscapingTest.php @@ -0,0 +1,10 @@ +,DocLister']); + + expect($html) + ->toContain('<img src=x onerror=alert(1)>
DocLister') + ->not->toContain('" data-tooltip=""> - + @@ -875,7 +875,7 @@ function evoRenderTvImageCheck(a) { " data-tooltip=""> - + @@ -928,8 +928,8 @@ function evoRenderTvImageCheck(a) { } ?> " onclick="enableParentSelection(!allowParentSelection);"> - () - + () + @@ -1411,7 +1411,7 @@ function evoRenderTvImageCheck(a) { ?> " /> - + classes.add(c), remove: (c) => classes.delete(c), contains: (c) => classes.has(c) } }, + classes, + }; +} + +function frame() { + const doc = new EventTarget(); + const live = { click: new Set(), keyup: new Set() }; + const add = doc.addEventListener.bind(doc); + const remove = doc.removeEventListener.bind(doc); + doc.addEventListener = (type, fn, o) => { live[type].add(fn); add(type, fn, o); }; + doc.removeEventListener = (type, fn, o) => { live[type].delete(fn); remove(type, fn, o); }; + const listeners = () => ({ click: live.click.size, keyup: live.keyup.size }); + return { win: { document: doc, documentDirty: false }, listeners }; +} + +test('the tab is marked changed only once the editor is dirty', () => { + const t = tab(); + const { win, listeners } = frame(); + t.watchChanges(win); + + win.document.dispatchEvent(new Event('click')); + assert.equal(t.classes.has('changed'), false); + + win.documentDirty = true; + win.document.dispatchEvent(new Event('keyup')); + assert.equal(t.classes.has('changed'), true); + assert.deepEqual(listeners(), { click: 0, keyup: 0 }); +}); + +test('watching again after a save does not stack listeners and marks the next edit', () => { + const t = tab(); + const { win, listeners } = frame(); + t.watchChanges(win); + t.watchChanges(win); + assert.deepEqual(listeners(), { click: 1, keyup: 1 }); + + win.documentDirty = true; + win.document.dispatchEvent(new Event('click')); + assert.equal(t.classes.has('changed'), true); + + // what the evo:saved handler does after a successful save + win.documentDirty = false; + t.tab.classList.remove('changed'); + t.watchChanges(win); + assert.deepEqual(listeners(), { click: 1, keyup: 1 }); + + win.documentDirty = true; + win.document.dispatchEvent(new Event('keyup')); + assert.equal(t.classes.has('changed'), true); +}); + +test('the editor announces a successful in-place save and the tab listens for it', () => { + assert.match(editorSource, /ajaxSaveHelper\.applyResult\([^\n]*\);\s*ajaxSaveHelper\.announceSaved\(document\);/); + const handler = evoSource.match(/addEventListener\('([^']+)', function \(\) \{\s*if \(!tabWindow\.documentDirty\) \{\s*s\.tab\.classList\.remove\('changed'\);\s*s\.watchChanges\(tabWindow\);/); + assert.ok(handler, 'evo.js clears the mark on a save event'); + assert.equal(handler[1], helper.savedEvent); +}); From 48fc8830d9d2b58dd9d701658c158b63ecf05e99 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Sun, 25 Oct 2026 20:32:57 +0100 Subject: [PATCH 07/11] fix(core): session proxy filename validation --- core/tests/Unit/Security/SafeHtmlTest.php | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/core/tests/Unit/Security/SafeHtmlTest.php b/core/tests/Unit/Security/SafeHtmlTest.php index 2d31666bb8..2fbe1d94e7 100644 --- a/core/tests/Unit/Security/SafeHtmlTest.php +++ b/core/tests/Unit/Security/SafeHtmlTest.php @@ -156,13 +156,18 @@ expect($out)->toBe('
text
'); }); -test('executable and resource loading elements are removed with their content', function () { - foreach (['script', 'style', 'iframe', 'object', 'embed', 'form', 'svg'] as $tag) { - $out = (string) sanitize_rich_html('

before

<' . $tag . '>payload

after

'); +test('executable and resource loading elements are removed with their content', function (string $tag) { + $out = (string) sanitize_rich_html('

before

<' . $tag . '>payload

after

'); - expect($out)->toBe('

before

after

'); - } -}); + expect($out)->toBe('

before

after

'); +})->with(['script', 'style', 'iframe', 'object', 'form', 'svg']); + +test('void resource loading elements are removed while adjacent text survives', function (string $tag) { + // Void elements have no children; self-closing syntax also keeps older libxml parsers consistent. + $out = (string) sanitize_rich_html('

before

<' . $tag . ' src="https://example.test/resource" />text

after

'); + + expect($out)->toBe('

before

text

after

'); +})->with(['embed', 'img', 'input', 'source', 'track']); test('an unknown element is unwrapped so its text stays visible', function () { expect((string) sanitize_rich_html('still readable')) From 45816e4e03cdaade2accd2577a84f9dc62f902c5 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Mon, 26 Oct 2026 02:21:17 +0100 Subject: [PATCH 08/11] Revert "upd(core): prepare 3.6 development branch and require PHP 8.4" This reverts commit b8cfb08f2cdf039a7d1632df27e8918c398da23f. --- .github/docker/ci/Dockerfile | 2 +- .github/docker/ci/README.md | 4 ++-- .github/docker/ci/docker-compose.yml | 4 ++-- .github/workflows/build.yml | 14 +++++++------- .github/workflows/ci.yml | 8 ++++---- .github/workflows/upgrade.yml | 4 ++-- README.md | 26 +++++++++++++------------- composer.json | 2 +- core/composer.json | 4 ++-- core/factory/version.php | 4 ++-- publiccode.yml | 8 ++++---- 11 files changed, 40 insertions(+), 40 deletions(-) diff --git a/.github/docker/ci/Dockerfile b/.github/docker/ci/Dockerfile index 18a95066b6..0fab93e61a 100644 --- a/.github/docker/ci/Dockerfile +++ b/.github/docker/ci/Dockerfile @@ -6,7 +6,7 @@ # installer, the migrations and the seeded data, and the built in web server is # enough to prove the installed site answers. Which web server serves it is what # the Salo images in .github/docker/Dockerfile are for. -ARG PHP_VERSION=8.4 +ARG PHP_VERSION=8.3 FROM php:${PHP_VERSION}-cli # libpq/libzip/libicu are the build inputs of the extensions below; the -dev diff --git a/.github/docker/ci/README.md b/.github/docker/ci/README.md index f96ece53d0..4aad2c0d19 100644 --- a/.github/docker/ci/README.md +++ b/.github/docker/ci/README.md @@ -34,7 +34,7 @@ EVO_DB_TYPE=sqlite EVO_APP_DIR="$PWD" .github/docker/ci/install-and-smoke.sh ``` `PHP_VERSION`, `MYSQL_IMAGE_TAG`, `POSTGRES_IMAGE_TAG`, `MYSQL_PORT` and -`POSTGRES_PORT` override the defaults (PHP 8.4, MySQL 9.7 LTS, PostgreSQL 18.6 +`POSTGRES_PORT` override the defaults (PHP 8.3, MySQL 9.7 LTS, PostgreSQL 18.6 LTS, the servers' own ports). ## Upgrading @@ -79,7 +79,7 @@ no PHP on it, and what pins the PHP version when a version question is the one being investigated. One PHP version, every database: what the job proves is the installer's database -paths, which do not vary with the PHP minor. PHP 8.4 and 8.5 are covered by +paths, which do not vary with the PHP minor. PHP 8.3 through 8.5 are covered by the analysis and unit test jobs. The sqlite leg starts no container. The same workflow also builds this image from the official `php:8.6-rc-cli` diff --git a/.github/docker/ci/docker-compose.yml b/.github/docker/ci/docker-compose.yml index 7f160caa0d..4af612dca3 100644 --- a/.github/docker/ci/docker-compose.yml +++ b/.github/docker/ci/docker-compose.yml @@ -57,7 +57,7 @@ services: context: ../../.. dockerfile: .github/docker/ci/Dockerfile args: - PHP_VERSION: ${PHP_VERSION:-8.4} + PHP_VERSION: ${PHP_VERSION:-8.3} depends_on: mysql: condition: service_healthy @@ -75,7 +75,7 @@ services: context: ../../.. dockerfile: .github/docker/ci/Dockerfile args: - PHP_VERSION: ${PHP_VERSION:-8.4} + PHP_VERSION: ${PHP_VERSION:-8.3} depends_on: pgsql: condition: service_healthy diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d3abf7e91d..b2a3cf56d4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -58,8 +58,8 @@ jobs: uses: shivammathur/setup-php@v2 with: # Built on the lowest supported version so the vendor tree stays - # usable on 8.4 and 8.5 alike. - php-version: '8.4' + # usable on 8.3 and 8.4 alike. + php-version: '8.3' extensions: ctype, dom, fileinfo, filter, iconv, intl, json, mbstring, openssl, pdo, pdo_sqlite, readline, simplexml, sqlite3, tokenizer, xml, xmlreader, zip coverage: none tools: composer:v2 @@ -72,8 +72,8 @@ jobs: uses: actions/cache@v6 with: path: ${{ steps.composer-cache.outputs.dir }} - key: composer-${{ runner.os }}-php8.4-build-${{ hashFiles('core/composer.lock') }} - restore-keys: composer-${{ runner.os }}-php8.4-build- + key: composer-${{ runner.os }}-php8.3-build-${{ hashFiles('core/composer.lock') }} + restore-keys: composer-${{ runner.os }}-php8.3-build- - name: Install core dependencies working-directory: core @@ -183,9 +183,9 @@ jobs: # the ones that did build stay usable. fail-fast: false matrix: - # 8.4 is the lowest supported version and the one the vendor tree in the + # 8.3 is the lowest supported version and the one the vendor tree in the # zip is built on, so it proves the archive is portable upwards. - php: ['8.4', '8.5'] + php: ['8.3', '8.4', '8.5'] # Three different PHP SAPIs, not three front ends over one PHP: apache # runs mod_php, nginx talks FastCGI to php-fpm, and FrankenPHP embeds # PHP inside Caddy. They cannot share a base image, so each is a build. @@ -196,7 +196,7 @@ jobs: PUSH_IMAGE: ${{ github.event_name != 'workflow_dispatch' || inputs.docker == 'push' }} PHP_VERSION: ${{ matrix.php }} SERVER: ${{ matrix.server }} - # The combination that carries the unsuffixed tags, so `:3.6.x` keeps + # The combination that carries the unsuffixed tags, so `:3.5.x` keeps # meaning what it meant before the matrix existed. DEFAULT_PHP: '8.4' DEFAULT_SERVER: apache diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5af9ca48c..5d04b1fd95 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: strategy: fail-fast: false matrix: - php: ['8.4', '8.5'] + php: ['8.3', '8.4', '8.5'] steps: - uses: actions/checkout@v7 @@ -61,7 +61,7 @@ jobs: strategy: fail-fast: false matrix: - php: ['8.4', '8.5'] + php: ['8.3', '8.4', '8.5'] steps: - uses: actions/checkout@v7 @@ -184,7 +184,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.4' + php-version: '8.3' extensions: ${{ env.PHP_EXTENSIONS }}, pdo_mysql, pdo_pgsql, sqlite3 ini-values: error_reporting=E_ALL, display_errors=On, display_startup_errors=On, log_errors=On, error_log=/dev/stderr coverage: none @@ -224,7 +224,7 @@ jobs: with: # The audit reads composer.lock and the advisory database; it does not run the code, # so one version is enough. Kept at the lowest supported one for consistency. - php-version: '8.4' + php-version: '8.3' extensions: ${{ env.PHP_EXTENSIONS }} coverage: none tools: composer:v2 diff --git a/.github/workflows/upgrade.yml b/.github/workflows/upgrade.yml index fa8e303556..8924575561 100644 --- a/.github/workflows/upgrade.yml +++ b/.github/workflows/upgrade.yml @@ -110,12 +110,12 @@ jobs: if: matrix.leg.db != 'sqlite' run: docker compose -f "new/$COMPOSE" --profile ${{ matrix.leg.db }} up -d --wait ${{ matrix.leg.db }} - # 8.4 is the lowest version both trees support, so it is the one that can + # 8.3 is the lowest version both trees support, so it is the one that can # run the old installer and the new updater in the same job. - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.4' + php-version: '8.3' extensions: ctype, dom, fileinfo, filter, iconv, intl, json, mbstring, openssl, pdo, pdo_mysql, pdo_pgsql, pdo_sqlite, readline, simplexml, sqlite3, tokenizer, xml, xmlreader, zip ini-values: error_reporting=E_ALL, display_errors=On, display_startup_errors=On, log_errors=On, error_log=/dev/stderr coverage: none diff --git a/README.md b/README.md index 8732c7d704..752ccadbf8 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![CMS Evolution](https://img.shields.io/badge/CMS-Evolution-brightgreen.svg)](https://packagist.org/packages/evolution-cms/evolution) [![GitHub release](https://img.shields.io/github/release/evolution-cms/evolution.svg)](https://github.com/evolution-cms/evolution/releases) -[![PHP supported](https://img.shields.io/badge/PHP_supported-8.4_%7C_8.5-777BB4?logo=php&logoColor=white)](#php-compatibility) +[![PHP supported](https://img.shields.io/badge/PHP_supported-8.3_%7C_8.4_%7C_8.5-777BB4?logo=php&logoColor=white)](#php-compatibility) [![PHP recommended](https://img.shields.io/badge/PHP_recommended-8.4-2ea44f?logo=php&logoColor=white)](#php-compatibility) [![Issues](https://img.shields.io/github/issues-closed-raw/evolution-cms/evolution.svg?maxAge=2592000)](https://github.com/evolution-cms/evolution/issues) [![Total Installs](https://img.shields.io/packagist/dt/evolution-cms/evolution)](https://packagist.org/packages/evolution-cms/evolution) @@ -53,21 +53,21 @@ You can use the single click installer: [Evolution CMS Installer](https://github ### PHP compatibility -Evolution CMS **3.6.x** requires **PHP 8.4 or later within PHP 8.x** (`^8.4` in [core/composer.json](core/composer.json)). +Evolution CMS **3.5.x** requires **PHP 8.3 or later within PHP 8.x** (`^8.3` in [core/composer.json](core/composer.json)). | PHP version | Evolution CMS support | Recommendation | | --- | --- | --- | -| **8.4** | Supported; minimum required version | **Recommended for production and new installations** | +| **8.3** | Supported; minimum required version | Suitable for existing installations | +| **8.4** | Supported | **Recommended for production and new installations** | | **8.5** | Supported | Check installed extras and custom code before upgrading | -| **8.6 RC** | Experimental compatibility checks only | Not recommended for production | -The [CI workflow](.github/workflows/ci.yml) runs static analysis and unit tests on PHP **8.4 and 8.5**. PHP **8.6 RC** has a separate experimental compatibility job and is not recommended for production. +The [CI workflow](.github/workflows/ci.yml) runs unit tests on PHP **8.3, 8.4 and 8.5**. PHP **8.6 RC** has a separate experimental compatibility job and is not recommended for production. Use the latest patch release of your chosen PHP version. See the [PHP support lifecycle](https://www.php.net/supported-versions.php) for maintenance and security support dates. ## Update and switch versions -A site can move between a release (such as `3.5.9`), a development branch (such as `3.6.x`) and a fork in either direction. The target version must support the server's PHP version. Take a database backup first: a switch back to an older release keeps the database schema of the newer one. +A site can move between a release (such as `3.5.8`), the `3.5.x` development branch (nightly) and a fork in either direction. Take a database backup first: a switch back to an older release keeps the database schema of the newer one. ### With Artisan (recommended) @@ -75,10 +75,10 @@ Run from the `core/` directory: | Command | What it does | | --- | --- | -| `php artisan make:site` | Updates along the line the site runs. A release moves to the newest release of its major version, a site on `3.6.x` takes the latest `3.6.x` again (run it every night to follow the nightly), a site on a fork branch takes that branch again. It never moves a site back to an older release. | -| `php artisan make:site update 3.6.x` | Switches to the `3.6.x` branch. The site stays on it until a version is passed again. | -| `php artisan make:site update 3.5.9` | Switches to a release, also an older one (back from `3.6.x` or from a fork). | -| `php artisan make:site update nightly-3.6.x` | Switches to the nightly build tag. | +| `php artisan make:site` | Updates along the line the site runs. A release moves to the newest release of its major version, a site on `3.5.x` takes the latest `3.5.x` again (run it every night to follow the nightly), a site on a fork branch takes that branch again. It never moves a site back to an older release. | +| `php artisan make:site update 3.5.x` | Switches to the `3.5.x` branch. The site stays on it until a version is passed again. | +| `php artisan make:site update 3.5.8` | Switches to a release, also an older one (back from `3.5.x` or from a fork). | +| `php artisan make:site update nightly-3.5.x` | Switches to the nightly build tag. | | `php artisan make:site update https://github.com//evolution/tree/` | Switches to a branch or tag of a fork, as GitHub shows it in the browser. `.../releases/tag/` and `.../commit/` work too. A plain `make:site` then stays on that branch of the fork. | | `php artisan make:site update --repository=/evolution` | The same as the URL form. | @@ -98,7 +98,7 @@ Packages added through `core/custom/composer.json` are kept on every update and Get the files of the version you want, lay them over the site and run the installer in update mode: ```bash -composer create-project --no-install evolution-cms/evolution /tmp/evo 3.6.x-dev # or 3.5.9 +composer create-project --no-install evolution-cms/evolution /tmp/evo 3.5.x-dev # or 3.5.8 cp -a /tmp/evo/. /path/to/site/ # or unpack a release / branch archive there cd /path/to/site/install php cli-install.php --typeInstall=2 --removeInstall=y @@ -110,11 +110,11 @@ Instead of the last two lines you can open `/install/` in the browser while logg ### From the manager -The update widget on the dashboard follows the line the site runs. A release is offered newer releases. A site updated from `3.6.x`, `nightly-3.6.x` or a fork branch is offered that branch again once it has new commits (with a link to the changes), from the repository it came from. **Manual update (WEB)** queues `make:site` for that target, so everything above applies. To leave a branch for a release, run `php artisan make:site update `. +The update widget on the dashboard follows the line the site runs. A release is offered newer releases. A site updated from `3.5.x`, `nightly-3.5.x` or a fork branch is offered that branch again once it has new commits (with a link to the changes), from the repository it came from. **Manual update (WEB)** queues `make:site` for that target, so everything above applies. To leave a branch for a release, run `php artisan make:site update `. ### Things to know -- When moving from **3.5.8** to `3.6.x`, the 3.5.8 updater reports the old version at the end; the site is on `3.6.x` all the same. The 3.5.8 CLI installer cannot restore custom Composer packages, so switch back to 3.5.8 with `make:site update 3.5.8` rather than with its `cli-install.php`. +- Moving from **3.5.8** to `3.5.x` is done by the 3.5.8 updater, which reports the old version at the end; the site is on `3.5.x` all the same. Its CLI installer cannot restore custom Composer packages, so switch back to 3.5.8 with `make:site update 3.5.8` rather than with its `cli-install.php`. - After a git pull or rsync of new code, with no update step, run `php artisan migrate` and `composer update ` in `core/`. Compiled templates refresh by themselves. - An old fork branch is an old version of Evolution CMS: switching to it is a downgrade. diff --git a/composer.json b/composer.json index 343d31163f..9657c40b05 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,7 @@ "vendor-dir": "vendor" }, "require": { - "php": "^8.4", + "php": "^8.3", "ext-pdo": "*", "ext-json": "*", "ext-readline": "*", diff --git a/core/composer.json b/core/composer.json index 5e785fb009..353da92301 100644 --- a/core/composer.json +++ b/core/composer.json @@ -1,7 +1,7 @@ { "name": "evolution-cms/evolution", "type": "project", - "version": "3.6.0", + "version": "3.5.9", "description": "Welcome to the Evolution CMS. The world’s fastest, most customizable Open Source PHP CMS. Your creative vision, no restrictions, no compromise.", "keywords": [ "content", @@ -27,7 +27,7 @@ } ], "require": { - "php": "^8.4", + "php": "^8.3", "composer/composer": "*", "dg/rss-php": "^1.7", "dmitry-suffi/redis-session-handler": "*", diff --git a/core/factory/version.php b/core/factory/version.php index f8711994d2..7678d134e1 100644 --- a/core/factory/version.php +++ b/core/factory/version.php @@ -1,6 +1,6 @@ '3.6.0', + 'version' => '3.5.9', 'release_date' => 'Oct 21, 2026', 'branch' => 'Evolution CMS', - 'full_appname' => 'Evolution CMS 3.6.0 (Oct 21, 2026)' + 'full_appname' => 'Evolution CMS 3.5.9 (Oct 21, 2026)' ]; diff --git a/publiccode.yml b/publiccode.yml index 8592cbe1c8..7785ccca3c 100644 --- a/publiccode.yml +++ b/publiccode.yml @@ -15,7 +15,7 @@ categories: developmentStatus: stable softwareType: standalone/web -softwareVersion: "3.6.x" +softwareVersion: "3.5.9" releaseDate: "2026-10-21" isBasedOn: @@ -45,7 +45,7 @@ description: Evolution CMS is an open-source content management system and web application framework written in PHP. The project originates from MODX Evolution (2004) and has been continuously developed by an international community. - The 3.6 series is based on Laravel 12 components and PHP 8.4+. + The 3.5 series is based on Laravel 12 and PHP 8.3+. The system provides flexible content modeling, granular permissions, and multi-site architecture. It supports multilingual websites and is suitable for public sector portals, intranets, and institutional websites. @@ -66,7 +66,7 @@ description: longDescription: | Evolution CMS — це система керування контентом з відкритим кодом, написана на PHP. - Проєкт розвивається міжнародною спільнотою та базується на компонентах Laravel 12 і PHP 8.4+. + Проєкт розвивається міжнародною спільнотою та базується на Laravel 12 і PHP 8.3+. legal: license: "GPL-3.0-or-later" @@ -114,7 +114,7 @@ localisation: dependsOn: open: - name: "PHP" - versionMin: "8.4" + versionMin: "8.3" optional: false - name: "Laravel" versionMin: "12.0" From d9aa2e5c45d3095932e48026c0210389602ccfe0 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Mon, 26 Oct 2026 02:27:41 +0100 Subject: [PATCH 09/11] fix(ci): dynamic php version based build --- .github/docker/Dockerfile | 3 ++- .github/docker/ci/README.md | 10 +++++--- .github/scripts/php-versions.sh | 45 +++++++++++++++++++++++++++++++++ .github/workflows/build.yml | 34 +++++++++++++++++++------ .github/workflows/ci.yml | 32 ++++++++++++++++++++--- .github/workflows/upgrade.yml | 19 +++++++++++--- 6 files changed, 123 insertions(+), 20 deletions(-) create mode 100755 .github/scripts/php-versions.sh diff --git a/.github/docker/Dockerfile b/.github/docker/Dockerfile index 7e4aca7cb0..331f0b0bf2 100644 --- a/.github/docker/Dockerfile +++ b/.github/docker/Dockerfile @@ -3,7 +3,8 @@ # The base image is built from evolution-cms/salo2 runtimes/ (Salo # is the Evolution CMS flavour of Laravel Sail): php:-apache with gd, # zip, pdo and mod_rewrite. sqlite3/pdo_sqlite come enabled in the official php -# images. The workflow builds 8.3, 8.4 and 8.5 and overrides BASE_IMAGE per leg. +# images. The workflow builds every version .github/scripts/php-versions.sh lists +# for the tree (8.3 too below CMS 3.6) and overrides BASE_IMAGE per leg. ARG BASE_IMAGE=evo-salo-runtime:8.4 FROM ${BASE_IMAGE} diff --git a/.github/docker/ci/README.md b/.github/docker/ci/README.md index 4aad2c0d19..298ceeac27 100644 --- a/.github/docker/ci/README.md +++ b/.github/docker/ci/README.md @@ -79,11 +79,13 @@ no PHP on it, and what pins the PHP version when a version question is the one being investigated. One PHP version, every database: what the job proves is the installer's database -paths, which do not vary with the PHP minor. PHP 8.3 through 8.5 are covered by -the analysis and unit test jobs. The sqlite leg starts no container. +paths, which do not vary with the PHP minor, and it runs on the lowest one +`.github/scripts/php-versions.sh` lists. The analysis and unit test jobs cover +all of them: 8.4 and 8.5, plus 8.3 while the CMS version is below 3.6. The +sqlite leg starts no container. -The same workflow also builds this image from the official `php:8.6-rc-cli` -image and runs the unit tests in it. The `8.6-rc` tag follows the latest PHP 8.6 +From CMS 3.6 on (the 3.5 line skips it), the same workflow also builds this +image from the official `php:8.6-rc-cli` image and runs the unit tests in it. The `8.6-rc` tag follows the latest PHP 8.6 release candidate; it currently resolves to PHP 8.6.0RC3. The image includes SQLite and mbstring support for the test suite. This prerelease compatibility job reports the build, version and test results in the Actions summary and as a diff --git a/.github/scripts/php-versions.sh b/.github/scripts/php-versions.sh new file mode 100755 index 0000000000..eea038faee --- /dev/null +++ b/.github/scripts/php-versions.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# The PHP versions CI runs a tree on, decided by the CMS version of that tree: +# 3.6 requires PHP 8.4, the 3.5 line still supports 8.3, and the PHP 8.6 release +# candidate is a 3.6 concern only. So one set of workflows keeps testing 8.3 on +# 3.5.x and moves to 8.6 on 3.6.x, whichever branch it was merged to. +# +# Usage: php-versions.sh [tree] (default: the current directory) +# Writes to $GITHUB_OUTPUT when set, to stdout otherwise: +# versions JSON list for a job matrix, lowest first +# lowest the lowest of them, for jobs that run on one version only +# rc the prerelease version to check as well, empty for none +set -eu + +tree=${1:-.} +file="$tree/core/factory/version.php" +version=$(sed -n "s/^[[:space:]]*'version'[[:space:]]*=>[[:space:]]*'\([0-9][0-9.]*\)'.*/\1/p" "$file" | head -n 1) +if [ -z "$version" ]; then + echo "::error file=$file::no 'version' => 'x.y.z' entry" >&2 + exit 1 +fi + +major=${version%%.*} +rest=${version#*.} +minor=${rest%%.*} +if [ "$major" -gt 3 ] || { [ "$major" -eq 3 ] && [ "$minor" -ge 6 ]; }; then + set -- 8.4 8.5 + rc=8.6 +else + set -- 8.3 8.4 8.5 + rc= +fi + +lowest=$1 +versions=$(printf '"%s",' "$@") +versions="[${versions%,}]" + +result="versions=$versions +lowest=$lowest +rc=$rc" +if [ -n "${GITHUB_OUTPUT:-}" ]; then + echo "$result" >> "$GITHUB_OUTPUT" +else + echo "$result" +fi +echo "CMS $version: PHP $*${rc:+, $rc RC}" >&2 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b2a3cf56d4..f5b5aa2864 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -44,8 +44,26 @@ env: SALO2_REF: master jobs: + php: + name: PHP versions + runs-on: ubuntu-latest + outputs: + versions: ${{ steps.php.outputs.versions }} + lowest: ${{ steps.php.outputs.lowest }} + steps: + - uses: actions/checkout@v7 + with: + sparse-checkout: | + .github/scripts + core/factory + + # 8.3 below CMS 3.6 only, see the script + - id: php + run: sh .github/scripts/php-versions.sh + zip: name: Installed CMS (zip, sqlite) + needs: php runs-on: ubuntu-latest permissions: contents: write # required to publish the nightly release @@ -58,8 +76,8 @@ jobs: uses: shivammathur/setup-php@v2 with: # Built on the lowest supported version so the vendor tree stays - # usable on 8.3 and 8.4 alike. - php-version: '8.3' + # usable on every version the docker job runs it on. + php-version: ${{ needs.php.outputs.lowest }} extensions: ctype, dom, fileinfo, filter, iconv, intl, json, mbstring, openssl, pdo, pdo_sqlite, readline, simplexml, sqlite3, tokenizer, xml, xmlreader, zip coverage: none tools: composer:v2 @@ -72,8 +90,8 @@ jobs: uses: actions/cache@v6 with: path: ${{ steps.composer-cache.outputs.dir }} - key: composer-${{ runner.os }}-php8.3-build-${{ hashFiles('core/composer.lock') }} - restore-keys: composer-${{ runner.os }}-php8.3-build- + key: composer-${{ runner.os }}-php${{ needs.php.outputs.lowest }}-build-${{ hashFiles('core/composer.lock') }} + restore-keys: composer-${{ runner.os }}-php${{ needs.php.outputs.lowest }}-build- - name: Install core dependencies working-directory: core @@ -169,7 +187,7 @@ jobs: docker: name: Docker image PHP ${{ matrix.php }} ${{ matrix.server }} (GHCR) - needs: zip + needs: [php, zip] # Pull requests get a read-only token and cannot push packages, so the job is # skipped there — including on the run that stays attached to a merged PR. # The push to the base branch, and workflow_dispatch, are what build the image. @@ -183,9 +201,9 @@ jobs: # the ones that did build stay usable. fail-fast: false matrix: - # 8.3 is the lowest supported version and the one the vendor tree in the - # zip is built on, so it proves the archive is portable upwards. - php: ['8.3', '8.4', '8.5'] + # The lowest of them is the one the vendor tree in the zip is built on, + # so the others prove the archive is portable upwards. + php: ${{ fromJSON(needs.php.outputs.versions) }} # Three different PHP SAPIs, not three front ends over one PHP: apache # runs mod_php, nginx talks FastCGI to php-fpm, and FrankenPHP embeds # PHP inside Caddy. They cannot share a base image, so each is a build. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d04b1fd95..a3b5326ef7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,13 +18,32 @@ env: PHP_EXTENSIONS: ctype, dom, fileinfo, filter, iconv, intl, json, mbstring, openssl, pdo, pdo_sqlite, readline, simplexml, tokenizer, xml, xmlreader, zip jobs: + php: + name: PHP versions + runs-on: ubuntu-latest + outputs: + versions: ${{ steps.php.outputs.versions }} + lowest: ${{ steps.php.outputs.lowest }} + rc: ${{ steps.php.outputs.rc }} + steps: + - uses: actions/checkout@v7 + with: + sparse-checkout: | + .github/scripts + core/factory + + # 8.3 below CMS 3.6 only, 8.6 RC from 3.6 only, see the script + - id: php + run: sh .github/scripts/php-versions.sh + analyze: name: Static analysis (PHP ${{ matrix.php }}) + needs: php runs-on: ubuntu-latest strategy: fail-fast: false matrix: - php: ['8.3', '8.4', '8.5'] + php: ${{ fromJSON(needs.php.outputs.versions) }} steps: - uses: actions/checkout@v7 @@ -57,11 +76,12 @@ jobs: tests: name: Unit tests (PHP ${{ matrix.php }}) + needs: php runs-on: ubuntu-latest strategy: fail-fast: false matrix: - php: ['8.3', '8.4', '8.5'] + php: ${{ fromJSON(needs.php.outputs.versions) }} steps: - uses: actions/checkout@v7 @@ -94,6 +114,8 @@ jobs: php86-rc: name: PHP 8.6 RC compatibility (Docker) + needs: php + if: needs.php.outputs.rc == '8.6' runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -143,6 +165,7 @@ jobs: install: name: Install on ${{ matrix.db }} + needs: php runs-on: ubuntu-latest strategy: fail-fast: false @@ -184,7 +207,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.3' + php-version: ${{ needs.php.outputs.lowest }} extensions: ${{ env.PHP_EXTENSIONS }}, pdo_mysql, pdo_pgsql, sqlite3 ini-values: error_reporting=E_ALL, display_errors=On, display_startup_errors=On, log_errors=On, error_log=/dev/stderr coverage: none @@ -215,6 +238,7 @@ jobs: audit: name: Dependency audit + needs: php runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -224,7 +248,7 @@ jobs: with: # The audit reads composer.lock and the advisory database; it does not run the code, # so one version is enough. Kept at the lowest supported one for consistency. - php-version: '8.3' + php-version: ${{ needs.php.outputs.lowest }} extensions: ${{ env.PHP_EXTENSIONS }} coverage: none tools: composer:v2 diff --git a/.github/workflows/upgrade.yml b/.github/workflows/upgrade.yml index 8924575561..74839c0a2e 100644 --- a/.github/workflows/upgrade.yml +++ b/.github/workflows/upgrade.yml @@ -110,12 +110,25 @@ jobs: if: matrix.leg.db != 'sqlite' run: docker compose -f "new/$COMPOSE" --profile ${{ matrix.leg.db }} up -d --wait ${{ matrix.leg.db }} - # 8.3 is the lowest version both trees support, so it is the one that can - # run the old installer and the new updater in the same job. + # The script comes from this workflow's own commit: a "to" ref may be older + # than the script. + - name: Check out the CI scripts + uses: actions/checkout@v7 + with: + path: ci + sparse-checkout: .github/scripts + + # The lowest version the new tree supports (8.3 below CMS 3.6, 8.4 from + # 3.6), which the older trees run on too, so one job can run the old + # installer and the new updater. + - name: PHP version of the new tree + id: php + run: sh ci/.github/scripts/php-versions.sh new + - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.3' + php-version: ${{ steps.php.outputs.lowest }} extensions: ctype, dom, fileinfo, filter, iconv, intl, json, mbstring, openssl, pdo, pdo_mysql, pdo_pgsql, pdo_sqlite, readline, simplexml, sqlite3, tokenizer, xml, xmlreader, zip ini-values: error_reporting=E_ALL, display_errors=On, display_startup_errors=On, log_errors=On, error_log=/dev/stderr coverage: none From a39eb0f8fc620b4fde32dc37d04b641941bc3a72 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Mon, 26 Oct 2026 02:27:58 +0100 Subject: [PATCH 10/11] fix(manager): tree selection sync --- core/tests/Unit/CiPhpVersionsTest.php | 77 ++++ .../media/browser/mcpuk/js/browser/files.js | 12 +- .../media/browser/mcpuk/js/browser/folders.js | 168 +++++++-- .../media/browser/mcpuk/js/browser/misc.js | 18 + .../js/tests/selection-shortcuts.test.js | 5 + .../js/tests/tree-selection-sync.test.js | 350 ++++++++++++++++++ .../media/browser/mcpuk/themes/evo/style.css | 2 + .../browser/mcpuk/themes/oxygen/style.css | 7 + 8 files changed, 595 insertions(+), 44 deletions(-) create mode 100644 core/tests/Unit/CiPhpVersionsTest.php create mode 100644 manager/media/browser/mcpuk/js/tests/tree-selection-sync.test.js diff --git a/core/tests/Unit/CiPhpVersionsTest.php b/core/tests/Unit/CiPhpVersionsTest.php new file mode 100644 index 0000000000..cb552ae2f0 --- /dev/null +++ b/core/tests/Unit/CiPhpVersionsTest.php @@ -0,0 +1,77 @@ +markTestSkipped('No .github in this tree.'); + } + evoRunCommand('sh -c true', $exitCode); + if ($exitCode !== 0) { + test()->markTestSkipped('No POSIX sh.'); + } + + return $script; +} + +function ciPhpVersionsFor(string $versionFile, ?int &$exitCode = null): string +{ + $tree = sys_get_temp_dir() . '/evo-ci-php-' . bin2hex(random_bytes(4)); + mkdir($tree . '/core/factory', 0777, true); + file_put_contents($tree . '/core/factory/version.php', $versionFile); + try { + return evoRunCommand( + 'sh ' . escapeshellarg(ciPhpVersionsScript()) . ' ' . escapeshellarg($tree) . ' 2>&1', + $exitCode + ); + } finally { + unlink($tree . '/core/factory/version.php'); + rmdir($tree . '/core/factory'); + rmdir($tree . '/core'); + rmdir($tree); + } +} + +test('the CMS version decides whether CI runs PHP 8.3 or the 8.6 RC', function (string $version, string $versions, string $lowest, string $rc) { + $output = ciPhpVersionsFor(" '$version',\n 'release_date' => 'Oct 21, 2026',\n];\n", $exitCode); + + expect($exitCode)->toBe(0) + ->and($output)->toContain("versions=$versions\nlowest=$lowest\nrc=$rc\n"); +})->with([ + '3.5 line' => ['3.5.9', '["8.3","8.4","8.5"]', '8.3', ''], + 'two-digit 3.5 patch' => ['3.5.10', '["8.3","8.4","8.5"]', '8.3', ''], + '3.6' => ['3.6.0', '["8.4","8.5"]', '8.4', '8.6'], + 'two-digit minor' => ['3.10.1', '["8.4","8.5"]', '8.4', '8.6'], + 'next major' => ['4.0.0', '["8.4","8.5"]', '8.4', '8.6'], +]); + +test('the 8.6 RC job runs only where the script asks for it', function () { + ciPhpVersionsScript(); + $ci = (string) file_get_contents(dirname(__DIR__, 3) . '/.github/workflows/ci.yml'); + + expect($ci)->toContain(" name: PHP 8.6 RC compatibility (Docker)\n needs: php\n if: needs.php.outputs.rc == '8.6'\n"); +}); + +test('a tree without a readable version fails instead of guessing', function () { + $output = ciPhpVersionsFor("not->toBe(0) + ->and($output)->not->toContain('versions='); +}); + +test('no workflow lists PHP versions of its own', function () { + ciPhpVersionsScript(); + $workflows = glob(dirname(__DIR__, 3) . '/.github/workflows/*.yml'); + + expect($workflows)->not->toBeEmpty(); + foreach ($workflows as $workflow) { + $yaml = (string) file_get_contents($workflow); + expect($yaml)->not->toMatch('/^\s*php:\s*\[/m', basename($workflow)) + ->and($yaml)->not->toMatch("/php-version:\\s*'?8\\./", basename($workflow)); + } +}); diff --git a/manager/media/browser/mcpuk/js/browser/files.js b/manager/media/browser/mcpuk/js/browser/files.js index bee2f1b8fb..231e15eccf 100755 --- a/manager/media/browser/mcpuk/js/browser/files.js +++ b/manager/media/browser/mcpuk/js/browser/files.js @@ -92,6 +92,7 @@ browser.initFiles = function() { }); this.statusDir(); this.updateDeleteButton(); + this.syncTreeSelection(); lazyLoadInstance.update(); this.updateMobileActions(); }; @@ -116,6 +117,7 @@ browser.selectFolder = function(file) { file.addClass('selected'); this.statusDir(); this.updateDeleteButton(); + this.syncTreeSelection(); this.updateMobileActions(); }; @@ -223,6 +225,7 @@ browser.clearSelection = function() { this.lastSelectedFile = null; this.statusDir(); this.updateDeleteButton(); + this.syncTreeSelection(); this.updateMobileActions(); }; @@ -232,6 +235,7 @@ browser.updateSelectionStatus = function() { }).get(); var size = 0; + this.syncTreeSelection(); if (!files.length) { this.statusDir(); this.updateDeleteButton(); @@ -504,9 +508,7 @@ browser.selectionAnchorIndex = function(file, files, targetIndex) { }; browser.openDir = function(file) { - var name = file.data('name'); - var path = browser.dir.length ? browser.dir + '/' + name : name; - var dir = $('#folders a[href="kcdir:/' + _.escapeDirs(path) + '"]'); + var dir = this.treeSubDir(file.data('name')); if (!dir.get(0)) return false; @@ -517,8 +519,7 @@ browser.openDir = function(file) { browser.menuFolder = function(file, e) { var name = file.data('name'); - var path = browser.dir.length ? browser.dir + '/' + name : name; - var dir = $('#folders a[href="kcdir:/' + _.escapeDirs(path) + '"]'); + var dir = this.treeSubDir(name); if (!dir.get(0)) return browser.openDir(file); @@ -526,6 +527,7 @@ browser.menuFolder = function(file, e) { $('.file').removeClass('selected'); file.addClass('selected'); this.updateDeleteButton(); + this.syncTreeSelection(); $('#fileinfo').text(name); browser.menuDir(dir, e); return true; diff --git a/manager/media/browser/mcpuk/js/browser/folders.js b/manager/media/browser/mcpuk/js/browser/folders.js index f78ba00970..707e397557 100755 --- a/manager/media/browser/mcpuk/js/browser/folders.js +++ b/manager/media/browser/mcpuk/js/browser/folders.js @@ -28,6 +28,7 @@ browser.initFolders = function() { $('div.folder > a > span.folder').unbind(); $('div.folder > a > span.folder').rightClick(function(e) { _.unselect(); + browser.selectListedFolder($(this).parent()); browser.menuDir($(this).parent(), e); }); @@ -53,6 +54,78 @@ browser.openTreeFolder = function(dir) { this.changeDir(dir); }; +// a folder's node in the tree; empty while the branch holding it is not loaded +browser.treeDir = function(path) { + return $('#folders a[href="kcdir:/' + _.escapeDirs(path) + '"]'); +}; + +browser.subDirPath = function(name) { + return this.dir.length ? this.dir + '/' + name : name; +}; + +// the subfolder of dir that leads to path: ("images", "images/team/2024") gives "team" +browser.subDirOnPath = function(dir, path) { + if ((typeof path != 'string') || (path.indexOf(dir + '/') !== 0)) + return undefined; + return path.substr(dir.length + 1).split('/')[0]; +}; + +/** + * The tree node of a folder listed in the file list. A tree folder opened by a click is not expanded, + * so its branch is loaded here when it never was: every listed folder has a node to open or mark. + */ +browser.treeSubDir = function(name) { + var dir = this.treeDir(this.subDirPath(name)); + if (dir.get(0)) + return dir; + + var current = this.treeDir(this.dir); + if (current.get(0) && !current.parent().children('.folders').get(0) && this.loadTreeBranch(current)) + dir = this.treeDir(this.subDirPath(name)); + return dir; +}; + +// opens the collapsed branches above a tree node and scrolls the tree to it +browser.revealTreeDir = function(dir) { + dir.parents('div.folders').each(function() { + var folders = $(this); + if (folders.css('display') != 'none') + return; + folders.css('display', ''); + folders.parent().children('a').first().children('.brace').removeClass('closed').addClass('opened'); + }); + this.scrollIntoPane('#folders', dir.children('span.folder').get(0)); +}; + +// the folders selected in the file list are marked in the tree too: both panes show one selection +browser.syncTreeSelection = function() { + $('div.folder > a > span.folder').removeClass('selected'); + $('#files .file.selected').each(function() { + var file = $(this); + if (!file.data('isDir')) + return; + var dir = browser.treeSubDir(file.data('name')); + if (!dir.get(0)) + return; + dir.children('span.folder').addClass('selected'); + browser.revealTreeDir(dir); + }); +}; + +// a tree folder that the file list shows (a subfolder of the folder on screen) is selected there too +browser.selectListedFolder = function(dir) { + var path = dir.data('path'); + var file = $('#files .file').filter(function() { + return $(this).data('isDir') && (browser.subDirPath($(this).data('name')) === path); + }).first(); + if (!file.get(0)) + return false; + + this.selectFolder(file); + this.scrollIntoPane('#files', file.get(0)); + return true; +}; + browser.toggleTreeFolder = function(dir) { var brace = dir.children('.brace'); if (brace.hasClass('opened') || brace.hasClass('closed')) @@ -152,57 +225,71 @@ browser.expandDir = function(dir) { dir.parent().append(this.loadingHtml('loadingDirs')); // a moment for the indicator to show before the request holds the page setTimeout(function() { - $.ajax({ - type: 'POST', - dataType: 'json', - url: browser.baseGetData('expand'), - data: {dir:path}, - async: false, - success: function(data) { - $('#loadingDirs').detach(); - if (browser.check4errors(data)) - return; - - var html = ''; - $.each(data.dirs, function(i, cdir) { - html += ''; - }); - if (html.length) { - dir.parent().append('
' + html + '
'); - var folders = $(dir.parent().children('.folders').first()); - folders.css('display', 'none'); - $(folders).show(500); - $.each(data.dirs, function(i, cdir) { - browser.setTreeData(cdir, path); - }); - } - if (data.dirs.length) { - dir.children('.brace').removeClass('closed'); - dir.children('.brace').addClass('opened'); - } else { - dir.children('.brace').removeClass('opened'); - dir.children('.brace').removeClass('closed'); - } - browser.initFolders(); - }, - error: function() { - $('#loadingDirs').detach(); - browser.alert(browser.label("Unknown error.")); - } - }); + browser.loadTreeBranch(dir, true); + $('#loadingDirs').detach(); + browser.syncTreeSelection(); }, 0); } } }; +// reads the subfolders of a tree node into the tree; false when the request fails +browser.loadTreeBranch = function(dir, animate) { + var path = dir.data('path'); + var loaded = false; + $.ajax({ + type: 'POST', + dataType: 'json', + url: browser.baseGetData('expand'), + data: {dir:path}, + async: false, + success: function(data) { + if (browser.check4errors(data)) + return; + + var html = ''; + $.each(data.dirs, function(i, cdir) { + html += ''; + }); + if (html.length) { + dir.parent().append('
' + html + '
'); + if (animate) { + var folders = $(dir.parent().children('.folders').first()); + folders.css('display', 'none'); + $(folders).show(500); + } + $.each(data.dirs, function(i, cdir) { + browser.setTreeData(cdir, path); + }); + } + if (data.dirs.length) { + dir.children('.brace').removeClass('closed'); + dir.children('.brace').addClass('opened'); + } else { + dir.children('.brace').removeClass('opened'); + dir.children('.brace').removeClass('closed'); + } + browser.initFolders(); + loaded = true; + }, + error: function() { + browser.alert(browser.label("Unknown error.")); + } + }); + return loaded; +}; + browser.changeDir = function(dir) { if (dir.children('span.folder').hasClass('regular')) { + var previous = this.dir; this.clearMobileActions(); $('div.folder > a > span.folder').removeClass('current'); + $('div.folder > a > span.folder').removeClass('selected'); $('div.folder > a > span.folder').removeClass('regular'); $('div.folder > a > span.folder').addClass('regular'); dir.children('span.folder').removeClass('regular'); dir.children('span.folder').addClass('current'); + this.revealTreeDir(dir); $('#files').html(browser.loadingHtml()); $.ajax({ type: 'POST', @@ -215,8 +302,11 @@ browser.changeDir = function(dir) { return; browser.files = data.files; browser.links = data.links ? data.links : []; - browser.orderFiles(); browser.dir = dir.data('path'); + // up the tree, the folder it came from is selected in the list (and so in the tree) + browser.orderFiles(function() { + browser.scrollIntoPane('#files', $('#files .file.selected').get(0)); + }, browser.subDirOnPath(browser.dir, previous)); browser.dirWritable = data.dirWritable; browser.updateWriteControls(); var title = "KCFinder: /" + browser.dir; diff --git a/manager/media/browser/mcpuk/js/browser/misc.js b/manager/media/browser/mcpuk/js/browser/misc.js index faab4f324a..46969dbdee 100755 --- a/manager/media/browser/mcpuk/js/browser/misc.js +++ b/manager/media/browser/mcpuk/js/browser/misc.js @@ -413,3 +413,21 @@ browser.fadeFiles = function() { filter: 'alpha(opacity:40)' }); }; + +// scrolls a pane just enough to show an element; not scrollIntoView(), which also moves the editor page around an iframe +browser.scrollIntoPane = function(pane, element) { + pane = $(pane).get(0); + if (!pane || !element || !element.getBoundingClientRect) + return; + + var view = pane.getBoundingClientRect(); + var box = element.getBoundingClientRect(); + if (box.top < view.top) + pane.scrollTop -= view.top - box.top; + else if (box.bottom > view.bottom) + pane.scrollTop += Math.min(box.bottom - view.bottom, box.top - view.top); + if (box.left < view.left) + pane.scrollLeft -= view.left - box.left; + else if (box.right > view.right) + pane.scrollLeft += Math.min(box.right - view.right, box.left - view.left); +}; diff --git a/manager/media/browser/mcpuk/js/tests/selection-shortcuts.test.js b/manager/media/browser/mcpuk/js/tests/selection-shortcuts.test.js index 7f1f81951c..093e9665ed 100644 --- a/manager/media/browser/mcpuk/js/tests/selection-shortcuts.test.js +++ b/manager/media/browser/mcpuk/js/tests/selection-shortcuts.test.js @@ -51,6 +51,7 @@ function loadBrowser() { vm.runInNewContext(source, { browser, $ }); vm.runInNewContext(toolbarSource, { browser, $ }); browser.updateMobileActions = () => {}; + browser.syncTreeSelection = () => {}; return browser; } @@ -86,6 +87,7 @@ function loadFileSelection() { browser.updateDeleteButton = () => {}; browser.updateSelectionStatus = () => {}; browser.updateMobileActions = () => {}; + browser.syncTreeSelection = () => {}; return { browser, files, wrap }; } @@ -259,9 +261,11 @@ test('clearing selection removes its highlight and resets the range anchor', () const browser = loadBrowser(); let statusCalls = 0; let buttonCalls = 0; + let treeCalls = 0; browser.lastSelectedFile = {}; browser.statusDir = () => statusCalls++; browser.updateDeleteButton = () => buttonCalls++; + browser.syncTreeSelection = () => treeCalls++; browser.clearSelection(); @@ -269,4 +273,5 @@ test('clearing selection removes its highlight and resets the range anchor', () assert.equal(browser.lastSelectedFile, null); assert.equal(statusCalls, 1); assert.equal(buttonCalls, 1); + assert.equal(treeCalls, 1); }); diff --git a/manager/media/browser/mcpuk/js/tests/tree-selection-sync.test.js b/manager/media/browser/mcpuk/js/tests/tree-selection-sync.test.js new file mode 100644 index 0000000000..1c2979bb88 --- /dev/null +++ b/manager/media/browser/mcpuk/js/tests/tree-selection-sync.test.js @@ -0,0 +1,350 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const read = (file) => fs.readFileSync(path.join(__dirname, '..', 'browser', file), 'utf8'); +const foldersSource = read('folders.js'); +const filesSource = read('files.js'); +const miscSource = read('misc.js'); + +// a few elements and just the jQuery the tree and the file list use +function el(tag, classes, parent) { + const node = { tag, cls: new Set(classes || []), attrs: {}, store: {}, style: {}, kids: [], parent: null }; + if (parent) { + node.parent = parent; + parent.kids.push(node); + } + return node; +} + +function matches(node, selector) { + const [tag, ...classes] = selector.split('.'); + return (!tag || node.tag === tag) && classes.every((name) => node.cls.has(name)); +} + +function descendants(node) { + return node.kids.flatMap((kid) => [kid, ...descendants(kid)]); +} + +function wrap(nodes) { + const set = { + length: nodes.length, + get: (i) => nodes[i], + first: () => wrap(nodes.slice(0, 1)), + each(fn) { nodes.forEach((node, i) => fn.call(node, i, node)); return set; }, + filter: (fn) => wrap(nodes.filter((node, i) => fn.call(node, i, node))), + children: (selector) => wrap(nodes.flatMap((node) => node.kids.filter((kid) => !selector || matches(kid, selector)))), + parent: () => wrap(nodes.map((node) => node.parent).filter(Boolean)), + parents(selector) { + const found = []; + nodes.forEach((node) => { + for (let up = node.parent; up; up = up.parent) + if (matches(up, selector)) found.push(up); + }); + return wrap(found); + }, + hasClass: (name) => nodes.some((node) => node.cls.has(name)), + addClass(name) { nodes.forEach((node) => node.cls.add(name)); return set; }, + removeClass(name) { nodes.forEach((node) => node.cls.delete(name)); return set; }, + css(key, value) { + if (typeof value == 'undefined') return nodes[0] ? (nodes[0].style[key] || '') : undefined; + nodes.forEach((node) => { node.style[key] = value; }); + return set; + }, + data(key, value) { + const node = nodes[0]; + if (typeof key == 'undefined') return node.store; + if (typeof key == 'object') { nodes.forEach((n) => Object.assign(n.store, key)); return set; } + if (typeof value == 'undefined') return node ? node.store[key] : undefined; + nodes.forEach((n) => { n.store[key] = value; }); + return set; + }, + html() { return set; }, + text() { return set; }, + }; + return set; +} + +function treeNode(container, dirPath, opts = {}) { + const folder = el('div', ['folder'], container); + const a = el('a', [], folder); + a.attrs.href = 'kcdir:/' + dirPath; + a.store = { path: dirPath, name: dirPath.split('/').pop(), hasDirs: !!opts.hasDirs }; + el('span', ['brace'].concat(opts.brace ? [opts.brace] : []), a); + el('span', ['folder', opts.current ? 'current' : 'regular'], a); + return { folder, a }; +} + +function subfolders(node, hidden) { + const folders = el('div', ['folders'], node.folder); + if (hidden) folders.style.display = 'none'; + return folders; +} + +function setup() { + const root = el('div', [], null); + const tree = el('div', [], root); + const files = el('div', [], root); + + const images = treeNode(tree, 'images', { current: true, hasDirs: true, brace: 'opened' }); + + const page = { + root, tree, files, images, + card(name, isDir, selected) { + const card = el('div', ['file'].concat(isDir ? ['folder'] : []).concat(selected ? ['selected'] : []), files); + card.store = { name, isDir }; + return card; + }, + treeSpans: () => descendants(tree).filter((n) => n.tag === 'span' && n.cls.has('folder')), + selectedInTree: () => page.treeSpans().filter((n) => n.cls.has('selected')).map((n) => n.parent.store.path), + $: null, + }; + + const $ = (selector) => { + if (selector && typeof selector == 'object') return wrap([selector]); + let match = /^#folders a\[href="(.*)"\]$/.exec(selector); + if (match) return wrap(descendants(tree).filter((n) => n.tag === 'a' && n.attrs.href === match[1])); + if (selector === 'div.folder > a > span.folder') return wrap(page.treeSpans()); + if (selector === '#files .file.selected') return wrap(files.kids.filter((n) => n.cls.has('file') && n.cls.has('selected'))); + if (selector === '#files .file' || selector === '.file') return wrap(files.kids.filter((n) => n.cls.has('file'))); + if (selector === '#files') return wrap([files]); + if (selector === '#folders') return wrap([tree]); + return wrap([]); + }; + $.each = (items, fn) => (Array.isArray(items) ? items : Object.values(items)).forEach((item, i) => fn(i, item)); + page.$ = $; + + const browser = {}; + const context = { browser, $, document: {}, _: { unselect() {}, escapeDirs: (p) => p, htmlData: (t) => t } }; + vm.runInNewContext(foldersSource, context); + vm.runInNewContext(filesSource, context); + Object.assign(browser, { + dir: 'images', + scrolled: [], + scrollIntoPane(pane, element) { browser.scrolled.push([pane, element]); }, + statusDir() {}, + updateDeleteButton() {}, + updateMobileActions() {}, + clearMobileActions() {}, + }); + + return { browser, page }; +} + +test('the subfolder of a folder that leads to a deeper path', () => { + const { browser } = setup(); + + assert.equal(browser.subDirOnPath('images', 'images/team/2024'), 'team'); + assert.equal(browser.subDirOnPath('images', 'images/team'), 'team'); + assert.equal(browser.subDirOnPath('images', 'images'), undefined); + assert.equal(browser.subDirOnPath('images', 'images-old/team'), undefined); + assert.equal(browser.subDirOnPath('images/team', 'images'), undefined); + assert.equal(browser.subDirOnPath('images', undefined), undefined); +}); + +test('a listed folder is found in the tree without loading anything when its branch is there', () => { + const { browser, page } = setup(); + treeNode(subfolders(page.images), 'images/team'); + let loads = 0; + browser.loadTreeBranch = () => { loads++; return true; }; + + assert.equal(browser.treeSubDir('team').get(0).store.path, 'images/team'); + assert.equal(loads, 0); +}); + +test('a listed folder whose branch was never loaded loads the branch of the folder on screen', () => { + const { browser, page } = setup(); + const loaded = []; + browser.loadTreeBranch = (dir) => { + loaded.push(dir.data('path')); + treeNode(subfolders(page.images), 'images/team'); + return true; + }; + + assert.equal(browser.treeSubDir('team').get(0).store.path, 'images/team'); + assert.deepEqual(loaded, ['images']); +}); + +test('a loaded branch without the folder is not read again on every selection', () => { + const { browser, page } = setup(); + treeNode(subfolders(page.images), 'images/other'); + let loads = 0; + browser.loadTreeBranch = () => { loads++; return true; }; + + assert.equal(browser.treeSubDir('team').length, 0); + assert.equal(loads, 0); +}); + +test('a folder selected in the file list is selected in the tree, and only that one', () => { + const { browser, page } = setup(); + const folders = subfolders(page.images); + treeNode(folders, 'images/team'); + const news = treeNode(folders, 'images/news'); + news.a.kids[1].cls.add('selected'); + page.card('team', true, true); + page.card('news', true, false); + page.card('photo.jpg', false, true); + + browser.syncTreeSelection(); + + assert.deepEqual(page.selectedInTree(), ['images/team']); +}); + +test('clearing the file list selection clears the tree selection', () => { + const { browser, page } = setup(); + const team = treeNode(subfolders(page.images), 'images/team'); + team.a.kids[1].cls.add('selected'); + page.card('team', true, true); + + browser.clearSelection(); + + assert.deepEqual(page.selectedInTree(), []); +}); + +test('selecting a folder in the file list opens the collapsed tree branch that holds it', () => { + const { browser, page } = setup(); + page.images.a.kids[0].cls.delete('opened'); + page.images.a.kids[0].cls.add('closed'); + const team = treeNode(subfolders(page.images, true), 'images/team'); + const card = page.card('team', true, false); + + browser.selectFolder(wrap([card])); + + assert.deepEqual(page.selectedInTree(), ['images/team']); + assert.equal(page.images.folder.kids[1].style.display, ''); + assert.ok(page.images.a.kids[0].cls.has('opened')); + assert.ok(!page.images.a.kids[0].cls.has('closed')); + assert.deepEqual(browser.scrolled.at(-1), ['#folders', team.a.kids[1]]); +}); + +test('right-clicking a tree folder that the file list shows selects it there', () => { + const { browser, page } = setup(); + const team = treeNode(subfolders(page.images), 'images/team'); + page.card('photo.jpg', false, true); + const card = page.card('team', true, false); + + assert.equal(browser.selectListedFolder(wrap([team.a])), true); + + assert.ok(card.cls.has('selected')); + assert.ok(!page.files.kids[0].cls.has('selected')); + assert.deepEqual(page.selectedInTree(), ['images/team']); + assert.deepEqual(browser.scrolled.at(-1), ['#files', card]); +}); + +test('right-clicking a tree folder that the file list does not show leaves its selection alone', () => { + const { browser, page } = setup(); + const photo = page.card('photo.jpg', false, true); + + assert.equal(browser.selectListedFolder(wrap([page.images.a])), false); + assert.ok(photo.cls.has('selected')); +}); + +test('the tree context menu selects the folder in the file list before it opens', () => { + const { browser } = setup(); + const handlers = {}; + const calls = []; + const span = {}; + const chain = new Proxy(function () {}, { + get: (target, prop) => (prop === 'rightClick' + ? (fn) => { handlers.rightClick = fn; return chain; } + : () => chain), + }); + const context = { + browser, + $: Object.assign((target) => (target === span ? { parent: () => 'node' } : chain), { browser: {} }), + _: { unselect() {} }, + }; + vm.runInNewContext(foldersSource, context); + browser.selectListedFolder = (dir) => calls.push('select:' + dir); + browser.menuDir = (dir) => calls.push('menu:' + dir); + + browser.initFolders(); + handlers.rightClick.call(span, {}); + + assert.deepEqual(calls, ['select:node', 'menu:node']); +}); + +test('double-clicking a listed folder opens it even when the tree branch was not loaded', () => { + const { browser, page } = setup(); + const opened = []; + browser.loadTreeBranch = () => { + treeNode(subfolders(page.images), 'images/team'); + return true; + }; + browser.changeDir = (dir) => opened.push(dir.data('path')); + + assert.equal(browser.openDir(wrap([page.card('team', true, false)])), true); + assert.deepEqual(opened, ['images/team']); +}); + +function openWithChDir(browser, page, target) { + const ordered = []; + page.$.ajax = (options) => options.success({ files: [], links: [], dirWritable: true }); + browser.loadingHtml = () => ''; + browser.check4errors = () => false; + browser.updateWriteControls = () => {}; + browser.opener = {}; + browser.orderFiles = (callBack, selected) => ordered.push(selected); + browser.baseGetData = (act) => act; + browser.changeDir(wrap([target.a])); + return ordered; +} + +test('going up the tree selects the folder it came from in the file list', () => { + const { browser, page } = setup(); + page.images.a.kids[1].cls.delete('current'); + page.images.a.kids[1].cls.add('regular'); + browser.dir = 'images/team/2024'; + + const ordered = openWithChDir(browser, page, page.images); + + assert.deepEqual(ordered, ['team']); + assert.equal(browser.dir, 'images'); +}); + +test('going down or sideways selects nothing in the file list', () => { + const { browser, page } = setup(); + const folders = subfolders(page.images); + const team = treeNode(folders, 'images/team'); + const news = treeNode(folders, 'images/news'); + + assert.deepEqual(openWithChDir(browser, page, team), [undefined]); + assert.deepEqual(openWithChDir(browser, page, news), [undefined]); +}); + +test('opening a folder moves the tree highlight: current on it, nothing left selected', () => { + const { browser, page } = setup(); + const folders = subfolders(page.images, true); + const team = treeNode(folders, 'images/team'); + team.a.kids[1].cls.add('selected'); + + openWithChDir(browser, page, team); + + assert.ok(team.a.kids[1].cls.has('current')); + assert.deepEqual(page.selectedInTree(), []); + // a folder opened from the file list is shown in the tree as well + assert.equal(folders.style.display, ''); +}); + +test('a pane scrolls just enough to show an element, and only when it is outside', () => { + const browser = {}; + vm.runInNewContext(miscSource, { browser, $: (pane) => ({ get: () => pane }), _: {}, document: {}, window: {} }); + const rect = (top, bottom, left = 0, right = 100) => ({ top, bottom, left, right }); + const pane = { scrollTop: 50, scrollLeft: 0, getBoundingClientRect: () => rect(100, 300) }; + + browser.scrollIntoPane(pane, { getBoundingClientRect: () => rect(150, 170) }); + assert.equal(pane.scrollTop, 50); + + browser.scrollIntoPane(pane, { getBoundingClientRect: () => rect(80, 100) }); + assert.equal(pane.scrollTop, 30); + + browser.scrollIntoPane(pane, { getBoundingClientRect: () => rect(310, 330) }); + assert.equal(pane.scrollTop, 60); + + browser.scrollIntoPane(pane, { getBoundingClientRect: () => rect(150, 170, 90, 140) }); + assert.equal(pane.scrollLeft, 40); + + assert.doesNotThrow(() => browser.scrollIntoPane(pane, undefined)); +}); diff --git a/manager/media/browser/mcpuk/themes/evo/style.css b/manager/media/browser/mcpuk/themes/evo/style.css index f585538a9a..41fffe62b4 100644 --- a/manager/media/browser/mcpuk/themes/evo/style.css +++ b/manager/media/browser/mcpuk/themes/evo/style.css @@ -26,6 +26,8 @@ span.current:before { display: inline-block; font-family: 'Font Awesome 5 Free'; span.regular { background-color: #f8f7f6; } span.regular:before { display: inline-block; font-family: 'Font Awesome 5 Free'; content: "\f07b"; width: 1.3em; font-size: 1.3rem; line-height: .8em; vertical-align: middle; color: #565656; font-weight:900} span.regular:hover, span.context { background-color: #ddebf8; border-color: #cee0f4; color: #000; } +span.folder.selected, span.folder.selected:hover { background-color: #5b9bda; border-color: #2973bd; color: #fff; } +span.folder.selected:before { color: #fff; } span.opened:before { display: inline-block; font-family: 'Font Awesome 5 Free'; content: "\f107"; width: 1.3em; font-size: 1.3rem; line-height: .8em; vertical-align: middle; color: #565656; font-weight:900} span.closed:before { display: inline-block; font-family: 'Font Awesome 5 Free'; content: "\f105"; width: 1.3em; font-size: 1.3rem; line-height: .8em; vertical-align: middle; color: #565656; font-weight:900} span.denied:before { display: inline-block; font-family: 'Font Awesome 5 Free'; content: "\f05e"; width: 1.3em; font-size: 1.3rem; line-height: .8em; vertical-align: middle; color: #565656; font-weight:900} diff --git a/manager/media/browser/mcpuk/themes/oxygen/style.css b/manager/media/browser/mcpuk/themes/oxygen/style.css index 4aa3a2efe7..d9b2ed6e4e 100755 --- a/manager/media/browser/mcpuk/themes/oxygen/style.css +++ b/manager/media/browser/mcpuk/themes/oxygen/style.css @@ -160,6 +160,13 @@ span.regular:hover, span.context { color: #000; } +span.folder.selected, +span.folder.selected:hover { + background-color: #b9d4f0; + border-color: #2973bd; + color: #000; +} + span.opened { background-image: url(img/tree/minus.png); } From 87e04213855125bc766081e2e067355aefd2e605 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Mon, 26 Oct 2026 03:02:04 +0100 Subject: [PATCH 11/11] fix(ci): ci php version test --- core/tests/Unit/CiPhpVersionsTest.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/core/tests/Unit/CiPhpVersionsTest.php b/core/tests/Unit/CiPhpVersionsTest.php index cb552ae2f0..e1f4b67e95 100644 --- a/core/tests/Unit/CiPhpVersionsTest.php +++ b/core/tests/Unit/CiPhpVersionsTest.php @@ -24,12 +24,18 @@ function ciPhpVersionsFor(string $versionFile, ?int &$exitCode = null): string $tree = sys_get_temp_dir() . '/evo-ci-php-' . bin2hex(random_bytes(4)); mkdir($tree . '/core/factory', 0777, true); file_put_contents($tree . '/core/factory/version.php', $versionFile); + // Inside GitHub Actions the script would write to the job's own output file, not stdout. + $githubOutput = getenv('GITHUB_OUTPUT'); + putenv('GITHUB_OUTPUT'); try { return evoRunCommand( 'sh ' . escapeshellarg(ciPhpVersionsScript()) . ' ' . escapeshellarg($tree) . ' 2>&1', $exitCode ); } finally { + if ($githubOutput !== false) { + putenv('GITHUB_OUTPUT=' . $githubOutput); + } unlink($tree . '/core/factory/version.php'); rmdir($tree . '/core/factory'); rmdir($tree . '/core');