From adc8b30e6dbd9a5ba2f7b5be4df3fdfcd22d02bc Mon Sep 17 00:00:00 2001 From: Daniel Siepmann Date: Thu, 4 May 2023 15:23:23 +0200 Subject: [PATCH] Extend TYPO3 page cache timout calculation based on rendered dates (#9) Relates: #10349 --- .github/workflows/ci.yaml | 2 +- Classes/Caching/PageCacheTimeout.php | 92 ++++++++ Configuration/Services.yaml | 6 + Documentation/Changelog/3.4.0.rst | 33 +++ Tests/Functional/Frontend/CacheTest.php | 207 ++++++++++++++++++ .../Fixtures/Extensions/example/composer.json | 13 ++ .../Extensions/example/ext_emconf.php | 17 ++ .../Extensions/example/ext_localconf.php | 7 + .../TypoScript/CachingMidnight.typoscript | 6 + .../Fixtures/TypoScript/Rendering.typoscript | 6 + composer.json | 1 + ext_emconf.php | 2 +- ext_localconf.php | 1 + phpstan-baseline.neon | 5 + shell.nix | 82 ++++++- 15 files changed, 470 insertions(+), 10 deletions(-) create mode 100644 Classes/Caching/PageCacheTimeout.php create mode 100644 Documentation/Changelog/3.4.0.rst create mode 100644 Tests/Functional/Frontend/CacheTest.php create mode 100644 Tests/Functional/Frontend/Fixtures/Extensions/example/composer.json create mode 100644 Tests/Functional/Frontend/Fixtures/Extensions/example/ext_emconf.php create mode 100644 Tests/Functional/Frontend/Fixtures/Extensions/example/ext_localconf.php create mode 100644 Tests/Functional/Frontend/Fixtures/TypoScript/CachingMidnight.typoscript diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3528f2a..ca4efc0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -47,7 +47,7 @@ jobs: tools: composer:v2 - name: Install xmllint - run: sudo apt-get install libxml2-utils + run: sudo apt update && sudo apt install libxml2-utils - name: Install dependencies run: composer install --prefer-dist --no-progress --no-suggest diff --git a/Classes/Caching/PageCacheTimeout.php b/Classes/Caching/PageCacheTimeout.php new file mode 100644 index 0000000..6e09c33 --- /dev/null +++ b/Classes/Caching/PageCacheTimeout.php @@ -0,0 +1,92 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +namespace Wrm\Events\Caching; + +use DateTime; +use DateTimeImmutable; +use TYPO3\CMS\Core\SingletonInterface; +use Wrm\Events\Events\Controller\DateListVariables; + +/** + * Teaches TYPO3 to set proper timeout for page cache. + * + * Takes timings of rendered dates into account. + * Reduced timeout of page cache in case events (dates) might change before already calculated timeout. + */ +class PageCacheTimeout implements SingletonInterface +{ + /** + * @var int + */ + private $earliestTimeout = 0; + + public function calculateCacheTimout( + array $parameters + ): int { + $timeout = $parameters['cacheTimeout']; + + if ($this->earliestTimeout <= 0) { + return $timeout; + } + + return min($timeout, $this->earliestTimeout); + } + + public function trackDates(DateListVariables $event): void + { + if ($event->getDemand()->shouldShowFromMidnight()) { + $this->updateTimeout((int) (new DateTimeImmutable('tomorrow midnight'))->format('U')); + return; + } + + foreach ($event->getDates() as $date) { + $endDate = $date->getEnd(); + if (!$endDate instanceof DateTime) { + continue; + } + + $this->updateTimeout((int)DateTimeImmutable::createFromMutable($endDate)->format('U')); + } + } + + private function updateTimeout(int $timestamp): void + { + $newTimeout = $timestamp - time(); + if ($newTimeout <= 0) { + return; + } + + if ($this->earliestTimeout === 0) { + $this->earliestTimeout = $newTimeout; + return; + } + + $this->earliestTimeout = min($this->earliestTimeout, $newTimeout); + } + + public static function register(): void + { + $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['get_cache_timeout']['events'] = self::class . '->calculateCacheTimout'; + } +} diff --git a/Configuration/Services.yaml b/Configuration/Services.yaml index a229aa6..2282efb 100644 --- a/Configuration/Services.yaml +++ b/Configuration/Services.yaml @@ -39,3 +39,9 @@ services: Wrm\Events\Updates\MigrateOldLocations: public: true + + Wrm\Events\Caching\PageCacheTimeout: + tags: + - name: event.listener + event: Wrm\Events\Events\Controller\DateListVariables + method: 'trackDates' diff --git a/Documentation/Changelog/3.4.0.rst b/Documentation/Changelog/3.4.0.rst new file mode 100644 index 0000000..1b619ab --- /dev/null +++ b/Documentation/Changelog/3.4.0.rst @@ -0,0 +1,33 @@ +3.4.0 +===== + +Breaking +-------- + +Nothing + +Features +-------- + +* Adjust TYPO3 page cache timeout based on rendered dates. + The end time of each rendered date will be used. + The lowest end date will be used to calculate the maximum time to life for the page cache. + This is compared to the already calculated time to life. + The lower value is then used by TYPO3. + + That allows visitors to always see the next dates. + +Fixes +----- + +Nothing + +Tasks +----- + +Nothing + +Deprecation +----------- + +Nothing diff --git a/Tests/Functional/Frontend/CacheTest.php b/Tests/Functional/Frontend/CacheTest.php new file mode 100644 index 0000000..4fc79e7 --- /dev/null +++ b/Tests/Functional/Frontend/CacheTest.php @@ -0,0 +1,207 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +namespace Wrm\Events\Tests\Functional\Frontend; + +use Codappix\Typo3PhpDatasets\PhpDataSet; +use DateTimeImmutable; +use DateTimeZone; +use Psr\Http\Message\ResponseInterface; +use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\InternalRequest; +use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase; + +/** + * @covers \Wrm\Events\Caching\PageCacheTimeout + */ +class CacheTest extends FunctionalTestCase +{ + protected $testExtensionsToLoad = [ + 'typo3conf/ext/events', + 'typo3conf/ext/events/Tests/Functional/Frontend/Fixtures/Extensions/example', + ]; + + protected $coreExtensionsToLoad = [ + 'fluid_styled_content', + ]; + + protected $pathsToProvideInTestInstance = [ + 'typo3conf/ext/events/Tests/Functional/Frontend/Fixtures/Sites/' => 'typo3conf/sites', + ]; + + protected function setUp(): void + { + parent::setUp(); + + $this->importCSVDataSet(__DIR__ . '/Fixtures/Database/SiteStructure.csv'); + (new PhpDataSet())->import(['tt_content' => [[ + 'uid' => '1', + 'pid' => '1', + 'CType' => 'list', + 'list_type' => 'events_datelisttest', + 'header' => 'All Dates', + ]]]); + $this->setUpFrontendRootPage(1, $this->getTypoScriptFiles()); + } + + /** + * @test + */ + public function setupReturnsSystemDefaults(): void + { + $request = new InternalRequest(); + $request = $request->withPageId(1); + $response = $this->executeFrontendRequest($request); + + self::assertSame(200, $response->getStatusCode()); + self::assertSame('max-age=86400', $response->getHeaderLine('Cache-Control')); + self::assertSame('public', $response->getHeaderLine('Pragma')); + } + + /** + * @test + */ + public function setupReturnsDefaultsIfEventsEndLater(): void + { + (new PhpDataSet())->import([ + 'tx_events_domain_model_event' => [ + [ + 'uid' => '1', + 'title' => 'Test Event 1', + ], + ], + 'tx_events_domain_model_date' => [ + [ + 'event' => '1', + 'start' => time(), + 'end' => time() + 86400 + 50, + ], + ], + ]); + + $request = new InternalRequest(); + $request = $request->withPageId(1); + $response = $this->executeFrontendRequest($request); + + self::assertSame(200, $response->getStatusCode()); + self::assertSame('max-age=86400', $response->getHeaderLine('Cache-Control')); + self::assertSame('public', $response->getHeaderLine('Pragma')); + } + + /** + * @test + */ + public function setupReturnsEarlierIfEventsChangeBeforeSystemDefault(): void + { + (new PhpDataSet())->import([ + 'tx_events_domain_model_event' => [ + [ + 'uid' => '1', + 'pid' => '2', + 'title' => 'Test Event 1', + ], + ], + 'tx_events_domain_model_date' => [ + [ + 'pid' => '2', + 'event' => '1', + 'start' => time(), + 'end' => time() + 50, + ], + ], + ]); + + $request = new InternalRequest(); + $request = $request->withPageId(1); + $response = $this->executeFrontendRequest($request); + + self::assertSame(200, $response->getStatusCode()); + self::assertMaxAge(50, $response); + self::assertSame('public', $response->getHeaderLine('Pragma')); + } + + /** + * @test + */ + public function setupReturnsMidnightIfConfigured(): void + { + (new PhpDataSet())->import([ + 'tx_events_domain_model_event' => [ + [ + 'uid' => '1', + 'pid' => '2', + 'title' => 'Test Event 1', + ], + ], + 'tx_events_domain_model_date' => [ + [ + 'pid' => '2', + 'event' => '1', + 'start' => time(), + 'end' => time() + 50, + ], + ], + ]); + + $this->setUpFrontendRootPage(1, array_merge_recursive($this->getTypoScriptFiles(), [ + 'setup' => [ + 'EXT:events/Tests/Functional/Frontend/Fixtures/TypoScript/CachingMidnight.typoscript' + ], + ])); + + $request = new InternalRequest(); + $request = $request->withPageId(1); + $response = $this->executeFrontendRequest($request); + + self::assertSame(200, $response->getStatusCode()); + $midnight = (int) (new DateTimeImmutable('tomorrow midnight', new DateTimeZone('UTC')))->format('U'); + $age = $midnight - time(); + self::assertMaxAge($age, $response); + self::assertSame('public', $response->getHeaderLine('Pragma')); + } + + private static function assertMaxAge(int $age, ResponseInterface $response): void + { + [$prefix, $value] = explode('=', $response->getHeaderLine('Cache-Control')); + + self::assertSame('max-age', $prefix); + + // We might be one sec off due to how fast code is executed, so add a small offset + $value = (int)$value; + self::assertLessThanOrEqual($age + 1, $value, 'Max age of cached response is higher than expected.'); + self::assertGreaterThanOrEqual($age - 1, $value, 'Max age of cached response is less than expected.'); + } + + private function getTypoScriptFiles(): array + { + return [ + 'constants' => [ + 'EXT:events/Configuration/TypoScript/constants.typoscript', + ], + 'setup' => [ + 'EXT:fluid_styled_content/Configuration/TypoScript/setup.typoscript', + 'EXT:events/Configuration/TypoScript/setup.typoscript', + 'EXT:events/Tests/Functional/Frontend/Fixtures/TypoScript/Rendering.typoscript' + ], + ]; + } +} diff --git a/Tests/Functional/Frontend/Fixtures/Extensions/example/composer.json b/Tests/Functional/Frontend/Fixtures/Extensions/example/composer.json new file mode 100644 index 0000000..47331b8 --- /dev/null +++ b/Tests/Functional/Frontend/Fixtures/Extensions/example/composer.json @@ -0,0 +1,13 @@ +{ + "name": "wrm/events-example", + "type": "typo3-cms-extension", + "license": "GPL-2.0-or-later", + "require": { + "wrm/events": "*" + }, + "extra": { + "typo3/cms": { + "extension-key": "example" + } + } +} diff --git a/Tests/Functional/Frontend/Fixtures/Extensions/example/ext_emconf.php b/Tests/Functional/Frontend/Fixtures/Extensions/example/ext_emconf.php new file mode 100644 index 0000000..58693ca --- /dev/null +++ b/Tests/Functional/Frontend/Fixtures/Extensions/example/ext_emconf.php @@ -0,0 +1,17 @@ + 'Events Test', + 'category' => 'plugin', + 'description' => 'Example for tests', + 'state' => 'alpha', + 'version' => '1.0.0', + 'constraints' => [ + 'depends' => [ + 'events' => '', + 'typo3' => '', + ], + 'conflicts' => [], + 'suggests' => [], + ], +]; diff --git a/Tests/Functional/Frontend/Fixtures/Extensions/example/ext_localconf.php b/Tests/Functional/Frontend/Fixtures/Extensions/example/ext_localconf.php new file mode 100644 index 0000000..0ea100d --- /dev/null +++ b/Tests/Functional/Frontend/Fixtures/Extensions/example/ext_localconf.php @@ -0,0 +1,7 @@ + 'list'] +); diff --git a/Tests/Functional/Frontend/Fixtures/TypoScript/CachingMidnight.typoscript b/Tests/Functional/Frontend/Fixtures/TypoScript/CachingMidnight.typoscript new file mode 100644 index 0000000..631c13a --- /dev/null +++ b/Tests/Functional/Frontend/Fixtures/TypoScript/CachingMidnight.typoscript @@ -0,0 +1,6 @@ +plugin.tx_events { + settings { + start > + useMidnight = 1 + } +} diff --git a/Tests/Functional/Frontend/Fixtures/TypoScript/Rendering.typoscript b/Tests/Functional/Frontend/Fixtures/TypoScript/Rendering.typoscript index 4e1b9c9..d3a17a6 100644 --- a/Tests/Functional/Frontend/Fixtures/TypoScript/Rendering.typoscript +++ b/Tests/Functional/Frontend/Fixtures/TypoScript/Rendering.typoscript @@ -1,3 +1,9 @@ +config { + cache_period = 86400 + no_cache = 0 + sendCacheHeaders = 1 +} + page = PAGE page { 10 < styles.content.get diff --git a/composer.json b/composer.json index 07ae1ae..c39fa19 100644 --- a/composer.json +++ b/composer.json @@ -46,6 +46,7 @@ } }, "require-dev": { + "codappix/typo3-php-datasets": "^1.2", "guzzlehttp/guzzle": "^6.3 || ^7.3", "jangregor/phpstan-prophecy": "1.0.0", "phpspec/prophecy-phpunit": "^1.0 || ^2.0", diff --git a/ext_emconf.php b/ext_emconf.php index 98c873b..e213833 100644 --- a/ext_emconf.php +++ b/ext_emconf.php @@ -9,7 +9,7 @@ $EM_CONF['events'] = [ 'state' => 'alpha', 'createDirs' => '', 'clearCacheOnLoad' => 0, - 'version' => '3.3.0', + 'version' => '3.4.0', 'constraints' => [ 'depends' => [ 'typo3' => '10.4.00-11.5.99', diff --git a/ext_localconf.php b/ext_localconf.php index 8ac73bb..f84164a 100644 --- a/ext_localconf.php +++ b/ext_localconf.php @@ -51,5 +51,6 @@ call_user_func(function () { ['source' => 'EXT:events/Resources/Public/Icons/Folder.svg'] ); + \Wrm\Events\Caching\PageCacheTimeout::register(); \Wrm\Events\Updates\MigrateOldLocations::register(); }); diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 4ecec40..a293006 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -75,3 +75,8 @@ parameters: count: 1 path: Classes/Updates/MigrateOldLocations.php + + - + message: "#^Cannot call method getEnd\\(\\) on mixed\\.$#" + count: 1 + path: Classes/Caching/PageCacheTimeout.php diff --git a/shell.nix b/shell.nix index 799dc22..3954dd4 100644 --- a/shell.nix +++ b/shell.nix @@ -1,7 +1,18 @@ -{ pkgs ? import { } }: +{ + pkgs ? import { } + ,phps ? import +}: let - php = pkgs.php82; + php = phps.packages.x86_64-linux.php82.buildEnv { + extensions = { enabled, all }: enabled ++ (with all; [ + xdebug + ]); + extraConfig = '' + xdebug.mode = debug + memory_limit = 4G + ''; + }; inherit(pkgs.php82Packages) composer; projectInstall = pkgs.writeShellApplication { @@ -12,9 +23,10 @@ let ]; text = '' rm -rf vendor/ composer.lock .Build/ - composer install --prefer-dist --no-progress --working-dir="$PROJECT_ROOT" + composer update --prefer-dist --no-progress --working-dir="$PROJECT_ROOT" ''; }; + projectValidateComposer = pkgs.writeShellApplication { name = "project-validate-composer"; runtimeInputs = [ @@ -25,6 +37,7 @@ let composer validate ''; }; + projectValidateXml = pkgs.writeShellApplication { name = "project-validate-xml"; runtimeInputs = [ @@ -40,25 +53,78 @@ let xmllint --schema xliff-core-1.2-strict.xsd --noout $(find Resources -name '*.xlf') ''; }; - projectCodingGuideline = pkgs.writeShellApplication { - name = "project-coding-guideline"; + + projectPhpstan = pkgs.writeShellApplication { + name = "project-phpstan"; + runtimeInputs = [ php - projectInstall ]; + + text = '' + ./vendor/bin/phpstan + ''; + }; + + projectCgl = pkgs.writeShellApplication { + name = "project-cgl"; + + runtimeInputs = [ + php + ]; + text = '' - project-install ./vendor/bin/ecs check --no-progress-bar --clear-cache ''; }; + projectCglFix = pkgs.writeShellApplication { + name = "project-cgl-fix"; + + runtimeInputs = [ + php + ]; + + text = '' + ./vendor/bin/ecs check --fix --no-progress-bar --clear-cache + ''; + }; + + projectTestsUnit = pkgs.writeShellApplication { + name = "project-tests-unit"; + + runtimeInputs = [ + php + ]; + + text = '' + ./vendor/bin/phpunit --testsuite unit --color --testdox + ''; + }; + + projectTestsFunctional = pkgs.writeShellApplication { + name = "project-tests-functional"; + + runtimeInputs = [ + php + ]; + + text = '' + ./vendor/bin/phpunit --testsuite functional --color --testdox + ''; + }; + in pkgs.mkShell { name = "TYPO3 Extension Watchlist"; buildInputs = [ projectInstall projectValidateComposer projectValidateXml - projectCodingGuideline + projectPhpstan + projectCgl + projectCglFix + projectTestsUnit + projectTestsFunctional php composer ];