diff --git a/.github/workflows/tasks.yml b/.github/workflows/tasks.yml index 466e97f..2556e29 100644 --- a/.github/workflows/tasks.yml +++ b/.github/workflows/tasks.yml @@ -9,25 +9,26 @@ jobs: strategy: fail-fast: false matrix: - php: [ '8.1', '8.2', '8.3' ] - typo3: [ '11', '12' ] + php: [ '8.1', '8.2', '8.3', '8.4' ] + typo3: [ '11', '12', '13' ] exclude: - php: '8.1' typo3: '13' + - php: '8.4' + typo3: '11' steps: - name: Setup PHP with PECL extension uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - - uses: actions/checkout@v2 - - uses: actions/cache@v3 + - uses: actions/checkout@v4 + - uses: actions/cache@v4 with: path: ~/.composer/cache/files - key: ${{ runner.os }}-${{ matrix.php }}-composer-${{ hashFiles('**/composer.lock') }} + key: ${{ runner.os }}-${{ matrix.php }} restore-keys: | ${{ runner.os }}-${{ matrix.php }}-composer- - - run: composer require typo3/minimal="^${{ matrix.typo3 }}" -W --dev - - run: composer install --no-interaction --no-progress + - run: composer update --with typo3/minimal="^${{ matrix.typo3 }}" --no-interaction --no-progress -W --dev - run: ./vendor/bin/grumphp run --ansi ter-release: diff --git a/Classes/Cache/ClearCache.php b/Classes/Cache/ClearCache.php deleted file mode 100644 index 84cc4f7..0000000 --- a/Classes/Cache/ClearCache.php +++ /dev/null @@ -1,39 +0,0 @@ - $parameters */ - public function clearCache(array $parameters): void - { - if (isset($parameters['cacheCmd']) && ($parameters['cacheCmd'] === 'pages' || $parameters['cacheCmd'] === 'all')) { - $path = Environment::getPublicPath() . RenderIncludeViewHelper::SSI_INCLUDE_DIR; - $this->removeFiles($path); - } - } - - protected function removeFiles(string $dir): void - { - if (is_dir($dir)) { - $objects = scandir($dir); - if (!$objects) { - return; - } - - foreach ($objects as $object) { - if ($object !== '.' && $object !== '..') { - $filePath = $dir . DIRECTORY_SEPARATOR . $object; - if (is_file($filePath) && is_writable($filePath)) { - unlink($filePath); - } - } - } - } - } -} diff --git a/Classes/Cache/Frontend/SsiIncludeCacheFrontend.php b/Classes/Cache/Frontend/SsiIncludeCacheFrontend.php index c1ddcce..3a89680 100644 --- a/Classes/Cache/Frontend/SsiIncludeCacheFrontend.php +++ b/Classes/Cache/Frontend/SsiIncludeCacheFrontend.php @@ -4,10 +4,12 @@ namespace AUS\SsiInclude\Cache\Frontend; +use AUS\SsiInclude\Cache\Backend\SsiIncludeCacheBackend; use InvalidArgumentException; use TYPO3\CMS\Core\Cache\Exception; use TYPO3\CMS\Core\Cache\Exception\InvalidDataException; use TYPO3\CMS\Core\Cache\Frontend\AbstractFrontend; +use Webimpress\SafeWriter\Exception\ExceptionInterface; /** * A cache frontend for SSI include cache entries. @@ -19,9 +21,10 @@ class SsiIncludeCacheFrontend extends AbstractFrontend /** * @inheritdoc - * @throws Exception - * @throws InvalidDataException * @param list $tags + * @throws InvalidDataException + * @throws Exception + * @throws ExceptionInterface */ public function set($entryIdentifier, $data, array $tags = [], $lifetime = null): void { @@ -39,6 +42,8 @@ public function set($entryIdentifier, $data, array $tags = [], $lifetime = null) } } + assert($this->backend instanceof SsiIncludeCacheBackend); + assert(is_string($data)); $this->backend->set($entryIdentifier, $data, $tags, $lifetime); } diff --git a/Classes/Proxy/Proxy.php b/Classes/Proxy/Proxy.php index 7dc55ac..d12f51f 100644 --- a/Classes/Proxy/Proxy.php +++ b/Classes/Proxy/Proxy.php @@ -12,6 +12,7 @@ /** * @implements ArrayAccess + * @implements Iterator */ final class Proxy implements Iterator, Countable, Stringable, ArrayAccess { diff --git a/Classes/ViewHelpers/RenderIncludeViewHelper.php b/Classes/ViewHelpers/RenderIncludeViewHelper.php index 348adf5..fc830be 100644 --- a/Classes/ViewHelpers/RenderIncludeViewHelper.php +++ b/Classes/ViewHelpers/RenderIncludeViewHelper.php @@ -8,11 +8,10 @@ use AUS\SsiInclude\Event\RenderedEvent; use AUS\SsiInclude\Register\LastRenderedContentRegister; use AUS\SsiInclude\Utility\FilenameUtility; -use Closure; use Exception; -use Override; use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; use TYPO3\CMS\Core\Cache\CacheManager; +use TYPO3\CMS\Core\Cache\Exception\InvalidDataException; use TYPO3\CMS\Core\Cache\Exception\NoSuchCacheException; use TYPO3\CMS\Core\Configuration\Exception\ExtensionConfigurationExtensionNotConfiguredException; use TYPO3\CMS\Core\Configuration\Exception\ExtensionConfigurationPathDoesNotExistException; @@ -22,8 +21,8 @@ use TYPO3\CMS\Core\Context\UserAspect; use TYPO3\CMS\Core\EventDispatcher\EventDispatcher; use TYPO3\CMS\Core\Utility\GeneralUtility; -use TYPO3Fluid\Fluid\Core\Rendering\RenderingContextInterface; use TYPO3Fluid\Fluid\ViewHelpers\RenderViewHelper; +use Webimpress\SafeWriter\Exception\ExceptionInterface; use function assert; @@ -48,48 +47,29 @@ public function initializeArguments(): void { parent::initializeArguments(); $this->registerArgument('name', 'string', 'Specifies the file name of the cache (without .html ending)', true); - $this->registerArgument('cacheLifeTime', 'int', 'Specifies the lifetime in seconds (defaults to 300)', false, 300); + $this->registerArgument('cacheLifeTime', 'int|null', 'Specifies the lifetime in seconds'); $this->registerArgument('cacheTags', 'array', 'Tags to set that can clear with flushByTags', false, []); } /** - * @deprecated can be removed if parent class does not have renderStatic anymore. - */ - #[Override] - public static function renderStatic(array $arguments, Closure $renderChildrenClosure, RenderingContextInterface $renderingContext): string - { - /** @noinspection PhpUnhandledExceptionInspection */ - $isDisabled = (bool)GeneralUtility::makeInstance(ExtensionConfiguration::class)->get('ssi_include', 'disabled'); - if ($isDisabled) { - return parent::renderStatic($arguments, $renderChildrenClosure, $renderingContext); - } - - $self = GeneralUtility::makeInstance(self::class); - assert($self instanceof self); - /** @noinspection PhpUnhandledExceptionInspection */ - return $self->renderNonStatic($arguments, $renderChildrenClosure, $renderingContext); - } - - /** - * @param array|null $arguments * @throws Exception + * @throws ExtensionConfigurationExtensionNotConfiguredException + * @throws ExtensionConfigurationPathDoesNotExistException * @throws NoSuchCacheException + * @throws \TYPO3\CMS\Core\Cache\Exception + * @throws InvalidDataException * @throws AspectNotFoundException - * @throws ExtensionConfigurationPathDoesNotExistException - * @throws ExtensionConfigurationExtensionNotConfiguredException + * @throws ExceptionInterface */ - public function renderNonStatic(?array $arguments = null, ?Closure $renderChildrenClosure = null, ?RenderingContextInterface $renderingContext = null): string + public function render(): string { - $this->arguments = $arguments ?? $this->arguments; - $this->renderingContext = $renderingContext ?? $this->renderingContext; - $renderChildrenClosure ??= $this->buildRenderChildrenClosure(); - // generate the cache filename $name = $this->validateName($this->arguments); if ($this->isBackendUser()) { - $content = parent::renderStatic($this->arguments, $renderChildrenClosure, $this->renderingContext); + $content = parent::render(); // Put the code to register to use in InternalSsiRedirectMiddleware if the site comes from page cache + assert(is_string($content)); $this->lastRenderedContentRegister->set($name, $content); return $content; } @@ -107,15 +87,22 @@ public function renderNonStatic(?array $arguments = null, ?Closure $renderChildr // If the cache has not the proper entry, generate it $cache = $this->cacheManager->getCache('aus_ssi_include_cache'); assert($cache instanceof SsiIncludeCacheFrontend); + if (!$cache->has($filename)) { - $html = parent::renderStatic($this->arguments, $renderChildrenClosure, $this->renderingContext); + $html = parent::render(); + assert(is_string($html)); $eventDispatcher = GeneralUtility::makeInstance(EventDispatcher::class); $renderedHtmlEvent = new RenderedEvent($html); $eventDispatcher->dispatch($renderedHtmlEvent); $html = $renderedHtmlEvent->getHtml(); - $cacheTags = ['tx_ssiinclude_' . $name, ...$this->arguments['cacheTags']]; - $cache->set($filename, $html, $cacheTags, $this->arguments['cacheLifeTime']); + $cacheTags = $this->arguments['cacheTags']; + assert(is_array($cacheTags)); + $cacheTags[] = 'tx_ssiinclude_' . $name; + + $cacheLifeTime = $this->arguments['cacheLifeTime']; + assert(is_int($cacheLifeTime) || null === $cacheLifeTime); + $cache->set($filename, $html, $cacheTags, $cacheLifeTime); $this->lastRenderedContentRegister->set($name, $html); } @@ -135,7 +122,8 @@ public function renderNonStatic(?array $arguments = null, ?Closure $renderChildr */ private function validateName(array $arguments): string { - if (ctype_alnum((string)$arguments['name'])) { + assert(is_string($arguments['name'])); + if (ctype_alnum($arguments['name'])) { return $arguments['name']; } @@ -147,7 +135,9 @@ private function validateName(array $arguments): string */ protected function getLanguage(): int { - return $this->context->getPropertyFromAspect('language', 'id'); + $language = $this->context->getPropertyFromAspect('language', 'id'); + assert(is_int($language)); + return $language; } /** @@ -155,7 +145,7 @@ protected function getLanguage(): int */ protected function isBackendUser(): bool { - return $this->context->getPropertyFromAspect('backend.user', 'isLoggedIn'); + return (bool)$this->context->getPropertyFromAspect('backend.user', 'isLoggedIn'); } protected function getSiteName(): string diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b94f2f2 --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +ACT_IMAGE ?= efrecon/act:v0.2.80 +WORKDIR ?= /work +UID_GID := $(shell id -u):$(shell id -g) +DOCKER_GID := $(shell stat -c '%g' /var/run/docker.sock) + +PLATFORM ?= -P ubuntu-latest=ghcr.io/catthehacker/ubuntu:act-24.04 + +# Secrets-File in ENV format +SECRETS ?= --secret-file .secrets + +ACT_ARGS ?= \ + --artifact-server-path /home/act/.cache/artifacts + +define DOCKER_RUN +docker run --rm -it \ + -u $(UID_GID) \ + --group-add $(DOCKER_GID) \ + -e HOME=/home/act \ + -e XDG_CACHE_HOME=/home/act/.cache \ + -e ACT_CACHE_DIR=/home/act/.cache/actcache \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v $(PWD):$(WORKDIR) -w $(WORKDIR) \ + -v $$HOME/.cache:/home/act/.cache \ + $(ACT_IMAGE) +endef + +ci: + $(DOCKER_RUN) $(PLATFORM) $(SECRETS) $(ACT_ARGS) diff --git a/Tests/Classes/SsiIncludeCacheBackendTest.php b/Tests/Classes/SsiIncludeCacheBackendTest.php index d7d8d41..bf4311a 100644 --- a/Tests/Classes/SsiIncludeCacheBackendTest.php +++ b/Tests/Classes/SsiIncludeCacheBackendTest.php @@ -4,17 +4,21 @@ namespace AUS\SsiInclude\Tests; +use TYPO3\CMS\Core\DataHandling\DataHandler; use AUS\SsiInclude\Cache\Backend\SsiIncludeCacheBackend; use AUS\SsiInclude\Cache\Frontend\SsiIncludeCacheFrontend; use AUS\SsiInclude\Utility\FilenameUtility; use Doctrine\DBAL\Exception; +use Psr\EventDispatcher\EventDispatcherInterface; use RuntimeException; -use TYPO3\CMS\Core\Cache\Backend\Typo3DatabaseBackend; +use Symfony\Component\Filesystem\Filesystem; use TYPO3\CMS\Core\Cache\CacheManager; +use TYPO3\CMS\Core\Cache\Event\CacheFlushEvent; use TYPO3\CMS\Core\Cache\Exception\NoSuchCacheException; use TYPO3\CMS\Core\Core\Environment; +use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\InternalRequest; +use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\InternalRequestContext; use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase; -use TYPO3\CMS\Core\Cache\Frontend\VariableFrontend; use TYPO3\CMS\Core\Utility\GeneralUtility; class SsiIncludeCacheBackendTest extends FunctionalTestCase @@ -37,23 +41,37 @@ protected function setUp(): void $this->initializeCacheFramework(); // setup always after putenv and the caching framework initialization parent::setUp(); + $this->copySiteConfiguration(); // now after setup public path is available for fulfil the variable $this->ssiIncludeDir = Environment::getPublicPath() . $this->ssiIncludeDir; GeneralUtility::mkdir_deep($this->ssiIncludeDir); GeneralUtility::fixPermissions($this->ssiIncludeDir); } - protected function tearDown(): void + private function copySiteConfiguration(): void { - parent::tearDown(); + $sourcePath = __DIR__ . '/../Fixtures/Sites/'; + // there the SiteConfiguration::getAllSiteConfigurationFromFiles it looks for our sites if it changes, check the path there + $destinationPath = $this->instancePath . '/typo3conf/sites/default/'; - $files = glob($this->ssiIncludeDir . '*.html'); - if (false === $files) { - throw new RuntimeException('Failed to glob files in ' . $this->ssiIncludeDir, 1642422545); + if (!is_dir($destinationPath)) { + mkdir($destinationPath, 0777, true); } - array_map('unlink', $files); - @rmdir($this->ssiIncludeDir); + (new Filesystem())->copy( + $sourcePath . 'config.yaml', + $destinationPath . 'config.yaml', + true + ); + } + + protected function tearDown(): void + { + parent::tearDown(); + + $cacheManager = GeneralUtility::makeInstance(CacheManager::class); + assert($cacheManager instanceof CacheManager); + $cacheManager->flushCaches(); } /** @@ -236,4 +254,189 @@ public function garbageCollectionRemovesOutdatedFiles(): void self::assertFalse($data); self::assertFileDoesNotExist($this->ssiIncludeDir . 'outdated.html'); } + + /** + * @test + */ + public function cacheFlushEventRemovesAllFiles(): void + { + $cacheManager = GeneralUtility::makeInstance(CacheManager::class); + assert($cacheManager instanceof CacheManager); + $cache = $cacheManager->getCache('aus_ssi_include_cache'); + $cache->set('cacheFlushEventRemovesAllFiles1.html', 'test', ['tag1']); + self::assertTrue($cache->has('cacheFlushEventRemovesAllFiles1.html')); + self::assertFileExists($this->ssiIncludeDir . 'cacheFlushEventRemovesAllFiles1.html'); + + $orphanedFile = $this->ssiIncludeDir . 'cacheFlushEventRemovesAllFiles2.html'; + file_put_contents($orphanedFile, '

Orphaned Content

'); + self::assertFileExists($orphanedFile); + + $eventDispatcher = GeneralUtility::makeInstance(EventDispatcherInterface::class); + $event = new CacheFlushEvent(['pages']); + $eventDispatcher->dispatch($event); + + self::assertFalse($cache->has('cacheFlushEventRemovesAllFiles1.html')); + self::assertFileDoesNotExist($this->ssiIncludeDir . 'cacheFlushEventRemovesAllFiles1.html'); + self::assertFileDoesNotExist($orphanedFile); + } + + /** + * @test + * @throws NoSuchCacheException + */ + public function noCacheFileCreatedWhenBackendUserIsLoggedIn(): void + { + #$cacheManager = GeneralUtility::makeInstance(CacheManager::class); + #assert($cacheManager instanceof CacheManager); + #$cacheManager->flushCaches(); + + // Import a page tree with a test page + $this->importCSVDataSet(__DIR__ . '/../Fixtures/pages.csv'); + + // Set extension configuration + $GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS']['ssi_include']['disabled'] = '0'; + $GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS']['ssi_include']['method'] = 'ssi'; + + // Set up TypoScript template for the test page + $this->setUpFrontendRootPage(1, [ + 'EXT:ssi_include/Tests/Fixtures/TypoScript/test_page.typoscript' + ]); + + // Get expected SSI include filename + $expectedFilename = 'default_0_testinclude.html'; // site_language_name_usergroups.html format + + // Verify file doesn't exist before request + $absoluteFilename = GeneralUtility::makeInstance(FilenameUtility::class)->getAbsoluteFilename($expectedFilename); + if (file_exists($absoluteFilename)) { + unlink($absoluteFilename); + } + + self::assertFileDoesNotExist($absoluteFilename); + + // Create a ServerRequest with the URI + $request = (new InternalRequest()); + $request = $request->withMethod('GET'); + + $context = (new InternalRequestContext())->withBackendUserId(1); + $response = $this->executeFrontendSubRequest($request, $context); + + // Verify the response was successful + self::assertEquals(200, $response->getStatusCode()); + + $responseBody = (string)$response->getBody(); + + // When backend user is logged in, content should be rendered directly + // and NO SSI include file should be created + self::assertStringContainsString('SSI Include Content', $responseBody); + self::assertStringContainsString('This content should be cached as SSI include', $responseBody); + self::assertStringNotContainsString('