From 1b5ee99726dd60838d5500d0f83499e80e2647b7 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Wed, 23 Sep 2026 23:31:29 +0200 Subject: [PATCH 1/2] upd(ci): enable GD and disable installer before HTTP smoke Co-Authored-By: Claude Opus 5.5 --- .github/docker/ci/Dockerfile | 6 ++++-- .github/docker/ci/install-and-smoke.sh | 6 ++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/docker/ci/Dockerfile b/.github/docker/ci/Dockerfile index 03217e38d6..c1da88914b 100644 --- a/.github/docker/ci/Dockerfile +++ b/.github/docker/ci/Dockerfile @@ -14,8 +14,10 @@ FROM php:${PHP_VERSION}-cli # image that lives for one workflow run does not earn the extra stage. RUN apt-get update \ && apt-get install -y --no-install-recommends \ - libpq-dev libzip-dev libicu-dev libxml2-dev libonig-dev unzip \ - && docker-php-ext-install -j"$(nproc)" pdo_mysql pdo_pgsql zip intl \ + libpq-dev libzip-dev libicu-dev libxml2-dev libonig-dev \ + libfreetype6-dev libjpeg62-turbo-dev libpng-dev unzip \ + && docker-php-ext-configure gd --with-freetype --with-jpeg \ + && docker-php-ext-install -j"$(nproc)" pdo_mysql pdo_pgsql zip intl gd \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* diff --git a/.github/docker/ci/install-and-smoke.sh b/.github/docker/ci/install-and-smoke.sh index a9ff874965..bd1f89a9cf 100755 --- a/.github/docker/ci/install-and-smoke.sh +++ b/.github/docker/ci/install-and-smoke.sh @@ -95,6 +95,12 @@ say "Checking the data survived the update unchanged" # inserts instead of updating doubles its table here. php "$APP_DIR/.github/docker/ci/smoke.php" "$APP_DIR" || fail "the update changed the installed data" +# A deployed CMS must not leave the installer reachable. Keep its files out of +# the HTTP document tree before exercising the installed site, while preserving +# them under a non-routable name for this disposable CI image's diagnostics. +say "Renaming the installer directory before the HTTP smoke test" +mv "$APP_DIR/install" "$APP_DIR/install.disabled" + say "Checking the installed site answers over HTTP" http_check "$APP_DIR" From ceea9db4f42c32f6c82a10463667fdf14d5abc45 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Wed, 23 Sep 2026 23:31:29 +0200 Subject: [PATCH 2/2] fix(manager): remove per-node alias queries from resource tree Full-tree rendering issued one SiteContent lookup per node to rebuild its alias path. Prime the alias listing from the tree query when URLs are resolved lazily (aliaslistingfolder / full_aliaslisting), and normalise tree rows with a cast whitelist instead of Eloquent toArray() so plugin payloads keep the same types. Co-Authored-By: Claude Opus 5.5 --- core/functions/nodes.php | 52 +++++- core/src/UrlProcessor.php | 71 ++++++-- .../Unit/UrlProcessorAliasListingTest.php | 160 ++++++++++++++++++ 3 files changed, 260 insertions(+), 23 deletions(-) create mode 100644 core/tests/Unit/UrlProcessorAliasListingTest.php diff --git a/core/functions/nodes.php b/core/functions/nodes.php index 4b68329627..dda5218a17 100644 --- a/core/functions/nodes.php +++ b/core/functions/nodes.php @@ -72,7 +72,7 @@ function makeHTML($indent, $parent, $expandAll, $hereid = '') 'menutitle', 'parent', 'isfolder' , 'published', 'pub_date', 'unpub_date', 'richtext', 'searchable', 'cacheable' , 'deleted', 'type', 'template', 'templatename', 'menuindex', 'hide_from_tree', 'hidemenu', 'alias' - , 'contentType', 'privateweb', 'privatemgr' + , 'alias_visible', 'contentType', 'privateweb', 'privatemgr' ) ->leftJoin('document_groups', 'site_content.id', '=', 'document_groups.document') ->leftJoin('site_templates', 'site_content.template', '=', 'site_templates.id') @@ -105,8 +105,18 @@ function makeHTML($indent, $parent, $expandAll, $hereid = '') 'menutitle', 'parent', 'isfolder' , 'published', 'pub_date', 'unpub_date', 'richtext', 'searchable', 'cacheable' , 'deleted', 'type', 'template', 'templatename', 'menuindex', 'hide_from_tree', 'hidemenu', 'alias' - , 'contentType', 'privateweb', 'privatemgr']); - $result = $result->get(); + , 'alias_visible', 'contentType', 'privateweb', 'privatemgr']); + $result = $result->get() + ->map(static fn ($item) => normalizeTreeNodeRow($item->getAttributes())); + + // Every row below needs a front-end URL. When URLs are resolved through + // lazy alias lookups, reuse the aliases selected by this tree query so a + // fully expanded tree does not issue one document lookup per node merely + // to rebuild its alias path. The site cache already holds them otherwise. + $urlProcessor = \EvolutionCMS\Facades\UrlProcessor::getFacadeRoot(); + if ($urlProcessor->usesLazyAliasListing()) { + $urlProcessor->primeAliasListings($result->all()); + } if ($result->count() == 0) { @@ -118,8 +128,7 @@ function makeHTML($indent, $parent, $expandAll, $hereid = '') } else { $nodeNameSource = $_SESSION['tree_nodename']; } - foreach ($result as $item) { - $row = $item->toArray(); + foreach ($result as $row) { $row['roles'] = ''; $row['nomove'] = 0; $row['hasAccess'] = 0; @@ -536,6 +545,39 @@ function makeHTML($indent, $parent, $expandAll, $hereid = '') } } +if (!function_exists('normalizeTreeNodeRow')) { + /** + * Give a raw tree query row the types SiteContent casts would give it, + * without running Eloquent's per-model cast/serialization path. The row + * reaches OnManagerNodePrerender / OnManagerNodeRender, so the types must + * match what toArray() produced. Null stays null; strings are untouched. + * + * @param array $row + * @return array + */ + function normalizeTreeNodeRow(array $row): array + { + static $intFields = [ + 'id', 'parent', 'isfolder', 'published', 'pub_date', 'unpub_date', 'searchable', 'cacheable', + 'deleted', 'template', 'menuindex', 'alias_visible', + ]; + static $boolFields = ['richtext', 'hide_from_tree', 'hidemenu', 'privateweb', 'privatemgr']; + + foreach ($intFields as $field) { + if (isset($row[$field])) { + $row[$field] = (int) $row[$field]; + } + } + foreach ($boolFields as $field) { + if (isset($row[$field])) { + $row[$field] = (bool) $row[$field]; + } + } + + return $row; + } +} + if (!function_exists('getIconInfo')) { /** * @param array $_style diff --git a/core/src/UrlProcessor.php b/core/src/UrlProcessor.php index e435a4674b..450f8b01f0 100644 --- a/core/src/UrlProcessor.php +++ b/core/src/UrlProcessor.php @@ -392,25 +392,23 @@ public static function cleanQueryString($query): string */ public function getAliasListing($id): ?array { - if (isset($this->aliasListing[$id])) { - return $this->aliasListing[$id]; - } + if (!isset($this->aliasListing[$id])) { + /** @var Models\SiteContent|null $query */ + $query = Models\SiteContent::where('id', '=', (int)$id)->first(); + if ($query === null) { + return null; + } - /** @var Models\SiteContent|null $query */ - $query = Models\SiteContent::where('id', '=', (int)$id)->first(); - if ($query === null) { - return null; + $this->aliasListing[$id] = [ + 'id' => $query->getKey(), + 'alias' => $query->alias === '' ? $query->getKey() : $query->alias, + 'parent' => $query->parent, + 'isfolder' => $query->isfolder, + 'alias_visible' => $query->alias_visible, + ]; } - $this->aliasListing[$id] = [ - 'id' => $query->getKey(), - 'alias' => $query->alias === '' ? $query->getKey() : $query->alias, - 'parent' => $query->parent, - 'isfolder' => $query->isfolder, - 'alias_visible' => $query->alias_visible, - ]; - - if ($query->parent <= 0) { + if (isset($this->aliasListing[$id]['path']) || $this->aliasListing[$id]['parent'] <= 0) { return $this->aliasListing[$id]; } @@ -419,7 +417,7 @@ public function getAliasListing($id): ?array return $this->aliasListing[$id]; } - $tmp = $this->getAliasListing($query->parent); + $tmp = $this->getAliasListing($this->aliasListing[$id]['parent']); if (!$tmp['alias_visible']) { $this->aliasListing[$id]['path'] = $tmp['path']; @@ -434,6 +432,43 @@ public function getAliasListing($id): ?array return $this->aliasListing[$id]; } + /** + * Whether makeUrl() resolves aliases lazily through getAliasListing() + * instead of reading the full alias listing loaded from the site cache. + */ + public function usesLazyAliasListing(): bool + { + return (bool)$this->core->getConfig('aliaslistingfolder') + || $this->core->getConfig('full_aliaslisting') == 1; + } + + /** + * Prime alias metadata that has already been selected by a caller. + * + * @param iterable $documents + */ + public function primeAliasListings(iterable $documents): void + { + foreach ($documents as $document) { + if (!isset($document['id'])) { + continue; + } + + $id = (int) $document['id']; + if (isset($this->aliasListing[$id])) { + continue; + } + + $this->aliasListing[$id] = [ + 'id' => $id, + 'alias' => $document['alias'] === '' ? $id : $document['alias'], + 'parent' => (int) $document['parent'], + 'isfolder' => (int) $document['isfolder'], + 'alias_visible' => (int) $document['alias_visible'], + ]; + } + } + /** * @param $alias * @return null|int @@ -565,7 +600,7 @@ public function makeUrl(int $id, string $alias = '', string $args = '', string $ if ($this->core->getConfig('friendly_alias_urls')) { - if ($this->core->getConfig('aliaslistingfolder') || $this->core->getConfig('full_aliaslisting') == 1) { + if ($this->usesLazyAliasListing()) { $al = $this->getAliasListing($id); } else { $al = $this->aliasListing[$id] ?? null; diff --git a/core/tests/Unit/UrlProcessorAliasListingTest.php b/core/tests/Unit/UrlProcessorAliasListingTest.php new file mode 100644 index 0000000000..be4143e6aa --- /dev/null +++ b/core/tests/Unit/UrlProcessorAliasListingTest.php @@ -0,0 +1,160 @@ + '', + 'friendly_url_prefix' => '', + 'friendly_url_suffix' => '', + 'site_start' => 1, + 'friendly_urls' => true, + 'friendly_alias_urls' => true, + 'use_alias_path' => true, + 'aliaslistingfolder' => true, + 'full_aliaslisting' => 1, + 'make_folders' => false, + 'base_url' => '', + ]; + $core = test()->getMockBuilder(Core::class) + ->disableOriginalConstructor() + ->onlyMethods(['getConfig', 'invokeEvent']) + ->getMock(); + $core->documentListing = []; + $core->aliasListing = []; + $core->virtualDir = ''; + $core->method('getConfig')->willReturnCallback(static fn ($name, $default = null) => $config[$name] ?? $default); + $core->method('invokeEvent')->willReturn(false); + + return new UrlProcessor($core); +} + +// getAliasListing() reads through the SiteContent model, so a query is caught +// at the Eloquent connection resolver rather than at Core::getDatabase(). +function forbidAliasListingQueries(): void +{ + $resolver = Mockery::mock(ConnectionResolverInterface::class); + $resolver->shouldReceive('connection') + ->andThrow(new LogicException('The primed alias-listing test must not query the database.')); + $resolver->shouldReceive('getDefaultConnection')->andReturn('default'); + Model::setConnectionResolver($resolver); +} + +function aliasListingDatabase(array $rows): Capsule +{ + $capsule = new Capsule(); + $capsule->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + Model::setConnectionResolver($capsule->getDatabaseManager()); + $capsule->getConnection()->getSchemaBuilder()->create('site_content', function (Blueprint $table) { + $table->increments('id'); + $table->string('alias')->default(''); + $table->integer('parent')->default(0); + $table->integer('isfolder')->default(0); + $table->integer('alias_visible')->default(1); + $table->integer('deleted')->default(0); + $table->integer('deletedon')->default(0); + }); + $capsule->table('site_content')->insert($rows); + $capsule->getConnection()->enableQueryLog(); + + return $capsule; +} + +afterEach(function () { + Model::unsetConnectionResolver(); + Mockery::close(); +}); + +test('primed alias listings build friendly tree URLs without per-node queries', function () { + forbidAliasListingQueries(); + $processor = aliasListingProcessor(); + + $processor->primeAliasListings([ + ['id' => 1, 'alias' => 'articles', 'parent' => 0, 'isfolder' => 1, 'alias_visible' => 1], + ['id' => 2, 'alias' => 'category-001', 'parent' => 1, 'isfolder' => 1, 'alias_visible' => 1], + ['id' => 3, 'alias' => 'article-000001', 'parent' => 2, 'isfolder' => 0, 'alias_visible' => 1], + ]); + + expect($processor->makeUrl(3))->toBe('articles/category-001/article-000001') + ->and($processor->aliasListing[3]['path'])->toBe('articles/category-001'); +}); + +test('primed alias listings skip a parent folder whose alias is hidden', function () { + forbidAliasListingQueries(); + $processor = aliasListingProcessor(); + + $processor->primeAliasListings([ + ['id' => 1, 'alias' => 'articles', 'parent' => 0, 'isfolder' => 1, 'alias_visible' => 1], + ['id' => 2, 'alias' => 'hidden-folder', 'parent' => 1, 'isfolder' => 1, 'alias_visible' => 0], + ['id' => 3, 'alias' => 'article', 'parent' => 2, 'isfolder' => 0, 'alias_visible' => 1], + ]); + + expect($processor->makeUrl(3))->toBe('articles/article') + ->and($processor->aliasListing[2]['path'])->toBe('articles'); +}); + +test('primed alias listings keep entries that are already loaded', function () { + forbidAliasListingQueries(); + $processor = aliasListingProcessor(); + $processor->aliasListing[2] = [ + 'id' => 2, 'alias' => 'cached', 'path' => 'from-cache', 'parent' => 1, 'isfolder' => 0, 'alias_visible' => 1, + ]; + + $processor->primeAliasListings([ + ['id' => 2, 'alias' => 'fresh', 'parent' => 1, 'isfolder' => 0, 'alias_visible' => 1], + ]); + + expect($processor->makeUrl(2))->toBe('from-cache/cached'); +}); + +test('a parent that was not primed is loaded once and then reused', function () { + $capsule = aliasListingDatabase([ + ['id' => 1, 'alias' => 'articles', 'parent' => 0, 'isfolder' => 1, 'alias_visible' => 1], + ]); + $processor = aliasListingProcessor(); + + $processor->primeAliasListings([ + ['id' => 2, 'alias' => 'first', 'parent' => 1, 'isfolder' => 0, 'alias_visible' => 1], + ['id' => 3, 'alias' => 'second', 'parent' => 1, 'isfolder' => 0, 'alias_visible' => 1], + ]); + + expect($processor->makeUrl(2))->toBe('articles/first') + ->and($processor->makeUrl(3))->toBe('articles/second') + ->and($capsule->getConnection()->getQueryLog())->toHaveCount(1); +}); + +test('lazy alias listing is used only when an alias listing mode requires it', function () { + $defaults = ['aliaslistingfolder' => false, 'full_aliaslisting' => 0]; + + expect(aliasListingProcessor($defaults)->usesLazyAliasListing())->toBeFalse() + ->and(aliasListingProcessor(['aliaslistingfolder' => true] + $defaults)->usesLazyAliasListing())->toBeTrue() + ->and(aliasListingProcessor(['full_aliaslisting' => '1'] + $defaults)->usesLazyAliasListing())->toBeTrue(); +}); + +test('tree node rows get the SiteContent cast types without touching strings or nulls', function () { + $row = normalizeTreeNodeRow([ + 'id' => '7', 'parent' => '1', 'isfolder' => '0', 'published' => '1', 'pub_date' => '1700000000', + 'unpub_date' => null, 'searchable' => '1', 'cacheable' => '0', 'deleted' => '0', 'template' => '3', + 'menuindex' => '12', 'alias_visible' => '1', + 'richtext' => '1', 'hide_from_tree' => 0, 'hidemenu' => '0', 'privateweb' => 1, 'privatemgr' => null, + 'type' => 'reference', 'alias' => '0012', 'pagetitle' => '42', 'contentType' => 'text/html', + 'templatename' => null, + ]); + + expect($row)->toBe([ + 'id' => 7, 'parent' => 1, 'isfolder' => 0, 'published' => 1, 'pub_date' => 1700000000, + 'unpub_date' => null, 'searchable' => 1, 'cacheable' => 0, 'deleted' => 0, 'template' => 3, + 'menuindex' => 12, 'alias_visible' => 1, + 'richtext' => true, 'hide_from_tree' => false, 'hidemenu' => false, 'privateweb' => true, 'privatemgr' => null, + 'type' => 'reference', 'alias' => '0012', 'pagetitle' => '42', 'contentType' => 'text/html', + 'templatename' => null, + ]); +});