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 09b78cb023..40dbed62e4 100644 --- a/.github/docker/ci/README.md +++ b/.github/docker/ci/README.md @@ -85,8 +85,10 @@ 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 -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 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 b83b41743e..62ff032a91 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.4 and 8.5 alike. - php-version: '8.4' + # usable on every version the docker job runs it on. + php-version: ${{ needs.php.outputs.lowest }} extensions: ctype, dom, exif, 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.4-build-${{ hashFiles('core/composer.lock') }} - restore-keys: composer-${{ runner.os }}-php8.4-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.4 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'] + # 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 52c2e1460f..3e1fa7f810 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,13 +18,32 @@ env: PHP_EXTENSIONS: ctype, dom, exif, 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.4', '8.5'] + php: ${{ fromJSON(needs.php.outputs.versions) }} steps: - uses: actions/checkout@v7 @@ -59,11 +78,12 @@ jobs: tests: name: Unit tests (PHP ${{ matrix.php }}) + needs: php runs-on: ubuntu-latest strategy: fail-fast: false matrix: - php: ['8.4', '8.5'] + php: ${{ fromJSON(needs.php.outputs.versions) }} steps: - uses: actions/checkout@v7 @@ -96,6 +116,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 @@ -115,6 +137,7 @@ jobs: install: name: Install on ${{ matrix.db }} + needs: php runs-on: ubuntu-latest strategy: fail-fast: false @@ -156,7 +179,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.4' + 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 @@ -189,6 +212,7 @@ jobs: audit: name: Dependency audit + needs: php runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -198,7 +222,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: ${{ 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 ad4f08414c..99414b42da 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.4 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.4' + php-version: ${{ steps.php.outputs.lowest }} extensions: ctype, dom, exif, 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/core/composer.lock b/core/composer.lock index 1848e9f81c..af158085d4 100644 --- a/core/composer.lock +++ b/core/composer.lock @@ -9876,20 +9876,20 @@ }, { "name": "pestphp/pest", - "version": "v5.3.3", + "version": "v5.3.4", "source": { "type": "git", "url": "https://github.com/pestphp/pest.git", - "reference": "c947def97e1d56a3f3f9a3ced04c6af74b7e3caf" + "reference": "159514bacefb30fd674aa74c52738f8a535bfaa9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest/zipball/c947def97e1d56a3f3f9a3ced04c6af74b7e3caf", - "reference": "c947def97e1d56a3f3f9a3ced04c6af74b7e3caf", + "url": "https://api.github.com/repos/pestphp/pest/zipball/159514bacefb30fd674aa74c52738f8a535bfaa9", + "reference": "159514bacefb30fd674aa74c52738f8a535bfaa9", "shasum": "" }, "require": { - "brianium/paratest": "^7.26.1", + "brianium/paratest": "^7.27.4", "nunomaduro/collision": "^8.9.5", "nunomaduro/termwind": "^2.4.0", "pestphp/pest-plugin": "^5.0.0", @@ -9897,13 +9897,13 @@ "pestphp/pest-plugin-mutate": "^5.0.2", "pestphp/pest-plugin-profanity": "^5.0.1", "php": "^8.4", - "phpunit/phpunit": "^13.4.3", - "symfony/process": "^8.1.7" + "phpunit/phpunit": "^13.4.5", + "symfony/process": "^8.1.9" }, "conflict": { "filp/whoops": "<2.18.3", "laravel/boost": "<2.6.0", - "phpunit/phpunit": ">13.4.3", + "phpunit/phpunit": ">13.4.5", "sebastian/exporter": "<7.0.0", "webmozart/assert": "<1.11.0" }, @@ -9980,7 +9980,7 @@ ], "support": { "issues": "https://github.com/pestphp/pest/issues", - "source": "https://github.com/pestphp/pest/tree/v5.3.3" + "source": "https://github.com/pestphp/pest/tree/v5.3.4" }, "funding": [ { @@ -9992,7 +9992,7 @@ "type": "github" } ], - "time": "2026-10-18T11:51:10+00:00" + "time": "2026-10-27T11:04:36+00:00" }, { "name": "pestphp/pest-plugin", @@ -11008,16 +11008,16 @@ }, { "name": "phpunit/phpunit", - "version": "13.4.3", + "version": "13.4.5", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "2180c47c4229e5da117ff248753370443c074f10" + "reference": "941457a4f147a60fe2074d40e1fce0d08ea8d930" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/2180c47c4229e5da117ff248753370443c074f10", - "reference": "2180c47c4229e5da117ff248753370443c074f10", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/941457a4f147a60fe2074d40e1fce0d08ea8d930", + "reference": "941457a4f147a60fe2074d40e1fce0d08ea8d930", "shasum": "" }, "require": { @@ -11042,7 +11042,7 @@ "sebastian/environment": "^9.3.3", "sebastian/exporter": "^8.2.1", "sebastian/file-filter": "^1.0", - "sebastian/git-state": "^1.0", + "sebastian/git-state": "^1.1", "sebastian/global-state": "^9.0.1", "sebastian/object-enumerator": "^8.1.0", "sebastian/recursion-context": "^8.0.1", @@ -11088,7 +11088,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/13.4.3" + "source": "https://github.com/sebastianbergmann/phpunit/tree/13.4.5" }, "funding": [ { @@ -11096,7 +11096,7 @@ "type": "other" } ], - "time": "2026-10-16T05:56:47+00:00" + "time": "2026-10-27T04:16:22+00:00" }, { "name": "roave/security-advisories", 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/phpunit.xml b/core/phpunit.xml index 7ff74786bd..fa13c86954 100644 --- a/core/phpunit.xml +++ b/core/phpunit.xml @@ -7,6 +7,7 @@ ./tests + ./tests/standalone 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/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/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/CiPhpVersionsTest.php b/core/tests/Unit/CiPhpVersionsTest.php new file mode 100644 index 0000000000..5fb93b285f --- /dev/null +++ b/core/tests/Unit/CiPhpVersionsTest.php @@ -0,0 +1,100 @@ +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); + // 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'); + 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('the checked out release metadata and CI agree on the supported PHP line', function () { + $root = dirname(__DIR__, 3); + $release = require $root . '/core/factory/version.php'; + $core = json_decode(file_get_contents($root . '/core/composer.json'), true, 512, JSON_THROW_ON_ERROR); + $project = json_decode(file_get_contents($root . '/composer.json'), true, 512, JSON_THROW_ON_ERROR); + $output = ciPhpVersionsFor(file_get_contents($root . '/core/factory/version.php'), $exitCode); + + expect($release['version'])->toBe($core['version']) + ->and($project['require']['php'])->toBe($core['require']['php']) + ->and($exitCode)->toBe(0); + + preg_match('/^lowest=(\d+\.\d+)$/m', $output, $matches); + expect($matches)->toHaveCount(2); + $minimum = ltrim($core['require']['php'], '^'); + expect(version_compare($matches[1] . '.999', $minimum, '>='))->toBeTrue(); +}); + +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/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); diff --git a/core/tests/Unit/PhpThumbCacheImagesTest.php b/core/tests/Unit/PhpThumbCacheImagesTest.php new file mode 100644 index 0000000000..eae138289e --- /dev/null +++ b/core/tests/Unit/PhpThumbCacheImagesTest.php @@ -0,0 +1,286 @@ + ['/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 non-image cache files closed in both Apache authorization dialects', function (string $name, bool $served) { + $htaccess = phpThumbRepoFile('assets/cache/.htaccess'); + expect($htaccess)->toContain('Require all denied')->toContain('Deny from all'); + preg_match_all('/\s*(.*?)\s*<\/FilesMatch>/s', $htaccess, $rules, PREG_SET_ORDER); + + expect($rules)->toHaveCount(2); + foreach ($rules as $rule) { + expect(preg_match('~' . $rule[1] . '~', $name))->toBe($served ? 1 : 0); + } +})->with([ + 'JPEG image' => ['thumbnail.jpg', true], + 'PNG image' => ['thumbnail.PNG', true], + 'WebP image' => ['thumbnail.jpg.webp', true], + 'site cache' => ['siteCache.idx.php', false], + 'PHP with image prefix' => ['thumbnail.jpg.php', false], + 'JSON cache' => ['settings.json', false], + 'HTML' => ['page.html', false], + 'SVG' => ['image.svg', false], + 'server configuration' => ['.htaccess', false], +]); + +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/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/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')) 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('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'); + $read = strpos($view, 'ManagerActivity::instance()->onlineUsers()'); + + expect($read)->toBeInt() + ->and($read)->toBeGreaterThan(strpos($view, "hasPermission('widget_online_info')")) + ->and($read)->toBeLessThan(strpos($view, '$activeUsers->isEmpty()')) + ->and($view)->not->toContain("->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/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']; } 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/manager/actions/mutate_content.dynamic.php b/manager/actions/mutate_content.dynamic.php index 4b3e91ef79..2068398179 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); @@ -775,7 +776,7 @@ function evoRenderTvImageCheck(a) { " data-tooltip=""> - + @@ -874,7 +875,7 @@ function evoRenderTvImageCheck(a) { " data-tooltip=""> - + @@ -927,8 +928,8 @@ function evoRenderTvImageCheck(a) { } ?> " onclick="enableParentSelection(!allowParentSelection);"> - () - + () + @@ -1410,7 +1411,7 @@ function evoRenderTvImageCheck(a) { ?> " /> - + 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 9c14f9aeca..ebc40214ec 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; content:"";display:inline-block;wi span.regular { background-color: #f8f7f6; } span.regular:before { display: inline-block; content:"";display:inline-block;width:1em;height:1em;vertical-align:middle;background-color:currentColor;-webkit-mask:var(--evo-icon-folder) center/contain no-repeat;mask:var(--evo-icon-folder) center/contain no-repeat; 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; content:"";display:inline-block;width:1em;height:1em;vertical-align:middle;background-color:currentColor;-webkit-mask:var(--evo-icon-chevron-down) center/contain no-repeat;mask:var(--evo-icon-chevron-down) center/contain no-repeat; width: 1.3em; font-size: 1.3rem; line-height: .8em; vertical-align: middle; color: #565656; font-weight:900} span.closed:before { display: inline-block; content:"";display:inline-block;width:1em;height:1em;vertical-align:middle;background-color:currentColor;-webkit-mask:var(--evo-icon-chevron-right) center/contain no-repeat;mask:var(--evo-icon-chevron-right) center/contain no-repeat; width: 1.3em; font-size: 1.3rem; line-height: .8em; vertical-align: middle; color: #565656; font-weight:900} span.denied:before { display: inline-block; content:"";display:inline-block;width:1em;height:1em;vertical-align:middle;background-color:currentColor;-webkit-mask:var(--evo-icon-ban) center/contain no-repeat;mask:var(--evo-icon-ban) center/contain no-repeat; 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 043af057e0..bd47cbfd72 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); } 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/script/tests/tab-changed-mark.test.js b/manager/media/script/tests/tab-changed-mark.test.js new file mode 100644 index 0000000000..ef796e8ffc --- /dev/null +++ b/manager/media/script/tests/tab-changed-mark.test.js @@ -0,0 +1,80 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const helper = require('../document-save-helper'); + +// "Save and continue" saves over XHR and keeps the editor open, so the tab frame is not reloaded +// and its onload never clears the "changed" mark; the editor announces the save instead. +const root = path.join(__dirname, '..', '..', '..', '..'); +const evoSource = fs.readFileSync(path.join(root, 'manager', 'media', 'style', 'default', 'js', 'evo.js'), 'utf8'); +const editorSource = fs.readFileSync(path.join(root, 'manager', 'actions', 'mutate_content.dynamic.php'), 'utf8'); + +function loadWatchChanges() { + const match = evoSource.match(/watchChanges: (function \(tabWindow\) \{[\s\S]*?\n {16}\}),\n/); + assert.ok(match, 'tab.watchChanges is defined'); + return new Function('return (' + match[1] + ');')(); +} + +function tab() { + const classes = new Set(); + return { + watchChanges: loadWatchChanges(), + tab: { classList: { add: (c) => 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); +}); diff --git a/manager/media/style/default/js/evo.js b/manager/media/style/default/js/evo.js index c9a424b4f8..7e4a7d40c5 100644 --- a/manager/media/style/default/js/evo.js +++ b/manager/media/style/default/js/evo.js @@ -2148,21 +2148,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) { 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.