diff --git a/Classes/ContentObject/BooleanContentObject.php b/Classes/ContentObject/BooleanContentObject.php index af91b90a..978c4822 100755 --- a/Classes/ContentObject/BooleanContentObject.php +++ b/Classes/ContentObject/BooleanContentObject.php @@ -22,14 +22,14 @@ class BooleanContentObject extends AbstractContentObject { /** * Rendering the cObject, JSON - * @param array $conf Array of TypoScript properties - * @return bool + * @param array $conf Array of TypoScript properties */ public function render($conf = []): bool { if (!is_array($conf)) { return false; } + $content = false; if (isset($conf['value'])) { $content = $conf['value']; diff --git a/Classes/ContentObject/IntegerContentObject.php b/Classes/ContentObject/IntegerContentObject.php index c660385b..f1ae4f0a 100755 --- a/Classes/ContentObject/IntegerContentObject.php +++ b/Classes/ContentObject/IntegerContentObject.php @@ -22,14 +22,14 @@ class IntegerContentObject extends AbstractContentObject { /** * Rendering the cObject, JSON - * @param array $conf Array of TypoScript properties - * @return int + * @param array $conf Array of TypoScript properties */ public function render($conf = []): int { if (!is_array($conf)) { return 0; } + $content = 0; if (isset($conf['value'])) { $content = $conf['value']; diff --git a/Classes/ContentObject/JsonContentContentObject.php b/Classes/ContentObject/JsonContentContentObject.php index a6641a41..64a4f48b 100755 --- a/Classes/ContentObject/JsonContentContentObject.php +++ b/Classes/ContentObject/JsonContentContentObject.php @@ -151,7 +151,7 @@ public function render($conf = []): string /** * @param array $contentElements * @param array $conf - * @return array> + * @return array> */ protected function groupContentElementsByColPos(array $contentElements, array $conf): array { @@ -168,11 +168,12 @@ protected function groupContentElementsByColPos(array $contentElements, array $c $element = $this->headlessUserInt->wrap($element); } - $element = json_decode($element, true); + $decoded = json_decode($element, true); - if ($element === []) { + if (!is_array($decoded) || $decoded === []) { continue; } + $element = $decoded; $colPos = $this->getColPosFromElement($groupingEnabled, $element); @@ -184,11 +185,14 @@ protected function groupContentElementsByColPos(array $contentElements, array $c } if ($groupingEnabled && $this->isSortByBackendLayoutEnabled($conf)) { - $backendLayout = $this->backendLayoutView->getSelectedBackendLayout($this->request->getAttribute('routing')->getPageId()); + $routing = $this->request->getAttribute('routing'); + $backendLayout = $routing !== null + ? $this->backendLayoutView->getSelectedBackendLayout($routing->getPageId()) + : null; $sorted = []; foreach ($backendLayout['__colPosList'] ?? [] as $value) { - $sorted['colPos' . $value] = $data['colPos' . $value]; + $sorted['colPos' . $value] = $data['colPos' . $value] ?? []; } $data = $sorted; @@ -199,6 +203,9 @@ protected function groupContentElementsByColPos(array $contentElements, array $c return $data; } + /** + * @var array + */ private array $recordRegister = []; /** @@ -315,21 +322,33 @@ private function prepareValue(array $conf): array return $theValue; } + /** + * @param array $conf + */ private function isSortByBackendLayoutEnabled(array $conf): bool { return isset($conf['sortByBackendLayout']) && (int)$conf['sortByBackendLayout'] === 1; } + /** + * @param array $conf + */ private function isColPolsGroupingEnabled(array $conf): bool { return !isset($conf['doNotGroupByColPos']) || (int)$conf['doNotGroupByColPos'] === 0; } + /** + * @param array $conf + */ private function returnSingleRowEnabled(array $conf): bool { return isset($conf['returnSingleRow']) && (int)$conf['returnSingleRow'] === 1; } + /** + * @param array $element + */ private function getColPosFromElement(bool $groupingEnabled, array $element): int { if ($groupingEnabled && !array_key_exists('colPos', $element)) { diff --git a/Classes/ContentObject/JsonContentObject.php b/Classes/ContentObject/JsonContentObject.php index ff08a25d..c027a926 100755 --- a/Classes/ContentObject/JsonContentObject.php +++ b/Classes/ContentObject/JsonContentObject.php @@ -12,7 +12,7 @@ namespace FriendsOfTYPO3\Headless\ContentObject; use FriendsOfTYPO3\Headless\Json\JsonDecoderInterface; -use FriendsOfTYPO3\Headless\Json\JsonEncoder; +use FriendsOfTYPO3\Headless\Json\JsonEncoderInterface; use FriendsOfTYPO3\Headless\Utility\HeadlessUserInt; use Generator; use Psr\Log\LoggerAwareInterface; @@ -30,32 +30,35 @@ class JsonContentObject extends AbstractContentObject implements LoggerAwareInte { use LoggerAwareTrait; - private array $conf; + /** + * @var array + */ + private array $conf = []; public function __construct( protected ContentDataProcessor $contentDataProcessor, - protected JsonEncoder $jsonEncoder, + protected JsonEncoderInterface $jsonEncoder, protected JsonDecoderInterface $jsonDecoder, protected HeadlessUserInt $headlessUserInt ) {} /** * Rendering the cObject, JSON - * @param array $conf Array of TypoScript properties + * @param array|null $conf Array of TypoScript properties * @return string The HTML output */ public function render($conf = []): string { + if (!is_array($conf)) { + $conf = []; + } + if (!empty($conf['if.']) && !$this->cObj->checkIf($conf['if.'])) { return ''; } $data = []; - if (!is_array($conf)) { - $conf = []; - } - $this->conf = $conf; if (isset($conf['fields.'])) { @@ -82,9 +85,9 @@ public function render($conf = []): string * Rendering of a "string array" of cObjects from TypoScript * Will call ->cObjGetSingle() for each cObject found and accumulate the output. * - * @param array $setup array with cObjects as values. + * @param array $setup array with cObjects as values. * @param string $addKey A prefix for the debugging information - * @return array Rendered output from the cObjects in the array. + * @return array Rendered output from the cObjects in the array. * @see cObjGetSingle() */ public function cObjGet(array $setup, string $addKey = ''): array @@ -139,9 +142,9 @@ public function cObjGet(array $setup, string $addKey = ''): array /** * Takes a TypoScript array as input and returns an array which contains all string properties found which had a value (not only properties). * - * @param array $setupArr TypoScript array with string array in + * @param array $setupArr TypoScript array with string array in * @param bool $acceptAnyKeys If set, then a value is not required - the properties alone will be enough. - * @return array An array with all string properties. + * @return array An array with all string properties. */ protected function filterByStringKeys(array $setupArr, bool $acceptAnyKeys = false): array { @@ -156,7 +159,7 @@ protected function filterByStringKeys(array $setupArr, bool $acceptAnyKeys = fal } /** - * @param array $dataProcessing + * @param array $dataProcessing */ protected function processFieldWithDataProcessing(array $dataProcessing): mixed { diff --git a/Classes/DataProcessing/DataProcessingTrait.php b/Classes/DataProcessing/DataProcessingTrait.php index 844cbd4a..04c097ce 100644 --- a/Classes/DataProcessing/DataProcessingTrait.php +++ b/Classes/DataProcessing/DataProcessingTrait.php @@ -14,9 +14,9 @@ trait DataProcessingTrait { /** - * @param array $processorConfiguration - * @param array $processedData - * @return array + * @param array $processorConfiguration + * @param array $processedData + * @return array */ protected function removeDataIfnotAppendInConfiguration(array $processorConfiguration, array $processedData): array { @@ -42,14 +42,13 @@ protected function removeDataIfnotAppendInConfiguration(array $processorConfigur protected function isMenuProcessor(): bool { - return __CLASS__ === MenuProcessor::class; + return $this instanceof MenuProcessor; } /** * Removes recursively "data" in children nodes * - * @param array $children - * @param string $nodeName + * @param array> $children */ private function removeDataInChildrenNodes(array &$children, string $nodeName = 'children'): void { diff --git a/Classes/DataProcessing/DatabaseQueryProcessor.php b/Classes/DataProcessing/DatabaseQueryProcessor.php index 75a15eb7..146d8961 100644 --- a/Classes/DataProcessing/DatabaseQueryProcessor.php +++ b/Classes/DataProcessing/DatabaseQueryProcessor.php @@ -19,6 +19,8 @@ use function json_decode; +use const JSON_THROW_ON_ERROR; + /** * Fetch records from the database, using the default .select syntax from TypoScript. * @@ -63,7 +65,10 @@ class DatabaseQueryProcessor implements DataProcessorInterface public function __construct(protected ContentDataProcessor $contentDataProcessor, protected TypoScriptService $typoScriptService) {} /** - * @inheritDoc + * @param array $contentObjectConfiguration + * @param array $processorConfiguration + * @param array $processedData + * @return array */ public function process(ContentObjectRenderer $cObj, array $contentObjectConfiguration, array $processorConfiguration, array $processedData): array { @@ -91,30 +96,30 @@ public function process(ContentObjectRenderer $cObj, array $contentObjectConfigu $request = $cObj->getRequest(); $processedRecordVariables = []; - $flattenRow = null; + $objConf = []; + $objName = '< ' . $tableName; + if (isset($processorConfiguration['fields.'])) { + $objName = 'JSON'; + $fields = $this->typoScriptService->convertTypoScriptArrayToPlainArray($processorConfiguration['fields.']); + $objConf = $this->typoScriptService->convertPlainArrayToTypoScriptArray(['fields' => $fields, '_typoScriptNodeValue' => 'JSON']); + } + + $overrideJsonCE = null; + if (isset($processorConfiguration['overrideFields.'])) { + $overrideFields = $this->typoScriptService->convertTypoScriptArrayToPlainArray($processorConfiguration['overrideFields.']); + $overrideJsonCE = $this->typoScriptService->convertPlainArrayToTypoScriptArray(['fields' => $overrideFields, '_typoScriptNodeValue' => 'JSON']); + } foreach ($records as $key => $record) { $recordContentObjectRenderer = $this->createContentObjectRenderer(); $recordContentObjectRenderer->setRequest($request); $recordContentObjectRenderer->start($record, $tableName); - $objConf = []; - $objName = '< ' . $tableName; - - if (isset($processorConfiguration['fields.'])) { - $objName = 'JSON'; - $fields = $this->typoScriptService->convertTypoScriptArrayToPlainArray($processorConfiguration['fields.']); - $objConf = $this->typoScriptService->convertPlainArrayToTypoScriptArray(['fields' => $fields, '_typoScriptNodeValue' => 'JSON']); - } - - $processedRecordVariables[$key] = $objConf !== [] ? json_decode($recordContentObjectRenderer->cObjGetSingle($objName, $objConf), true) : $record; + $processedRecordVariables[$key] = $objConf !== [] ? json_decode($recordContentObjectRenderer->cObjGetSingle($objName, $objConf), true, 512, JSON_THROW_ON_ERROR) : $record; $processedRecordVariables[$key] = $this->contentDataProcessor->process($recordContentObjectRenderer, $processorConfiguration, $processedRecordVariables[$key]); - if (isset($processorConfiguration['overrideFields.'])) { - $overrideFields = $this->typoScriptService->convertTypoScriptArrayToPlainArray($processorConfiguration['overrideFields.']); - $jsonCE = $this->typoScriptService->convertPlainArrayToTypoScriptArray(['fields' => $overrideFields, '_typoScriptNodeValue' => 'JSON']); - $record = json_decode($recordContentObjectRenderer->cObjGetSingle('JSON', $jsonCE), true); - + if ($overrideJsonCE !== null) { + $record = json_decode($recordContentObjectRenderer->cObjGetSingle('JSON', $overrideJsonCE), true, 512, JSON_THROW_ON_ERROR); foreach ($record as $fieldName => $overrideData) { $processedRecordVariables[$key][$fieldName] = $overrideData; } diff --git a/Classes/DataProcessing/ExtractPropertyProcessor.php b/Classes/DataProcessing/ExtractPropertyProcessor.php index 6ce1bd28..ddd6f5ea 100644 --- a/Classes/DataProcessing/ExtractPropertyProcessor.php +++ b/Classes/DataProcessing/ExtractPropertyProcessor.php @@ -38,17 +38,17 @@ class ExtractPropertyProcessor implements DataProcessorInterface * Extract a single (maybe nested) property from a given array * * @param ContentObjectRenderer $cObj The content object renderer, which contains data of the content element - * @param array $contentObjectConfiguration The configuration of Content Object - * @param array $processorConfiguration The configuration of this processor - * @param array $processedData Key/value store of processed data (e.g. to be passed to a Fluid View) - * @return array the processed data as key/value store + * @param array $contentObjectConfiguration The configuration of Content Object + * @param array $processorConfiguration The configuration of this processor + * @param array $processedData Key/value store of processed data (e.g. to be passed to a Fluid View) + * @return array the processed data as key/value store */ public function process( ContentObjectRenderer $cObj, array $contentObjectConfiguration, array $processorConfiguration, array $processedData - ) { + ): array { if (empty($processorConfiguration['as'])) { throw new Exception('Please specify property \'as\''); } @@ -64,13 +64,17 @@ public function process( $key = GeneralUtility::trimExplode('.', $processorConfiguration['key'], true); - // Extract (nested) property - do { - $processedData = $processedData[array_shift($key)] ?? null; - } while (count($key)); + $value = $processedData; + foreach ($key as $segment) { + if (!is_array($value)) { + $value = null; + break; + } + $value = $value[$segment] ?? null; + } return [ - $targetFieldName => $processedData, + $targetFieldName => $value, ]; } } diff --git a/Classes/DataProcessing/FilesProcessor.php b/Classes/DataProcessing/FilesProcessor.php index f1711f17..938b9274 100644 --- a/Classes/DataProcessing/FilesProcessor.php +++ b/Classes/DataProcessing/FilesProcessor.php @@ -12,7 +12,7 @@ namespace FriendsOfTYPO3\Headless\DataProcessing; use FriendsOfTYPO3\Headless\Utility\File\ProcessingConfiguration; -use FriendsOfTYPO3\Headless\Utility\FileUtility; +use FriendsOfTYPO3\Headless\Utility\FileUtilityInterface; use TYPO3\CMS\Core\Resource\FileInterface; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer; @@ -29,44 +29,37 @@ class FilesProcessor implements DataProcessorInterface use DataProcessingTrait; /** - * @var array + * @var array */ - public $defaults = [ + public array $defaults = [ 'as' => 'media', 'filesAs' => 'files', ]; - public function __construct(private readonly FileUtility $fileUtility) {} + public function __construct(private readonly FileUtilityInterface $fileUtility) {} - /** - * The content object renderer - * - * @var ContentObjectRenderer - */ - protected $contentObjectRenderer; + protected ContentObjectRenderer $contentObjectRenderer; /** - * The processor configuration - * - * @var array + * @var array */ - protected $processorConfiguration; + protected array $processorConfiguration = []; /** * The (filtered) media files to be used in the gallery * * @var FileInterface[] */ - protected $fileObjects = []; + protected array $fileObjects = []; /** * Process data for a gallery, for instance the CType "textmedia" * * @param ContentObjectRenderer $cObj The content object renderer, which contains data of the content element - * @param array $contentObjectConfiguration The configuration of Content Object - * @param array $processorConfiguration The configuration of this processor - * @param array $processedData Key/value store of processed data (e.g. to be passed to a Fluid View) - * @return array the processed data as key/value store + * @param array $contentObjectConfiguration The configuration of Content Object + * @param array $processorConfiguration The configuration of this processor + * @param array $processedData Key/value store of processed data (e.g. to be passed to a Fluid View) + * @return array the processed data as key/value store */ public function process( ContentObjectRenderer $cObj, @@ -95,6 +88,11 @@ public function process( $this->defaults['as'] ); + if (!$this->hasFileSources($processorConfiguration)) { + $processedData[$targetFieldName] = []; + return $this->removeDataIfnotAppendInConfiguration($processorConfiguration, $processedData); + } + $this->fileObjects = $this->fetchData(); $processedData[$targetFieldName] = $this->processFiles($properties); @@ -102,7 +100,20 @@ public function process( } /** - * @return array + * @param array $processorConfiguration + */ + private function hasFileSources(array $processorConfiguration): bool + { + foreach (['references', 'references.', 'files', 'files.', 'collections', 'collections.', 'folders', 'folders.'] as $key) { + if (!empty($processorConfiguration[$key])) { + return true; + } + } + return false; + } + + /** + * @return array */ protected function fetchData(): array { @@ -171,8 +182,8 @@ protected function fetchData(): array } /** - * @param array $properties - * @return array|null + * @param array $properties + * @return array|null */ protected function processFiles(array $properties = []): ?array { @@ -198,7 +209,7 @@ protected function processFiles(array $properties = []): ?array return $data; } - protected function getFileUtility(): FileUtility + protected function getFileUtility(): FileUtilityInterface { return $this->fileUtility; } diff --git a/Classes/DataProcessing/FlexFormProcessor.php b/Classes/DataProcessing/FlexFormProcessor.php index 7eb4a991..b74b766f 100644 --- a/Classes/DataProcessing/FlexFormProcessor.php +++ b/Classes/DataProcessing/FlexFormProcessor.php @@ -11,7 +11,7 @@ namespace FriendsOfTYPO3\Headless\DataProcessing; -use TYPO3\CMS\Core\Service\FlexFormService; +use TYPO3\CMS\Core\Configuration\FlexForm\FlexFormTools; use TYPO3\CMS\Core\TypoScript\TypoScriptService; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer; @@ -21,6 +21,8 @@ use function is_string; use function json_decode; +use const JSON_THROW_ON_ERROR; + /** * Basic TypoScript configuration: * Processing the field pi_flexform and overrides the values stored in data @@ -78,16 +80,16 @@ class FlexFormProcessor implements DataProcessorInterface * Constructor */ public function __construct( - protected FlexFormService $flexFormService, + protected FlexFormTools $flexFormTools, private readonly TypoScriptService $typoScriptService, ) {} /** * @param ContentObjectRenderer $cObj The data of the content element or page - * @param array $contentObjectConfiguration The configuration of Content Object - * @param array $processorConfiguration The configuration of this processor - * @param array $processedData Key/value store of processed data (e.g. to be passed to a Fluid View) - * @return array the processed data as key/value store + * @param array $contentObjectConfiguration The configuration of Content Object + * @param array $processorConfiguration The configuration of this processor + * @param array $processedData Key/value store of processed data (e.g. to be passed to a Fluid View) + * @return array the processed data as key/value store */ public function process(ContentObjectRenderer $cObj, array $contentObjectConfiguration, array $processorConfiguration, array $processedData) { @@ -112,7 +114,7 @@ public function process(ContentObjectRenderer $cObj, array $contentObjectConfigu if (is_array($originalValue)) { $flexformData = $originalValue; } elseif (is_string($originalValue)) { - $flexformData = $this->flexFormService->convertFlexFormContentToArray($originalValue); + $flexformData = $this->flexFormTools->convertFlexFormContentToArray($originalValue); } else { return $processedData; } @@ -138,10 +140,10 @@ public function process(ContentObjectRenderer $cObj, array $contentObjectConfigu } /** - * @param array $data Current data-record - * @param array $flexformData - * @param array $processorConfiguration - * @return array + * @param array $data Current data-record + * @param array $flexformData + * @param array $processorConfiguration + * @return array */ public function processOverrideFields(array $data, array $flexformData, array $processorConfiguration): array { @@ -151,7 +153,7 @@ public function processOverrideFields(array $data, array $flexformData, array $p $overrideFields = $this->typoScriptService->convertTypoScriptArrayToPlainArray($processorConfiguration['overrideFields.']); $jsonCE = $this->typoScriptService->convertPlainArrayToTypoScriptArray(['fields' => $overrideFields, '_typoScriptNodeValue' => 'JSON']); - $record = json_decode($recordContentObjectRenderer->cObjGetSingle('JSON', $jsonCE), true); + $record = json_decode($recordContentObjectRenderer->cObjGetSingle('JSON', $jsonCE), true, 512, JSON_THROW_ON_ERROR); foreach ($record as $fieldName => $overrideData) { $flexformData[$fieldName] = $overrideData; diff --git a/Classes/DataProcessing/GalleryProcessor.php b/Classes/DataProcessing/GalleryProcessor.php index 64c45f92..7e0f15e4 100644 --- a/Classes/DataProcessing/GalleryProcessor.php +++ b/Classes/DataProcessing/GalleryProcessor.php @@ -12,7 +12,7 @@ namespace FriendsOfTYPO3\Headless\DataProcessing; use FriendsOfTYPO3\Headless\Utility\File\ProcessingConfiguration; -use FriendsOfTYPO3\Headless\Utility\FileUtility; +use FriendsOfTYPO3\Headless\Utility\FileUtilityInterface; use TYPO3\CMS\Core\Imaging\ImageManipulation\CropVariantCollection; use TYPO3\CMS\Core\Resource\FileInterface; use TYPO3\CMS\Core\Resource\FileReference; @@ -33,19 +33,27 @@ class GalleryProcessor extends \TYPO3\CMS\Frontend\DataProcessing\GalleryProcess protected $fileReferenceCache = []; /** - * @var array> + * @var array + */ + private array $croppedDimensionCache = []; + + /** + * @var array>> */ protected $fileObjects = []; protected ProcessingConfiguration $processorConfigurationObject; public function __construct( - private readonly FileUtility $fileUtility, + private readonly FileUtilityInterface $fileUtility, private readonly ImageService $imageService, ) {} /** - * @inheritDoc + * @param array $contentObjectConfiguration + * @param array $processorConfiguration + * @param array $processedData + * @return array */ public function process( ContentObjectRenderer $cObj, @@ -71,7 +79,7 @@ public function process( * replaced only calls to $this->getCroppedDimensionalPropertyFromProcessedFile() * because of already processed files by FilesProcessor */ - protected function calculateMediaWidthsAndHeights() + protected function calculateMediaWidthsAndHeights(): void { $columnSpacingTotal = ($this->galleryData['count']['columns'] - 1) * $this->columnSpacing; @@ -185,9 +193,7 @@ protected function calculateMediaWidthsAndHeights() /** * Replaces original method (because of already processed files) * - * @param array $processedFile - * @param string $property - * @return int + * @param array $processedFile */ private function getCroppedDimensionalPropertyFromProcessedFile(array $processedFile, string $property): int { @@ -197,19 +203,29 @@ private function getCroppedDimensionalPropertyFromProcessedFile(array $processed } $croppingConfiguration = $processedFile['properties']['crop']; + $uid = (int)$processedFile['properties']['uidLocal']; } else { if (empty($processedFile['crop'])) { return (int)($this->processorConfigurationObject->flattenProperties ? ($processedFile[$property] ?? 0) : ($processedFile['dimensions'][$property] ?? 0)); } $croppingConfiguration = $processedFile['crop']; + $uid = (int)$processedFile['uidLocal']; } - $cropVariantCollection = CropVariantCollection::create((string)$croppingConfiguration); + if (!isset($this->croppedDimensionCache[$uid])) { + $cropArea = CropVariantCollection::create((string)$croppingConfiguration) + ->getCropArea($this->cropVariant) + ->makeAbsoluteBasedOnFile($this->createFileObject($processedFile)) + ->asArray(); + + $this->croppedDimensionCache[$uid] = [ + 'width' => (int)($cropArea['width'] ?? 0), + 'height' => (int)($cropArea['height'] ?? 0), + ]; + } - return (int)$cropVariantCollection->getCropArea($this->cropVariant) - ->makeAbsoluteBasedOnFile($this->createFileObject($processedFile)) - ->asArray()[$property]; + return $this->croppedDimensionCache[$uid][$property] ?? 0; } /** @@ -217,7 +233,7 @@ private function getCroppedDimensionalPropertyFromProcessedFile(array $processed * * Make an array for rows, columns and configuration */ - protected function prepareGalleryData() + protected function prepareGalleryData(): void { for ($row = 1; $row <= $this->galleryData['count']['rows']; $row++) { for ($column = 1; $column <= $this->galleryData['count']['columns']; $column++) { @@ -250,9 +266,9 @@ protected function prepareGalleryData() } /** - * @return FileUtility + * @return FileUtilityInterface */ - protected function getFileUtility(): FileUtility + protected function getFileUtility(): FileUtilityInterface { return $this->fileUtility; } @@ -268,8 +284,7 @@ protected function getImageService(): ImageService /** * small helper for handling cropping based on already processed file * - * @param array $processedFile - * @return FileInterface + * @param array $processedFile */ private function createFileObject(array $processedFile): FileInterface { diff --git a/Classes/DataProcessing/LanguageMenuProcessor.php b/Classes/DataProcessing/LanguageMenuProcessor.php index c1af4954..875eab84 100644 --- a/Classes/DataProcessing/LanguageMenuProcessor.php +++ b/Classes/DataProcessing/LanguageMenuProcessor.php @@ -20,6 +20,12 @@ class LanguageMenuProcessor extends \TYPO3\CMS\Frontend\DataProcessing\LanguageM { use DataProcessingTrait; + /** + * @param array $contentObjectConfiguration + * @param array $processorConfiguration + * @param array $processedData + * @return array + */ public function process( ContentObjectRenderer $cObj, array $contentObjectConfiguration, diff --git a/Classes/DataProcessing/MenuProcessor.php b/Classes/DataProcessing/MenuProcessor.php index 1f5a2152..ce13ca6a 100644 --- a/Classes/DataProcessing/MenuProcessor.php +++ b/Classes/DataProcessing/MenuProcessor.php @@ -12,6 +12,7 @@ namespace FriendsOfTYPO3\Headless\DataProcessing; use TYPO3\CMS\Core\Utility\ArrayUtility; +use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer; use function is_array; @@ -61,7 +62,7 @@ class MenuProcessor extends \TYPO3\CMS\Frontend\DataProcessing\MenuProcessor use DataProcessingTrait; /** - * @inheritDoc + * @var array */ public array $allowedConfigurationKeys = [ 'cache_period', @@ -112,7 +113,7 @@ class MenuProcessor extends \TYPO3\CMS\Frontend\DataProcessing\MenuProcessor ]; /** - * @inheritDoc + * @var array */ public array $removeConfigurationKeysForHmenu = [ 'levels', @@ -155,7 +156,10 @@ public function buildConfiguration(): void } /** - * @inheritDoc + * @param array $contentObjectConfiguration + * @param array $processorConfiguration + * @param array $processedData + * @return array */ public function process( ContentObjectRenderer $cObj, @@ -181,15 +185,24 @@ public function process( return $this->removeDataIfnotAppendInConfiguration($processorConfiguration, $processedData); } + /** + * @param array $processorConfiguration + * @return array + */ protected function getAdditionalFields(array $processorConfiguration): array { - $additionalFields = $processorConfiguration['additionalFields'] ?? ''; + $additionalFields = (string)($processorConfiguration['additionalFields'] ?? ''); if ($additionalFields === '') { return []; } - return array_map('trim', explode(',', $additionalFields)); + return GeneralUtility::trimExplode(',', $additionalFields, true); } + /** + * @param array> $menuItems + * @param array $additionalFields + * @return array> + */ protected function addAdditionalFieldsToMenuItems(array $menuItems, array $additionalFields): array { foreach ($menuItems as $key => $item) { diff --git a/Classes/DataProcessing/RootSiteProcessing/DomainSchema.php b/Classes/DataProcessing/RootSiteProcessing/DomainSchema.php index 8a8d5336..9b3450fe 100644 --- a/Classes/DataProcessing/RootSiteProcessing/DomainSchema.php +++ b/Classes/DataProcessing/RootSiteProcessing/DomainSchema.php @@ -23,8 +23,6 @@ class DomainSchema implements SiteSchemaInterface { /** * @codeCoverageIgnore - * @param HeadlessFrontendUrlInterface|null $urlUtility - * @param ContentDataProcessor|null $contentObjectRenderer */ public function __construct( protected HeadlessFrontendUrlInterface $urlUtility, @@ -43,9 +41,10 @@ public function process(SiteProviderInterface $provider, array $options = []): a $result = []; foreach ($provider->getSites() as $site) { + $urlUtility = $this->urlUtility->withSite($site); $protocol = $site->getBase()->getScheme() . '://'; $baseUrl = $protocol . $site->getBase()->getHost(); - $url = $this->urlUtility->getFrontendUrlForPage($baseUrl, $site->getRootPageId()); + $url = $urlUtility->getFrontendUrlForPage($baseUrl, $site->getRootPageId()); $locales = []; @@ -57,7 +56,7 @@ public function process(SiteProviderInterface $provider, array $options = []): a 'name' => str_replace($protocol, '', $url), 'baseURL' => $url, 'api' => [ - 'baseURL' => $this->urlUtility->getProxyUrl(), + 'baseURL' => $urlUtility->getProxyUrl(), ], 'i18n' => [ 'locales' => $locales, diff --git a/Classes/DataProcessing/RootSiteProcessing/SiteProvider.php b/Classes/DataProcessing/RootSiteProcessing/SiteProvider.php index eeac0c57..bf98dd68 100644 --- a/Classes/DataProcessing/RootSiteProcessing/SiteProvider.php +++ b/Classes/DataProcessing/RootSiteProcessing/SiteProvider.php @@ -36,7 +36,7 @@ class SiteProvider implements SiteProviderInterface */ private array $sites; /** - * @var array[] + * @var array> */ private array $pagesData; private Site $currentRootPage; @@ -82,10 +82,8 @@ public function prepare(array $config, int $siteUid): self $sorting = GeneralUtility::makeInstance($customSorting, $sites, $pages, $sortingField); $sites = $sorting->sort(); } else { - usort($sites, static function (Site $siteA, Site $siteB) use ($pages, $sortingField) { - // phpcs:ignore Generic.Files.LineLength - return (int)$pages[$siteA->getRootPageId()][$sortingField] >= (int)$pages[$siteB->getRootPageId()][$sortingField] ? 1 : -1; - }); + usort($sites, static fn(Site $siteA, Site $siteB): int => + (int)$pages[$siteA->getRootPageId()][$sortingField] <=> (int)$pages[$siteB->getRootPageId()][$sortingField]); } $this->sites = $sites; @@ -104,7 +102,7 @@ public function getSites(): array } /** - * @return array + * @return array> */ public function getPages(): array { @@ -125,24 +123,12 @@ public function getCurrentRootPage(): Site */ private function filterSites(array $allowedSites = []): array { - $allSites = $this->siteFinder->getAllSites(); - - if (count($allowedSites) === 0) { - return array_filter($allSites, static function (Site $site) { - return $site->getConfiguration()['headless'] ?? false; - }); - } - - $sites = []; - - foreach ($allSites as $site) { - if (in_array($site->getRootPageId(), $allowedSites, true) && - $site->getConfiguration()['headless'] ?? false) { - $sites[] = $site; - } - } - - return $sites; + return array_filter( + $this->siteFinder->getAllSites(), + static fn(Site $site): bool => + ($site->getConfiguration()['headless'] ?? false) + && ($allowedSites === [] || in_array($site->getRootPageId(), $allowedSites, true)) + ); } /** @@ -174,7 +160,7 @@ private function fetchAvailableRootSitesByPid(int $pid): array * * @param array $sites * @param array $config - * @return array + * @return array> * @throws Exception */ private function fetchPageData(array $sites, array $config = []): array diff --git a/Classes/DataProcessing/RootSiteProcessing/SiteProviderInterface.php b/Classes/DataProcessing/RootSiteProcessing/SiteProviderInterface.php index 1ae03bb4..4ccdb881 100644 --- a/Classes/DataProcessing/RootSiteProcessing/SiteProviderInterface.php +++ b/Classes/DataProcessing/RootSiteProcessing/SiteProviderInterface.php @@ -17,9 +17,8 @@ interface SiteProviderInterface { /** * @param array $config - * @param int $siteUid */ - public function prepare(array $config, int $siteUid); + public function prepare(array $config, int $siteUid): self; /** * @return array @@ -27,7 +26,7 @@ public function prepare(array $config, int $siteUid); public function getSites(): array; /** - * @return array + * @return array> */ public function getPages(): array; diff --git a/Classes/DataProcessing/RootSiteProcessing/SiteSchema.php b/Classes/DataProcessing/RootSiteProcessing/SiteSchema.php index 7db1c30e..4c0219b9 100644 --- a/Classes/DataProcessing/RootSiteProcessing/SiteSchema.php +++ b/Classes/DataProcessing/RootSiteProcessing/SiteSchema.php @@ -22,7 +22,7 @@ class SiteSchema implements SiteSchemaInterface { public function __construct( - protected HeadlessFrontendUrlInterface $urlUtitlity, + protected HeadlessFrontendUrlInterface $urlUtility, protected ContentDataProcessor $contentDataProcessor ) {} @@ -49,7 +49,7 @@ public function process(SiteProviderInterface $provider, array $options = []): a $active = 0; $spacer = 0; $baseUrl = $site->getBase()->getScheme() . '://' . $site->getBase()->getHost(); - $url = $this->urlUtitlity->getFrontendUrlForPage($baseUrl, $site->getRootPageId()); + $url = $this->urlUtility->getFrontendUrlForPage($baseUrl, $site->getRootPageId()); if ($provider->getCurrentRootPage() === $site) { $active = 1; @@ -93,9 +93,9 @@ public function process(SiteProviderInterface $provider, array $options = []): a * @return array */ protected function processAdditionalDataProcessors( - $page, + array $page, ContentObjectRenderer $cObj, - $processorConfiguration + array $processorConfiguration ): array { $cObj->start($page, 'pages'); return $this->contentDataProcessor->process($cObj, $processorConfiguration, $page); diff --git a/Classes/Event/EnrichFileDataEvent.php b/Classes/Event/EnrichFileDataEvent.php index 515782d6..d8d625eb 100644 --- a/Classes/Event/EnrichFileDataEvent.php +++ b/Classes/Event/EnrichFileDataEvent.php @@ -16,8 +16,14 @@ final class EnrichFileDataEvent { + /** + * @var array + */ private array $properties; + /** + * @param array $properties + */ public function __construct( private readonly FileInterface $originalFileReference, private readonly FileInterface $processedFileReference, @@ -27,11 +33,17 @@ public function __construct( $this->properties = $properties; } + /** + * @return array + */ public function getProperties(): array { return $this->properties; } + /** + * @param array $properties + */ public function setProperties(array $properties): void { $this->properties = $properties; diff --git a/Classes/Event/FileDataAfterCropVariantProcessingEvent.php b/Classes/Event/FileDataAfterCropVariantProcessingEvent.php index 0265141b..ee3059ce 100644 --- a/Classes/Event/FileDataAfterCropVariantProcessingEvent.php +++ b/Classes/Event/FileDataAfterCropVariantProcessingEvent.php @@ -19,8 +19,14 @@ */ final class FileDataAfterCropVariantProcessingEvent { + /** + * @var array + */ private array $processedFile; + /** + * @param array $processedFile + */ public function __construct( private readonly FileInterface $originalFileReference, private readonly ProcessingConfiguration $processingConfiguration, @@ -29,11 +35,17 @@ public function __construct( $this->processedFile = $processedFile; } + /** + * @return array + */ public function getProcessedFile(): array { return $this->processedFile; } + /** + * @param array $processedFile + */ public function setProcessedFile(array $processedFile): void { $this->processedFile = $processedFile; diff --git a/Classes/Event/Listener/AfterCacheableContentIsGeneratedListener.php b/Classes/Event/Listener/AfterCacheableContentIsGeneratedListener.php index 01a83427..6752ac42 100644 --- a/Classes/Event/Listener/AfterCacheableContentIsGeneratedListener.php +++ b/Classes/Event/Listener/AfterCacheableContentIsGeneratedListener.php @@ -15,16 +15,19 @@ use FriendsOfTYPO3\Headless\Seo\MetaHandlerInterface; use FriendsOfTYPO3\Headless\Utility\HeadlessModeInterface; use FriendsOfTYPO3\Headless\Utility\HeadlessUserInt; +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerAwareTrait; use Throwable; - use TYPO3\CMS\Frontend\Event\AfterCacheableContentIsGeneratedEvent; use function json_decode; use const JSON_THROW_ON_ERROR; -class AfterCacheableContentIsGeneratedListener +class AfterCacheableContentIsGeneratedListener implements LoggerAwareInterface { + use LoggerAwareTrait; + public function __construct( private readonly JsonEncoderInterface $encoder, private readonly MetaHandlerInterface $metaHandler, @@ -35,12 +38,12 @@ public function __construct( public function __invoke(AfterCacheableContentIsGeneratedEvent $event): void { try { - if (!$this->headlessMode->withRequest($event->getRequest())->isEnabled()) { + if (!$this->headlessMode->isEnabledFor($event->getRequest())) { return; } if ($this->headlessUserInt->hasNonCacheableContent($event->getContent())) { - // we have dynamic content on page, we fire MetaHandler later on middleware + // dynamic content on the page → MetaHandler runs later in the middleware return; } @@ -54,7 +57,10 @@ public function __invoke(AfterCacheableContentIsGeneratedEvent $event): void $event->setContent($this->encoder->encode($content)); } catch (Throwable $e) { - return; + $this->logger?->warning( + 'Failed to post-process cacheable content for headless SEO meta tags', + ['exception' => $e] + ); } } } diff --git a/Classes/Event/Listener/AfterLinkIsGeneratedListener.php b/Classes/Event/Listener/AfterLinkIsGeneratedListener.php index a1a09424..989658fc 100644 --- a/Classes/Event/Listener/AfterLinkIsGeneratedListener.php +++ b/Classes/Event/Listener/AfterLinkIsGeneratedListener.php @@ -12,6 +12,7 @@ namespace FriendsOfTYPO3\Headless\Event\Listener; use FriendsOfTYPO3\Headless\Utility\HeadlessFrontendUrlInterface; +use FriendsOfTYPO3\Headless\Utility\HeadlessModeInterface; use Psr\Log\LoggerInterface; use Throwable; use TYPO3\CMS\Core\LinkHandling\Exception\UnknownLinkHandlerException; @@ -36,7 +37,8 @@ public function __construct( private readonly HeadlessFrontendUrlInterface $urlUtility, private readonly LinkService $linkService, private readonly TypoLinkCodecService $typoLinkCodecService, - private readonly SiteFinder $siteFinder + private readonly SiteFinder $siteFinder, + private readonly HeadlessModeInterface $headlessMode, ) {} public function __invoke(AfterLinkIsGeneratedEvent $event): void @@ -47,6 +49,11 @@ public function __invoke(AfterLinkIsGeneratedEvent $event): void return; } + $request = $event->getContentObjectRenderer()->getRequest(); + if (!$this->headlessMode->isEnabledFor($request)) { + return; + } + $pageId = $result->getLinkConfiguration()['parameter'] ?? 0; if ((int)($result->getLinkConfiguration()['page']['doktype'] ?? 1) === 4) { @@ -55,7 +62,7 @@ public function __invoke(AfterLinkIsGeneratedEvent $event): void $pageId = (int)($this->linkService->resolve($event->getContentObjectRenderer()->parameters['href'] ?? '')['pageuid'] ?? 0); } - $urlUtility = $this->urlUtility->withRequest($event->getContentObjectRenderer()->getRequest()); + $urlUtility = $this->urlUtility->withRequest($request); if (is_numeric($pageId) && ((int)$pageId) > 0) { $href = $urlUtility->getFrontendUrlForPage( @@ -139,6 +146,10 @@ private function getTargetSite(AfterLinkIsGeneratedEvent $event): Site return $this->siteFinder->getSiteByPageId((int)$linkDetails['pageuid']); } + /** + * @param array $linkConfiguration + * @return array|null + */ protected function resolveLinkDetails( string $linkParameter, array $linkConfiguration, @@ -171,6 +182,10 @@ protected function resolveLinkDetails( return $linkDetails; } + /** + * @param array $linkConfiguration + * @return array + */ private function resolveTypolinkParameterString(string $mixedLinkParameter, array &$linkConfiguration = []): array { $linkParameterParts = $this->typoLinkCodecService->decode($mixedLinkParameter); @@ -182,16 +197,16 @@ private function resolveTypolinkParameterString(string $mixedLinkParameter, arra )) { // Disallow insecure scheme's like javascript: or data: throw new UnableToLinkException( - 'Insuecure scheme for linking detected with "' . $mixedLinkParameter . "'", + 'Insecure scheme for linking detected with "' . $mixedLinkParameter . "'", 1641986533 ); } // additional parameters that need to be set if (($linkParameterParts['additionalParams'] ?? '') !== '') { - $forceParams = $linkParameterParts['additionalParams']; - // params value - $linkConfiguration['additionalParams'] = ($linkConfiguration['additionalParams'] ?? '') . $forceParams[0] === '&' ? $forceParams : '&' . $forceParams; + $forceParams = (string)$linkParameterParts['additionalParams']; + $prefix = $forceParams[0] === '&' ? '' : '&'; + $linkConfiguration['additionalParams'] = ($linkConfiguration['additionalParams'] ?? '') . $prefix . $forceParams; } return [ diff --git a/Classes/Event/Listener/AfterPagePreviewUriGeneratedListener.php b/Classes/Event/Listener/AfterPagePreviewUriGeneratedListener.php deleted file mode 100644 index 1707a7bc..00000000 --- a/Classes/Event/Listener/AfterPagePreviewUriGeneratedListener.php +++ /dev/null @@ -1,48 +0,0 @@ -workspace !== 0) { - return; - } - - try { - $site = $this->siteFinder->getSiteByPageId($event->getPageId()); - $languageUid = $event->getLanguageId(); - $language = $languageUid === -1 ? null : $site->getLanguageById($languageUid); - - $headlessMode = $this->headlessMode->withRequest($GLOBALS['TYPO3_REQUEST']); - $request = $headlessMode->overrideBackendRequestBySite($site, $language); - - $urlUtility = $this->urlUtility->withRequest($request); - $event->setPreviewUri(new Uri($urlUtility->getFrontendUrlWithSite($event->getPreviewUri()->__toString(), $site))); - } catch (SiteNotFoundException) { - } - } -} diff --git a/Classes/Event/Listener/AfterPageUriGeneratedListener.php b/Classes/Event/Listener/AfterPageUriGeneratedListener.php new file mode 100644 index 00000000..cd91389a --- /dev/null +++ b/Classes/Event/Listener/AfterPageUriGeneratedListener.php @@ -0,0 +1,70 @@ +isBackend()) { + return; + } + + $site = $event->getSite(); + $headlessMode = $this->headlessMode->withRequest($request); + $boundRequest = $headlessMode->overrideBackendRequestBySite($site, $event->getLanguage()); + + if (!$this->headlessMode->isEnabledFor($boundRequest)) { + return; + } + + $originalUri = (string)$event->getUri(); + $rewritten = $this->urlUtility + ->withRequest($boundRequest) + ->getFrontendUrlWithSite($originalUri, $site); + + if ($rewritten === $originalUri) { + return; + } + + try { + $event->setUri(new Uri($rewritten)); + } catch (InvalidArgumentException $e) { + $this->logger?->warning( + 'Headless: rewritten preview URI was invalid; keeping the original backend URI', + ['originalUri' => $originalUri, 'rewritten' => $rewritten, 'exception' => $e] + ); + } + } +} diff --git a/Classes/Event/Listener/HeadlessHreflangGeneratorListener.php b/Classes/Event/Listener/HeadlessHreflangGeneratorListener.php index 91053cfe..3365c283 100644 --- a/Classes/Event/Listener/HeadlessHreflangGeneratorListener.php +++ b/Classes/Event/Listener/HeadlessHreflangGeneratorListener.php @@ -12,24 +12,37 @@ namespace FriendsOfTYPO3\Headless\Event\Listener; use FriendsOfTYPO3\Headless\Utility\HeadlessFrontendUrlInterface; +use FriendsOfTYPO3\Headless\Utility\HeadlessModeInterface; use TYPO3\CMS\Frontend\Event\ModifyHrefLangTagsEvent; -/** - * @codeCoverageIgnore - */ class HeadlessHreflangGeneratorListener { - public function __construct(private readonly HeadlessFrontendUrlInterface $urlUtility) {} + public function __construct( + private readonly HeadlessFrontendUrlInterface $urlUtility, + private readonly HeadlessModeInterface $headlessMode, + ) {} public function __invoke(ModifyHrefLangTagsEvent $event): void { - $hrefLangs = []; - $urlUtility = $this->urlUtility->withRequest($event->getRequest()); + $request = $event->getRequest(); + + if (!$this->headlessMode->isEnabledFor($request)) { + return; + } + + $hrefLangs = $event->getHrefLangs(); + if ($hrefLangs === []) { + return; + } + + $urlUtility = $this->urlUtility->withRequest($request); + $site = $request->getAttribute('site'); + $data = []; - foreach ($event->getHrefLangs() as $lang => $hrefLang) { - $hrefLangs[$lang] = $urlUtility->getFrontendUrlWithSite($hrefLang, $event->getRequest()->getAttribute('site')); + foreach ($hrefLangs as $lang => $href) { + $data[$lang] = $urlUtility->getFrontendUrlWithSite($href, $site); } - $event->setHrefLangs($hrefLangs); + $event->setHrefLangs($data); } } diff --git a/Classes/Event/Listener/HeadlessRedirectResponseListener.php b/Classes/Event/Listener/HeadlessRedirectResponseListener.php new file mode 100644 index 00000000..cff755e4 --- /dev/null +++ b/Classes/Event/Listener/HeadlessRedirectResponseListener.php @@ -0,0 +1,59 @@ +getRequest(); + $site = $request->getAttribute('site'); + if (!$site instanceof Site || !$this->headlessMode->isEnabledFor($request)) { + return; + } + + $matchedRedirect = $event->getMatchedRedirect(); + $uri = $event->getTargetUrl(); + + $urlUtility = $this->urlUtility->withRequest($request); + $targetUrl = $urlUtility->prepareRelativeUrlIfPossible( + $urlUtility->getFrontendUrlWithSite((string)$uri, $site) + ); + + $urlEvent = $this->eventDispatcher->dispatch(new RedirectUrlEvent( + $request, + $uri, + $targetUrl, + (int)($matchedRedirect['target_statuscode'] ?? 0), + $matchedRedirect + )); + + $event->setResponse(new JsonResponse([ + 'redirectUrl' => $urlEvent->getTargetUrl(), + 'statusCode' => $urlEvent->getTargetStatusCode(), + ])); + } +} diff --git a/Classes/Event/Listener/ProxyResourcePublicUrlListener.php b/Classes/Event/Listener/ProxyResourcePublicUrlListener.php new file mode 100644 index 00000000..81f9e87e --- /dev/null +++ b/Classes/Event/Listener/ProxyResourcePublicUrlListener.php @@ -0,0 +1,111 @@ + */ + private array $baseUriCache = []; + + public function __construct( + private readonly HeadlessModeInterface $headlessMode, + private readonly HeadlessFrontendUrlInterface $urlUtility, + ) {} + + public function __invoke(GeneratePublicUrlForResourceEvent $event): void + { + if ($event->getPublicUrl() !== null) { + return; + } + + $driver = $event->getDriver(); + if (!$driver instanceof LocalDriver) { + return; + } + + if (!$driver->hasCapability(Capabilities::CAPABILITY_PUBLIC)) { + return; + } + + $request = $GLOBALS['TYPO3_REQUEST'] ?? null; + if (!$request instanceof ServerRequestInterface + || !$this->headlessMode->isEnabledFor($request) + || !ApplicationType::fromRequest($request)->isFrontend() + ) { + return; + } + + $baseUri = $this->buildBaseUri($event->getStorage()->getConfiguration(), $request); + if ($baseUri === '') { + return; + } + + $event->setPublicUrl( + rtrim($baseUri, '/') . '/' . $this->encodeIdentifier($event->getResource()->getIdentifier()) + ); + } + + /** + * @param array $config + */ + private function buildBaseUri(array $config, ServerRequestInterface $request): string + { + $storagePath = match (true) { + ($config['baseUri'] ?? '') !== '' => (string)$config['baseUri'], + ($config['basePath'] ?? '') !== '' && ($config['pathType'] ?? '') === 'relative' => (string)$config['basePath'], + default => '', + }; + + $cacheKey = spl_object_hash($request) . '|' . $storagePath; + if (isset($this->baseUriCache[$cacheKey])) { + return $this->baseUriCache[$cacheKey]; + } + + $urlUtility = $this->urlUtility->withRequest($request); + + if ($storagePath === '') { + return $this->baseUriCache[$cacheKey] = $urlUtility->getStorageProxyUrl(); + } + + $frontend = new Uri($urlUtility->getFrontendUrl()); + $proxy = new Uri($urlUtility->getProxyUrl()); + $storage = new Uri($storagePath); + + $path = trim($proxy->getPath(), '/') . '/' . trim($storage->getPath(), '/'); + + return $this->baseUriCache[$cacheKey] = (string)$frontend->withPath('/' . trim($path, '/')); + } + + private function encodeIdentifier(string $identifier): string + { + $parts = explode('/', ltrim($identifier, '/')); + + return implode('/', array_map(rawurlencode(...), $parts)); + } +} diff --git a/Classes/Event/Listener/RedirectUrlAdditionalParamsListener.php b/Classes/Event/Listener/RedirectUrlAdditionalParamsListener.php index 9027ddfc..d0299f57 100644 --- a/Classes/Event/Listener/RedirectUrlAdditionalParamsListener.php +++ b/Classes/Event/Listener/RedirectUrlAdditionalParamsListener.php @@ -27,25 +27,17 @@ use TYPO3\CMS\Core\Utility\GeneralUtility; use function parse_str; -use function strpos; +use function str_contains; class RedirectUrlAdditionalParamsListener implements LoggerAwareInterface { use LoggerAwareTrait; - private TypoLinkCodecService $typoLinkCodecService; - private LinkService $linkService; - private HeadlessFrontendUrlInterface $urlUtility; - public function __construct( - TypoLinkCodecService $typoLinkCodecService, - LinkService $linkService, - HeadlessFrontendUrlInterface $urlUtility - ) { - $this->typoLinkCodecService = $typoLinkCodecService; - $this->linkService = $linkService; - $this->urlUtility = $urlUtility; - } + private readonly TypoLinkCodecService $typoLinkCodecService, + private readonly LinkService $linkService, + private readonly HeadlessFrontendUrlInterface $urlUtility, + ) {} public function __invoke(RedirectUrlEvent $event): void { @@ -61,16 +53,21 @@ public function __invoke(RedirectUrlEvent $event): void ); $redirectTarget = $linkParameterParts['url'] ?? ''; $linkDetails = $this->resolveLinkDetailsFromLinkTarget($redirectTarget); + $additionalParams = (string)($linkParameterParts['additionalParams'] ?? ''); - if (($linkDetails['type'] === LinkService::TYPE_PAGE) && - strpos($linkParameterParts['additionalParams'], '[action]=') > 0 && - strpos($linkParameterParts['additionalParams'], '[controller]=') > 0) { + if (($linkDetails['type'] ?? null) === LinkService::TYPE_PAGE && + str_contains($additionalParams, '[action]=') && + str_contains($additionalParams, '[controller]=')) { try { $site = $request->getAttribute('site'); - parse_str($linkParameterParts['url'], $typolinkData); - parse_str($linkParameterParts['additionalParams'], $params); + $urlQuery = parse_url((string)($linkParameterParts['url'] ?? ''), PHP_URL_QUERY) ?? ''; + parse_str($urlQuery, $typolinkData); + parse_str((string)$linkParameterParts['additionalParams'], $params); - $languageId = isset($typolinkData['L']) ? (int)$typolinkData['L'] : 0; + $languageId = (int)($typolinkData['L'] + ?? $typolinkData['_language'] + ?? $linkDetails['_language'] + ?? 0); if ($languageId > 0) { $language = $site->getLanguageById($languageId); @@ -97,7 +94,7 @@ public function __invoke(RedirectUrlEvent $event): void * @todo this metod is not fully utilized, author should take a look at it * @codeCoverageIgnore * - * @return array + * @return array */ protected function resolveLinkDetailsFromLinkTarget(string $redirectTarget): array { @@ -110,16 +107,12 @@ protected function resolveLinkDetailsFromLinkTarget(string $redirectTarget): arr case LinkService::TYPE_FILE: /** @var File $file */ $file = $linkDetails['file']; - if ($file instanceof File) { - $linkDetails['url'] = $file->getPublicUrl(); - } + $linkDetails['url'] = $file->getPublicUrl(); break; case LinkService::TYPE_FOLDER: /** @var Folder $folder */ $folder = $linkDetails['folder']; - if ($folder instanceof Folder) { - $linkDetails['url'] = $folder->getPublicUrl(); - } + $linkDetails['url'] = $folder->getPublicUrl(); break; default: // we have to return the link details without having a "URL" parameter @@ -141,7 +134,7 @@ protected function getPageRouterForSite(Site $site): PageRouter /** * @codeCoverageIgnore * - * @param array $context + * @param array $context */ protected function logError(string $message, array $context): void { diff --git a/Classes/Event/RedirectUrlEvent.php b/Classes/Event/RedirectUrlEvent.php index 3f82b8a5..41e2cb0b 100644 --- a/Classes/Event/RedirectUrlEvent.php +++ b/Classes/Event/RedirectUrlEvent.php @@ -79,6 +79,9 @@ public function getOriginalTargetUrl(): UriInterface return $this->originalTargetUrl; } + /** + * @return array + */ public function getRedirectRecord(): array { return $this->redirectRecord; diff --git a/Classes/Form/Decorator/AbstractFormDefinitionDecorator.php b/Classes/Form/Decorator/AbstractFormDefinitionDecorator.php index 94471b14..cf30a650 100644 --- a/Classes/Form/Decorator/AbstractFormDefinitionDecorator.php +++ b/Classes/Form/Decorator/AbstractFormDefinitionDecorator.php @@ -21,6 +21,9 @@ abstract class AbstractFormDefinitionDecorator implements DefinitionDecoratorInt protected array $formStatus; protected string $formId = ''; + /** + * @param array $formStatus + */ public function __construct(array $formStatus = []) { $this->formStatus = $formStatus; diff --git a/Classes/Form/Finisher/JsonRedirectFinisher.php b/Classes/Form/Finisher/JsonRedirectFinisher.php index f7a2d9c9..dc20e4b6 100644 --- a/Classes/Form/Finisher/JsonRedirectFinisher.php +++ b/Classes/Form/Finisher/JsonRedirectFinisher.php @@ -13,6 +13,9 @@ use FriendsOfTYPO3\Headless\Utility\HeadlessFrontendUrlInterface; use JsonException; +use TYPO3\CMS\Core\Exception\SiteNotFoundException; +use TYPO3\CMS\Core\Site\Entity\Site; +use TYPO3\CMS\Core\Site\SiteFinder; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Extbase\Mvc\RequestInterface; use TYPO3\CMS\Extbase\Mvc\Web\Routing\UriBuilder; @@ -40,6 +43,7 @@ class JsonRedirectFinisher extends AbstractFinisher 'additionalParameters' => '', 'statusCode' => 303, 'message' => null, + 'sameSiteOnly' => false, ]; protected RequestInterface $request; @@ -69,7 +73,9 @@ protected function executeInternal(): ?string $pageUid = (int)$pageUid; } - $statusCode = (int)$this->parseOption('statusCode'); + /** @var int|string|null $statusCodeOption */ + $statusCodeOption = $this->parseOption('statusCode'); + $statusCode = (int)$statusCodeOption; /** * @var string|null @@ -79,21 +85,28 @@ protected function executeInternal(): ?string $additionalParameters = is_string($additionalParameters) ? $additionalParameters : ''; $additionalParameters = '&' . ltrim($additionalParameters, '&'); + $sameSiteOnly = (bool)$this->parseOption('sameSiteOnly'); + $this->finisherContext->cancel(); - return $this->prepareRedirect($pageUid, $additionalParameters, $statusCode, $message); + return $this->prepareRedirect($pageUid, $additionalParameters, $statusCode, $message, $sameSiteOnly); } protected function prepareRedirect( int $pageUid = 1, string $additionalParameters = '', int $statusCode = 303, - ?string $message = null + ?string $message = null, + bool $sameSiteOnly = false ): ?string { try { $serverRequest = $this->request->getAttribute('extbase.request.originalRequest') ?? $GLOBALS['TYPO3_REQUEST']; + if ($sameSiteOnly) { + $pageUid = $this->restrictToCurrentSite($pageUid, $serverRequest?->getAttribute('site')); + } + $urlUtility = ($this->urlUtility ??= GeneralUtility::makeInstance(HeadlessFrontendUrlInterface::class))->withRequest($serverRequest); $cObj = $serverRequest->getAttribute('currentContentObject'); @@ -119,7 +132,30 @@ protected function prepareRedirect( JSON_THROW_ON_ERROR ); } catch (JsonException $e) { + $this->logger?->warning('JsonRedirectFinisher: JSON encode failed', ['exception' => $e]); return null; } } + + private function restrictToCurrentSite(int $pageUid, mixed $currentSite): int + { + if (!$currentSite instanceof Site || $pageUid <= 0) { + return $pageUid; + } + try { + $targetSite = GeneralUtility::makeInstance(SiteFinder::class)->getSiteByPageId($pageUid); + } catch (SiteNotFoundException) { + $this->logger?->warning('JsonRedirectFinisher: pageUid not found, falling back', ['pageUid' => $pageUid]); + return (int)($this->defaultOptions['pageUid'] ?? 1); + } + if ($targetSite->getIdentifier() !== $currentSite->getIdentifier()) { + $this->logger?->warning('JsonRedirectFinisher: cross-site redirect blocked', [ + 'pageUid' => $pageUid, + 'currentSite' => $currentSite->getIdentifier(), + 'targetSite' => $targetSite->getIdentifier(), + ]); + return (int)($this->defaultOptions['pageUid'] ?? 1); + } + return $pageUid; + } } diff --git a/Classes/Form/Service/FormTranslationService.php b/Classes/Form/Service/FormTranslationService.php index fc47c887..e52e4356 100644 --- a/Classes/Form/Service/FormTranslationService.php +++ b/Classes/Form/Service/FormTranslationService.php @@ -99,134 +99,99 @@ public function translateElementValue( if ($property === 'options' && is_array($defaultValue)) { foreach ($defaultValue as $optionValue => &$optionLabel) { - $translationKeyChain = []; - foreach ($translationFiles as $translationFile) { - if (!empty($originalFormIdentifier)) { - $translationKeyChain[] = sprintf( - '%s:%s.element.%s.%s.%s.%s', - $translationFile, - $originalFormIdentifier, - $element['identifier'], - $propertyType, - $property, - $optionValue - ); - } - $translationKeyChain[] = sprintf( - '%s:%s.element.%s.%s.%s.%s', - $translationFile, - $formRuntime['identifier'], - $element['identifier'], - $propertyType, - $property, - $optionValue - ); - $translationKeyChain[] = sprintf( - '%s:element.%s.%s.%s.%s', - $translationFile, - $element['identifier'], - $propertyType, - $property, - $optionValue - ); - $translationKeyChain[] = sprintf( - '%s:element.%s.%s.%s.%s', - $translationFile, - $element['type'], - $propertyType, - $property, - $optionValue - ); - } - - $translatedValue = $this->processTranslationChain($translationKeyChain, $language, $arguments); + $chain = $this->buildElementTranslationKeyChain( + $translationFiles, + $element, + $formRuntime, + $propertyType, + $property . '.' . $optionValue, + $originalFormIdentifier, + ); + $translatedValue = $this->processTranslationChain($chain, $language, $arguments); $optionLabel = empty($translatedValue) ? $optionLabel : $translatedValue; } - $translatedValue = $defaultValue; - } elseif ($property === 'fluidAdditionalAttributes' && is_array($defaultValue)) { - foreach ($defaultValue as $propertyName => &$propertyValue) { - $translationKeyChain = []; - foreach ($translationFiles as $translationFile) { - if (!empty($originalFormIdentifier)) { - $translationKeyChain[] = sprintf( - '%s:%s.element.%s.%s.%s', - $translationFile, - $originalFormIdentifier, - $element['identifier'], - $propertyType, - $propertyName - ); - } - $translationKeyChain[] = sprintf( - '%s:%s.element.%s.%s.%s', - $translationFile, - $formRuntime['identifier'], - $element['identifier'], - $propertyType, - $propertyName - ); - $translationKeyChain[] = sprintf( - '%s:element.%s.%s.%s', - $translationFile, - $element['identifier'], - $propertyType, - $propertyName - ); - $translationKeyChain[] = sprintf( - '%s:element.%s.%s.%s', - $translationFile, - $element['type'], - $propertyType, - $propertyName - ); - } + return $defaultValue; + } - $translatedValue = $this->processTranslationChain($translationKeyChain, $language, $arguments); + if ($property === 'fluidAdditionalAttributes' && is_array($defaultValue)) { + foreach ($defaultValue as $propertyName => &$propertyValue) { + $chain = $this->buildElementTranslationKeyChain( + $translationFiles, + $element, + $formRuntime, + $propertyType, + (string)$propertyName, + $originalFormIdentifier, + ); + $translatedValue = $this->processTranslationChain($chain, $language, $arguments); $propertyValue = empty($translatedValue) ? $propertyValue : $translatedValue; } - $translatedValue = $defaultValue; - } else { - $translationKeyChain = []; - foreach ($translationFiles as $translationFile) { - if (!empty($originalFormIdentifier)) { - $translationKeyChain[] = sprintf( - '%s:%s.element.%s.%s.%s', - $translationFile, - $originalFormIdentifier, - $element['identifier'], - $propertyType, - $property - ); - } - $translationKeyChain[] = sprintf( + return $defaultValue; + } + + $chain = $this->buildElementTranslationKeyChain( + $translationFiles, + $element, + $formRuntime, + $propertyType, + $property, + $originalFormIdentifier, + ); + $translatedValue = $this->processTranslationChain($chain, $language, $arguments); + + return empty($translatedValue) ? $defaultValue : $translatedValue; + } + + /** + * @param list $translationFiles + * @param array $element + * @param array $formRuntime + * @return list + */ + private function buildElementTranslationKeyChain( + array $translationFiles, + array $element, + array $formRuntime, + string $propertyType, + string $leaf, + ?string $originalFormIdentifier, + ): array { + $chain = []; + foreach ($translationFiles as $translationFile) { + if (!empty($originalFormIdentifier)) { + $chain[] = sprintf( '%s:%s.element.%s.%s.%s', $translationFile, - 'identifier', - $element['identifier'], - $propertyType, - $property - ); - $translationKeyChain[] = sprintf( - '%s:element.%s.%s.%s', - $translationFile, + $originalFormIdentifier, $element['identifier'], $propertyType, - $property - ); - $translationKeyChain[] = sprintf( - '%s:element.%s.%s.%s', - $translationFile, - $element['type'] ?? '', - $propertyType, - $property + $leaf, ); } - - $translatedValue = $this->processTranslationChain($translationKeyChain, $language, $arguments); - $translatedValue = empty($translatedValue) ? $defaultValue : $translatedValue; + $chain[] = sprintf( + '%s:%s.element.%s.%s.%s', + $translationFile, + $formRuntime['identifier'], + $element['identifier'], + $propertyType, + $leaf, + ); + $chain[] = sprintf( + '%s:element.%s.%s.%s', + $translationFile, + $element['identifier'], + $propertyType, + $leaf, + ); + $chain[] = sprintf( + '%s:element.%s.%s.%s', + $translationFile, + $element['type'] ?? '', + $propertyType, + $leaf, + ); } - - return $translatedValue; + return $chain; } /** diff --git a/Classes/Form/Translator.php b/Classes/Form/Translator.php index ee3d6259..8d1c8305 100644 --- a/Classes/Form/Translator.php +++ b/Classes/Form/Translator.php @@ -23,9 +23,10 @@ class Translator public function __construct(protected FormTranslationService $translator) {} /** - * @param array $formDefinition - * @param array $renderingOptions - * @return array + * @param array $formDefinition + * @param array $renderingOptions + * @param array $sentValues + * @return array */ public function translate(array $formDefinition, array $renderingOptions, array $sentValues = []): array { @@ -43,16 +44,18 @@ public function translate(array $formDefinition, array $renderingOptions, array } foreach ($formDefinition['renderables'] as $page) { + if ($page === []) { + continue; + } + $pageTranslation = [ 'label' => $this->translator->translateElementValue($page, ['label'], $formRuntime), ]; - if (!isset($page['renderables']) || !is_array($page['renderables'])) { - continue; + if (isset($page['renderables']) && is_array($page['renderables'])) { + $pageTranslation['renderables'] = $this->translateRenderables($page['renderables'], $formRuntime, $sentValues); } - $pageTranslation['renderables'] = $this->translateRenderables($page['renderables'], $formRuntime, $sentValues); - $result['renderables'][] = array_replace_recursive($page, $pageTranslation); } @@ -62,7 +65,7 @@ public function translate(array $formDefinition, array $renderingOptions, array /** * @param array $renderables * @param array $formRuntime - * @param array sentValues + * @param array $sentValues * @return array */ private function translateRenderables(array $renderables, array $formRuntime, array $sentValues): array diff --git a/Classes/Hooks/PreviewUrlHook.php b/Classes/Hooks/PreviewUrlHook.php deleted file mode 100644 index 6c0074d8..00000000 --- a/Classes/Hooks/PreviewUrlHook.php +++ /dev/null @@ -1,42 +0,0 @@ -workspace !== 0) { - return $previewUrl; - } - return $this->urlUtility->getFrontendUrlForPage($previewUrl, $pageUid); - } -} diff --git a/Classes/Json/JsonDecoder.php b/Classes/Json/JsonDecoder.php index 3ad1b917..d53c4aeb 100644 --- a/Classes/Json/JsonDecoder.php +++ b/Classes/Json/JsonDecoder.php @@ -18,52 +18,68 @@ use function json_decode; use function trim; +use const PHP_VERSION_ID; + class JsonDecoder implements JsonDecoderInterface { /** - * @inheritDoc + * @param array $data + * @return array */ public function decode(array $data): array { - $json = []; - foreach ($data as $key => $singleData) { if (is_string($singleData)) { - if ($this->isJson($singleData)) { - $json[$key] = json_decode($singleData); - } else { - $json[$key] = $singleData; + $decoded = $this->tryDecodeJsonString($singleData); + if ($decoded !== null) { + $data[$key] = $decoded; } } elseif (is_array($singleData)) { - $json[$key] = $this->decode($singleData); - } else { - $json[$key] = $singleData; + $data[$key] = $this->decode($singleData); } } - return $json; + return $data; + } + + public function isJson(mixed $possibleJson): bool + { + if (!is_string($possibleJson)) { + return false; + } + + return $this->tryDecodeJsonString($possibleJson) !== null; } /** - * @param mixed $possibleJson + * @return array|object|null */ - public function isJson($possibleJson): bool + private function tryDecodeJsonString(string $value): array|object|null { - if (is_numeric($possibleJson)) { - return false; + if (is_numeric($value)) { + return null; } - $possibleJson = trim((string)$possibleJson); + $trimmed = trim($value); + if ($trimmed === '') { + return null; + } - if ($possibleJson === '') { - return false; + $first = $trimmed[0]; + $last = $trimmed[-1]; + if (!(($first === '{' && $last === '}') || ($first === '[' && $last === ']'))) { + return null; } - $data = json_decode($possibleJson); + if (PHP_VERSION_ID >= 80300 && !json_validate($trimmed)) { + return null; + } - if (!is_object($data) && !is_array($data)) { - return false; + $decoded = json_decode($trimmed); + + if (!is_object($decoded) && !is_array($decoded)) { + return null; } - return $data !== null; + return $decoded; } } diff --git a/Classes/Json/JsonDecoderInterface.php b/Classes/Json/JsonDecoderInterface.php index 09a99810..e74f1e71 100644 --- a/Classes/Json/JsonDecoderInterface.php +++ b/Classes/Json/JsonDecoderInterface.php @@ -14,14 +14,10 @@ interface JsonDecoderInterface { /** - * @param array $data - * @return array + * @param array $data + * @return array */ public function decode(array $data): array; - /** - * @param mixed $possibleJson - * @return bool - */ - public function isJson($possibleJson): bool; + public function isJson(mixed $possibleJson): bool; } diff --git a/Classes/Json/JsonEncoder.php b/Classes/Json/JsonEncoder.php index 5c69d26f..0243a885 100644 --- a/Classes/Json/JsonEncoder.php +++ b/Classes/Json/JsonEncoder.php @@ -18,13 +18,23 @@ use function json_encode; +use const JSON_HEX_AMP; +use const JSON_HEX_APOS; +use const JSON_PRETTY_PRINT; use const JSON_THROW_ON_ERROR; class JsonEncoder implements JsonEncoderInterface, LoggerAwareInterface { use LoggerAwareTrait; - public function __construct(private readonly Features $features) {} + private const DEFAULT_FLAGS = JSON_HEX_APOS | JSON_HEX_AMP | JSON_THROW_ON_ERROR; + + private readonly bool $prettyPrint; + + public function __construct(Features $features) + { + $this->prettyPrint = $features->isFeatureEnabled('headless.prettyPrint'); + } /** * @inheritDoc @@ -32,18 +42,16 @@ public function __construct(private readonly Features $features) {} public function encode($data, int $options = 0): string { try { - if ($this->features->isFeatureEnabled('headless.prettyPrint') && !($options & JSON_PRETTY_PRINT)) { - $options |= JSON_PRETTY_PRINT; - } + $options |= self::DEFAULT_FLAGS; - if (!($options & JSON_THROW_ON_ERROR)) { - $options |= JSON_THROW_ON_ERROR; + if ($this->prettyPrint) { + $options |= JSON_PRETTY_PRINT; } return json_encode($data, $options); } catch (JsonException $e) { $this->logger->critical($e->getMessage()); - return json_encode([]); + return json_encode([], self::DEFAULT_FLAGS); } } } diff --git a/Classes/Middleware/CookieDomainPerSite.php b/Classes/Middleware/CookieDomainPerSite.php index dbd32523..7fc66c6a 100644 --- a/Classes/Middleware/CookieDomainPerSite.php +++ b/Classes/Middleware/CookieDomainPerSite.php @@ -18,48 +18,62 @@ use Psr\Http\Server\RequestHandlerInterface; use Psr\Log\LoggerInterface; use TYPO3\CMS\Core\Http\NormalizedParams; +use TYPO3\CMS\Core\Http\Uri; use TYPO3\CMS\Core\Site\SiteFinder; +use function strtolower; + class CookieDomainPerSite implements MiddlewareInterface { - private HeadlessFrontendUrlInterface $urlUtility; - private SiteFinder $siteFinder; - private LoggerInterface $logger; - public function __construct( - HeadlessFrontendUrlInterface $urlUtility, - SiteFinder $siteFinder, - LoggerInterface $logger - ) { - $this->urlUtility = $urlUtility; - $this->siteFinder = $siteFinder; - $this->logger = $logger; - } + private readonly HeadlessFrontendUrlInterface $urlUtility, + private readonly SiteFinder $siteFinder, + private readonly LoggerInterface $logger, + ) {} public function process( ServerRequestInterface $request, RequestHandlerInterface $handler ): ResponseInterface { - /** @var NormalizedParams $normalizedParams */ $normalizedParams = $request->getAttribute('normalizedParams'); - $requestHost = $normalizedParams->getHttpHost(); - $allSites = $this->siteFinder->getAllSites(); + if (!$normalizedParams instanceof NormalizedParams) { + return $handler->handle($request); + } + $requestHost = strtolower($normalizedParams->getHttpHost()); - foreach ($allSites as $site) { + $cookieDomain = null; + foreach ($this->siteFinder->getAllSites() as $site) { $urlUtility = $this->urlUtility->withSite($site); $base = $urlUtility->resolveKey('base'); - $cookieDomain = $urlUtility->resolveKey('cookieDomain'); - if (str_contains($base, $requestHost) && $cookieDomain) { - $GLOBALS['TYPO3_CONF_VARS']['SYS']['cookieDomain'] = $cookieDomain; + if ($base === '' || strtolower((new Uri($base))->getHost()) !== $requestHost) { + continue; + } + + $resolved = $urlUtility->resolveKey('cookieDomain'); + if ($resolved) { + $cookieDomain = $resolved; break; } } - if (!$GLOBALS['TYPO3_CONF_VARS']['SYS']['cookieDomain']) { - $this->logger->warning('missing cookieDomain configuration'); + if ($cookieDomain === null) { + if (!($GLOBALS['TYPO3_CONF_VARS']['SYS']['cookieDomain'] ?? '')) { + $this->logger->warning('missing cookieDomain configuration'); + } + return $handler->handle($request); } - return $handler->handle($request); + $previous = $GLOBALS['TYPO3_CONF_VARS']['SYS']['cookieDomain'] ?? null; + $GLOBALS['TYPO3_CONF_VARS']['SYS']['cookieDomain'] = $cookieDomain; + try { + return $handler->handle($request); + } finally { + if ($previous === null) { + unset($GLOBALS['TYPO3_CONF_VARS']['SYS']['cookieDomain']); + } else { + $GLOBALS['TYPO3_CONF_VARS']['SYS']['cookieDomain'] = $previous; + } + } } } diff --git a/Classes/Middleware/ElementBodyResponseMiddleware.php b/Classes/Middleware/ElementBodyResponseMiddleware.php index b52bc105..b3283fdc 100644 --- a/Classes/Middleware/ElementBodyResponseMiddleware.php +++ b/Classes/Middleware/ElementBodyResponseMiddleware.php @@ -11,56 +11,80 @@ namespace FriendsOfTYPO3\Headless\Middleware; -use FriendsOfTYPO3\Headless\Json\JsonEncoder; +use FriendsOfTYPO3\Headless\Json\JsonDecoderInterface; +use FriendsOfTYPO3\Headless\Json\JsonEncoderInterface; use FriendsOfTYPO3\Headless\Utility\HeadlessModeInterface; +use JsonException; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; use TYPO3\CMS\Core\Http\Stream; + use TYPO3\CMS\Core\Site\Entity\Site; use function in_array; use function is_array; + use function json_decode; +use const JSON_THROW_ON_ERROR; + class ElementBodyResponseMiddleware implements MiddlewareInterface { - public function __construct(protected JsonEncoder $jsonEncoder, protected HeadlessModeInterface $headlessMode) {} + public function __construct( + protected JsonEncoderInterface $jsonEncoder, + protected HeadlessModeInterface $headlessMode, + protected JsonDecoderInterface $jsonDecoder, + ) {} public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { $response = $handler->handle($request); - /** - * @var Site - */ $site = $request->getAttribute('site'); if (!($site instanceof Site)) { return $response; } - if (!$this->headlessMode->withRequest($request)->isEnabled()) { + if (!$this->headlessMode->isEnabledFor($request)) { return $response; } $elementId = (int)($request->getParsedBody()['responseElementId'] ?? 0); - if (!$elementId || !in_array($request->getMethod(), ['POST', 'PUT', 'DELETE'], true)) { + if ($elementId <= 0 || !in_array($request->getMethod(), ['POST', 'PUT', 'DELETE'], true)) { return $response; } $recursiveElement = (bool)(int)($request->getParsedBody()['responseElementRecursive'] ?? 0); - $responseJson = json_decode($response->getBody()->__toString(), true); - if ($responseJson === null) { + $body = $response->getBody()->__toString(); + if ($body === '') { + return $response; + } + + try { + $responseJson = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException) { return $response; } + if (!is_array($responseJson)) { + return $response; + } + + $content = $responseJson['content'] ?? []; + if (is_array($content)) { + $content = $this->jsonDecoder->decode($content); + } else { + $content = []; + } + $stream = new Stream('php://temp', 'r+'); $stream->write($this->jsonEncoder->encode($this->extractElement( - $responseJson['content'] ?? [], + $content, $elementId, $recursiveElement ))); diff --git a/Classes/Middleware/HeadlessModeSetter.php b/Classes/Middleware/HeadlessModeSetter.php index 8e031de4..232e5bac 100644 --- a/Classes/Middleware/HeadlessModeSetter.php +++ b/Classes/Middleware/HeadlessModeSetter.php @@ -21,15 +21,10 @@ class HeadlessModeSetter implements MiddlewareInterface { - public function __construct() {} - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { $mode = HeadlessModeInterface::NONE; - /** - * @var Site $site - */ $site = $request->getAttribute('site'); if ($site instanceof Site) { $mode = (int)($site->getConfiguration()['headless'] ?? HeadlessModeInterface::NONE); diff --git a/Classes/Middleware/RedirectHandler.php b/Classes/Middleware/RedirectHandler.php deleted file mode 100644 index a33d743e..00000000 --- a/Classes/Middleware/RedirectHandler.php +++ /dev/null @@ -1,94 +0,0 @@ -urlUtility = $urlUtility; - $this->headlessMode = $headlessMode; - } - - /** - * @inheritDoc - */ - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface - { - $this->request = $request; - return parent::process($request, $handler); - } - - /** - * @inheritDoc - */ - protected function buildRedirectResponse(UriInterface $uri, array $redirectRecord): ResponseInterface - { - /** - * @var Site - */ - $site = $this->request->getAttribute('site'); - - if (!($site instanceof Site)) { - return parent::buildRedirectResponse($uri, $redirectRecord); - } - - if (!$this->headlessMode->withRequest($this->request)->isEnabled()) { - return parent::buildRedirectResponse($uri, $redirectRecord); - } - - $this->urlUtility = $this->urlUtility->withRequest($this->request); - - $redirectUrlEvent = new RedirectUrlEvent( - $this->request, - $uri, - $this->urlUtility->prepareRelativeUrlIfPossible($this->urlUtility->getFrontendUrlWithSite((string)$uri, $site)), - (int)$redirectRecord['target_statuscode'], - $redirectRecord - ); - - $redirectUrlEvent = $this->eventDispatcher->dispatch($redirectUrlEvent); - - return new JsonResponse([ - 'redirectUrl' => $redirectUrlEvent->getTargetUrl(), - 'statusCode' => $redirectUrlEvent->getTargetStatusCode(), - ]); - } -} diff --git a/Classes/Middleware/ShortcutAndMountPointRedirect.php b/Classes/Middleware/ShortcutAndMountPointRedirect.php index 007732d8..e6918c68 100644 --- a/Classes/Middleware/ShortcutAndMountPointRedirect.php +++ b/Classes/Middleware/ShortcutAndMountPointRedirect.php @@ -58,6 +58,6 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface private function isHeadlessEnabled(ServerRequestInterface $request): bool { - return $this->headlessMode->withRequest($request)->isEnabled(); + return $this->headlessMode->isEnabledFor($request); } } diff --git a/Classes/Middleware/SiteBaseRedirectResolver.php b/Classes/Middleware/SiteBaseRedirectResolver.php index 128d6296..6d615667 100644 --- a/Classes/Middleware/SiteBaseRedirectResolver.php +++ b/Classes/Middleware/SiteBaseRedirectResolver.php @@ -37,7 +37,7 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface return $response; } - if (!$this->headlessMode->withRequest($request)->isEnabled()) { + if (!$this->headlessMode->isEnabledFor($request)) { return $response; } diff --git a/Classes/Middleware/UserIntMiddleware.php b/Classes/Middleware/UserIntMiddleware.php index 0ba87901..0797d79c 100644 --- a/Classes/Middleware/UserIntMiddleware.php +++ b/Classes/Middleware/UserIntMiddleware.php @@ -11,30 +11,39 @@ namespace FriendsOfTYPO3\Headless\Middleware; -use FriendsOfTYPO3\Headless\Seo\MetaHandler; +use FriendsOfTYPO3\Headless\Json\JsonDecoderInterface; +use FriendsOfTYPO3\Headless\Json\JsonEncoderInterface; +use FriendsOfTYPO3\Headless\Seo\MetaHandlerInterface; use FriendsOfTYPO3\Headless\Utility\HeadlessModeInterface; use FriendsOfTYPO3\Headless\Utility\HeadlessUserInt; +use JsonException; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; + use TYPO3\CMS\Core\Http\Stream; +use function is_array; use function json_decode; +use const JSON_THROW_ON_ERROR; + class UserIntMiddleware implements MiddlewareInterface { public function __construct( private readonly HeadlessUserInt $headlessUserInt, private readonly HeadlessModeInterface $headlessMode, - private readonly MetaHandler $metaHandler + private readonly MetaHandlerInterface $metaHandler, + private readonly JsonEncoderInterface $jsonEncoder, + private readonly JsonDecoderInterface $jsonDecoder, ) {} public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { $response = $handler->handle($request); - if (!$this->headlessMode->withRequest($request)->isEnabled()) { + if (!$this->headlessMode->isEnabledFor($request)) { return $response; } @@ -45,14 +54,19 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface } $jsonContent = $this->headlessUserInt->unwrap($jsonContent); - $responseBody = json_decode($jsonContent, true); - if (($responseBody['seo']['title'] ?? null) !== null) { + try { + $responseBody = json_decode($jsonContent, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException) { + $responseBody = null; + } + + if (is_array($responseBody) && ($responseBody['seo']['title'] ?? null) !== null) { $responseBody = $this->metaHandler->process( $request, - $responseBody + $this->jsonDecoder->decode($responseBody) ); - $jsonContent = json_encode($responseBody); + $jsonContent = $this->jsonEncoder->encode($responseBody); } $stream = new Stream('php://temp', 'r+'); diff --git a/Classes/Resource/Rendering/AudioTagRenderer.php b/Classes/Resource/Rendering/AudioTagRenderer.php index 8d450270..66bcdaf6 100644 --- a/Classes/Resource/Rendering/AudioTagRenderer.php +++ b/Classes/Resource/Rendering/AudioTagRenderer.php @@ -11,7 +11,7 @@ namespace FriendsOfTYPO3\Headless\Resource\Rendering; -use FriendsOfTYPO3\Headless\Utility\FileUtility; +use FriendsOfTYPO3\Headless\Utility\FileUtilityInterface; use TYPO3\CMS\Core\Resource\FileInterface; use TYPO3\CMS\Core\Utility\GeneralUtility; @@ -22,7 +22,7 @@ */ class AudioTagRenderer extends \TYPO3\CMS\Core\Resource\Rendering\AudioTagRenderer { - private ?FileUtility $fileUtility = null; + private ?FileUtilityInterface $fileUtility = null; public function getPriority(): int { @@ -35,13 +35,13 @@ public function getPriority(): int * @param FileInterface $file * @param int|string $width TYPO3 known format; examples: 220, 200m or 200c * @param int|string $height TYPO3 known format; examples: 220, 200m or 200c - * @param array $options + * @param array $options * @return string */ public function render(FileInterface $file, $width, $height, array $options = []): string { if (($options['returnUrl'] ?? false) === true) { - $fileUtility = $this->fileUtility ??= GeneralUtility::makeInstance(FileUtility::class); + $fileUtility = $this->fileUtility ??= GeneralUtility::makeInstance(FileUtilityInterface::class); return htmlspecialchars($fileUtility->getAbsoluteUrl($file->getPublicUrl()), ENT_QUOTES | ENT_HTML5); } return parent::render(...func_get_args()); diff --git a/Classes/Resource/Rendering/VideoTagRenderer.php b/Classes/Resource/Rendering/VideoTagRenderer.php index b93df99f..d82116f4 100644 --- a/Classes/Resource/Rendering/VideoTagRenderer.php +++ b/Classes/Resource/Rendering/VideoTagRenderer.php @@ -11,7 +11,7 @@ namespace FriendsOfTYPO3\Headless\Resource\Rendering; -use FriendsOfTYPO3\Headless\Utility\FileUtility; +use FriendsOfTYPO3\Headless\Utility\FileUtilityInterface; use TYPO3\CMS\Core\Resource\FileInterface; use TYPO3\CMS\Core\Utility\GeneralUtility; @@ -22,7 +22,7 @@ */ class VideoTagRenderer extends \TYPO3\CMS\Core\Resource\Rendering\VideoTagRenderer { - private ?FileUtility $fileUtility = null; + private ?FileUtilityInterface $fileUtility = null; public function getPriority(): int { @@ -35,14 +35,13 @@ public function getPriority(): int * @param FileInterface $file * @param int|string $width TYPO3 known format; examples: 220, 200m or 200c * @param int|string $height TYPO3 known format; examples: 220, 200m or 200c - * @param array $options - * @param bool $usedPathsRelativeToCurrentScript See $file->getPublicUrl() + * @param array $options * @return string */ public function render(FileInterface $file, $width, $height, array $options = []): string { if (($options['returnUrl'] ?? false) === true) { - $fileUtility = $this->fileUtility ??= GeneralUtility::makeInstance(FileUtility::class); + $fileUtility = $this->fileUtility ??= GeneralUtility::makeInstance(FileUtilityInterface::class); return htmlspecialchars($fileUtility->getAbsoluteUrl($file->getPublicUrl()), ENT_QUOTES | ENT_HTML5); } return parent::render(...func_get_args()); diff --git a/Classes/Resource/Rendering/VimeoRenderer.php b/Classes/Resource/Rendering/VimeoRenderer.php index 66d72b3c..4be59569 100644 --- a/Classes/Resource/Rendering/VimeoRenderer.php +++ b/Classes/Resource/Rendering/VimeoRenderer.php @@ -31,7 +31,7 @@ public function getPriority(): int * @param FileInterface $file * @param int|string $width TYPO3 known format; examples: 220, 200m or 200c * @param int|string $height TYPO3 known format; examples: 220, 200m or 200c - * @param array $options + * @param array $options * @return string */ public function render(FileInterface $file, $width, $height, array $options = []): string diff --git a/Classes/Resource/Rendering/YouTubeRenderer.php b/Classes/Resource/Rendering/YouTubeRenderer.php index a0156783..03966765 100644 --- a/Classes/Resource/Rendering/YouTubeRenderer.php +++ b/Classes/Resource/Rendering/YouTubeRenderer.php @@ -31,7 +31,7 @@ public function getPriority(): int * @param FileInterface $file * @param int|string $width TYPO3 known format; examples: 220, 200m or 200c * @param int|string $height TYPO3 known format; examples: 220, 200m or 200c - * @param array $options + * @param array $options * @return string */ public function render(FileInterface $file, $width, $height, array $options = []): string diff --git a/Classes/Resource/Service/HeadlessImageService.php b/Classes/Resource/Service/HeadlessImageService.php new file mode 100644 index 00000000..724aa59e --- /dev/null +++ b/Classes/Resource/Service/HeadlessImageService.php @@ -0,0 +1,59 @@ +stripProxyPrefix($src), $image, $treatIdAsReference); + } + + private function stripProxyPrefix(string $src): string + { + $request = $GLOBALS['TYPO3_REQUEST'] ?? null; + // headlessMode check first so we short-circuit before + // ApplicationType::fromRequest(), which throws on requests that lack + // the `applicationType` attribute (CLI sub-requests, malformed middleware). + if (!$request instanceof ServerRequestInterface + || !$this->headlessMode->isEnabledFor($request) + || !ApplicationType::fromRequest($request)->isFrontend() + ) { + return $src; + } + + $proxyUrl = $this->urlUtility->withRequest($request)->getProxyUrl(); + if ($proxyUrl === '') { + return $src; + } + + return str_replace($proxyUrl . '/', '', $src); + } +} diff --git a/Classes/Seo/CanonicalGenerator.php b/Classes/Seo/CanonicalGenerator.php index 9a2a54e2..a455d304 100644 --- a/Classes/Seo/CanonicalGenerator.php +++ b/Classes/Seo/CanonicalGenerator.php @@ -11,12 +11,12 @@ namespace FriendsOfTYPO3\Headless\Seo; +use FriendsOfTYPO3\Headless\Json\JsonEncoderInterface; use FriendsOfTYPO3\Headless\Utility\HeadlessModeInterface; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Seo\Canonical\CanonicalGenerator as CoreCanonicalGenerator; use function htmlspecialchars; -use function json_encode; /** * Decorate Core version with headless flavor @@ -25,6 +25,9 @@ */ class CanonicalGenerator { + /** + * @param array $params + */ public function handle(array &$params): string { $canonical = GeneralUtility::makeInstance(CoreCanonicalGenerator::class)->generate($params); @@ -33,14 +36,14 @@ public function handle(array &$params): string return ''; } - if ($this->getHeadlessMode()->withRequest($params['request'])->isEnabled()) { + if ($this->getHeadlessMode()->isEnabledFor($params['request'])) { $canonical = [ 'href' => $this->processCanonical($canonical), 'rel' => 'canonical', ]; $params['_seoLinks'][] = $canonical; - $canonical = json_encode($canonical); + $canonical = $this->getJsonEncoder()->encode($canonical); } return $canonical; @@ -48,11 +51,16 @@ public function handle(array &$params): string protected function processCanonical(string $canonical): string { - return htmlspecialchars(GeneralUtility::get_tag_attributes($canonical)['href'] ?? ''); + return htmlspecialchars(GeneralUtility::get_tag_attributes($canonical, true)['href'] ?? ''); } protected function getHeadlessMode(): HeadlessModeInterface { return GeneralUtility::makeInstance(HeadlessModeInterface::class); } + + protected function getJsonEncoder(): JsonEncoderInterface + { + return GeneralUtility::makeInstance(JsonEncoderInterface::class); + } } diff --git a/Classes/Seo/MetaHandler.php b/Classes/Seo/MetaHandler.php index c04395ef..80c6fd0d 100644 --- a/Classes/Seo/MetaHandler.php +++ b/Classes/Seo/MetaHandler.php @@ -11,6 +11,7 @@ namespace FriendsOfTYPO3\Headless\Seo; +use FriendsOfTYPO3\Headless\Seo\MetaTag\AbstractMetaTagManager; use InvalidArgumentException; use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Http\Message\ServerRequestInterface; @@ -26,6 +27,9 @@ use function array_merge_recursive; use function htmlspecialchars; use function implode; +use function json_decode; + +use const JSON_THROW_ON_ERROR; class MetaHandler implements MetaHandlerInterface { @@ -36,11 +40,18 @@ public function __construct( private readonly TypoScriptService $typoScriptService, ) {} + /** + * @param array $content + * @return array + */ public function process( ServerRequestInterface $request, array $content ): array { $pageInformation = $request->getAttribute('frontend.page.information'); + if ($pageInformation === null) { + return $content; + } $page = $pageInformation->getPageRecord(); $_params = ['page' => $page, 'request' => $request, '_seoLinks' => []]; @@ -64,8 +75,14 @@ public function process( $metaTagManagers = $this->metaTagRegistry->getAllManagers(); foreach ($metaTagManagers as $managerObject) { - $properties = json_decode($managerObject->renderAllProperties(), true); - if (!empty($properties)) { + if ($managerObject instanceof AbstractMetaTagManager) { + $properties = $managerObject->renderAllHeadlessPropertiesAsArray(); + } else { + $rendered = $managerObject->renderAllProperties(); + $properties = $rendered === '' ? [] : (json_decode($rendered, true, 512, JSON_THROW_ON_ERROR) ?: []); + } + + if ($properties !== []) { $metaTags = array_merge($metaTags, $properties); } } @@ -97,7 +114,7 @@ public function process( $defaultBodyAttrs = [ 'class' => implode(' ', [ - 'pid-' . $request->getAttribute('routing')->getPageId(), + 'pid-' . $pageInformation->getId(), 'layout-' . ($content['appearance']['layout'] ?? ''), ]), ]; @@ -126,11 +143,17 @@ public function process( return $content; } + /** + * @param array $typoScriptConfig + */ protected function generatePageTitle(ServerRequestInterface $request, array $typoScriptConfig): string { return $this->pageTitleProviderManager->getTitle($request); } + /** + * @param array $page + */ protected function createContentObjectRenderer(ServerRequestInterface $request, array $page): ContentObjectRenderer { $cObj = GeneralUtility::makeInstance(ContentObjectRenderer::class); @@ -141,8 +164,10 @@ protected function createContentObjectRenderer(ServerRequestInterface $request, /** * @codeCoverageIgnore + * + * @param array $metaTagTypoScript */ - protected function generateMetaTagsFromTyposcript(array $metaTagTypoScript, ContentObjectRenderer $cObj) + protected function generateMetaTagsFromTyposcript(array $metaTagTypoScript, ContentObjectRenderer $cObj): void { $conf = $this->typoScriptService->convertTypoScriptArrayToPlainArray($metaTagTypoScript); foreach ($conf as $key => $properties) { @@ -182,13 +207,15 @@ protected function generateMetaTagsFromTyposcript(array $metaTagTypoScript, Cont /** * @codeCoverageIgnore + * + * @param array $subProperties */ private function setMetaTag( string $type, string $name, string $content, array $subProperties = [], - $replace = true + bool $replace = true ): void { $type = strtolower($type); $name = strtolower($name); @@ -204,6 +231,9 @@ private function setMetaTag( /** * @codeCoverageIgnore + * + * @param array $rawHtmlAttrs + * @return array */ private function normalizeAttr(array $rawHtmlAttrs): array { diff --git a/Classes/Seo/MetaHandlerInterface.php b/Classes/Seo/MetaHandlerInterface.php index 6b3c7506..c11cdd29 100644 --- a/Classes/Seo/MetaHandlerInterface.php +++ b/Classes/Seo/MetaHandlerInterface.php @@ -15,6 +15,10 @@ interface MetaHandlerInterface { + /** + * @param array $content + * @return array + */ public function process( ServerRequestInterface $request, array $content diff --git a/Classes/Seo/MetaTag/AbstractMetaTagManager.php b/Classes/Seo/MetaTag/AbstractMetaTagManager.php index 22660798..9cc74168 100644 --- a/Classes/Seo/MetaTag/AbstractMetaTagManager.php +++ b/Classes/Seo/MetaTag/AbstractMetaTagManager.php @@ -12,13 +12,15 @@ namespace FriendsOfTYPO3\Headless\Seo\MetaTag; use FriendsOfTYPO3\Headless\Utility\HeadlessModeInterface; +use Psr\Http\Message\ServerRequestInterface; use TYPO3\CMS\Core\Type\DocType; use TYPO3\CMS\Core\Utility\GeneralUtility; use function array_merge; -use function json_decode; use function json_encode; +use const JSON_THROW_ON_ERROR; + /** * Overridden core version with headless implementation */ @@ -31,9 +33,9 @@ private function getHeadlessMode(): HeadlessModeInterface return $this->headlessMode ??= GeneralUtility::makeInstance(HeadlessModeInterface::class); } - public function renderAllProperties(DocType|null $docType = null): string + public function renderAllProperties(?DocType $docType = null): string { - if ($this->getHeadlessMode()->withRequest($GLOBALS['TYPO3_REQUEST'])->isEnabled()) { + if ($this->isHeadlessRequest()) { return $this->renderAllHeadlessProperties(); } @@ -42,19 +44,34 @@ public function renderAllProperties(DocType|null $docType = null): string public function renderProperty(string $property, ?DocType $docType = null): string { - if ($this->getHeadlessMode()->withRequest($GLOBALS['TYPO3_REQUEST'])->isEnabled()) { + if ($this->isHeadlessRequest()) { return $this->renderHeadlessProperty($property); } return parent::renderProperty($property, $docType); } + private function isHeadlessRequest(): bool + { + $request = $GLOBALS['TYPO3_REQUEST'] ?? null; + return $request instanceof ServerRequestInterface + && $this->getHeadlessMode()->isEnabledFor($request); + } + /** * Render a meta tag for a specific property * * @param string $property Name of the property */ public function renderHeadlessProperty(string $property): string + { + return json_encode($this->renderHeadlessPropertyAsArray($property), JSON_THROW_ON_ERROR); + } + + /** + * @return array> + */ + public function renderHeadlessPropertyAsArray(string $property): array { $property = strtolower($property); $metaTags = []; @@ -71,28 +88,30 @@ public function renderHeadlessProperty(string $property): string $contentAttribute = (string)$this->handledProperties[$property]['contentAttribute']; } - if ($nameAttribute && $contentAttribute) { - foreach ($this->getProperty($property) as $propertyItem) { - $metaTags[] = [ - htmlspecialchars($nameAttribute) => htmlspecialchars($property), - htmlspecialchars($contentAttribute) => htmlspecialchars($propertyItem['content']), - ]; + if (!$nameAttribute || !$contentAttribute) { + return $metaTags; + } + + foreach ($this->getProperty($property) as $propertyItem) { + $metaTags[] = [ + htmlspecialchars($nameAttribute) => htmlspecialchars($property), + htmlspecialchars($contentAttribute) => htmlspecialchars($propertyItem['content']), + ]; - if (!count($propertyItem['subProperties'])) { - continue; - } - foreach ($propertyItem['subProperties'] as $subProperty => $subPropertyItems) { - foreach ($subPropertyItems as $subPropertyItem) { - $metaTags[] = [ - htmlspecialchars($nameAttribute) => htmlspecialchars($property . $this->subPropertySeparator . $subProperty), - htmlspecialchars($contentAttribute) => htmlspecialchars((string)$subPropertyItem), - ]; - } + if (!count($propertyItem['subProperties'])) { + continue; + } + foreach ($propertyItem['subProperties'] as $subProperty => $subPropertyItems) { + foreach ($subPropertyItems as $subPropertyItem) { + $metaTags[] = [ + htmlspecialchars($nameAttribute) => htmlspecialchars($property . $this->subPropertySeparator . $subProperty), + htmlspecialchars($contentAttribute) => htmlspecialchars((string)$subPropertyItem), + ]; } } } - return json_encode($metaTags); + return $metaTags; } /** @@ -100,11 +119,18 @@ public function renderHeadlessProperty(string $property): string */ public function renderAllHeadlessProperties(): string { - $metatags = []; + return json_encode($this->renderAllHeadlessPropertiesAsArray(), JSON_THROW_ON_ERROR); + } + + /** + * @return array> + */ + public function renderAllHeadlessPropertiesAsArray(): array + { + $metaTags = []; foreach (array_keys($this->properties) as $property) { - $metatags = array_merge($metatags, json_decode($this->renderHeadlessProperty($property), true)); + $metaTags = array_merge($metaTags, $this->renderHeadlessPropertyAsArray($property)); } - - return json_encode($metatags); + return $metaTags; } } diff --git a/Classes/Seo/MetaTag/Html5MetaTagManager.php b/Classes/Seo/MetaTag/Html5MetaTagManager.php index 69cc8f78..4a200e44 100644 --- a/Classes/Seo/MetaTag/Html5MetaTagManager.php +++ b/Classes/Seo/MetaTag/Html5MetaTagManager.php @@ -19,7 +19,7 @@ class Html5MetaTagManager extends AbstractMetaTagManager /** * Array of properties that can be handled by this manager * - * @var array + * @var array> */ protected $handledProperties = [ 'application-name' => [], diff --git a/Classes/Seo/MetaTag/OpenGraphMetaTagManager.php b/Classes/Seo/MetaTag/OpenGraphMetaTagManager.php index 1c59a35f..4fa875ae 100644 --- a/Classes/Seo/MetaTag/OpenGraphMetaTagManager.php +++ b/Classes/Seo/MetaTag/OpenGraphMetaTagManager.php @@ -28,7 +28,7 @@ class OpenGraphMetaTagManager extends AbstractMetaTagManager /** * Array of properties that can be handled by this manager * - * @var array + * @var array> */ protected $handledProperties = [ 'og:type' => [], diff --git a/Classes/Seo/MetaTag/TwitterCardMetaTagManager.php b/Classes/Seo/MetaTag/TwitterCardMetaTagManager.php index baf486a7..06713373 100644 --- a/Classes/Seo/MetaTag/TwitterCardMetaTagManager.php +++ b/Classes/Seo/MetaTag/TwitterCardMetaTagManager.php @@ -19,7 +19,7 @@ class TwitterCardMetaTagManager extends AbstractMetaTagManager /** * Array of properties that can be handled by this manager * - * @var array + * @var array> */ protected $handledProperties = [ 'twitter:card' => [], diff --git a/Classes/Service/PaginationService.php b/Classes/Service/PaginationService.php index 96bf092b..069c494d 100644 --- a/Classes/Service/PaginationService.php +++ b/Classes/Service/PaginationService.php @@ -20,7 +20,7 @@ class PaginationService { /** - * @var array + * @var array */ protected $configuration = [ 'itemsPerPage' => 10, @@ -30,7 +30,7 @@ class PaginationService ]; /** - * @var QueryResultInterface + * @var QueryResultInterface */ protected $objects; @@ -75,12 +75,7 @@ class PaginationService protected $displayRangeEnd = 0; /** - * PaginationService constructor. - * @param QueryResultInterface $objects - * @param int $itemsPerPage - * @param int $maximumNumberOfLinks - * @param bool $insertAbove - * @param bool $insertBelow + * @param QueryResultInterface $objects */ public function __construct( QueryResultInterface $objects, @@ -90,15 +85,20 @@ public function __construct( bool $insertBelow = true ) { $this->objects = $objects; + $itemsPerPage = max(1, $itemsPerPage); $this->configuration = [ 'itemsPerPage' => $itemsPerPage, 'maximumNumberOfLinks' => $maximumNumberOfLinks, 'insertAbove' => $insertAbove, 'insertBelow' => $insertBelow, ]; + $this->maximumNumberOfLinks = $maximumNumberOfLinks; $this->numberOfPages = (int)ceil(count($this->objects) / $itemsPerPage); } + /** + * @return array + */ public function paginate(int $currentPage = 1): array { // set current page @@ -157,6 +157,8 @@ protected function getCurrentPageId(): int /** * Returns an array with the keys "pages", "current", "numberOfPages", "nextPage" & "previousPage" + * + * @return array */ protected function buildPagination(): array { @@ -193,7 +195,7 @@ protected function calculateDisplayRange(): void if ($maximumNumberOfLinks > $this->numberOfPages) { $maximumNumberOfLinks = $this->numberOfPages; } - $delta = floor($maximumNumberOfLinks / 2); + $delta = (int)floor($maximumNumberOfLinks / 2); $this->displayRangeStart = $this->currentPage - $delta; $this->displayRangeEnd = $this->currentPage + $delta - ($maximumNumberOfLinks % 2 === 0 ? 1 : 0); if ($this->displayRangeStart < 1) { diff --git a/Classes/Hooks/FileOrFolderLinkBuilder.php b/Classes/Typolink/FileOrFolderLinkBuilder.php similarity index 85% rename from Classes/Hooks/FileOrFolderLinkBuilder.php rename to Classes/Typolink/FileOrFolderLinkBuilder.php index 0b773d22..243f6041 100644 --- a/Classes/Hooks/FileOrFolderLinkBuilder.php +++ b/Classes/Typolink/FileOrFolderLinkBuilder.php @@ -9,7 +9,7 @@ declare(strict_types=1); -namespace FriendsOfTYPO3\Headless\Hooks; +namespace FriendsOfTYPO3\Headless\Typolink; use FriendsOfTYPO3\Headless\Utility\HeadlessModeInterface; use Psr\Http\Message\ServerRequestInterface; @@ -28,6 +28,8 @@ protected function getHeadlessMode(): HeadlessModeInterface } /** + * @param array $linkDetails + * @param array $configuration * @throws UnableToLinkException */ public function buildLink( @@ -36,7 +38,7 @@ public function buildLink( ServerRequestInterface $request, string $linkText = '', ): LinkResultInterface { - if ($this->getHeadlessMode()->withRequest($request)->isEnabled()) { + if ($this->getHeadlessMode()->isEnabledFor($request)) { $configuration['forceAbsoluteUrl'] = 1; } diff --git a/Classes/Utility/File/ProcessingConfiguration.php b/Classes/Utility/File/ProcessingConfiguration.php index 062909e5..ab60cebe 100644 --- a/Classes/Utility/File/ProcessingConfiguration.php +++ b/Classes/Utility/File/ProcessingConfiguration.php @@ -16,11 +16,14 @@ /** * @codeCoverageIgnore */ -class ProcessingConfiguration +final class ProcessingConfiguration { private const RETINA_RATIO = 2; private const LQIP_RATIO = 0.1; + /** + * @param array $options + */ public static function fromOptions(array $options): static { return new static( @@ -53,6 +56,14 @@ public static function fromOptions(array $options): static ); } + /** + * @param array $includeProperties + * @param array $defaultFieldsByType + * @param array $defaultImageFields + * @param array $defaultVideoFields + * @param array> $autogenerate + * @param array $rawOptions + */ private function __construct( public readonly string $width = '', public readonly string $height = '', @@ -82,6 +93,10 @@ private function __construct( public readonly array $rawOptions = [], ) {} + /** + * @param array $configuration + * @return array + */ private static function handleLegacyOptions(array $configuration): array { if ((int)($configuration['retina2x'] ?? 0)) { @@ -97,6 +112,9 @@ private static function handleLegacyOptions(array $configuration): array return $configuration; } + /** + * @param array $options + */ public function withOptions(array $options): static { return self::fromOptions(array_merge($this->rawOptions, $options)); diff --git a/Classes/Utility/FileUtility.php b/Classes/Utility/FileUtility.php index e405a49b..b48169d5 100644 --- a/Classes/Utility/FileUtility.php +++ b/Classes/Utility/FileUtility.php @@ -37,7 +37,7 @@ use function array_merge; use function in_array; -class FileUtility +class FileUtility implements FileUtilityInterface { /** * @var array> @@ -118,13 +118,16 @@ public function process(FileInterface $fileReference, ProcessingConfiguration $p $disableProcessingFor, true )) { - $fileReference = $this->processImageFile($fileReference, $processingConfiguration); + $processed = $this->processImageFile($fileReference, $processingConfiguration); + if ($processed !== null) { + $fileReference = $processed; + } } $publicUrl = $this->imageService->getImageUri($fileReference, true); } elseif ($fileRenderer !== null) { $publicUrl = $fileRenderer->render($fileReference, '', '', ['returnUrl' => true]); } else { - $publicUrl = $this->getAbsoluteUrl($fileReference->getPublicUrl()); + $publicUrl = $this->getAbsoluteUrl($fileReference->getPublicUrl() ?? ''); } $processedProperties = [ @@ -213,9 +216,12 @@ public function process(FileInterface $fileReference, ProcessingConfiguration $p return $processedFile; } + /** + * @param array $properties + * @return array + */ private function onDemandProperties(ProcessingConfiguration $processingConfiguration, array $properties): array { - $processed = []; $props = []; foreach ($processingConfiguration->includeProperties as $prop) { @@ -226,11 +232,9 @@ private function onDemandProperties(ProcessingConfiguration $processingConfigura $propName = $prop; if (str_contains($prop, ' as ')) { - [$prop, $propName] = GeneralUtility::trimExplode(' as ', $prop, true); - - if ($propName === '') { - $propName = $prop; - } + $parts = GeneralUtility::trimExplode(' as ', $prop, true); + $prop = $parts[0]; + $propName = $parts[1] ?? $prop; } if (in_array($prop, ['width', 'height'], true)) { @@ -246,9 +250,13 @@ private function onDemandProperties(ProcessingConfiguration $processingConfigura } } - return array_merge($processed, $props); + return $props; } + /** + * @param array $properties + * @return array + */ private function filterProperties(ProcessingConfiguration $processingConfiguration, array $properties): array { $allowedDefault = $processingConfiguration->defaultFieldsByType !== [] ? $processingConfiguration->defaultFieldsByType : [ @@ -297,7 +305,7 @@ private function filterProperties(ProcessingConfiguration $processingConfigurati public function processImageFile( FileInterface $fileReference, ProcessingConfiguration $processingConfiguration - ): ProcessedFile { + ): ?ProcessedFile { try { $cropVariantCollection = $this->createCropVariant((string)$fileReference->getProperty('crop')); $cropArea = $cropVariantCollection->getCropArea($processingConfiguration->cropVariant); @@ -318,9 +326,9 @@ public function processImageFile( return $this->imageService->applyProcessingInstructions($fileReference, $instructions); } catch (UnexpectedValueException|RuntimeException|InvalidArgumentException $e) { - $type = lcfirst(get_class($fileReference)); - $status = get_class($e); - $this->errors['processImageFile'][$type . '-' . $fileReference->getUid()] = $status; + $type = lcfirst($fileReference::class); + $this->errors['processImageFile'][$type . '-' . $fileReference->getUid()] = $e::class; + return null; } } @@ -329,7 +337,7 @@ public function getAbsoluteUrl(string $fileUrl): string $siteUrl = $this->getNormalizedParams()->getSiteUrl(); $sitePath = str_replace($this->getNormalizedParams()->getRequestHost(), '', $siteUrl); $absoluteUrl = trim($fileUrl); - if (stripos($absoluteUrl, 'http') !== 0) { + if (stripos($absoluteUrl, 'http') !== 0 && !str_starts_with($absoluteUrl, '//')) { $fileUrl = preg_replace('#^' . preg_quote($sitePath, '#') . '#', '', $fileUrl); $fileUrl = $siteUrl . $fileUrl; } @@ -377,9 +385,12 @@ protected function getNormalizedParams(): NormalizedParams return $this->contentObjectRenderer->getRequest()->getAttribute('normalizedParams'); } + /** @var array */ + private array $cropVariantCache = []; + protected function createCropVariant(string $cropString): CropVariantCollection { - return CropVariantCollection::create($cropString); + return $this->cropVariantCache[$cropString] ??= CropVariantCollection::create($cropString); } /** @@ -390,14 +401,18 @@ protected function translate(string $key, string $extensionName): ?string return LocalizationUtility::translate($key, $extensionName); } + /** + * @param array $processedFile + * @return array + */ private function processAutogenerate( FileInterface $originalReference, FileInterface $fileReference, array $processedFile, ProcessingConfiguration $processingConfiguration ): array { - $originalWidth = $originalReference->getProperty('width'); - $originalHeight = $originalReference->getProperty('height'); + $originalWidth = $this->getCroppedDimensionalProperty($originalReference, 'width', $processingConfiguration->cropVariant); + $originalHeight = $this->getCroppedDimensionalProperty($originalReference, 'height', $processingConfiguration->cropVariant); $targetWidth = (int)($processingConfiguration->width !== '' ? $processingConfiguration->width : $fileReference->getProperty('width')); $targetHeight = (int)($processingConfiguration->height !== '' ? $processingConfiguration->height : $fileReference->getProperty('height')); @@ -436,14 +451,14 @@ public function processCropVariants( */ $crop = $originalFileReference->getProperty('crop'); - if ($crop !== null) { + if ($crop !== null && $crop !== '') { if (!$processingConfiguration->legacyReturn) { unset($processedFile['crop'], $processedFile['properties']['crop']); } - $cropVariants = json_decode($originalFileReference->getProperty('crop'), true); + $cropVariants = json_decode($crop, true); - $collection = CropVariantCollection::create($originalFileReference->getProperty('crop')); + $collection = $this->createCropVariant($crop); if (is_array($cropVariants) && count($cropVariants) > 1 && str_starts_with( $originalFileReference->getMimeType(), @@ -454,10 +469,10 @@ public function processCropVariants( continue; } - $processingConfiguration = $processingConfiguration->withOptions(['cropVariant' => $cropVariantName]); - $file = $this->process($originalFileReference, $processingConfiguration); + $variantConfiguration = $processingConfiguration->withOptions(['cropVariant' => $cropVariantName]); + $file = $this->process($originalFileReference, $variantConfiguration); $processedFile['cropVariants'][$cropVariantName] = $this->cropVariant( - $processingConfiguration, + $variantConfiguration, $file, $cropVariants[$cropVariantName] ); @@ -474,6 +489,11 @@ public function processCropVariants( )->getProcessedFile(); } + /** + * @param array $file + * @param array $cropVariant + * @return array + */ private function cropVariant( ProcessingConfiguration $processingConfiguration, array $file, diff --git a/Classes/Utility/FileUtilityInterface.php b/Classes/Utility/FileUtilityInterface.php new file mode 100644 index 00000000..89cbd3db --- /dev/null +++ b/Classes/Utility/FileUtilityInterface.php @@ -0,0 +1,57 @@ + $arguments + * @return array + */ + public function processFile( + FileInterface $fileReference, + array $arguments = [], + string $cropVariant = 'default', + bool $delayProcessing = false + ): array; + + /** + * @return array + */ + public function process(FileInterface $fileReference, ProcessingConfiguration $processingConfiguration): array; + + public function processImageFile( + FileInterface $fileReference, + ProcessingConfiguration $processingConfiguration + ): ?ProcessedFile; + + /** + * @param array $processedFile + * @return array + */ + public function processCropVariants( + FileInterface $originalFileReference, + ProcessingConfiguration $processingConfiguration, + array $processedFile + ): array; + + public function getAbsoluteUrl(string $fileUrl): string; + + /** + * @return array> + */ + public function getErrors(): array; +} diff --git a/Classes/Utility/HeadlessMode.php b/Classes/Utility/HeadlessMode.php index 788acafc..bf6ef1bd 100644 --- a/Classes/Utility/HeadlessMode.php +++ b/Classes/Utility/HeadlessMode.php @@ -11,6 +11,7 @@ namespace FriendsOfTYPO3\Headless\Utility; +use LogicException; use Psr\Http\Message\ServerRequestInterface; use Symfony\Component\DependencyInjection\Attribute\AsAlias; use TYPO3\CMS\Core\Site\Entity\SiteInterface; @@ -27,27 +28,44 @@ final class HeadlessMode implements HeadlessModeInterface public function withRequest(ServerRequestInterface $request): self { - $this->request = $request; - return $this; + $clone = clone $this; + $clone->request = $request; + return $clone; } + public function isEnabled(): bool { - if ($this->request === null) { - return false; - } + return $this->request !== null && $this->checkRequest($this->request); + } + + public function isEnabledFor(ServerRequestInterface $request): bool + { + return $this->checkRequest($request); + } - $headless = $this->request->getAttribute('headless') ?? new Headless(); + private function checkRequest(ServerRequestInterface $request): bool + { + $mode = ($request->getAttribute('headless') ?? new Headless())->getMode(); - if ($headless->getMode() === self::NONE) { + if ($mode === self::NONE) { return false; } - - return $headless->getMode() === self::FULL || - ($headless->getMode() === self::MIXED && ($this->request->getHeader('Accept')[0] ?? '') === 'application/json'); + if ($mode === self::FULL) { + return true; + } + return $mode === self::MIXED + && ($request->getHeader('Accept')[0] ?? '') === 'application/json'; } public function overrideBackendRequestBySite(SiteInterface $site, ?SiteLanguage $language = null): ServerRequestInterface { + if ($this->request === null) { + throw new LogicException( + 'HeadlessMode::overrideBackendRequestBySite() requires a request; call withRequest() first.', + 1747200000 + ); + } + $mode = (int)($site->getConfiguration()['headless'] ?? self::NONE); if ($mode === self::MIXED) { diff --git a/Classes/Utility/HeadlessModeInterface.php b/Classes/Utility/HeadlessModeInterface.php index b9eb8716..59439ef0 100644 --- a/Classes/Utility/HeadlessModeInterface.php +++ b/Classes/Utility/HeadlessModeInterface.php @@ -23,5 +23,7 @@ interface HeadlessModeInterface public function withRequest(ServerRequestInterface $request): self; public function isEnabled(): bool; + + public function isEnabledFor(ServerRequestInterface $request): bool; public function overrideBackendRequestBySite(SiteInterface $site, ?SiteLanguage $language = null): ServerRequestInterface; } diff --git a/Classes/Utility/HeadlessUserInt.php b/Classes/Utility/HeadlessUserInt.php index 4a25b1d3..9fbfe019 100644 --- a/Classes/Utility/HeadlessUserInt.php +++ b/Classes/Utility/HeadlessUserInt.php @@ -71,13 +71,16 @@ public function unwrap(string $content): string protected function buildPattern(string $primary, string $nullable): string { - return self::$regexPatterns[$primary] ??= sprintf( + return self::$regexPatterns[$primary . '|' . $nullable] ??= sprintf( self::REGEX, preg_quote($nullable, '/'), preg_quote($primary, '/') ); } + /** + * @param array $m + */ protected function replace(array $m, bool $isNullable): string { $hasQuotes = $m['quote'] !== ''; @@ -98,7 +101,8 @@ protected function replace(array $m, bool $isNullable): string return $rawContent; } - return json_encode($rawContent); + $encoded = json_encode($rawContent); + return $encoded !== false ? $encoded : 'null'; } $jsonEncoded = json_encode($rawContent); diff --git a/Classes/Utility/UrlUtility.php b/Classes/Utility/UrlUtility.php index 9d90c6ff..d57d14ac 100644 --- a/Classes/Utility/UrlUtility.php +++ b/Classes/Utility/UrlUtility.php @@ -16,7 +16,6 @@ use Psr\Log\LoggerAwareInterface; use Psr\Log\LoggerAwareTrait; use Symfony\Component\ExpressionLanguage\SyntaxError; -use TYPO3\CMS\Core\Configuration\Features; use TYPO3\CMS\Core\Exception\SiteNotFoundException; use TYPO3\CMS\Core\ExpressionLanguage\Resolver; use TYPO3\CMS\Core\Http\Uri; @@ -29,23 +28,32 @@ use function array_key_exists; use function array_merge; use function array_unique; +use function in_array; use function ltrim; use function rtrim; use function str_contains; use function str_starts_with; use function strlen; +use function strtolower; use function substr; class UrlUtility implements LoggerAwareInterface, HeadlessFrontendUrlInterface { use LoggerAwareTrait; + /** @var array */ private array $conf = []; + /** @var array> */ private array $variants = []; + /** @var array */ private array $frontendDomains = []; + /** @var array */ + private array $backendDomains = []; + + /** @var array */ + private array $variantConditionCache = []; public function __construct( - private readonly Features $features, private readonly Resolver $resolver, private readonly SiteFinder $siteFinder, private HeadlessModeInterface $headlessMode, @@ -53,29 +61,43 @@ public function __construct( public function withSite(Site $site): HeadlessFrontendUrlInterface { - return $this->handleSiteConfiguration($site, clone $this); + $clone = clone $this; + $clone->applySite($site); + return $clone; } public function withRequest(ServerRequestInterface $request): HeadlessFrontendUrlInterface { - return $this->extractConfigurationFromRequest($request, clone $this); + $clone = clone $this; + $clone->applyRequest($request); + return $clone; } public function withLanguage(SiteLanguage $language): HeadlessFrontendUrlInterface { - return $this->handleLanguageConfiguration($language, clone $this); + $clone = clone $this; + $clone->applyLanguage($language); + return $clone; } + /** + * @param string|null $url + */ public function getFrontendUrlWithSite($url, SiteInterface $site, string $returnField = 'frontendBase'): string { $clone = clone $this; - $clone->handleSiteConfiguration($site, $clone); - $siteLanguage = $clone->overrideByLanguageIfNecessary($clone, $site, $url); + $clone->applySite($site); + $siteLanguage = $clone->collectLanguageDomainsAndMatch($site, $url); if ($siteLanguage !== null) { - $clone->handleLanguageConfiguration($siteLanguage, $clone); + $clone->applyLanguage($siteLanguage); } - if (!$clone->headlessMode->isEnabled() || $clone->alreadyFrontendLink($url)) { + $targetUri = new Uri($clone->sanitizeBaseUrl($url)); + + if (!$clone->headlessMode->isEnabled() || + $targetUri->getHost() === '' || + $clone->isExternalUrl($targetUri->getHost()) || + $clone->alreadyFrontendLink($targetUri->getHost())) { return $url; } @@ -91,11 +113,14 @@ public function getFrontendUrlWithSite($url, SiteInterface $site, string $return } $frontendBase = GeneralUtility::makeInstance(Uri::class, $clone->sanitizeBaseUrl($frontendBaseUrl)); - $frontBase = $frontendBase->getHost(); - $frontExtraPath = $frontendBase->getPath(); - $frontPort = $frontendBase->getPort(); - $targetUri = new Uri($clone->sanitizeBaseUrl($url)); - $targetUri = $targetUri->withHost($frontBase); + + $scheme = strtolower($frontendBase->getScheme()); + if ($scheme !== '' && !in_array($scheme, ['http', 'https'], true)) { + return $url; + } + + $targetUri = $targetUri->withHost($frontendBase->getHost()); + if ($targetUri->getScheme() === '') { $targetUri = $targetUri->withScheme($frontendBase->getScheme()); } @@ -105,17 +130,14 @@ public function getFrontendUrlWithSite($url, SiteInterface $site, string $return $targetUri = $targetUri->withScheme(''); } + $frontExtraPath = $frontendBase->getPath(); if ($frontExtraPath) { - $targetUri = $targetUri->withPath($clone->handleFrontendAndBackendPaths($frontExtraPath, $targetUri, $site->getBase()->getPath())); + $targetUri = $targetUri->withPath( + $clone->handleFrontendAndBackendPaths($frontExtraPath, $targetUri, $site->getBase()->getPath()) + ); } - if ($site->getBase()->getPort() === $frontPort) { - return (string)$targetUri; - } - - if ($frontPort) { - $targetUri = $targetUri->withPort($frontPort); - } + $targetUri = $targetUri->withPort($frontendBase->getPort()); return (string)$targetUri; } catch (SiteNotFoundException $e) { @@ -208,6 +230,9 @@ private function sanitizeBaseUrl(string $base): string return $base; } + /** + * @param array> $variants + */ private function resolveWithVariants( string $frontendUrl, array $variants = [], @@ -219,8 +244,15 @@ private function resolveWithVariants( } foreach ($variants as $baseVariant) { + $condition = (string)($baseVariant['condition'] ?? ''); + if ($condition === '') { + continue; + } try { - if ($this->resolver->evaluate($baseVariant['condition'])) { + if (!array_key_exists($condition, $this->variantConditionCache)) { + $this->variantConditionCache[$condition] = (bool)$this->resolver->evaluate($condition); + } + if ($this->variantConditionCache[$condition]) { return rtrim($baseVariant[$returnField] ?? '', '/'); } } catch (SyntaxError $e) { @@ -232,7 +264,28 @@ private function resolveWithVariants( return $frontendUrl; } - private function handleLanguageConfiguration(SiteLanguage $language, HeadlessFrontendUrlInterface $object): HeadlessFrontendUrlInterface + private function applySite(Site $site): void + { + $this->conf = $site->getConfiguration(); + $this->variants = $this->conf['baseVariants'] ?? []; + $this->variantConditionCache = []; + $this->frontendDomains = []; + $this->backendDomains = [$site->getBase()->getHost()]; + + foreach ($this->variants as $variant) { + $variantBase = trim($variant['base'] ?? ''); + if ($variantBase !== '') { + $this->backendDomains[] = $this->hostFromBase($variantBase); + } + } + + $base = trim($this->conf['frontendBase'] ?? ''); + if ($base !== '') { + $this->frontendDomains[] = $this->hostFromBase($base); + } + } + + private function applyLanguage(SiteLanguage $language): void { $langConf = $language->toArray(); $variants = $langConf['baseVariants'] ?? []; @@ -241,83 +294,68 @@ private function handleLanguageConfiguration(SiteLanguage $language, HeadlessFro $frontendFileApi = trim($langConf['frontendFileApi'] ?? ''); $overrides = []; - if ($frontendBase !== '') { - $overrides['frontendBase'] = $frontendBase; - $object->frontendDomains[] = (new Uri($this->sanitizeBaseUrl($frontendBase)))->getHost(); + if ($language->getBase()->getHost() !== '') { + $this->backendDomains[] = $language->getBase()->getHost(); } + if ($frontendBase !== '') { + $overrides['frontendBase'] = $frontendBase; + $this->frontendDomains[] = $this->hostFromBase($frontendBase); + } if ($frontendApiProxy !== '') { - $overrides['frontendApiProxy'] = $frontendApiProxy; + $overrides['frontendApiProxy'] = $frontendApiProxy; } - if ($frontendFileApi !== '') { - $overrides['frontendFileApi'] = $frontendFileApi; + $overrides['frontendFileApi'] = $frontendFileApi; } - $object->conf = array_merge($object->conf, $overrides); + $this->conf = array_merge($this->conf, $overrides); if ($variants !== []) { - $object->variants = $variants; - } - - return $object; - } - - private function handleSiteConfiguration(Site $site, UrlUtility $object): self - { - $object->conf = $site->getConfiguration(); - $object->variants = $object->conf['baseVariants'] ?? []; - $object->frontendDomains = []; - - $base = trim($object->conf['frontendBase'] ?? ''); - if ($base !== '') { - $object->frontendDomains[] = (new Uri($this->sanitizeBaseUrl($base)))->getHost(); + $this->variants = $variants; + $this->variantConditionCache = []; } - - return $object; } - private function extractConfigurationFromRequest(ServerRequestInterface $request, HeadlessFrontendUrlInterface $object): HeadlessFrontendUrlInterface + private function applyRequest(ServerRequestInterface $request): void { $site = $request->getAttribute('site'); - if ($site instanceof Site) { - $object->handleSiteConfiguration($site, $object); + $this->applySite($site); } $language = $request->getAttribute('language'); if ($language instanceof SiteLanguage) { - $object->handleLanguageConfiguration($language, $object); + $this->applyLanguage($language); } - $object->headlessMode = $object->headlessMode->withRequest($request); - - return $object; + $this->headlessMode = $this->headlessMode->withRequest($request); } private function handleFrontendAndBackendPaths(string $frontendPath, UriInterface $targetUri, string $baseBackendPath = ''): string { - return rtrim($frontendPath, '/') . ($targetUri->getPath() !== '' ? '/' . ltrim(substr($targetUri->getPath(), strlen($baseBackendPath)), '/') : ''); + $frontendPath = rtrim($frontendPath, '/'); + $targetPath = $targetUri->getPath(); + if ($targetPath === '') { + return $frontendPath; + } + return $frontendPath . '/' . ltrim(substr($targetPath, strlen($baseBackendPath)), '/'); } - private function overrideByLanguageIfNecessary(UrlUtility $object, SiteInterface $site, string $backendUrl): ?SiteLanguage + private function collectLanguageDomainsAndMatch(SiteInterface $site, string $backendUrl): ?SiteLanguage { $backendUri = GeneralUtility::makeInstance(Uri::class, $this->sanitizeBaseUrl($backendUrl)); $matchedLanguage = null; foreach ($site->getLanguages() as $language) { - $conf = $language->toArray(); - - if (!array_key_exists('frontendBase', $conf)) { - continue; - } - - $base = trim($conf['frontendBase'] ?? ''); - + $base = trim($language->toArray()['frontendBase'] ?? ''); if ($base === '') { continue; } - $object->frontendDomains[] = (new Uri($this->sanitizeBaseUrl($base)))->getHost(); + if ($language->getBase()->getHost() !== '') { + $this->backendDomains[] = $language->getBase()->getHost(); + } + $this->frontendDomains[] = $this->hostFromBase($base); if ($language->getBase()->getHost() === $backendUri->getHost()) { $matchedLanguage = $language; @@ -326,15 +364,25 @@ private function overrideByLanguageIfNecessary(UrlUtility $object, SiteInterface } } - $object->frontendDomains = array_unique($object->frontendDomains); + $this->backendDomains = array_unique($this->backendDomains); + $this->frontendDomains = array_unique($this->frontendDomains); return $matchedLanguage; } + private function hostFromBase(string $base): string + { + return (new Uri($this->sanitizeBaseUrl($base)))->getHost(); + } + protected function alreadyFrontendLink(string $url): bool { - $targetUri = new Uri($this->sanitizeBaseUrl($url)); + return in_array($url, $this->frontendDomains, true); + } - return in_array($targetUri->getHost(), $this->frontendDomains, true); + protected function isExternalUrl(string $url): bool + { + return !in_array($url, $this->backendDomains, true) + && !in_array($url, $this->frontendDomains, true); } } diff --git a/Classes/View/HeadlessPhpView.php b/Classes/View/HeadlessPhpView.php new file mode 100644 index 00000000..1401d91d --- /dev/null +++ b/Classes/View/HeadlessPhpView.php @@ -0,0 +1,165 @@ + */ + private array $variables = []; + + /** @var list|null */ + private ?array $resolvedRoots = null; + + public function __construct(private readonly ViewFactoryData $data) {} + + public function assign(string $key, mixed $value): self + { + $this->variables[$key] = $value; + return $this; + } + + /** + * @param array $values + */ + public function assignMultiple(array $values): self + { + $this->variables = array_replace($this->variables, $values); + return $this; + } + + public function render(string $templateFileName = ''): string + { + $templateFile = $this->resolvePhpTemplate($templateFileName); + if ($templateFile === null) { + throw new RuntimeException( + 'Headless PHP template "' . $templateFileName . '" could not be resolved.', + 1747300000 + ); + } + + try { + extract($this->variables, EXTR_SKIP); + ob_start(); + include $templateFile; + return (string)ob_get_clean(); + } catch (Throwable $e) { + if (ob_get_level() > 0) { + ob_end_clean(); + } + throw $e; + } + } + + private function resolvePhpTemplate(string $name): ?string + { + if ($name === '') { + return $this->resolveDirectFile(); + } + + if (!$this->isSafeTemplateName($name)) { + return null; + } + + $resolvedRoots = $this->resolvedTemplateRoots(); + if ($resolvedRoots === []) { + return null; + } + + $relative = ltrim($name, '/') . '.php'; + + foreach (array_reverse($resolvedRoots) as $root) { + $candidate = $root . '/' . $relative; + if (!is_file($candidate)) { + continue; + } + $real = realpath($candidate); + if ($real === false) { + continue; + } + if ($real === $root || str_starts_with($real, $root . '/')) { + return $real; + } + } + + return null; + } + + private function resolveDirectFile(): ?string + { + $direct = $this->data->templatePathAndFilename; + if ($direct === null || $direct === '') { + return null; + } + // getFileAbsFileName resolves EXT:, asserts allowed roots and runs validPathStr. + $absolute = GeneralUtility::getFileAbsFileName($direct); + if ($absolute === '' || !is_file($absolute)) { + return null; + } + $real = realpath($absolute); + return $real === false ? null : $real; + } + + private function isSafeTemplateName(string $name): bool + { + if ($name[0] === '/' + || preg_match('#^[a-zA-Z][a-zA-Z0-9+.\-]*:#', $name) === 1) { + return false; + } + return GeneralUtility::validPathStr($name); + } + + /** + * @return list canonical absolute roots without trailing slash + */ + private function resolvedTemplateRoots(): array + { + if ($this->resolvedRoots !== null) { + return $this->resolvedRoots; + } + $roots = []; + foreach ($this->data->templateRootPaths ?? [] as $root) { + if (!is_string($root) || $root === '') { + continue; + } + $absolute = GeneralUtility::getFileAbsFileName($root); + if ($absolute === '') { + continue; + } + $real = realpath($absolute); + if ($real === false) { + continue; + } + $roots[] = rtrim($real, '/'); + } + return $this->resolvedRoots = $roots; + } +} diff --git a/Classes/View/HeadlessViewFactory.php b/Classes/View/HeadlessViewFactory.php new file mode 100644 index 00000000..4b9d58f4 --- /dev/null +++ b/Classes/View/HeadlessViewFactory.php @@ -0,0 +1,48 @@ +enabled = $features->isFeatureEnabled('headless.overrideFluidTemplates'); + } + + public function create(ViewFactoryData $data): ViewInterface + { + if ($data->format !== 'php' || !$this->enabled) { + return $this->inner->create($data); + } + + $request = $data->request; + if ($request === null + || !ApplicationType::fromRequest($request)->isFrontend() + || !$this->headlessMode->isEnabledFor($request)) { + return $this->inner->create($data); + } + + return new HeadlessPhpView($data); + } +} diff --git a/Classes/ViewHelpers/Format/Json/DecodeViewHelper.php b/Classes/ViewHelpers/Format/Json/DecodeViewHelper.php index e9da0c89..5b7bf67e 100644 --- a/Classes/ViewHelpers/Format/Json/DecodeViewHelper.php +++ b/Classes/ViewHelpers/Format/Json/DecodeViewHelper.php @@ -7,50 +7,49 @@ * LICENSE.md file that was distributed with this source code. */ +declare(strict_types=1); + namespace FriendsOfTYPO3\Headless\ViewHelpers\Format\Json; -use Exception; +use JsonException; use TYPO3Fluid\Fluid\Core\ViewHelper\AbstractViewHelper; +use function json_decode; +use function trim; + +use const JSON_THROW_ON_ERROR; + /** * Converts the JSON encoded argument into a PHP variable * @codeCoverageIgnore */ class DecodeViewHelper extends AbstractViewHelper { - /** - * Initialize - */ public function initializeArguments(): void { $this->registerArgument('json', 'string', 'json to decode', false); } - /** - * @return mixed - */ - public function render() + public function render(): mixed { $json = $this->arguments['json']; if ($json === null) { $json = $this->renderChildren(); if ($json !== null) { - $json = trim($json); + $json = trim((string)$json); } if (empty($json)) { return null; } } - $object = json_decode($json, true); - if (json_last_error() === JSON_ERROR_NONE) { - return $object; - } - if ($GLOBALS['TYPO3_CONF_VARS']['FE']['debug'] ?? false) { - throw new Exception(sprintf( - 'Failure "%s" occured when running json_decode() for string: %s', - json_last_error_msg(), - $json - )); + + try { + return json_decode((string)$json, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + if ($GLOBALS['TYPO3_CONF_VARS']['FE']['debug'] ?? false) { + throw $e; + } + return null; } } } diff --git a/Classes/ViewHelpers/Iterator/ExplodeViewHelper.php b/Classes/ViewHelpers/Iterator/ExplodeViewHelper.php index 752d7aa8..791c019d 100644 --- a/Classes/ViewHelpers/Iterator/ExplodeViewHelper.php +++ b/Classes/ViewHelpers/Iterator/ExplodeViewHelper.php @@ -11,6 +11,12 @@ use TYPO3Fluid\Fluid\Core\ViewHelper\AbstractViewHelper; +use function constant; +use function explode; +use function str_contains; +use function strlen; +use function trim; + /** * Explode ViewHelper * Explodes a string by $glue @@ -18,14 +24,6 @@ */ class ExplodeViewHelper extends AbstractViewHelper { - /** - * @var string - */ - protected $method = 'explode'; - - /** - * Initialize - */ public function initializeArguments(): void { $this->registerArgument('content', 'string', 'String to be exploded by glue)', false, ''); @@ -33,12 +31,7 @@ public function initializeArguments(): void $this->registerArgument('as', 'string', 'Template variable name to assign. If not specified returns the result array instead'); } - /** - * Render method - * - * @return mixed - */ - public function render() + public function render(): mixed { $content = $this->arguments['content']; $as = $this->arguments['as']; @@ -48,7 +41,7 @@ public function render() $content = $this->renderChildren(); $contentWasSource = true; } - $output = call_user_func_array($this->method, [$glue, $content]); + $output = explode($glue, (string)$content); if (empty($as) === true || $contentWasSource === true) { return $output; } diff --git a/Classes/ViewHelpers/LoginFormViewHelper.php b/Classes/ViewHelpers/LoginFormViewHelper.php index 25941d7b..ee49e5fd 100644 --- a/Classes/ViewHelpers/LoginFormViewHelper.php +++ b/Classes/ViewHelpers/LoginFormViewHelper.php @@ -12,14 +12,16 @@ namespace FriendsOfTYPO3\Headless\ViewHelpers; use LogicException; -use Psr\Http\Message\RequestInterface; use RuntimeException; use TYPO3\CMS\Core\Context\Context; use TYPO3\CMS\Core\Context\SecurityAspect; +use TYPO3\CMS\Core\Crypto\HashAlgo; use TYPO3\CMS\Core\Security\RequestToken; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Extbase\DomainObject\AbstractDomainObject; +use TYPO3\CMS\Extbase\Mvc\RequestInterface; use TYPO3\CMS\Extbase\Persistence\Generic\LazyLoadingProxy; +use TYPO3\CMS\Extbase\Security\HashScope; use TYPO3\CMS\Fluid\ViewHelpers\FormViewHelper; use function base64_encode; @@ -27,10 +29,13 @@ use function is_object; use function is_string; use function json_encode; + use function serialize; use function sprintf; use function strtolower; +use const JSON_THROW_ON_ERROR; + /** * Form ViewHelper. Generates a :html:`
` Tag. * @@ -64,8 +69,6 @@ * * This automatically inserts the value of ``{customer.name}`` inside the * textbox and adjusts the name of the textbox accordingly. - * - * @codeCoverageIgnore */ class LoginFormViewHelper extends FormViewHelper { @@ -125,7 +128,7 @@ public function render(): string $this->removeFormFieldNamesFromViewHelperVariableContainer(); $this->removeCheckboxFieldNamesFromViewHelperVariableContainer(); - return json_encode($this->data); + return json_encode($this->data, JSON_THROW_ON_ERROR); } /** @@ -168,29 +171,31 @@ protected function renderHiddenReferrerFields(): string ]; $this->addHiddenField( - '__referrer[@extension]', + $this->prefixFieldName('__referrer[@extension]'), $extensionName ); $this->addHiddenField( - '__referrer[@controller]', + $this->prefixFieldName('__referrer[@controller]'), $controllerName ); $this->addHiddenField( - '__referrer[@action]', + $this->prefixFieldName('__referrer[@action]'), $actionName ); $this->addHiddenField( - '__referrer[arguments]', + $this->prefixFieldName('__referrer[arguments]'), $this->hashService->appendHmac( base64_encode(serialize($request->getArguments())), - class_exists(\TYPO3\CMS\Extbase\Security\HashScope::class) ? \TYPO3\CMS\Extbase\Security\HashScope::class::ReferringArguments->prefix() : '' + HashScope::ReferringArguments->prefix(), + HashAlgo::SHA3_256 ) ); $this->addHiddenField( - '__referrer[@request]', + $this->prefixFieldName('__referrer[@request]'), $this->hashService->appendHmac( - json_encode($actionRequest), - class_exists(\TYPO3\CMS\Extbase\Security\HashScope::class) ? \TYPO3\CMS\Extbase\Security\HashScope::class::ReferringRequest->prefix() : '' + json_encode($actionRequest, JSON_THROW_ON_ERROR), + HashScope::ReferringRequest->prefix(), + HashAlgo::SHA3_256 ) ); @@ -235,6 +240,8 @@ protected function renderHiddenIdentityField(mixed $object, ?string $name): stri $this->registerFieldNameForFormTokenGeneration($name); $this->addHiddenField($name, $identifier); + + return ''; } /** diff --git a/Classes/XClass/Controller/FormFrontendController.php b/Classes/XClass/Controller/FormFrontendController.php index 4289f30d..ac0da857 100644 --- a/Classes/XClass/Controller/FormFrontendController.php +++ b/Classes/XClass/Controller/FormFrontendController.php @@ -28,10 +28,11 @@ use TYPO3\CMS\Form\Domain\Factory\ArrayFormFactory; use TYPO3\CMS\Form\Domain\Model\FormDefinition; +use TYPO3\CMS\Form\Security\HashScope; + use function array_merge; use function array_pop; use function base64_encode; -use function class_exists; use function count; use function in_array; use function is_array; @@ -39,6 +40,8 @@ use function serialize; use function str_replace; +use const JSON_THROW_ON_ERROR; + /** * Overridden form implementation with headless flavor * @@ -66,7 +69,7 @@ private function getHeadlessMode(): HeadlessModeInterface */ public function renderAction(): ResponseInterface { - if (!$this->getHeadlessMode()->withRequest($this->request)->isEnabled()) { + if (!$this->getHeadlessMode()->isEnabledFor($this->request)) { return parent::renderAction(); } @@ -138,7 +141,7 @@ public function renderAction(): ResponseInterface $stateHash = $this->getHashService()->appendHmac( base64_encode(serialize($formState)), - class_exists(\TYPO3\CMS\Form\Security\HashScope::class) ? \TYPO3\CMS\Form\Security\HashScope::FormState->prefix() : '', + HashScope::FormState->prefix(), HashAlgo::SHA3_256 ); @@ -212,7 +215,7 @@ class_exists(\TYPO3\CMS\Form\Security\HashScope::class) ? \TYPO3\CMS\Form\Securi $formStatus['status'] = null; $formStatus['errors'] = null; - $formStatus['actionAfterSuccess'] = $finisherResponse ? json_decode($finisherResponse) : null; + $formStatus['actionAfterSuccess'] = $finisherResponse ? json_decode((string)$finisherResponse, false, 512, JSON_THROW_ON_ERROR) : null; $formStatus['page'] = [ 'current' => $currentPageIndex, 'nextPage' => $this->getNextPage($formRuntime), @@ -233,14 +236,10 @@ class_exists(\TYPO3\CMS\Form\Security\HashScope::class) ? \TYPO3\CMS\Form\Securi $formStatus['errors'] = $this->prepareErrors($errors, $formDefinition['identifier']); } - /** - * @var DefinitionDecoratorInterface $definitionDecorator - */ - $definitionDecorator = GeneralUtility::makeInstance($decoratorClass, $formStatus); - - if (!($definitionDecorator instanceof DefinitionDecoratorInterface)) { - $definitionDecorator = GeneralUtility::makeInstance(FormDefinitionDecorator::class, $formStatus); + if (!is_string($decoratorClass) || !is_a($decoratorClass, DefinitionDecoratorInterface::class, true)) { + $decoratorClass = FormDefinitionDecorator::class; } + $definitionDecorator = GeneralUtility::makeInstance($decoratorClass, $formStatus); $this->view->assign('formConfiguration', $definitionDecorator($formDefinition, $currentPageIndex)); @@ -293,15 +292,15 @@ private function generateFieldNamesAndReplaceCustomOptions( ); } else { if (!empty($field['properties']['customOptions'])) { - $customOptions = GeneralUtility::makeInstance( - $field['properties']['customOptions'], - $field, - $formFields, - $identifier, - $formRuntime - ); - - if ($customOptions instanceof CustomOptionsInterface) { + $customOptionsClass = $field['properties']['customOptions']; + if (is_string($customOptionsClass) && is_a($customOptionsClass, CustomOptionsInterface::class, true)) { + $customOptions = GeneralUtility::makeInstance( + $customOptionsClass, + $field, + $formFields, + $identifier, + $formRuntime + ); $field['properties']['options'] = $customOptions->get(); } diff --git a/Classes/XClass/Controller/LoginController.php b/Classes/XClass/Controller/LoginController.php index c53c3a4c..2a367e99 100644 --- a/Classes/XClass/Controller/LoginController.php +++ b/Classes/XClass/Controller/LoginController.php @@ -23,9 +23,6 @@ use function implode; use function json_encode; -/** - * @codeCoverageIgnore - */ class LoginController extends \TYPO3\CMS\FrontendLogin\Controller\LoginController { private ?HeadlessModeInterface $headlessMode = null; @@ -100,21 +97,19 @@ protected function handleRedirect(string $status = 'success'): ?ResponseInterfac $this->request )); - $data = [ + if ($event->getRedirectUrl() === '') { + return null; + } + + return $this->jsonResponse(json_encode([ 'redirectUrl' => $event->getRedirectUrl(), 'statusCode' => 303, 'status' => $status, - ]; - - return $this->responseFactory->createResponse()->withHeader( - 'Content-Type', - 'application/json; charset=utf-8' - ) - ->withBody($this->streamFactory->createStream(json_encode($data))); + ], JSON_THROW_ON_ERROR)); } private function isHeadlessEnabled(): bool { - return $this->getHeadlessMode()->withRequest($this->request)->isEnabled(); + return $this->getHeadlessMode()->isEnabledFor($this->request); } } diff --git a/Classes/XClass/ImageService.php b/Classes/XClass/ImageService.php deleted file mode 100644 index 30180f68..00000000 --- a/Classes/XClass/ImageService.php +++ /dev/null @@ -1,60 +0,0 @@ -headlessMode = GeneralUtility::makeInstance(HeadlessModeInterface::class); - $this->urlUtility = GeneralUtility::makeInstance(HeadlessFrontendUrlInterface::class); - } - - /** - * @inheritDoc - */ - protected function getImageFromSourceString(string $src, bool $treatIdAsReference): ?FileInterface - { - $headlessMode = $this->headlessMode->withRequest($GLOBALS['TYPO3_REQUEST']); - - if (($GLOBALS['TYPO3_REQUEST'] ?? null) instanceof ServerRequestInterface - && ApplicationType::fromRequest($GLOBALS['TYPO3_REQUEST'])->isFrontend() - && $headlessMode->isEnabled() - ) { - $urlUtility = $this->urlUtility->withRequest($GLOBALS['TYPO3_REQUEST']); - $baseUriForProxy = $urlUtility->getProxyUrl(); - - if ($baseUriForProxy) { - $src = str_replace($baseUriForProxy . '/', '', $src); - } - } - - return parent::getImageFromSourceString($src, $treatIdAsReference); - } -} diff --git a/Classes/XClass/Preview/PreviewUriBuilder.php b/Classes/XClass/Preview/PreviewUriBuilder.php deleted file mode 100644 index 65d48b43..00000000 --- a/Classes/XClass/Preview/PreviewUriBuilder.php +++ /dev/null @@ -1,67 +0,0 @@ -compilePreviewKeyword(); - try { - $site = $this->getSiteFinder()->getSiteByPageId($uid); - try { - $language = $site->getLanguageById($languageId); - } catch (InvalidArgumentException) { - $language = $site->getDefaultLanguage(); - } - - $uri = $site->getRouter()->generateUri($uid, ['ADMCMD_prev' => $previewKeyword, '_language' => $language], ''); - - $headlessMode = $this->getHeadlessMode()->withRequest($GLOBALS['TYPO3_REQUEST']); - $request = $headlessMode->overrideBackendRequestBySite($site, $language); - - return $this->getUrlUtility() - ->withRequest($request) - ->getFrontendUrlForPage((string)$uri, $uid); - - } catch (SiteNotFoundException | InvalidRouteArgumentsException $e) { - throw new UnableToLinkToPageException(sprintf('The link to the page with ID "%d" could not be generated: %s', $uid, $e->getMessage()), 1559794916, $e); - } - } -} diff --git a/Classes/XClass/ResourceLocalDriver.php b/Classes/XClass/ResourceLocalDriver.php deleted file mode 100644 index d733f244..00000000 --- a/Classes/XClass/ResourceLocalDriver.php +++ /dev/null @@ -1,81 +0,0 @@ -headlessMode ??= GeneralUtility::makeInstance(HeadlessModeInterface::class); - } - - private function getUrlUtility(): HeadlessFrontendUrlInterface - { - return $this->urlUtility ??= GeneralUtility::makeInstance(HeadlessFrontendUrlInterface::class); - } - - protected function determineBaseUrl(): void - { - $request = $GLOBALS['TYPO3_REQUEST'] ?? null; - - if (!$request instanceof ServerRequestInterface) { - parent::determineBaseUrl(); - return; - } - - $headlessMode = $this->getHeadlessMode()->withRequest($request); - - if (!$headlessMode->isEnabled() || ApplicationType::fromRequest($request)->isBackend()) { - parent::determineBaseUrl(); - - return; - } - - if ($this->hasCapability(Capabilities::CAPABILITY_PUBLIC)) { - $urlUtility = $this->getUrlUtility()->withRequest($request); - - $basePath = match (true) { - (($this->configuration['baseUri'] ?? '') !== '') => $this->configuration['baseUri'], - (($this->configuration['basePath'] ?? '') !== '' && $this->configuration['pathType'] === 'relative') => $this->configuration['basePath'], - default => '', - }; - - if ($basePath !== '') { - $frontendUri = new Uri($urlUtility->getFrontendUrl()); - $proxyUri = new Uri($urlUtility->getProxyUrl()); - $baseUri = new Uri($basePath); - - $path = trim($proxyUri->getPath(), '/') . '/' . trim($baseUri->getPath(), '/'); - $this->configuration['baseUri'] = (string)$frontendUri->withPath('/' . trim($path, '/')); - } else { - $this->configuration['baseUri'] = $urlUtility->getStorageProxyUrl(); - } - } - - parent::determineBaseUrl(); - } -} diff --git a/Classes/XClass/TemplateView.php b/Classes/XClass/TemplateView.php deleted file mode 100644 index ef307e78..00000000 --- a/Classes/XClass/TemplateView.php +++ /dev/null @@ -1,82 +0,0 @@ -headlessMode ??= GeneralUtility::makeInstance(HeadlessModeInterface::class); - } - - public function render($actionName = null) - { - $headlessMode = $this->getHeadlessMode()->withRequest($GLOBALS['TYPO3_REQUEST']); - - if (!ApplicationType::fromRequest($GLOBALS['TYPO3_REQUEST'])->isFrontend() || !$headlessMode->isEnabled()) { - return parent::render($actionName); - } - - $renderingContext = $this->getCurrentRenderingContext(); - - if ((int)($renderingContext->getVariableProvider()->get('settings')['phpTemplate'] ?? 0) !== 1) { - return parent::render($actionName); - } - - $templatePaths = $renderingContext->getTemplatePaths(); - if ($actionName) { - $actionName = ucfirst($actionName); - $renderingContext->setControllerAction($actionName); - } - - $templateFile = $templatePaths->resolveTemplateFileForControllerAndActionAndFormat($renderingContext->getControllerName(), $renderingContext->getControllerAction(), 'php'); - - if ($templateFile === null) { - throw new InvalidTemplateResourceException('Template is not found', 1740000000); - } - - return $this->loadTemplate($templateFile, $renderingContext); - } - - private function loadTemplate(string $templateFile, RenderingContextInterface $renderingContext): string - { - $__jsonContent = ''; - - try { - extract($renderingContext->getVariableProvider()->getAll()); - - ob_start(); - include $templateFile; - $__jsonContent = ob_get_clean(); - } catch (Throwable $e) { - ob_end_clean(); - throw $e; - } - - return $__jsonContent; - } -} diff --git a/Configuration/RequestMiddlewares.php b/Configuration/RequestMiddlewares.php index ed92a175..1afe03c0 100644 --- a/Configuration/RequestMiddlewares.php +++ b/Configuration/RequestMiddlewares.php @@ -9,7 +9,6 @@ use FriendsOfTYPO3\Headless\Middleware\ElementBodyResponseMiddleware; use FriendsOfTYPO3\Headless\Middleware\HeadlessModeSetter; -use FriendsOfTYPO3\Headless\Middleware\RedirectHandler; use FriendsOfTYPO3\Headless\Middleware\ShortcutAndMountPointRedirect; use FriendsOfTYPO3\Headless\Middleware\SiteBaseRedirectResolver; use FriendsOfTYPO3\Headless\Middleware\UserIntMiddleware; @@ -31,7 +30,7 @@ 'headless/mode-setter' => [ 'before' => [ 'typo3/cms-frontend/base-redirect-resolver', - 'headless/cms-redirects/redirecthandler', + 'typo3/cms-redirects/redirecthandler', ], 'target' => HeadlessModeSetter::class, ], @@ -64,11 +63,11 @@ ]; } - if (!$features->isFeatureEnabled('headless.redirectMiddlewares')) { + if (!ExtensionManagementUtility::isLoaded('redirects')) { return $middlewares; } - $middlewares = array_merge_recursive($middlewares, [ + return array_merge_recursive($middlewares, [ 'frontend' => [ 'typo3/cms-frontend/shortcut-and-mountpoint-redirect' => [ 'disabled' => true, @@ -87,25 +86,4 @@ ], ], ]); - - if (!ExtensionManagementUtility::isLoaded('redirects')) { - return $middlewares; - } - - return array_merge_recursive($middlewares, [ - 'frontend' => [ - 'typo3/cms-redirects/redirecthandler' => [ - 'disabled' => true, - ], - 'headless/cms-redirects/redirecthandler' => [ - 'target' => RedirectHandler::class, - 'before' => [ - 'typo3/cms-frontend/base-redirect-resolver', - ], - 'after' => [ - 'typo3/cms-frontend/authentication', - ], - ], - ], - ]); })(); diff --git a/Configuration/Services.php b/Configuration/Services.php index b4bedf09..9ab79ed0 100644 --- a/Configuration/Services.php +++ b/Configuration/Services.php @@ -15,6 +15,7 @@ use FriendsOfTYPO3\Headless\ContentObject\JsonContentContentObject; use FriendsOfTYPO3\Headless\ContentObject\JsonContentObject; use FriendsOfTYPO3\Headless\DataProcessing\DatabaseQueryProcessor; +use FriendsOfTYPO3\Headless\DataProcessing\ExtractPropertyProcessor; use FriendsOfTYPO3\Headless\DataProcessing\FilesProcessor; use FriendsOfTYPO3\Headless\DataProcessing\FlexFormProcessor; use FriendsOfTYPO3\Headless\DataProcessing\GalleryProcessor; @@ -25,21 +26,33 @@ use FriendsOfTYPO3\Headless\DataProcessing\RootSitesProcessor; use FriendsOfTYPO3\Headless\Event\Listener\AfterCacheableContentIsGeneratedListener; use FriendsOfTYPO3\Headless\Event\Listener\AfterLinkIsGeneratedListener; -use FriendsOfTYPO3\Headless\Event\Listener\AfterPagePreviewUriGeneratedListener; +use FriendsOfTYPO3\Headless\Event\Listener\AfterPageUriGeneratedListener; use FriendsOfTYPO3\Headless\Event\Listener\HeadlessHreflangGeneratorListener; +use FriendsOfTYPO3\Headless\Event\Listener\HeadlessRedirectResponseListener; use FriendsOfTYPO3\Headless\Event\Listener\LoginConfirmedEventListener; +use FriendsOfTYPO3\Headless\Event\Listener\ProxyResourcePublicUrlListener; use FriendsOfTYPO3\Headless\Form\Translator; use FriendsOfTYPO3\Headless\Frontend\BackendEditorUrl; +use FriendsOfTYPO3\Headless\Json\JsonDecoder; +use FriendsOfTYPO3\Headless\Json\JsonDecoderInterface; +use FriendsOfTYPO3\Headless\Json\JsonEncoder; +use FriendsOfTYPO3\Headless\Json\JsonEncoderInterface; +use FriendsOfTYPO3\Headless\Resource\Service\HeadlessImageService; use FriendsOfTYPO3\Headless\Utility\FileUtility; +use FriendsOfTYPO3\Headless\Utility\FileUtilityInterface; use FriendsOfTYPO3\Headless\Utility\HeadlessFrontendUrlInterface; use FriendsOfTYPO3\Headless\Utility\UrlUtility; -use FriendsOfTYPO3\Headless\XClass\TemplateView; +use FriendsOfTYPO3\Headless\View\HeadlessViewFactory; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use TYPO3\CMS\Core\Configuration\Features; use TYPO3\CMS\Core\ExpressionLanguage\Resolver; use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\View\ViewFactoryInterface; +use TYPO3\CMS\Extbase\Service\ImageService; +use TYPO3\CMS\Fluid\View\FluidViewFactory; use TYPO3\CMS\Form\Controller\FormFrontendController; use TYPO3\CMS\FrontendLogin\Controller\LoginController; +use TYPO3\CMS\Redirects\Service\RedirectService; use function Symfony\Component\DependencyInjection\Loader\Configurator\service; @@ -71,6 +84,8 @@ ]); } + $redirectsInstalled = class_exists(RedirectService::class, false); + $toLoad->exclude($excludes); $toLoad->set(JsonContentObject::class)->tag('frontend.contentobject', ['identifier' => 'JSON']); @@ -83,15 +98,15 @@ $services->set(DomainSchema::class)->public(); $services->set(BackendEditorUrl::class)->public(); $services->set(FileUtility::class)->public(); + $services->alias(FileUtilityInterface::class, FileUtility::class)->public(); + $services->set(JsonEncoderInterface::class, JsonEncoder::class)->public(); + $services->set(JsonDecoderInterface::class, JsonDecoder::class); $services->set('headless.expression_language.resolver.site', Resolver::class) ->args(['site', []]); $services->set(UrlUtility::class) - ->share(false) - ->arg('$resolver', service('headless.expression_language.resolver.site')); - $services->set(HeadlessFrontendUrlInterface::class, UrlUtility::class) - ->share(false) ->arg('$resolver', service('headless.expression_language.resolver.site')); + $services->alias(HeadlessFrontendUrlInterface::class, UrlUtility::class)->public(); $services->set(AfterLinkIsGeneratedListener::class)->tag( 'event.listener', ['identifier' => 'headless/AfterLinkIsGenerated'] @@ -108,9 +123,16 @@ ); } - $services->set(AfterPagePreviewUriGeneratedListener::class)->tag( + if ($redirectsInstalled) { + $services->set(HeadlessRedirectResponseListener::class)->tag( + 'event.listener', + ['identifier' => 'headless/RedirectWasHit'] + ); + } + + $services->set(AfterPageUriGeneratedListener::class)->tag( 'event.listener', - ['identifier' => 'headless/AfterPagePreviewUriGenerated'] + ['identifier' => 'headless/AfterPageUriGenerated'] ); if (class_exists(\TYPO3\CMS\Seo\HrefLang\HrefLangGenerator::class)) { @@ -129,12 +151,19 @@ $features = GeneralUtility::makeInstance(Features::class); if ($features->isFeatureEnabled('headless.overrideFluidTemplates')) { - $templateService = $services->alias( - \TYPO3\CMS\Fluid\View\TemplateView::class, - TemplateView::class - ); + $services->set(HeadlessViewFactory::class) + ->arg('$inner', service(FluidViewFactory::class)) + ->public(); + $services->alias(ViewFactoryInterface::class, HeadlessViewFactory::class)->public(); + } - $templateService->public(); + if ($features->isFeatureEnabled('headless.storageProxy')) { + $services->set(ProxyResourcePublicUrlListener::class)->tag( + 'event.listener', + ['identifier' => 'headless/ProxyResourcePublicUrl'] + ); + $services->set(HeadlessImageService::class); + $services->alias(ImageService::class, HeadlessImageService::class)->public(); } foreach ( @@ -150,6 +179,11 @@ 'public' => true, ], FlexFormProcessor::class => ['identifier' => 'headless-flex-form', 'share' => false, 'public' => true], + ExtractPropertyProcessor::class => [ + 'identifier' => 'headless-extract-property', + 'share' => false, + 'public' => true, + ], ] as $class => $processorConfig ) { $service = $services->set($class) diff --git a/Tests/Functional/BaseHeadlessTesting.php b/Tests/Functional/BaseHeadlessTesting.php index 08e49125..ad840ccc 100644 --- a/Tests/Functional/BaseHeadlessTesting.php +++ b/Tests/Functional/BaseHeadlessTesting.php @@ -117,7 +117,7 @@ protected function checkHeaderFields($contentElement, $header = '', $subheader = self::assertEquals($subheader, $contentElementContent['subheader'], 'subheader mismatch'); self::assertEquals($headerLayout, $contentElementContent['headerLayout'], 'headerLayout mismatch'); self::assertEquals($headerPosition, $contentElementContent['headerPosition'], 'headerPosition mismatch'); - self::assertTrue(isset($contentElementContent['headerLink']), 'headerLink not set'); + self::assertArrayHasKey('headerLink', $contentElementContent, 'headerLink not set'); } protected function checkHeaderFieldsLink($contentElement, $link, $urlPrefix, $target) diff --git a/Tests/Functional/ContentTypes/BaseContentTypeTesting.php b/Tests/Functional/ContentTypes/BaseContentTypeTesting.php index 5a0880c9..36930ddb 100644 --- a/Tests/Functional/ContentTypes/BaseContentTypeTesting.php +++ b/Tests/Functional/ContentTypes/BaseContentTypeTesting.php @@ -51,7 +51,7 @@ protected function checkHeaderFields($contentElement, $header = '', $subheader = self::assertEquals($subheader, $contentElementContent['subheader'], 'subheader mismatch'); self::assertEquals($headerLayout, $contentElementContent['headerLayout'], 'headerLayout mismatch'); self::assertEquals($headerPosition, $contentElementContent['headerPosition'], 'headerPosition mismatch'); - self::assertTrue(isset($contentElementContent['headerLink']), 'headerLink not set'); + self::assertArrayHasKey('headerLink', $contentElementContent, 'headerLink not set'); } protected function checkHeaderFieldsLink($contentElement, $link, $urlPrefix, $target) @@ -95,7 +95,7 @@ protected function checkGalleryContentFields($contentElement) protected function checkGalleryFile($fileElement, $originalUrl, $mimeType, $title, $width, $height, $autoplay) { - self::assertTrue(isset($fileElement['publicUrl']), 'publicUrl not set'); + self::assertArrayHasKey('publicUrl', $fileElement, 'publicUrl not set'); self::assertIsArray($fileElement['properties'], 'properties not set'); self::assertEquals($originalUrl, $fileElement['properties']['originalUrl'], 'properties originalUrl mismatch'); diff --git a/Tests/Functional/ContentTypes/BasicListElementTest.php b/Tests/Functional/ContentTypes/BasicListElementTest.php index 8cfeea05..b8fe315d 100644 --- a/Tests/Functional/ContentTypes/BasicListElementTest.php +++ b/Tests/Functional/ContentTypes/BasicListElementTest.php @@ -21,7 +21,7 @@ public function testBasicListContentElement() new InternalRequest('https://website.local/') ); - self::assertEquals(200, $response->getStatusCode()); + self::assertSame(200, $response->getStatusCode()); $fullTree = json_decode((string)$response->getBody(), true); @@ -31,6 +31,6 @@ public function testBasicListContentElement() $this->checkAppearanceFields($contentElement, 'layout-1', 'Frame', 'SpaceBefore', 'SpaceAfter'); $this->checkHeaderFields($contentElement, 'Header', 'SubHeader', 1, 2); $this->checkHeaderFieldsLink($contentElement, 'Page 1', '/page1?parameter=999&cHash=', '_blank'); - self::assertFalse(isset($contentElement['content']['bodytext'])); + self::assertArrayNotHasKey('bodytext', $contentElement['content']); } } diff --git a/Tests/Functional/ContentTypes/BulletsElementTest.php b/Tests/Functional/ContentTypes/BulletsElementTest.php index ed3af242..cc04a3e7 100644 --- a/Tests/Functional/ContentTypes/BulletsElementTest.php +++ b/Tests/Functional/ContentTypes/BulletsElementTest.php @@ -23,7 +23,7 @@ public function testBulletsContentElement() new InternalRequest('https://website.local/') ); - self::assertEquals(200, $response->getStatusCode()); + self::assertSame(200, $response->getStatusCode()); $fullTree = json_decode((string)$response->getBody(), true); diff --git a/Tests/Functional/ContentTypes/DefaultContentsTest.php b/Tests/Functional/ContentTypes/DefaultContentsTest.php index c10a281e..af9a800a 100644 --- a/Tests/Functional/ContentTypes/DefaultContentsTest.php +++ b/Tests/Functional/ContentTypes/DefaultContentsTest.php @@ -21,16 +21,16 @@ public function testContentStructure() new InternalRequest('https://website.local/') ); - self::assertEquals(200, $response->getStatusCode()); + self::assertSame(200, $response->getStatusCode()); $fullTree = json_decode((string)$response->getBody(), true); $contentTree = $fullTree['content']; - self::assertTrue(isset($contentTree['colPos0'])); - self::assertTrue(count($contentTree['colPos0']) > 0); - self::assertTrue(isset($contentTree['colPos0'][0]['appearance'])); + self::assertArrayHasKey('colPos0', $contentTree); + self::assertNotEmpty($contentTree['colPos0']); + self::assertArrayHasKey('appearance', $contentTree['colPos0'][0]); self::assertIsArray($contentTree['colPos0'][0]['appearance']); - self::assertTrue(isset($contentTree['colPos1'])); - self::assertTrue(count($contentTree['colPos1']) > 0); + self::assertArrayHasKey('colPos1', $contentTree); + self::assertNotEmpty($contentTree['colPos1']); } } diff --git a/Tests/Functional/ContentTypes/DivElementTest.php b/Tests/Functional/ContentTypes/DivElementTest.php index 124355a5..9cc61406 100644 --- a/Tests/Functional/ContentTypes/DivElementTest.php +++ b/Tests/Functional/ContentTypes/DivElementTest.php @@ -21,7 +21,7 @@ public function testDivContentElement() new InternalRequest('https://website.local/') ); - self::assertEquals(200, $response->getStatusCode()); + self::assertSame(200, $response->getStatusCode()); $fullTree = json_decode((string)$response->getBody(), true); @@ -30,7 +30,7 @@ public function testDivContentElement() $this->checkDefaultContentFields($contentElement, 5, 1, 'div', 0); $this->checkAppearanceFields($contentElement, 'layout-1', 'Frame', 'SpaceBefore', 'SpaceAfter'); self::assertEquals('Header', $contentElement['content']['header']); - self::assertFalse(isset($contentElement['content']['subheader'])); - self::assertFalse(isset($contentElement['content']['bodytext'])); + self::assertArrayNotHasKey('subheader', $contentElement['content']); + self::assertArrayNotHasKey('bodytext', $contentElement['content']); } } diff --git a/Tests/Functional/ContentTypes/HeaderElementTest.php b/Tests/Functional/ContentTypes/HeaderElementTest.php index 97ad857b..de0f4680 100644 --- a/Tests/Functional/ContentTypes/HeaderElementTest.php +++ b/Tests/Functional/ContentTypes/HeaderElementTest.php @@ -21,7 +21,7 @@ public function testHeaderContentElement() new InternalRequest('https://website.local/') ); - self::assertEquals(200, $response->getStatusCode()); + self::assertSame(200, $response->getStatusCode()); $fullTree = json_decode((string)$response->getBody(), true); diff --git a/Tests/Functional/ContentTypes/HtmlElementTest.php b/Tests/Functional/ContentTypes/HtmlElementTest.php index a6ca5be8..2afd8969 100644 --- a/Tests/Functional/ContentTypes/HtmlElementTest.php +++ b/Tests/Functional/ContentTypes/HtmlElementTest.php @@ -21,7 +21,7 @@ public function testHtmlContentElement() new InternalRequest('https://website.local/') ); - self::assertEquals(200, $response->getStatusCode()); + self::assertSame(200, $response->getStatusCode()); $fullTree = json_decode((string)$response->getBody(), true); @@ -29,7 +29,7 @@ public function testHtmlContentElement() $this->checkDefaultContentFields($contentElement, 4, 1, 'html', 0); $this->checkAppearanceFields($contentElement, 'layout-1', 'Frame', 'SpaceBefore', 'SpaceAfter'); - self::assertFalse(isset($contentElement['content']['subheader'])); + self::assertArrayNotHasKey('subheader', $contentElement['content']); // typolink parser was NOT called on bodytext self::assertEquals('Link', $contentElement['content']['bodytext']); diff --git a/Tests/Functional/ContentTypes/ImageElementTest.php b/Tests/Functional/ContentTypes/ImageElementTest.php index a799ac1a..b3cf0533 100644 --- a/Tests/Functional/ContentTypes/ImageElementTest.php +++ b/Tests/Functional/ContentTypes/ImageElementTest.php @@ -21,7 +21,7 @@ public function testImageContentElement() new InternalRequest('https://website.local/') ); - self::assertEquals(200, $response->getStatusCode()); + self::assertSame(200, $response->getStatusCode()); $fullTree = json_decode((string)$response->getBody(), true); @@ -32,7 +32,7 @@ public function testImageContentElement() $this->checkHeaderFields($contentElement); // no bodytext - self::assertFalse(isset($contentElement['content']['bodytext'])); + self::assertArrayNotHasKey('bodytext', $contentElement['content']); $this->checkGalleryContentFields($contentElement); } diff --git a/Tests/Functional/ContentTypes/MenuAbstractPagesElementTest.php b/Tests/Functional/ContentTypes/MenuAbstractPagesElementTest.php index 797dfd4e..e313782b 100644 --- a/Tests/Functional/ContentTypes/MenuAbstractPagesElementTest.php +++ b/Tests/Functional/ContentTypes/MenuAbstractPagesElementTest.php @@ -21,7 +21,7 @@ public function testMenuContentElement() new InternalRequest('https://website.local/') ); - self::assertEquals(200, $response->getStatusCode()); + self::assertSame(200, $response->getStatusCode()); $fullTree = json_decode((string)$response->getBody(), true); diff --git a/Tests/Functional/ContentTypes/MenuCategorizedContentElementTest.php b/Tests/Functional/ContentTypes/MenuCategorizedContentElementTest.php index 1aac8129..e5724256 100644 --- a/Tests/Functional/ContentTypes/MenuCategorizedContentElementTest.php +++ b/Tests/Functional/ContentTypes/MenuCategorizedContentElementTest.php @@ -21,7 +21,7 @@ public function testMenuContentElement() new InternalRequest('https://website.local/') ); - self::assertEquals(200, $response->getStatusCode()); + self::assertSame(200, $response->getStatusCode()); $fullTree = json_decode((string)$response->getBody(), true); diff --git a/Tests/Functional/ContentTypes/MenuCategorizedPagesElementTest.php b/Tests/Functional/ContentTypes/MenuCategorizedPagesElementTest.php index 22c3c7d6..86b4a657 100644 --- a/Tests/Functional/ContentTypes/MenuCategorizedPagesElementTest.php +++ b/Tests/Functional/ContentTypes/MenuCategorizedPagesElementTest.php @@ -21,7 +21,7 @@ public function testMenuContentElement() new InternalRequest('https://website.local/page1') ); - self::assertEquals(200, $response->getStatusCode()); + self::assertSame(200, $response->getStatusCode()); $fullTree = json_decode((string)$response->getBody(), true); diff --git a/Tests/Functional/ContentTypes/MenuPagesElementTest.php b/Tests/Functional/ContentTypes/MenuPagesElementTest.php index 5fcb33a0..ddc20f21 100644 --- a/Tests/Functional/ContentTypes/MenuPagesElementTest.php +++ b/Tests/Functional/ContentTypes/MenuPagesElementTest.php @@ -21,7 +21,7 @@ public function testMenuContentElement() new InternalRequest('https://website.local/') ); - self::assertEquals(200, $response->getStatusCode()); + self::assertSame(200, $response->getStatusCode()); $fullTree = json_decode((string)$response->getBody(), true); diff --git a/Tests/Functional/ContentTypes/MenuRecentlyUpdatedPagesElementTest.php b/Tests/Functional/ContentTypes/MenuRecentlyUpdatedPagesElementTest.php index dad36e9b..6051949c 100644 --- a/Tests/Functional/ContentTypes/MenuRecentlyUpdatedPagesElementTest.php +++ b/Tests/Functional/ContentTypes/MenuRecentlyUpdatedPagesElementTest.php @@ -45,7 +45,7 @@ public function testMenuContentElement() new InternalRequest('https://website.local/') ); - self::assertEquals(200, $response->getStatusCode()); + self::assertSame(200, $response->getStatusCode()); $fullTree = json_decode((string)$response->getBody(), true); diff --git a/Tests/Functional/ContentTypes/MenuRelatedPagesElementTest.php b/Tests/Functional/ContentTypes/MenuRelatedPagesElementTest.php index 1b07c95d..e9ad8757 100644 --- a/Tests/Functional/ContentTypes/MenuRelatedPagesElementTest.php +++ b/Tests/Functional/ContentTypes/MenuRelatedPagesElementTest.php @@ -21,7 +21,7 @@ public function testMenuContentElement() new InternalRequest('https://website.local/') ); - self::assertEquals(200, $response->getStatusCode()); + self::assertSame(200, $response->getStatusCode()); $fullTree = json_decode((string)$response->getBody(), true); diff --git a/Tests/Functional/ContentTypes/MenuSectionElementTest.php b/Tests/Functional/ContentTypes/MenuSectionElementTest.php index 569d1aec..f71457b2 100644 --- a/Tests/Functional/ContentTypes/MenuSectionElementTest.php +++ b/Tests/Functional/ContentTypes/MenuSectionElementTest.php @@ -21,7 +21,7 @@ public function testMenuContentElement() new InternalRequest('https://website.local/') ); - self::assertEquals(200, $response->getStatusCode()); + self::assertSame(200, $response->getStatusCode()); $fullTree = json_decode((string)$response->getBody(), true); diff --git a/Tests/Functional/ContentTypes/MenuSitemapElementTest.php b/Tests/Functional/ContentTypes/MenuSitemapElementTest.php index 54fe8d34..10314ef6 100644 --- a/Tests/Functional/ContentTypes/MenuSitemapElementTest.php +++ b/Tests/Functional/ContentTypes/MenuSitemapElementTest.php @@ -21,7 +21,7 @@ public function testMenuContentElement() new InternalRequest('https://website.local/') ); - self::assertEquals(200, $response->getStatusCode()); + self::assertSame(200, $response->getStatusCode()); $fullTree = json_decode((string)$response->getBody(), true); diff --git a/Tests/Functional/ContentTypes/MenuSitemapSelectedPagesElementTest.php b/Tests/Functional/ContentTypes/MenuSitemapSelectedPagesElementTest.php index 96800b47..67280f62 100644 --- a/Tests/Functional/ContentTypes/MenuSitemapSelectedPagesElementTest.php +++ b/Tests/Functional/ContentTypes/MenuSitemapSelectedPagesElementTest.php @@ -21,7 +21,7 @@ public function testMenuContentElement() new InternalRequest('https://website.local/page3') ); - self::assertEquals(200, $response->getStatusCode()); + self::assertSame(200, $response->getStatusCode()); $fullTree = json_decode((string)$response->getBody(), true); diff --git a/Tests/Functional/ContentTypes/MenuSubpagesElementTest.php b/Tests/Functional/ContentTypes/MenuSubpagesElementTest.php index a0b2205e..997f185d 100644 --- a/Tests/Functional/ContentTypes/MenuSubpagesElementTest.php +++ b/Tests/Functional/ContentTypes/MenuSubpagesElementTest.php @@ -21,7 +21,7 @@ public function testMenuSubpagesContentElement() new InternalRequest('https://website.local/') ); - self::assertEquals(200, $response->getStatusCode()); + self::assertSame(200, $response->getStatusCode()); $fullTree = json_decode((string)$response->getBody(), true); diff --git a/Tests/Functional/ContentTypes/ShortcutElementTest.php b/Tests/Functional/ContentTypes/ShortcutElementTest.php index 5331c14e..ad617fa7 100644 --- a/Tests/Functional/ContentTypes/ShortcutElementTest.php +++ b/Tests/Functional/ContentTypes/ShortcutElementTest.php @@ -21,7 +21,7 @@ public function testShortcutContentElement() new InternalRequest('https://website.local/') ); - self::assertEquals(200, $response->getStatusCode()); + self::assertSame(200, $response->getStatusCode()); $fullTree = json_decode((string)$response->getBody(), true); @@ -29,17 +29,17 @@ public function testShortcutContentElement() $this->checkDefaultContentFields($contentElement, 9, 1, 'shortcut', 0); $this->checkAppearanceFields($contentElement, 'layout-1', 'Frame', 'SpaceBefore', 'SpaceAfter'); - self::assertFalse(isset($contentElement['content']['header'])); - self::assertFalse(isset($contentElement['content']['bodytext'])); - self::assertTrue(isset($contentElement['content']['shortcut'])); + self::assertArrayNotHasKey('header', $contentElement['content']); + self::assertArrayNotHasKey('bodytext', $contentElement['content']); + self::assertArrayHasKey('shortcut', $contentElement['content']); self::assertCount(2, $contentElement['content']['shortcut']); // element at pos 0 is our TextMediaElement $this->checkDefaultContentFields($contentElement['content']['shortcut'][0], 2, 1, 'textmedia', 1); $this->checkAppearanceFields($contentElement['content']['shortcut'][0]); $this->checkHeaderFields($contentElement['content']['shortcut'][0]); - self::assertFalse(isset($contentElement['content']['shortcut'][0]['headerLink'])); - self::assertFalse(isset($contentElement['content']['shortcut'][0]['bodytext'])); + self::assertArrayNotHasKey('headerLink', $contentElement['content']['shortcut'][0]); + self::assertArrayNotHasKey('bodytext', $contentElement['content']['shortcut'][0]); // element at pos 1 is our TextElement $this->checkDefaultContentFields($contentElement['content']['shortcut'][1], 1, 1, 'text', 0, 'SysCategory1Title,SysCategory2Title'); diff --git a/Tests/Functional/ContentTypes/TableElementTest.php b/Tests/Functional/ContentTypes/TableElementTest.php index bc839dfb..959f295f 100644 --- a/Tests/Functional/ContentTypes/TableElementTest.php +++ b/Tests/Functional/ContentTypes/TableElementTest.php @@ -23,7 +23,7 @@ public function testTableContentElement() new InternalRequest('https://website.local/') ); - self::assertEquals(200, $response->getStatusCode()); + self::assertSame(200, $response->getStatusCode()); $fullTree = json_decode((string)$response->getBody(), true); diff --git a/Tests/Functional/ContentTypes/TextElementTest.php b/Tests/Functional/ContentTypes/TextElementTest.php index 9cfa3c34..a9ff144c 100644 --- a/Tests/Functional/ContentTypes/TextElementTest.php +++ b/Tests/Functional/ContentTypes/TextElementTest.php @@ -21,7 +21,7 @@ public function testTextContentElement() new InternalRequest('https://website.local/') ); - self::assertEquals(200, $response->getStatusCode()); + self::assertSame(200, $response->getStatusCode()); $fullTree = json_decode((string)$response->getBody(), true); diff --git a/Tests/Functional/ContentTypes/TextMediaElementTest.php b/Tests/Functional/ContentTypes/TextMediaElementTest.php index 3ebdc69d..c9024e7e 100644 --- a/Tests/Functional/ContentTypes/TextMediaElementTest.php +++ b/Tests/Functional/ContentTypes/TextMediaElementTest.php @@ -21,7 +21,7 @@ public function testTextMediaContentElement() new InternalRequest('https://website.local/') ); - self::assertEquals(200, $response->getStatusCode()); + self::assertSame(200, $response->getStatusCode()); $fullTree = json_decode((string)$response->getBody(), true); diff --git a/Tests/Functional/ContentTypes/TextpicElementTest.php b/Tests/Functional/ContentTypes/TextpicElementTest.php index 2d807660..9dc0235c 100644 --- a/Tests/Functional/ContentTypes/TextpicElementTest.php +++ b/Tests/Functional/ContentTypes/TextpicElementTest.php @@ -21,7 +21,7 @@ public function testTextpicContentElement() new InternalRequest('https://website.local/') ); - self::assertEquals(200, $response->getStatusCode()); + self::assertSame(200, $response->getStatusCode()); $fullTree = json_decode((string)$response->getBody(), true); diff --git a/Tests/Functional/PageTypes/SchemaPageTypesTest.php b/Tests/Functional/PageTypes/SchemaPageTypesTest.php index ddb003db..c90707c0 100644 --- a/Tests/Functional/PageTypes/SchemaPageTypesTest.php +++ b/Tests/Functional/PageTypes/SchemaPageTypesTest.php @@ -21,7 +21,7 @@ public function testGetMenu() new InternalRequest('https://website.local/?type=834') ); - self::assertEquals(200, $response->getStatusCode()); + self::assertSame(200, $response->getStatusCode()); } public function testGetPage() @@ -30,6 +30,6 @@ public function testGetPage() new InternalRequest('https://website.local/') ); - self::assertEquals(200, $response->getStatusCode()); + self::assertSame(200, $response->getStatusCode()); } } diff --git a/Tests/Functional/PageTypes/StructurePageTypesTest.php b/Tests/Functional/PageTypes/StructurePageTypesTest.php index c33eeb8f..6779ac78 100644 --- a/Tests/Functional/PageTypes/StructurePageTypesTest.php +++ b/Tests/Functional/PageTypes/StructurePageTypesTest.php @@ -21,12 +21,12 @@ public function testGetMenuStructure() new InternalRequest('https://website.local/?type=834') ); - self::assertEquals(200, $response->getStatusCode()); + self::assertSame(200, $response->getStatusCode()); $pageTree = json_decode((string)$response->getBody(), true); - self::assertTrue(isset($pageTree['navigation'])); + self::assertArrayHasKey('navigation', $pageTree); self::assertCount(1, $pageTree['navigation']); - self::assertTrue(isset($pageTree['navigation'][0])); + self::assertArrayHasKey(0, $pageTree['navigation']); self::assertCount(5, $pageTree['navigation'][0]['children']); self::assertCount(1, $pageTree['navigation'][0]['children'][0]['children']); self::assertCount(1, $pageTree['navigation'][0]['children'][2]['children']); diff --git a/Tests/Functional/View/HeadlessViewFactoryIntegrationTest.php b/Tests/Functional/View/HeadlessViewFactoryIntegrationTest.php new file mode 100644 index 00000000..a9566b5b --- /dev/null +++ b/Tests/Functional/View/HeadlessViewFactoryIntegrationTest.php @@ -0,0 +1,99 @@ + [ + 'features' => [ + 'headless.overrideFluidTemplates' => true, + ], + ], + ]; + + public function testContainerAliasResolvesToHeadlessFactoryWhenFeatureFlagIsOn(): void + { + $factory = $this->get(ViewFactoryInterface::class); + + self::assertInstanceOf( + HeadlessViewFactory::class, + $factory, + 'feature flag must replace the default Fluid factory in the container alias' + ); + } + + public function testFactoryStillReturnsFluidViewForDefaultFormat(): void + { + $factory = $this->get(ViewFactoryInterface::class); + + $view = $factory->create(new ViewFactoryData( + templateRootPaths: [Environment::getFrameworkBasePath() . '/headless/Resources/Private/'], + format: 'html', + )); + + self::assertInstanceOf( + FluidViewAdapter::class, + $view, + 'non-php format must still be served by Fluid' + ); + } + + public function testFactoryReturnsHeadlessPhpViewForPhpFormatWithHeadlessRequest(): void + { + $factory = $this->get(ViewFactoryInterface::class); + $request = (new ServerRequest('https://website.local/')) + ->withAttribute('applicationType', SystemEnvironmentBuilder::REQUESTTYPE_FE) + ->withAttribute('headless', new Headless(HeadlessModeInterface::FULL)); + + $view = $factory->create(new ViewFactoryData( + templateRootPaths: [Environment::getPublicPath() . '/typo3temp/var/tests/'], + request: $request, + format: 'php', + )); + + self::assertInstanceOf(HeadlessPhpView::class, $view); + } + + public function testFactoryFallsBackToFluidWhenHeadlessDisabled(): void + { + $factory = $this->get(ViewFactoryInterface::class); + $request = (new ServerRequest('https://website.local/')) + ->withAttribute('applicationType', SystemEnvironmentBuilder::REQUESTTYPE_FE); + // No 'headless' attribute → NONE → fall-through. + + $view = $factory->create(new ViewFactoryData( + templateRootPaths: [Environment::getPublicPath() . '/typo3temp/var/tests/'], + request: $request, + format: 'php', + )); + + self::assertNotInstanceOf(HeadlessPhpView::class, $view); + } +} diff --git a/Tests/Unit/ContentObject/JsonContentObjectTest.php b/Tests/Unit/ContentObject/JsonContentObjectTest.php index b6911d44..319d04c1 100644 --- a/Tests/Unit/ContentObject/JsonContentObjectTest.php +++ b/Tests/Unit/ContentObject/JsonContentObjectTest.php @@ -18,10 +18,10 @@ use FriendsOfTYPO3\Headless\ContentObject\JsonContentObject; use FriendsOfTYPO3\Headless\Json\JsonDecoder; use FriendsOfTYPO3\Headless\Json\JsonEncoder; +use FriendsOfTYPO3\Headless\Tests\Unit\HeadlessUnitTestCase; use FriendsOfTYPO3\Headless\Utility\HeadlessUserInt; use PHPUnit\Framework\Attributes\DataProvider; use Psr\EventDispatcher\EventDispatcherInterface; -use ReflectionProperty; use stdClass; use Symfony\Component\DependencyInjection\Container; use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface; @@ -49,12 +49,10 @@ use TYPO3\CMS\Frontend\ContentObject\UserInternalContentObject; use TYPO3\CMS\Frontend\DataProcessing\DataProcessorRegistry; -use TYPO3\TestingFramework\Core\Unit\UnitTestCase; - use function json_encode; use function md5; -class JsonContentObjectTest extends UnitTestCase +class JsonContentObjectTest extends HeadlessUnitTestCase { private JsonContentObject $contentObject; @@ -226,9 +224,4 @@ public static function dataProvider(): array ]; } - protected function tearDown(): void - { - (new ReflectionProperty(GeneralUtility::class, 'container'))->setValue(null, null); - parent::tearDown(); - } } diff --git a/Tests/Unit/DataProcessing/RootSiteProcessing/DomainSchemaTest.php b/Tests/Unit/DataProcessing/RootSiteProcessing/DomainSchemaTest.php index 24dc4981..baa04e6f 100644 --- a/Tests/Unit/DataProcessing/RootSiteProcessing/DomainSchemaTest.php +++ b/Tests/Unit/DataProcessing/RootSiteProcessing/DomainSchemaTest.php @@ -17,7 +17,6 @@ use FriendsOfTYPO3\Headless\Utility\HeadlessMode; use FriendsOfTYPO3\Headless\Utility\UrlUtility; use Psr\Http\Message\UriInterface; -use TYPO3\CMS\Core\Configuration\Features; use TYPO3\CMS\Core\ExpressionLanguage\Resolver; use TYPO3\CMS\Core\Http\ServerRequest; use TYPO3\CMS\Core\Http\Uri; @@ -129,7 +128,7 @@ protected function getUrlUtility($site = null): UrlUtility $dummyRequest = (new ServerRequest())->withAttribute('site', $site); $dummyRequest = $dummyRequest->withAttribute('headless', new Headless()); - return (new UrlUtility(new Features(), $resolver, $mock, (new HeadlessMode())->withRequest($dummyRequest)))->withRequest($dummyRequest); + return (new UrlUtility($resolver, $mock, (new HeadlessMode())->withRequest($dummyRequest)))->withRequest($dummyRequest); } protected function getSiteWithBase(UriInterface $uri, $withLanguage = null) diff --git a/Tests/Unit/DataProcessing/RootSiteProcessing/TestSiteProvider.php b/Tests/Unit/DataProcessing/RootSiteProcessing/TestSiteProvider.php index 9b1b67a5..15cda255 100644 --- a/Tests/Unit/DataProcessing/RootSiteProcessing/TestSiteProvider.php +++ b/Tests/Unit/DataProcessing/RootSiteProcessing/TestSiteProvider.php @@ -16,7 +16,10 @@ class TestSiteProvider implements SiteProviderInterface { - public function prepare(array $config, int $siteUid) {} + public function prepare(array $config, int $siteUid): self + { + return $this; + } public function getSites(): array { diff --git a/Tests/Unit/DataProcessing/RootSitesProcessorTest.php b/Tests/Unit/DataProcessing/RootSitesProcessorTest.php index 7d7357be..df5cef82 100644 --- a/Tests/Unit/DataProcessing/RootSitesProcessorTest.php +++ b/Tests/Unit/DataProcessing/RootSitesProcessorTest.php @@ -14,16 +14,15 @@ use FriendsOfTYPO3\Headless\DataProcessing\RootSitesProcessor; use FriendsOfTYPO3\Headless\Tests\Unit\DataProcessing\RootSiteProcessing\TestDomainSchema; use FriendsOfTYPO3\Headless\Tests\Unit\DataProcessing\RootSiteProcessing\TestSiteProvider; +use FriendsOfTYPO3\Headless\Tests\Unit\HeadlessUnitTestCase; use InvalidArgumentException; use Psr\EventDispatcher\EventDispatcherInterface; -use ReflectionProperty; use stdClass; use Symfony\Component\DependencyInjection\Container; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer; -use TYPO3\TestingFramework\Core\Unit\UnitTestCase; -class RootSitesProcessorTest extends UnitTestCase +class RootSitesProcessorTest extends HeadlessUnitTestCase { protected function setUp(): void { @@ -36,12 +35,6 @@ protected function setUp(): void GeneralUtility::setContainer($c); } - protected function tearDown(): void - { - (new ReflectionProperty(GeneralUtility::class, 'container'))->setValue(null, null); - parent::tearDown(); - } - public function testCustomImplementation(): void { $processor = new RootSitesProcessor(); diff --git a/Tests/Unit/Event/Listener/AfterCacheableContentIsGeneratedListenerTest.php b/Tests/Unit/Event/Listener/AfterCacheableContentIsGeneratedListenerTest.php index 8703b135..1ac0c049 100644 --- a/Tests/Unit/Event/Listener/AfterCacheableContentIsGeneratedListenerTest.php +++ b/Tests/Unit/Event/Listener/AfterCacheableContentIsGeneratedListenerTest.php @@ -15,22 +15,20 @@ use FriendsOfTYPO3\Headless\Json\JsonEncoder; use FriendsOfTYPO3\Headless\Seo\MetaHandler; use FriendsOfTYPO3\Headless\Seo\MetaHandlerInterface; +use FriendsOfTYPO3\Headless\Tests\Unit\HeadlessUnitTestCase; use FriendsOfTYPO3\Headless\Utility\Headless; use FriendsOfTYPO3\Headless\Utility\HeadlessMode; use FriendsOfTYPO3\Headless\Utility\HeadlessModeInterface; use FriendsOfTYPO3\Headless\Utility\HeadlessUserInt; use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Http\Message\ServerRequestInterface; -use ReflectionProperty; use TYPO3\CMS\Core\MetaTag\MetaTagManagerRegistry; use TYPO3\CMS\Core\PageTitle\PageTitleProviderManager; -use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Frontend\Event\AfterCacheableContentIsGeneratedEvent; -use TYPO3\TestingFramework\Core\Unit\UnitTestCase; use function json_encode; -class AfterCacheableContentIsGeneratedListenerTest extends UnitTestCase +class AfterCacheableContentIsGeneratedListenerTest extends HeadlessUnitTestCase { protected bool $resetSingletonInstances = true; @@ -220,9 +218,4 @@ public function testHreflangs(): void ]), $event->getContent()); } - protected function tearDown(): void - { - (new ReflectionProperty(GeneralUtility::class, 'container'))->setValue(null, null); - parent::tearDown(); - } } diff --git a/Tests/Unit/Event/Listener/AfterLinkIsGeneratedListenerTest.php b/Tests/Unit/Event/Listener/AfterLinkIsGeneratedListenerTest.php index a21684c2..526ca6d1 100644 --- a/Tests/Unit/Event/Listener/AfterLinkIsGeneratedListenerTest.php +++ b/Tests/Unit/Event/Listener/AfterLinkIsGeneratedListenerTest.php @@ -10,13 +10,13 @@ namespace FriendsOfTYPO3\Headless\Tests\Unit\Event\Listener; use FriendsOfTYPO3\Headless\Event\Listener\AfterLinkIsGeneratedListener; +use FriendsOfTYPO3\Headless\Tests\Unit\HeadlessUnitTestCase; +use FriendsOfTYPO3\Headless\Utility\Headless; use FriendsOfTYPO3\Headless\Utility\HeadlessMode; use FriendsOfTYPO3\Headless\Utility\HeadlessModeInterface; use FriendsOfTYPO3\Headless\Utility\UrlUtility; use Psr\EventDispatcher\EventDispatcherInterface; -use ReflectionProperty; use Symfony\Component\DependencyInjection\Container; -use TYPO3\CMS\Core\Configuration\Features; use TYPO3\CMS\Core\ExpressionLanguage\Resolver; use TYPO3\CMS\Core\Http\ServerRequest; use TYPO3\CMS\Core\LinkHandling\LinkService; @@ -28,9 +28,8 @@ use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer; use TYPO3\CMS\Frontend\Event\AfterLinkIsGeneratedEvent; use TYPO3\CMS\Frontend\Typolink\LinkResult; -use TYPO3\TestingFramework\Core\Unit\UnitTestCase; -class AfterLinkIsGeneratedListenerTest extends UnitTestCase +class AfterLinkIsGeneratedListenerTest extends HeadlessUnitTestCase { protected function setUp(): void { @@ -41,12 +40,6 @@ protected function setUp(): void GeneralUtility::setContainer($container); } - protected function tearDown(): void - { - (new ReflectionProperty(GeneralUtility::class, 'container'))->setValue(null, null); - parent::tearDown(); - } - public function test__construct() { $resolver = $this->createMock(Resolver::class); @@ -55,10 +48,11 @@ public function test__construct() $listener = new AfterLinkIsGeneratedListener( $this->createMock(Logger::class), - new UrlUtility(new Features(), $resolver, $siteFinder, new HeadlessMode()), + new UrlUtility($resolver, $siteFinder, new HeadlessMode()), $this->createMock(LinkService::class), new TypoLinkCodecService($this->createMock(EventDispatcherInterface::class)), - $siteFinder + $siteFinder, + new HeadlessMode() ); self::assertInstanceOf(AfterLinkIsGeneratedListener::class, $listener); @@ -72,15 +66,20 @@ public function test__invokeNotModifingAnything() $listener = new AfterLinkIsGeneratedListener( $this->createMock(Logger::class), - new UrlUtility(new Features(), $resolver, $siteFinder, new HeadlessMode()), + new UrlUtility($resolver, $siteFinder, new HeadlessMode()), $this->createMock(LinkService::class), new TypoLinkCodecService($this->createMock(EventDispatcherInterface::class)), - $siteFinder + $siteFinder, + new HeadlessMode() ); $site = new Site('test', 1, []); $cObj = $this->createMock(ContentObjectRenderer::class); - $cObj->method('getRequest')->willReturn((new ServerRequest())->withAttribute('site', $site)); + $cObj->method('getRequest')->willReturn( + (new ServerRequest()) + ->withAttribute('site', $site) + ->withAttribute('headless', new Headless(HeadlessMode::FULL)) + ); $cObj->method('stdWrapValue')->with('ATagParams', [])->willReturn(''); $linkResult = new LinkResult('page', '/'); @@ -111,7 +110,9 @@ public function test__invokeModifingFromPageUid() $site = new Site('test', 1, []); $cObj = $this->createMock(ContentObjectRenderer::class); - $request = (new ServerRequest())->withAttribute('site', $site); + $request = (new ServerRequest()) + ->withAttribute('site', $site) + ->withAttribute('headless', new Headless(HeadlessMode::FULL)); $cObj->method('getRequest')->willReturn($request); $urlUtility->method('withRequest')->with($request)->willReturn($urlUtility); @@ -121,7 +122,8 @@ public function test__invokeModifingFromPageUid() $urlUtility, $this->createMock(LinkService::class), new TypoLinkCodecService($this->createMock(EventDispatcherInterface::class)), - $this->createMock(SiteFinder::class) + $this->createMock(SiteFinder::class), + new HeadlessMode() ); $linkResult = new LinkResult('page', '/'); @@ -148,7 +150,9 @@ public function test__invokeModifingExternalSite() $linkService->method('resolve')->willReturn(['pageuid' => 5]); $cObj = $this->createMock(ContentObjectRenderer::class); - $request = (new ServerRequest())->withAttribute('site', $site); + $request = (new ServerRequest()) + ->withAttribute('site', $site) + ->withAttribute('headless', new Headless(HeadlessMode::FULL)); $cObj->method('getRequest')->willReturn($request); $urlUtility->method('withRequest')->with($request)->willReturn($urlUtility); @@ -158,7 +162,8 @@ public function test__invokeModifingExternalSite() $urlUtility, $linkService, new TypoLinkCodecService($this->createMock(EventDispatcherInterface::class)), - $this->createMock(SiteFinder::class) + $this->createMock(SiteFinder::class), + new HeadlessMode() ); $linkResult = new LinkResult('page', '/'); $linkResult = $linkResult->withLinkConfiguration(['parameter.' => ['data' => 'parameters:href']]); @@ -188,7 +193,9 @@ public function test__SitemapLink() $linkService->method('resolve')->willReturn(['pageuid' => 5]); $cObj = $this->createMock(ContentObjectRenderer::class); - $request = (new ServerRequest())->withAttribute('site', $site); + $request = (new ServerRequest()) + ->withAttribute('site', $site) + ->withAttribute('headless', new Headless(HeadlessMode::FULL)); $cObj->method('getRequest')->willReturn($request); $siteFinder = $this->createPartialMock(SiteFinder::class, ['getSiteByPageId']); @@ -204,7 +211,8 @@ public function test__SitemapLink() $urlUtility, $linkService, new TypoLinkCodecService($eventDispatcher), - $siteFinder + $siteFinder, + new HeadlessMode() ); $linkResult = new LinkResult('page', 'https://typo3.tld/sitemap-type/pages/sitemap.xml'); @@ -230,14 +238,17 @@ public function testInvokeFollowsShortcutDoktype(): void $urlUtility->method('getFrontendUrlForPage')->with('/', 7)->willReturn('https://front.tld/page-7'); $cObj = $this->createMock(ContentObjectRenderer::class); - $cObj->method('getRequest')->willReturn(new ServerRequest()); + $cObj->method('getRequest')->willReturn( + (new ServerRequest())->withAttribute('headless', new Headless(HeadlessMode::FULL)) + ); $listener = new AfterLinkIsGeneratedListener( $this->createMock(Logger::class), $urlUtility, $this->createMock(LinkService::class), new TypoLinkCodecService($this->createMock(EventDispatcherInterface::class)), - $this->createMock(SiteFinder::class) + $this->createMock(SiteFinder::class), + new HeadlessMode() ); $linkResult = new LinkResult('page', '/'); @@ -252,6 +263,40 @@ public function testInvokeFollowsShortcutDoktype(): void self::assertSame('https://front.tld/page-7', $event->getLinkResult()->getUrl()); } + public function testListenerShortCircuitsWhenHeadlessDisabled(): void + { + $urlUtility = $this->createMock(UrlUtility::class); + // The listener must not touch urlUtility when headless is off. + $urlUtility->expects(self::never())->method('withRequest'); + + $siteFinder = $this->createMock(SiteFinder::class); + $siteFinder->expects(self::never())->method('getSiteByPageId'); + + $cObj = $this->createMock(ContentObjectRenderer::class); + $cObj->method('getRequest')->willReturn( + // No 'headless' attribute → defaults to NONE → isEnabled() === false. + new ServerRequest() + ); + + $listener = new AfterLinkIsGeneratedListener( + $this->createMock(Logger::class), + $urlUtility, + $this->createMock(LinkService::class), + new TypoLinkCodecService($this->createMock(EventDispatcherInterface::class)), + $siteFinder, + new HeadlessMode() + ); + + $linkResult = (new LinkResult('page', '/original')) + ->withLinkConfiguration(['parameter' => 2]) + ->withLinkText('|'); + + $event = new AfterLinkIsGeneratedEvent($linkResult, $cObj, []); + $listener($event); + + self::assertSame('/original', $event->getLinkResult()->getUrl()); + } + public function testInvokeWithEmptyLinkLogsErrorWhenNoSite(): void { $logger = $this->createMock(Logger::class); @@ -261,7 +306,9 @@ public function testInvokeWithEmptyLinkLogsErrorWhenNoSite(): void $urlUtility->method('withRequest')->willReturnSelf(); $cObj = $this->createMock(ContentObjectRenderer::class); - $cObj->method('getRequest')->willReturn(new ServerRequest()); + $cObj->method('getRequest')->willReturn( + (new ServerRequest())->withAttribute('headless', new Headless(HeadlessMode::FULL)) + ); $cObj->method('stdWrap')->willReturn(''); $listener = new AfterLinkIsGeneratedListener( @@ -269,7 +316,8 @@ public function testInvokeWithEmptyLinkLogsErrorWhenNoSite(): void $urlUtility, $this->createMock(LinkService::class), new TypoLinkCodecService($this->createMock(EventDispatcherInterface::class)), - $this->createMock(SiteFinder::class) + $this->createMock(SiteFinder::class), + new HeadlessMode() ); $linkResult = new LinkResult('page', ''); diff --git a/Tests/Unit/Event/Listener/AfterPagePreviewUriGeneratedListenerTest.php b/Tests/Unit/Event/Listener/AfterPagePreviewUriGeneratedListenerTest.php deleted file mode 100644 index 2c176bfd..00000000 --- a/Tests/Unit/Event/Listener/AfterPagePreviewUriGeneratedListenerTest.php +++ /dev/null @@ -1,129 +0,0 @@ -set(HeadlessModeInterface::class, new HeadlessMode()); - GeneralUtility::setContainer($container); - } - - protected function tearDown(): void - { - (new ReflectionProperty(GeneralUtility::class, 'container'))->setValue(null, null); - parent::tearDown(); - } - - public function test__construct() - { - $resolver = $this->createMock(Resolver::class); - $resolver->method('evaluate')->willReturn(true); - $siteFinder = $this->createMock(SiteFinder::class); - - $listener = new AfterPagePreviewUriGeneratedListener(new UrlUtility( - new Features(), - $resolver, - $siteFinder, - new HeadlessMode() - ), $siteFinder, new HeadlessMode()); - - self::assertInstanceOf(AfterPagePreviewUriGeneratedListener::class, $listener); - } - - public function testLink() - { - $resolver = $this->createMock(Resolver::class); - $resolver->method('evaluate')->willReturn(true); - $siteFinder = $this->createPartialMock(SiteFinder::class, ['getSiteByPageId']); - $siteFinder->method('getSiteByPageId')->willReturn($site = new Site('test', 1, ['headless' => HeadlessModeInterface::MIXED, 'frontendBase' => 'https://front.test.tld', 'base' => 'https://test.tld'])); - - $listener = new AfterPagePreviewUriGeneratedListener(new UrlUtility( - new Features(), - $resolver, - $siteFinder, - new HeadlessMode() - ), $siteFinder, new HeadlessMode()); - - $event = new AfterPagePreviewUriGeneratedEvent( - new Uri('https://test.tld/page'), - 1, - 0, - [], - '', - [], - $this->createMock(Context::class), - [] - ); - - $GLOBALS['TYPO3_REQUEST'] = new ServerRequest(); - $listener->__invoke($event); - - self::assertSame('https://test.tld/page', (string)$event->getPreviewUri()); - - $GLOBALS['BE_USER'] = new BackendUserAuthentication(); - $listener->__invoke($event); - self::assertSame('https://test.tld/page', (string)$event->getPreviewUri()); - } - - public function testSiteNotFound() - { - $resolver = $this->createMock(Resolver::class); - $resolver->method('evaluate')->willReturn(true); - $siteFinder = $this->createPartialMock(SiteFinder::class, ['getSiteByPageId']); - $siteFinder->method('getSiteByPageId')->willThrowException(new SiteNotFoundException()); - - $listener = new AfterPagePreviewUriGeneratedListener(new UrlUtility( - new Features(), - $resolver, - $siteFinder, - new HeadlessMode() - ), $siteFinder, new HeadlessMode()); - - $event = new AfterPagePreviewUriGeneratedEvent( - new Uri('https://test.tld/page'), - 1, - 0, - [], - '', - [], - $this->createMock(Context::class), - [] - ); - - $GLOBALS['TYPO3_REQUEST'] = new ServerRequest(); - $listener->__invoke($event); - - self::assertSame('https://test.tld/page', (string)$event->getPreviewUri()); - } -} diff --git a/Tests/Unit/Event/Listener/ProxyResourcePublicUrlListenerTest.php b/Tests/Unit/Event/Listener/ProxyResourcePublicUrlListenerTest.php new file mode 100644 index 00000000..b4637447 --- /dev/null +++ b/Tests/Unit/Event/Listener/ProxyResourcePublicUrlListenerTest.php @@ -0,0 +1,219 @@ +createFrontendRequest(); + } + + protected function tearDown(): void + { + unset($GLOBALS['TYPO3_REQUEST']); + parent::tearDown(); + } + + public function testSetsProxiedUrlForRelativeBasePath(): void + { + $event = $this->event( + driver: $this->localDriver(public: true), + storage: $this->storage(['basePath' => '/fileadmin/', 'pathType' => 'relative']), + identifier: '/foo/bar.jpg', + ); + + ($this->listener())($event); + + self::assertSame( + 'https://example.com/proxy/fileadmin/foo/bar.jpg', + $event->getPublicUrl() + ); + } + + public function testFallsBackToStorageProxyUrlWhenNoBasePath(): void + { + $event = $this->event( + driver: $this->localDriver(public: true), + storage: $this->storage([]), + identifier: '/foo/bar.jpg', + ); + + ($this->listener())($event); + + self::assertSame( + self::STORAGE_PROXY_URL . '/foo/bar.jpg', + $event->getPublicUrl() + ); + } + + public function testEncodesSpecialCharsInIdentifier(): void + { + $event = $this->event( + driver: $this->localDriver(public: true), + storage: $this->storage(['basePath' => '/fileadmin/', 'pathType' => 'relative']), + identifier: '/some folder/file with space.jpg', + ); + + ($this->listener())($event); + + self::assertSame( + 'https://example.com/proxy/fileadmin/some%20folder/file%20with%20space.jpg', + $event->getPublicUrl() + ); + } + + public function testIgnoresAlreadySetPublicUrl(): void + { + $event = $this->event( + driver: $this->localDriver(public: true), + storage: $this->storage(['basePath' => '/fileadmin/', 'pathType' => 'relative']), + identifier: '/foo.jpg', + ); + $event->setPublicUrl('https://cdn.example.com/already-set.jpg'); + + ($this->listener())($event); + + self::assertSame('https://cdn.example.com/already-set.jpg', $event->getPublicUrl()); + } + + public function testIgnoresNonLocalDriver(): void + { + $event = $this->event( + driver: $this->createMock(DriverInterface::class), + storage: $this->storage(['basePath' => '/fileadmin/', 'pathType' => 'relative']), + identifier: '/foo.jpg', + ); + + ($this->listener())($event); + + self::assertNull($event->getPublicUrl()); + } + + public function testIgnoresNonPublicDriver(): void + { + $event = $this->event( + driver: $this->localDriver(public: false), + storage: $this->storage(['basePath' => '/fileadmin/', 'pathType' => 'relative']), + identifier: '/foo.jpg', + ); + + ($this->listener())($event); + + self::assertNull($event->getPublicUrl()); + } + + public function testIgnoresBackendRequest(): void + { + $GLOBALS['TYPO3_REQUEST'] = $this->createMock(ServerRequestInterface::class); + $GLOBALS['TYPO3_REQUEST']->method('getAttribute')->willReturnCallback( + static fn(string $key) => $key === 'applicationType' ? SystemEnvironmentBuilder::REQUESTTYPE_BE : null + ); + + $event = $this->event( + driver: $this->localDriver(public: true), + storage: $this->storage(['basePath' => '/fileadmin/', 'pathType' => 'relative']), + identifier: '/foo.jpg', + ); + + ($this->listener())($event); + + self::assertNull($event->getPublicUrl()); + } + + public function testIgnoresWhenHeadlessModeDisabled(): void + { + $event = $this->event( + driver: $this->localDriver(public: true), + storage: $this->storage(['basePath' => '/fileadmin/', 'pathType' => 'relative']), + identifier: '/foo.jpg', + ); + + ($this->listener(headlessEnabled: false))($event); + + self::assertNull($event->getPublicUrl()); + } + + private function listener(bool $headlessEnabled = true): ProxyResourcePublicUrlListener + { + $headlessMode = $this->createMock(HeadlessModeInterface::class); + $headlessMode->method('isEnabledFor')->willReturn($headlessEnabled); + + $urlUtility = $this->createMock(HeadlessFrontendUrlInterface::class); + $urlUtility->method('withRequest')->willReturnSelf(); + $urlUtility->method('getFrontendUrl')->willReturn(self::FRONTEND_URL); + $urlUtility->method('getProxyUrl')->willReturn(self::PROXY_URL); + $urlUtility->method('getStorageProxyUrl')->willReturn(self::STORAGE_PROXY_URL); + + return new ProxyResourcePublicUrlListener($headlessMode, $urlUtility); + } + + /** + * @param array $config + */ + private function storage(array $config): ResourceStorage + { + $storage = $this->createMock(ResourceStorage::class); + $storage->method('getConfiguration')->willReturn($config); + + return $storage; + } + + private function localDriver(bool $public): LocalDriver + { + $driver = $this->createMock(LocalDriver::class); + $driver->method('hasCapability')->willReturnCallback( + static fn(int $cap) => $cap === Capabilities::CAPABILITY_PUBLIC && $public + ); + + return $driver; + } + + private function event( + DriverInterface $driver, + ResourceStorage $storage, + string $identifier, + ): GeneratePublicUrlForResourceEvent { + $resource = $this->createMock(ResourceInterface::class); + $resource->method('getIdentifier')->willReturn($identifier); + + return new GeneratePublicUrlForResourceEvent($resource, $storage, $driver); + } + + private function createFrontendRequest(): ServerRequestInterface + { + $request = $this->createMock(ServerRequestInterface::class); + $request->method('getAttribute')->willReturnCallback( + static fn(string $key) => $key === 'applicationType' ? SystemEnvironmentBuilder::REQUESTTYPE_FE : null + ); + + return $request; + } +} diff --git a/Tests/Unit/Event/Listener/RedirectUrlAdditionalParamsListenerTest.php b/Tests/Unit/Event/Listener/RedirectUrlAdditionalParamsListenerTest.php index 14f956a6..1e7ff24d 100644 --- a/Tests/Unit/Event/Listener/RedirectUrlAdditionalParamsListenerTest.php +++ b/Tests/Unit/Event/Listener/RedirectUrlAdditionalParamsListenerTest.php @@ -13,15 +13,14 @@ use FriendsOfTYPO3\Headless\Event\Listener\RedirectUrlAdditionalParamsListener; use FriendsOfTYPO3\Headless\Event\RedirectUrlEvent; +use FriendsOfTYPO3\Headless\Tests\Unit\HeadlessUnitTestCase; use FriendsOfTYPO3\Headless\Utility\Headless; use FriendsOfTYPO3\Headless\Utility\HeadlessMode; use FriendsOfTYPO3\Headless\Utility\UrlUtility; use InvalidArgumentException; use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Http\Message\UriInterface; -use ReflectionProperty; use Symfony\Component\DependencyInjection\Container; -use TYPO3\CMS\Core\Configuration\Features; use TYPO3\CMS\Core\ExpressionLanguage\Resolver; use TYPO3\CMS\Core\Http\ServerRequest; use TYPO3\CMS\Core\Http\Uri; @@ -32,9 +31,8 @@ use TYPO3\CMS\Core\Site\Entity\SiteLanguage; use TYPO3\CMS\Core\Site\SiteFinder; use TYPO3\CMS\Core\Utility\GeneralUtility; -use TYPO3\TestingFramework\Core\Unit\UnitTestCase; -class RedirectUrlAdditionalParamsListenerTest extends UnitTestCase +class RedirectUrlAdditionalParamsListenerTest extends HeadlessUnitTestCase { public function testInvoke(): void { @@ -246,12 +244,7 @@ protected function getUrlUtility($site = null): UrlUtility $siteFinder->method('getSiteByPageId')->willReturn($site); - return new UrlUtility(new Features(), $resolver, $siteFinder, (new HeadlessMode())->withRequest((new ServerRequest())->withAttribute('headless', new Headless()))); + return new UrlUtility($resolver, $siteFinder, (new HeadlessMode())->withRequest((new ServerRequest())->withAttribute('headless', new Headless()))); } - protected function tearDown(): void - { - (new ReflectionProperty(GeneralUtility::class, 'container'))->setValue(null, null); - parent::tearDown(); - } } diff --git a/Tests/Unit/HeadlessUnitTestCase.php b/Tests/Unit/HeadlessUnitTestCase.php new file mode 100644 index 00000000..0d7d052e --- /dev/null +++ b/Tests/Unit/HeadlessUnitTestCase.php @@ -0,0 +1,33 @@ +setValue(null, null); + parent::tearDown(); + } +} diff --git a/Tests/Unit/Middleware/CookieDomainPerSiteTest.php b/Tests/Unit/Middleware/CookieDomainPerSiteTest.php index cc3c395f..c1e56a86 100644 --- a/Tests/Unit/Middleware/CookieDomainPerSiteTest.php +++ b/Tests/Unit/Middleware/CookieDomainPerSiteTest.php @@ -12,14 +12,13 @@ namespace FriendsOfTYPO3\Headless\Tests\Unit\Middleware; use FriendsOfTYPO3\Headless\Middleware\CookieDomainPerSite; +use FriendsOfTYPO3\Headless\Tests\Unit\HeadlessUnitTestCase; use FriendsOfTYPO3\Headless\Utility\HeadlessMode; use FriendsOfTYPO3\Headless\Utility\HeadlessModeInterface; use FriendsOfTYPO3\Headless\Utility\UrlUtility; use PHPUnit\Framework\Attributes\Test; use Psr\Log\LoggerInterface; -use ReflectionProperty; use Symfony\Component\DependencyInjection\Container; -use TYPO3\CMS\Core\Configuration\Features; use TYPO3\CMS\Core\ExpressionLanguage\Resolver; use TYPO3\CMS\Core\Http\JsonResponse; use TYPO3\CMS\Core\Http\NormalizedParams; @@ -28,9 +27,8 @@ use TYPO3\CMS\Core\Site\SiteFinder; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Frontend\Http\RequestHandler; -use TYPO3\TestingFramework\Core\Unit\UnitTestCase; -class CookieDomainPerSiteTest extends UnitTestCase +class CookieDomainPerSiteTest extends HeadlessUnitTestCase { protected function setUp(): void { @@ -41,12 +39,6 @@ protected function setUp(): void GeneralUtility::setContainer($container); } - protected function tearDown(): void - { - (new ReflectionProperty(GeneralUtility::class, 'container'))->setValue(null, null); - parent::tearDown(); - } - #[Test] public function emptyCookieDomain() { @@ -83,12 +75,12 @@ public function emptyCookieDomain() $site, ]); - $urlUtility = new UrlUtility(new Features(), $resolver, $siteFinder, new HeadlessMode()); + $urlUtility = new UrlUtility($resolver, $siteFinder, new HeadlessMode()); $urlUtility = $urlUtility->withSite($site); $middleware = new CookieDomainPerSite($urlUtility, $siteFinder, $this->createMock(LoggerInterface::class)); - $request = new ServerRequest('https://test-backend-api.tld'); + $request = new ServerRequest('https://test-backend-api.tld', 'GET', null, [], ['HTTP_HOST' => 'test-backend-api.tld', 'HTTPS' => 'on']); $request = $request->withAttribute('normalizedParams', NormalizedParams::createFromRequest($request)); $response = new JsonResponse([]); @@ -141,24 +133,31 @@ public function cookieDomainIsSet() $site, ]); - $urlUtility = new UrlUtility(new Features(), $resolver, $siteFinder, new HeadlessMode()); + $urlUtility = new UrlUtility($resolver, $siteFinder, new HeadlessMode()); $urlUtility = $urlUtility->withSite($site); $middleware = new CookieDomainPerSite($urlUtility, $siteFinder, $this->createMock(LoggerInterface::class)); - $request = new ServerRequest('https://test-backend-api.tld'); + $request = new ServerRequest('https://test-backend-api.tld', 'GET', null, [], ['HTTP_HOST' => 'test-backend-api.tld', 'HTTPS' => 'on']); $request = $request->withAttribute('normalizedParams', NormalizedParams::createFromRequest($request)); $response = new JsonResponse([]); - $middleware->process( - $request, - $this->getMockHandlerWithResponse($response) - ); - - self::assertEquals( - '.test-backend-api.tld', - $GLOBALS['TYPO3_CONF_VARS']['SYS']['cookieDomain'], + $before = $GLOBALS['TYPO3_CONF_VARS']['SYS']['cookieDomain'] ?? null; + $observedCookieDomain = null; + $handler = $this->createPartialMock(RequestHandler::class, ['handle']); + $handler->method('handle')->willReturnCallback(function () use (&$observedCookieDomain, $response) { + $observedCookieDomain = $GLOBALS['TYPO3_CONF_VARS']['SYS']['cookieDomain'] ?? null; + return $response; + }); + + $middleware->process($request, $handler); + + self::assertSame('.test-backend-api.tld', $observedCookieDomain); + self::assertSame( + $before, + $GLOBALS['TYPO3_CONF_VARS']['SYS']['cookieDomain'] ?? null, + 'cookieDomain must not persist past the middleware call', ); } diff --git a/Tests/Unit/Middleware/ElementBodyResponseMiddlewareTest.php b/Tests/Unit/Middleware/ElementBodyResponseMiddlewareTest.php index 30be7615..5f2a1a06 100644 --- a/Tests/Unit/Middleware/ElementBodyResponseMiddlewareTest.php +++ b/Tests/Unit/Middleware/ElementBodyResponseMiddlewareTest.php @@ -26,7 +26,11 @@ class ElementBodyResponseMiddlewareTest extends UnitTestCase { public function testProcess(): void { - $middleware = new ElementBodyResponseMiddleware(new JsonEncoder(new \TYPO3\CMS\Core\Configuration\Features()), new HeadlessMode()); + $middleware = new ElementBodyResponseMiddleware( + new JsonEncoder(new \TYPO3\CMS\Core\Configuration\Features()), + new HeadlessMode(), + new \FriendsOfTYPO3\Headless\Json\JsonDecoder(), + ); $responseArray = ['content' => ['colPos1' => [['id' => 1]]]]; $result = json_encode($responseArray['content']['colPos1'][0]); @@ -117,7 +121,11 @@ public function testProcess(): void ) ); - $middleware = new ElementBodyResponseMiddleware(new JsonEncoder(new \TYPO3\CMS\Core\Configuration\Features()), new HeadlessMode()); + $middleware = new ElementBodyResponseMiddleware( + new JsonEncoder(new \TYPO3\CMS\Core\Configuration\Features()), + new HeadlessMode(), + new \FriendsOfTYPO3\Headless\Json\JsonDecoder(), + ); $responseArray = ['content' => ['colPos2' => null, 'colPos1' => [['id' => 1]]]]; $result = json_encode($responseArray['content']['colPos1'][0]); diff --git a/Tests/Unit/Middleware/SiteBaseRedirectResolverTest.php b/Tests/Unit/Middleware/SiteBaseRedirectResolverTest.php index b8e1e22d..ff7e904e 100644 --- a/Tests/Unit/Middleware/SiteBaseRedirectResolverTest.php +++ b/Tests/Unit/Middleware/SiteBaseRedirectResolverTest.php @@ -12,14 +12,13 @@ namespace FriendsOfTYPO3\Headless\Tests\Unit\Middleware; use FriendsOfTYPO3\Headless\Middleware\SiteBaseRedirectResolver; +use FriendsOfTYPO3\Headless\Tests\Unit\HeadlessUnitTestCase; use FriendsOfTYPO3\Headless\Utility\Headless; use FriendsOfTYPO3\Headless\Utility\HeadlessMode; use FriendsOfTYPO3\Headless\Utility\HeadlessModeInterface; use FriendsOfTYPO3\Headless\Utility\UrlUtility; use Psr\Http\Server\RequestHandlerInterface; -use ReflectionProperty; use Symfony\Component\DependencyInjection\Container; -use TYPO3\CMS\Core\Configuration\Features; use TYPO3\CMS\Core\ExpressionLanguage\Resolver; use TYPO3\CMS\Core\Http\JsonResponse; use TYPO3\CMS\Core\Http\ServerRequest; @@ -31,11 +30,10 @@ use TYPO3\CMS\Core\Site\SiteFinder; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Frontend\Controller\ErrorController; -use TYPO3\TestingFramework\Core\Unit\UnitTestCase; use function json_decode; -class SiteBaseRedirectResolverTest extends UnitTestCase +class SiteBaseRedirectResolverTest extends HeadlessUnitTestCase { protected bool $resetSingletonInstances = true; @@ -67,7 +65,7 @@ public function testJsonRedirect() $container->set(HeadlessModeInterface::class, new HeadlessMode()); GeneralUtility::setContainer($container); - $urlUtility = new UrlUtility(new Features(), $this->createMock(Resolver::class), $siteFinder, new HeadlessMode()); + $urlUtility = new UrlUtility($this->createMock(Resolver::class), $siteFinder, new HeadlessMode()); $container->set(UrlUtility::class, $urlUtility); GeneralUtility::setContainer($container); @@ -140,7 +138,7 @@ public function testJsonRedirect() $container = new Container(); $container->set(HeadlessModeInterface::class, new HeadlessMode()); - $urlUtility = new UrlUtility(new Features(), $this->createMock(Resolver::class), $siteFinder, new HeadlessMode()); + $urlUtility = new UrlUtility($this->createMock(Resolver::class), $siteFinder, new HeadlessMode()); $container->set(UrlUtility::class, $urlUtility); $errorController = $this->createMock(ErrorController::class); $errorController->method('pageNotFoundAction')->willReturn(new JsonResponse(['ErrorController' => true])); @@ -175,9 +173,4 @@ public function testJsonRedirect() self::assertSame(['ErrorController' => true], json_decode($response->getBody()->getContents(), true)); } - protected function tearDown(): void - { - (new ReflectionProperty(GeneralUtility::class, 'container'))->setValue(null, null); - parent::tearDown(); - } } diff --git a/Tests/Unit/Middleware/UserIntMiddlewareTest.php b/Tests/Unit/Middleware/UserIntMiddlewareTest.php index 19da6483..be5c880b 100644 --- a/Tests/Unit/Middleware/UserIntMiddlewareTest.php +++ b/Tests/Unit/Middleware/UserIntMiddlewareTest.php @@ -30,7 +30,13 @@ class UserIntMiddlewareTest extends UnitTestCase #[Test] public function process(): void { - $middleware = new UserIntMiddleware(new HeadlessUserInt(), new HeadlessMode(), $this->createMock(MetaHandler::class)); + $middleware = new UserIntMiddleware( + new HeadlessUserInt(), + new HeadlessMode(), + $this->createMock(MetaHandler::class), + new \FriendsOfTYPO3\Headless\Json\JsonEncoder(new \TYPO3\CMS\Core\Configuration\Features()), + new \FriendsOfTYPO3\Headless\Json\JsonDecoder(), + ); $request = new ServerRequest(); @@ -60,7 +66,13 @@ public function process(): void )->getBody()->__toString() ); - $middleware = new UserIntMiddleware(new HeadlessUserInt(), new HeadlessMode(), $this->createMock(MetaHandler::class)); + $middleware = new UserIntMiddleware( + new HeadlessUserInt(), + new HeadlessMode(), + $this->createMock(MetaHandler::class), + new \FriendsOfTYPO3\Headless\Json\JsonEncoder(new \TYPO3\CMS\Core\Configuration\Features()), + new \FriendsOfTYPO3\Headless\Json\JsonDecoder(), + ); $request = new ServerRequest(); $request = $request->withAttribute('headless', new Headless()); @@ -91,7 +103,13 @@ public function process(): void $metaHandlerMock = $this->createMock(MetaHandler::class); $metaHandlerMock->method('process')->withAnyParameters()->willReturn(['seo' => ['title' => 'test2']]); - $middleware = new UserIntMiddleware(new HeadlessUserInt(), new HeadlessMode(), $metaHandlerMock); + $middleware = new UserIntMiddleware( + new HeadlessUserInt(), + new HeadlessMode(), + $metaHandlerMock, + new \FriendsOfTYPO3\Headless\Json\JsonEncoder(new \TYPO3\CMS\Core\Configuration\Features()), + new \FriendsOfTYPO3\Headless\Json\JsonDecoder(), + ); $c = json_encode(['seo' => ['title' => 'test']]); $responseString = '"' . HeadlessUserInt::STANDARD . '_START<<' . $c . '>>' . HeadlessUserInt::STANDARD . '_END"'; diff --git a/Tests/Unit/Resource/Service/HeadlessImageServiceTest.php b/Tests/Unit/Resource/Service/HeadlessImageServiceTest.php new file mode 100644 index 00000000..c2d62c9f --- /dev/null +++ b/Tests/Unit/Resource/Service/HeadlessImageServiceTest.php @@ -0,0 +1,138 @@ +createFrontendRequest(); + } + + protected function tearDown(): void + { + unset($GLOBALS['TYPO3_REQUEST']); + parent::tearDown(); + } + + public function testStripsProxyPrefixThenResolvesByNumericId(): void + { + $file = $this->createMock(File::class); + $resourceFactory = $this->createMock(ResourceFactory::class); + $resourceFactory->expects(self::once()) + ->method('getFileObject') + ->with('123') + ->willReturn($file); + + $service = $this->service($resourceFactory); + + self::assertSame( + $file, + $service->getImage(self::PROXY_URL . '/123', null, false) + ); + } + + public function testDoesNotStripWhenHeadlessModeDisabled(): void + { + $resourceFactory = $this->createMock(ResourceFactory::class); + $resourceFactory->expects(self::once()) + ->method('retrieveFileOrFolderObject') + ->with(self::PROXY_URL . '/123') + ->willReturn($this->createMock(File::class)); + + $service = $this->service($resourceFactory, headlessEnabled: false); + + $service->getImage(self::PROXY_URL . '/123', null, false); + } + + public function testDoesNotStripWhenProxyUrlEmpty(): void + { + $resourceFactory = $this->createMock(ResourceFactory::class); + $resourceFactory->expects(self::once()) + ->method('retrieveFileOrFolderObject') + ->with('fileadmin/test.jpg') + ->willReturn($this->createMock(File::class)); + + $service = $this->service($resourceFactory, proxyUrl: ''); + + $service->getImage('fileadmin/test.jpg', null, false); + } + + public function testDoesNotStripWhenBackendRequest(): void + { + $GLOBALS['TYPO3_REQUEST'] = $this->createMock(ServerRequestInterface::class); + $GLOBALS['TYPO3_REQUEST']->method('getAttribute')->willReturnCallback( + static fn(string $key) => $key === 'applicationType' ? SystemEnvironmentBuilder::REQUESTTYPE_BE : null + ); + + $resourceFactory = $this->createMock(ResourceFactory::class); + $resourceFactory->expects(self::once()) + ->method('retrieveFileOrFolderObject') + ->with(self::PROXY_URL . '/123') + ->willReturn($this->createMock(File::class)); + + $service = $this->service($resourceFactory); + + $service->getImage(self::PROXY_URL . '/123', null, false); + } + + public function testReturnsAlreadyResolvedFileWithoutTouchingResourceFactory(): void + { + $file = $this->createMock(File::class); + $resourceFactory = $this->createMock(ResourceFactory::class); + $resourceFactory->expects(self::never())->method(self::anything()); + + $service = $this->service($resourceFactory); + + self::assertSame( + $file, + $service->getImage(self::PROXY_URL . '/123', $file, false) + ); + } + + private function service( + ResourceFactory $resourceFactory, + bool $headlessEnabled = true, + string $proxyUrl = self::PROXY_URL, + ): HeadlessImageService { + $headlessMode = $this->createMock(HeadlessModeInterface::class); + $headlessMode->method('isEnabledFor')->willReturn($headlessEnabled); + + $urlUtility = $this->createMock(HeadlessFrontendUrlInterface::class); + $urlUtility->method('withRequest')->willReturnSelf(); + $urlUtility->method('getProxyUrl')->willReturn($proxyUrl); + + return new HeadlessImageService($resourceFactory, $headlessMode, $urlUtility); + } + + private function createFrontendRequest(): ServerRequestInterface + { + $request = $this->createMock(ServerRequestInterface::class); + $request->method('getAttribute')->willReturnCallback( + static fn(string $key) => $key === 'applicationType' ? SystemEnvironmentBuilder::REQUESTTYPE_FE : null + ); + + return $request; + } +} diff --git a/Tests/Unit/Seo/MetaHandlerTest.php b/Tests/Unit/Seo/MetaHandlerTest.php index 014a79ab..74ec3689 100644 --- a/Tests/Unit/Seo/MetaHandlerTest.php +++ b/Tests/Unit/Seo/MetaHandlerTest.php @@ -12,6 +12,7 @@ namespace FriendsOfTYPO3\Headless\Tests\Unit\Seo; use FriendsOfTYPO3\Headless\Seo\MetaHandler; +use FriendsOfTYPO3\Headless\Tests\Unit\HeadlessUnitTestCase; use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Http\Message\ServerRequestInterface; use ReflectionProperty; @@ -28,9 +29,8 @@ use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer; use TYPO3\CMS\Frontend\Event\ModifyHrefLangTagsEvent; use TYPO3\CMS\Frontend\Page\PageInformation; -use TYPO3\TestingFramework\Core\Unit\UnitTestCase; -class MetaHandlerTest extends UnitTestCase +class MetaHandlerTest extends HeadlessUnitTestCase { protected bool $resetSingletonInstances = true; @@ -40,12 +40,6 @@ protected function setUp(): void GeneralUtility::setContainer(new Container()); } - protected function tearDown(): void - { - (new ReflectionProperty(GeneralUtility::class, 'container'))->setValue(null, null); - parent::tearDown(); - } - public function testProcessBuildsSeoBlock(): void { $registry = $this->createMock(MetaTagManagerRegistry::class); @@ -138,6 +132,31 @@ public function testProcessOverwriteBodyTagReplacesAttributes(): void self::assertSame(['class' => 'custom'], $result['seo']['bodyAttrs']); } + public function testProcessReturnsContentUnchangedWhenPageInformationMissing(): void + { + $registry = $this->createMock(MetaTagManagerRegistry::class); + $registry->method('getAllManagers')->willReturn([]); + + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $eventDispatcher->method('dispatch')->willReturnArgument(0); + + $titleProvider = $this->createMock(PageTitleProviderManager::class); + + $handler = new MetaHandler( + $registry, + $eventDispatcher, + $titleProvider, + new \TYPO3\CMS\Core\TypoScript\TypoScriptService() + ); + + $request = $this->createMock(ServerRequestInterface::class); + $request->method('getAttribute')->willReturn(null); + + $content = ['appearance' => ['layout' => 'default'], 'other' => 'kept']; + + self::assertSame($content, $handler->process($request, $content)); + } + private function buildRequest(array $typoScriptSetup = []): ServerRequestInterface { $pageInfo = new PageInformation(); diff --git a/Tests/Unit/Seo/MetaTag/MetaTagTest.php b/Tests/Unit/Seo/MetaTag/MetaTagTest.php index d8cf5038..1248caeb 100644 --- a/Tests/Unit/Seo/MetaTag/MetaTagTest.php +++ b/Tests/Unit/Seo/MetaTag/MetaTagTest.php @@ -13,18 +13,17 @@ use FriendsOfTYPO3\Headless\Seo\MetaTag\Html5MetaTagManager; use FriendsOfTYPO3\Headless\Seo\MetaTag\OpenGraphMetaTagManager; +use FriendsOfTYPO3\Headless\Tests\Unit\HeadlessUnitTestCase; use FriendsOfTYPO3\Headless\Utility\Headless; use FriendsOfTYPO3\Headless\Utility\HeadlessMode; use FriendsOfTYPO3\Headless\Utility\HeadlessModeInterface; -use ReflectionProperty; use Symfony\Component\DependencyInjection\Container; use TYPO3\CMS\Core\Http\ServerRequest; use TYPO3\CMS\Core\MetaTag\MetaTagManagerRegistry; use TYPO3\CMS\Core\Page\PageRenderer; use TYPO3\CMS\Core\Utility\GeneralUtility; -use TYPO3\TestingFramework\Core\Unit\UnitTestCase; -class MetaTagTest extends UnitTestCase +class MetaTagTest extends HeadlessUnitTestCase { protected bool $resetSingletonInstances = true; @@ -71,6 +70,23 @@ public function testProps(): void self::assertSame('[{"property":"og:image","content":"Powered by TYPO3"},{"property":"og:image:url","content":"https:\/\/example.com\/image.jpg"}]', $ogManager->renderAllProperties()); } + public function testRenderHeadlessPropertyAsArrayReturnsRawStructure(): void + { + $container = new Container(); + $container->set(HeadlessModeInterface::class, new HeadlessMode()); + GeneralUtility::setContainer($container); + + $manager = new Html5MetaTagManager(); + $manager->addProperty('generator', 'TYPO3 CMS x T3Headless', [], true, 'name'); + + $result = $manager->renderHeadlessPropertyAsArray('generator'); + + self::assertSame( + [['name' => 'generator', 'content' => 'TYPO3 CMS x T3Headless']], + $result + ); + } + public function testCustomContentAttribute(): void { $container = new Container(); @@ -92,9 +108,4 @@ public function testCustomContentAttribute(): void ); } - protected function tearDown(): void - { - (new ReflectionProperty(GeneralUtility::class, 'container'))->setValue(null, null); - parent::tearDown(); - } } diff --git a/Tests/Unit/Utility/FileUtilityTest.php b/Tests/Unit/Utility/FileUtilityTest.php index e1b91d04..8df81367 100644 --- a/Tests/Unit/Utility/FileUtilityTest.php +++ b/Tests/Unit/Utility/FileUtilityTest.php @@ -189,7 +189,7 @@ public function testCustomProcessingOptions(): void 'size' => 72392, 'creation_date' => 1639061876, 'modification_date' => 1639061876, - 'crop' => '', + 'crop' => null, 'width' => 526, 'height' => 526, ]; @@ -460,6 +460,462 @@ public function testExceptionCatching(): void $this->testProcessImageFileException(new InvalidArgumentException('test')); } + /** + * A 100x100 source with a 75x25 crop area only contains 75x25 pixels of source + * data inside that crop. Even with autogenerate `factor = 2`, the variant cannot + * exceed the crop dimensions — the cap must come from the cropped area, not the + * uncropped original. Otherwise the inner processor receives an over-sized, + * aspect-mismatched request (e.g. 100x50 from a 3:1 crop) and produces a + * stretched/padded image. + */ + public function testProcessAutogenerateCapsByCroppedDimensionsNotOriginal(): void + { + $cropJson = '{"default":{"cropArea":{"x":0,"y":0,"width":0.75,"height":0.25},"selectedRatio":"NaN","focusArea":null}}'; + + $fileData = [ + 'uid' => 103, + 'pid' => 0, + 'missing' => 0, + 'type' => '2', + 'storage' => 1, + 'identifier' => '/test-file.jpg', + 'extension' => 'jpg', + 'mime_type' => 'image/jpeg', + 'name' => 'test-file.jpg', + 'size' => 72392, + 'creation_date' => 1639061876, + 'modification_date' => 1639061876, + 'crop' => $cropJson, + 'width' => 100, + 'height' => 100, + ]; + + $croppedFileData = array_merge($fileData, ['width' => 75, 'height' => 25]); + + $file = $this->getMockFileForData($fileData, ['crop' => $cropJson]); + $processedFile = $this->getMockProcessedFileForData($croppedFileData); + + $capturedInstructions = []; + $imageService = $this->createMock(ImageService::class); + $imageService->method('getImageUri')->willReturn('https://test-frontend.tld/fileadmin/test-file.jpg'); + $imageService->method('applyProcessingInstructions')->willReturnCallback( + static function ($_file, $instructions) use (&$capturedInstructions, $processedFile) { + $capturedInstructions[] = $instructions; + return $processedFile; + } + ); + + $fileUtility = $this->getFileUtility(null, $imageService); + + $options = [ + 'legacyReturn' => 0, + 'cacheBusting' => 1, + 'autogenerate.' => [ + 'big' => ['factor' => 2], + ], + ]; + + $fileUtility->process($file, ProcessingConfiguration::fromOptions($options)); + + self::assertCount(2, $capturedInstructions, 'Expected outer + autogenerate inner call'); + + // Outer call: crop only, no explicit dimensions. + self::assertNull($capturedInstructions[0]['width']); + self::assertNull($capturedInstructions[0]['height']); + + // Autogenerate "big" with factor=2 on a 75x25 crop of a 100x100 image. + // The cap should be the crop's dimensions (75x25), not the uncropped + // file's dimensions (100x100). Without this cap, the inner request becomes + // 100x50 — an aspect mismatch against the 3:1 crop area that the image + // processor resolves by stretching or padding. + self::assertSame('75', $capturedInstructions[1]['width']); + self::assertSame('25', $capturedInstructions[1]['height']); + } + + public function testProcessAutogenerateWithoutCropUsesFileDimensionsAsCap(): void + { + $fileData = $this->getImageFileData(); + $file = $this->getMockFileForData($fileData); + $processedFile = $this->getMockProcessedFileForData($fileData); + + $captured = []; + $fileUtility = $this->getFileUtility(null, $this->createCapturingImageService($captured, $processedFile)); + + $fileUtility->process($file, ProcessingConfiguration::fromOptions([ + 'legacyReturn' => 0, + 'autogenerate.' => ['big' => ['factor' => 2]], + ])); + + self::assertCount(2, $captured); + // factor=2 on a 100x100 (no crop) capped at 100x100 — can't enlarge past source pixels. + self::assertSame('100', $captured[1]['width']); + self::assertSame('100', $captured[1]['height']); + } + + public function testProcessAutogenerateWithFractionalFactorScalesDown(): void + { + $fileData = $this->getImageFileData(); + $file = $this->getMockFileForData($fileData); + $processedFile = $this->getMockProcessedFileForData($fileData); + + $captured = []; + $fileUtility = $this->getFileUtility(null, $this->createCapturingImageService($captured, $processedFile)); + + $fileUtility->process($file, ProcessingConfiguration::fromOptions([ + 'legacyReturn' => 0, + 'autogenerate.' => ['lqip' => ['factor' => 0.1]], + ])); + + // 100 * 0.1 = 10, well under the 100 cap. + self::assertSame('10', $captured[1]['width']); + self::assertSame('10', $captured[1]['height']); + } + + public function testProcessAutogenerateRespectsExplicitProcessingDimensions(): void + { + $fileData = $this->getImageFileData(['width' => 200, 'height' => 200]); + $processedData = array_merge($fileData, ['width' => 50, 'height' => 50]); + + $file = $this->getMockFileForData($fileData); + $processedFile = $this->getMockProcessedFileForData($processedData); + + $captured = []; + $fileUtility = $this->getFileUtility(null, $this->createCapturingImageService($captured, $processedFile)); + + $fileUtility->process($file, ProcessingConfiguration::fromOptions([ + 'legacyReturn' => 0, + 'width' => 50, + 'height' => 50, + 'autogenerate.' => ['big' => ['factor' => 2]], + ])); + + // Outer call carries the explicit dimensions. + self::assertSame('50', $captured[0]['width']); + self::assertSame('50', $captured[0]['height']); + + // Autogenerate target = explicit width/height (50), factor=2 → 100 (capped by 200 source). + self::assertSame('100', $captured[1]['width']); + self::assertSame('100', $captured[1]['height']); + } + + public function testProcessAutogenerateGeneratesMultipleVariantsInOrder(): void + { + $fileData = $this->getImageFileData(); + $file = $this->getMockFileForData($fileData); + $processedFile = $this->getMockProcessedFileForData($fileData); + + $captured = []; + $fileUtility = $this->getFileUtility(null, $this->createCapturingImageService($captured, $processedFile)); + + $result = $fileUtility->process($file, ProcessingConfiguration::fromOptions([ + 'legacyReturn' => 0, + 'autogenerate.' => [ + 'big' => ['factor' => 2], + 'thumb' => ['factor' => 0.1], + ], + ])); + + // 1 outer + 2 inner. + self::assertCount(3, $captured); + self::assertSame('100', $captured[1]['width']); // big capped at 100 + self::assertSame('10', $captured[2]['width']); // thumb 100*0.1=10 + self::assertArrayHasKey('big', $result); + self::assertArrayHasKey('thumb', $result); + } + + public function testProcessAutogenerateSkippedWhenTargetDimensionsAreZero(): void + { + $fileData = $this->getImageFileData(['width' => 0, 'height' => 0]); + $file = $this->getMockFileForData($fileData); + $processedFile = $this->getMockProcessedFileForData($fileData); + + $captured = []; + $fileUtility = $this->getFileUtility(null, $this->createCapturingImageService($captured, $processedFile)); + + $result = $fileUtility->process($file, ProcessingConfiguration::fromOptions([ + 'legacyReturn' => 0, + 'autogenerate.' => ['big' => ['factor' => 2]], + ])); + + // Only the outer call — autogenerate loop is skipped when both targets are 0. + self::assertCount(1, $captured); + self::assertArrayNotHasKey('big', $result); + } + + public function testProcessAutogenerateExpandsLegacyRetina2xAndLqipKeys(): void + { + $fileData = $this->getImageFileData(); + $file = $this->getMockFileForData($fileData); + $processedFile = $this->getMockProcessedFileForData($fileData); + + $captured = []; + $fileUtility = $this->getFileUtility(null, $this->createCapturingImageService($captured, $processedFile)); + + $result = $fileUtility->process($file, ProcessingConfiguration::fromOptions([ + 'legacyReturn' => 0, + 'autogenerate.' => ['retina2x' => 1, 'lqip' => 1], + ])); + + self::assertArrayHasKey('urlRetina', $result); + self::assertArrayHasKey('urlLqip', $result); + self::assertArrayNotHasKey('retina2x', $result); + self::assertArrayNotHasKey('lqip', $result); + } + + public function testProcessAutogenerateTrimsTrailingDotInVariantKey(): void + { + $fileData = $this->getImageFileData(); + $file = $this->getMockFileForData($fileData); + $processedFile = $this->getMockProcessedFileForData($fileData); + + $captured = []; + $fileUtility = $this->getFileUtility(null, $this->createCapturingImageService($captured, $processedFile)); + + $result = $fileUtility->process($file, ProcessingConfiguration::fromOptions([ + 'legacyReturn' => 0, + 'autogenerate.' => ['big.' => ['factor' => 2]], + ])); + + self::assertArrayHasKey('big', $result); + self::assertArrayNotHasKey('big.', $result); + } + + public function testProcessAutogenerateForwardsFileExtensionPerVariant(): void + { + $fileData = $this->getImageFileData(); + $file = $this->getMockFileForData($fileData); + $processedFile = $this->getMockProcessedFileForData($fileData); + + $captured = []; + $fileUtility = $this->getFileUtility(null, $this->createCapturingImageService($captured, $processedFile)); + + $fileUtility->process($file, ProcessingConfiguration::fromOptions([ + 'legacyReturn' => 0, + 'autogenerate.' => ['webpVariant' => ['factor' => 1, 'fileExtension' => 'webp']], + ])); + + self::assertSame('webp', $captured[1]['fileExtension']); + } + + public function testProcessImageFileForwardsMinMaxAndFileExtension(): void + { + $fileData = $this->getImageFileData(); + $file = $this->getMockFileForData($fileData); + $processedFile = $this->getMockProcessedFileForData($fileData); + + $captured = []; + $fileUtility = $this->getFileUtility(null, $this->createCapturingImageService($captured, $processedFile)); + + $fileUtility->process($file, ProcessingConfiguration::fromOptions([ + 'legacyReturn' => 0, + 'minWidth' => 10, + 'minHeight' => 20, + 'maxWidth' => 200, + 'maxHeight' => 300, + 'fileExtension' => 'png', + ])); + + self::assertCount(1, $captured); + self::assertSame(10, $captured[0]['minWidth']); + self::assertSame(20, $captured[0]['minHeight']); + self::assertSame(200, $captured[0]['maxWidth']); + self::assertSame(300, $captured[0]['maxHeight']); + self::assertSame('png', $captured[0]['fileExtension']); + } + + public function testProcessSkipsImageProcessingWhenDelayProcessing(): void + { + $fileData = $this->getImageFileData(); + $file = $this->getMockFileForData($fileData); + $processedFile = $this->getMockProcessedFileForData($fileData); + + $captured = []; + $fileUtility = $this->getFileUtility(null, $this->createCapturingImageService($captured, $processedFile)); + + $fileUtility->process($file, ProcessingConfiguration::fromOptions([ + 'legacyReturn' => 0, + 'delayProcessing' => 1, + ])); + + // Image processing skipped entirely. + self::assertCount(0, $captured); + } + + public function testProcessSkipsImageProcessingForSvgWhenProcessSvgFalse(): void + { + $fileData = $this->getImageFileData([ + 'extension' => 'svg', + 'mime_type' => 'image/svg+xml', + 'name' => 'test-file.svg', + 'identifier' => '/test-file.svg', + ]); + $file = $this->getMockFileForData($fileData); + $processedFile = $this->getMockProcessedFileForData($fileData); + + $captured = []; + $fileUtility = $this->getFileUtility(null, $this->createCapturingImageService($captured, $processedFile)); + + $fileUtility->process($file, ProcessingConfiguration::fromOptions(['legacyReturn' => 0])); + + self::assertCount(0, $captured); + } + + public function testProcessSkipsImageProcessingForGifWhenProcessGifFalse(): void + { + $fileData = $this->getImageFileData([ + 'extension' => 'gif', + 'mime_type' => 'image/gif', + 'name' => 'test-file.gif', + 'identifier' => '/test-file.gif', + ]); + $file = $this->getMockFileForData($fileData); + $processedFile = $this->getMockProcessedFileForData($fileData); + + $captured = []; + $fileUtility = $this->getFileUtility(null, $this->createCapturingImageService($captured, $processedFile)); + + $fileUtility->process($file, ProcessingConfiguration::fromOptions(['legacyReturn' => 0])); + + self::assertCount(0, $captured); + } + + public function testProcessSkipsImageProcessingForPdfWhenProcessPdfAsImageFalse(): void + { + $fileData = $this->getImageFileData([ + 'extension' => 'pdf', + 'mime_type' => 'application/pdf', + 'name' => 'test-file.pdf', + 'identifier' => '/test-file.pdf', + ]); + $file = $this->getMockFileForData($fileData); + $processedFile = $this->getMockProcessedFileForData($fileData); + + $captured = []; + $fileUtility = $this->getFileUtility(null, $this->createCapturingImageService($captured, $processedFile)); + + $fileUtility->process($file, ProcessingConfiguration::fromOptions(['legacyReturn' => 0])); + + self::assertCount(0, $captured); + } + + public function testProcessCacheBusterFallsBackToTstampWhenModificationDateMissing(): void + { + $fileData = $this->getImageFileData([ + 'modification_date' => null, + 'tstamp' => 9999, + ]); + $file = $this->getMockFileForData($fileData); + $processedFile = $this->getMockProcessedFileForData($fileData); + + $captured = []; + $fileUtility = $this->getFileUtility(null, $this->createCapturingImageService($captured, $processedFile)); + + $result = $fileUtility->process($file, ProcessingConfiguration::fromOptions([ + 'legacyReturn' => 0, + 'cacheBusting' => 1, + ])); + + self::assertStringEndsWith('?9999', $result['url']); + } + + public function testProcessCropVariantsConditionalSkipsEmptyCropArea(): void + { + // Area::isEmpty() returns true for the full-image sentinel (0,0,1,1) — that's + // TYPO3's "no real crop applied" marker. With conditionalCropVariant=1 those + // are skipped. The 'default' variant has a real crop, 'mobile' is the sentinel. + $cropJson = '{"default":{"cropArea":{"x":0.1,"y":0.1,"width":0.5,"height":0.5},"selectedRatio":"NaN","focusArea":null},' + . '"mobile":{"cropArea":{"x":0,"y":0,"width":1,"height":1},"selectedRatio":"NaN","focusArea":null}}'; + + $fileData = $this->getImageFileData(['crop' => $cropJson]); + $file = $this->getMockFileForData($fileData, ['crop' => $cropJson]); + $processedFile = $this->getMockProcessedFileForData($fileData); + + $captured = []; + $fileUtility = $this->getFileUtility(null, $this->createCapturingImageService($captured, $processedFile)); + + $options = ['legacyReturn' => 0, 'conditionalCropVariant' => 1]; + $processed = $fileUtility->process($file, ProcessingConfiguration::fromOptions($options)); + $processed = $fileUtility->processCropVariants($file, ProcessingConfiguration::fromOptions($options), $processed); + + self::assertArrayHasKey('cropVariants', $processed); + self::assertArrayHasKey('default', $processed['cropVariants']); + self::assertArrayNotHasKey('mobile', $processed['cropVariants']); + } + + public function testProcessCropVariantsOutputCropAreaIncludesCoordinates(): void + { + $defaultArea = ['x' => 0.1, 'y' => 0.2, 'width' => 0.5, 'height' => 0.6]; + $mobileArea = ['x' => 0, 'y' => 0, 'width' => 1, 'height' => 1]; + $cropJson = json_encode([ + 'default' => ['cropArea' => $defaultArea, 'selectedRatio' => 'NaN', 'focusArea' => null], + 'mobile' => ['cropArea' => $mobileArea, 'selectedRatio' => 'NaN', 'focusArea' => null], + ]); + + $fileData = $this->getImageFileData(['crop' => $cropJson]); + $file = $this->getMockFileForData($fileData, ['crop' => $cropJson]); + $processedFile = $this->getMockProcessedFileForData($fileData); + + $captured = []; + $fileUtility = $this->getFileUtility(null, $this->createCapturingImageService($captured, $processedFile)); + + $options = ['legacyReturn' => 0, 'outputCropArea' => 1]; + $processed = $fileUtility->process($file, ProcessingConfiguration::fromOptions($options)); + $processed = $fileUtility->processCropVariants($file, ProcessingConfiguration::fromOptions($options), $processed); + + self::assertArrayHasKey('crop', $processed['cropVariants']['default']['dimensions']); + self::assertSame( + ['cropArea' => $defaultArea, 'selectedRatio' => 'NaN', 'focusArea' => null], + $processed['cropVariants']['default']['dimensions']['crop'] + ); + } + + public function testProcessOnDemandPropertiesSupportsAsAliasAndPublicUrlSkip(): void + { + $fileData = $this->getImageFileData(['alternative' => 'alt-text']); + $file = $this->getMockFileForData($fileData); + $processedFile = $this->getMockProcessedFileForData($fileData); + + $captured = []; + $fileUtility = $this->getFileUtility(null, $this->createCapturingImageService($captured, $processedFile)); + + $result = $fileUtility->process($file, ProcessingConfiguration::fromOptions([ + 'legacyReturn' => 0, + 'properties.' => [ + // 'publicUrl' should be skipped, alternative aliased to 'alt', width aliased. + 'includeOnly' => 'publicUrl,alternative as alt,width', + ], + ])); + + self::assertArrayHasKey('alt', $result); + self::assertSame('alt-text', $result['alt']); + self::assertArrayNotHasKey('publicUrl', $result); + self::assertArrayNotHasKey('alternative', $result); + // 'width' falls under dimensions.* (not flattened). + self::assertSame(100, $result['dimensions']['width']); + } + + public function testProcessFlattenPropertiesPlacesWidthAtTopLevel(): void + { + $fileData = $this->getImageFileData(); + $file = $this->getMockFileForData($fileData); + $processedFile = $this->getMockProcessedFileForData($fileData); + + $captured = []; + $fileUtility = $this->getFileUtility(null, $this->createCapturingImageService($captured, $processedFile)); + + $result = $fileUtility->process($file, ProcessingConfiguration::fromOptions([ + 'legacyReturn' => 0, + 'properties.' => [ + 'includeOnly' => 'width,height', + 'flatten' => 1, + ], + ])); + + self::assertSame(100, $result['width']); + self::assertSame(100, $result['height']); + self::assertArrayNotHasKey('dimensions', $result); + } + protected function getFileUtility( ?MockObject $normalizedParams = null, $imageService = null, @@ -554,6 +1010,9 @@ protected function getMockFileForData($data, array $overrideToArray = []) }); $file->method('toArray')->willReturn($overrideToArray); } else { + $file->method('getProperty')->willReturnCallback(static function ($key) use ($data) { + return $data[$key] ?? null; + }); $file->method('toArray')->willReturn( [ 'extension' => 'jpg', @@ -630,6 +1089,44 @@ protected function getMockProcessedFileForData($data) return $processedFile; } + protected function createCapturingImageService( + array &$captured, + $processedFile, + string $publicUrl = 'https://test-frontend.tld/fileadmin/test-file.jpg' + ) { + $imageService = $this->createMock(ImageService::class); + $imageService->method('getImageUri')->willReturn($publicUrl); + $imageService->method('applyProcessingInstructions')->willReturnCallback( + static function ($_file, $instructions) use (&$captured, $processedFile) { + $captured[] = $instructions; + return $processedFile; + } + ); + + return $imageService; + } + + protected function getImageFileData(array $overrides = []): array + { + return array_merge([ + 'uid' => 103, + 'pid' => 0, + 'missing' => 0, + 'type' => '2', + 'storage' => 1, + 'identifier' => '/test-file.jpg', + 'extension' => 'jpg', + 'mime_type' => 'image/jpeg', + 'name' => 'test-file.jpg', + 'size' => 72392, + 'creation_date' => 1639061876, + 'modification_date' => 1639061876, + 'crop' => null, + 'width' => 100, + 'height' => 100, + ], $overrides); + } + protected function getImageServiceWithProcessedFile($file, $processedFile, $processingInstruction = []) { if ($processingInstruction === []) { diff --git a/Tests/Unit/Utility/HeadlessModeTest.php b/Tests/Unit/Utility/HeadlessModeTest.php index 7a95beea..f4d7fd04 100644 --- a/Tests/Unit/Utility/HeadlessModeTest.php +++ b/Tests/Unit/Utility/HeadlessModeTest.php @@ -12,13 +12,14 @@ use FriendsOfTYPO3\Headless\Utility\Headless; use FriendsOfTYPO3\Headless\Utility\HeadlessMode; use FriendsOfTYPO3\Headless\Utility\HeadlessModeInterface; -use PHPUnit\Framework\TestCase; +use LogicException; use TYPO3\CMS\Core\Http\ServerRequest; use TYPO3\CMS\Core\Http\Uri; use TYPO3\CMS\Core\Site\Entity\Site; use TYPO3\CMS\Core\Site\Entity\SiteLanguage; +use TYPO3\TestingFramework\Core\Unit\UnitTestCase; -class HeadlessModeTest extends TestCase +class HeadlessModeTest extends UnitTestCase { public function testMixedModeWithoutHeader(): void { @@ -84,6 +85,75 @@ public function testFullMode(): void self::assertTrue($mode->isEnabled()); } + public function testWithRequestReturnsCloneAndDoesNotLeakToOriginal(): void + { + $base = new HeadlessMode(); + + $a = $base->withRequest( + (new ServerRequest())->withAttribute('headless', new Headless(HeadlessMode::FULL)) + ); + $b = $a->withRequest( + (new ServerRequest())->withAttribute('headless', new Headless(HeadlessMode::NONE)) + ); + + self::assertNotSame($base, $a, 'withRequest must return a fresh instance'); + self::assertNotSame($a, $b, 'each withRequest call must return a fresh instance'); + self::assertTrue($a->isEnabled(), 'first clone keeps its own request'); + self::assertFalse($b->isEnabled(), 'second clone has its own request'); + self::assertFalse($base->isEnabled(), 'untouched original is still requestless'); + } + + public function testMixedModeRejectsCompositeAcceptHeader(): void + { + $request = (new ServerRequest()) + ->withHeader('Accept', 'application/json, text/plain, */*') + ->withAttribute('headless', new Headless(HeadlessMode::MIXED)); + + self::assertFalse( + (new HeadlessMode())->withRequest($request)->isEnabled(), + 'MIXED mode must only react to an Accept header that is exactly application/json' + ); + } + + public function testMixedModeAcceptsStrictApplicationJsonHeader(): void + { + $request = (new ServerRequest()) + ->withHeader('Accept', 'application/json') + ->withAttribute('headless', new Headless(HeadlessMode::MIXED)); + + self::assertTrue((new HeadlessMode())->withRequest($request)->isEnabled()); + } + + public function testIsEnabledForDoesNotBindRequestToInstance(): void + { + $mode = new HeadlessMode(); + $request = (new ServerRequest())->withAttribute('headless', new Headless(HeadlessMode::FULL)); + + self::assertTrue($mode->isEnabledFor($request), 'pure check returns true for FULL request'); + self::assertFalse($mode->isEnabled(), 'isEnabledFor must not bind the request to the instance'); + } + + public function testIsEnabledForMirrorsWithRequestIsEnabled(): void + { + $mode = new HeadlessMode(); + + foreach ([HeadlessMode::NONE => false, HeadlessMode::FULL => true] as $modeValue => $expected) { + $request = (new ServerRequest())->withAttribute('headless', new Headless($modeValue)); + self::assertSame($expected, $mode->isEnabledFor($request)); + self::assertSame($expected, $mode->withRequest($request)->isEnabled()); + } + } + + public function testOverrideBackendRequestThrowsWithoutPriorWithRequest(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionCode(1747200000); + + (new HeadlessMode())->overrideBackendRequestBySite( + new Site('test', 1, ['headless' => HeadlessModeInterface::FULL]) + ); + } + public function testBackendRequestOverride(): void { $mode = new HeadlessMode(); diff --git a/Tests/Unit/Utility/PluginUtilityTest.php b/Tests/Unit/Utility/PluginUtilityTest.php index e9876455..caef1e4d 100644 --- a/Tests/Unit/Utility/PluginUtilityTest.php +++ b/Tests/Unit/Utility/PluginUtilityTest.php @@ -11,14 +11,12 @@ namespace FriendsOfTYPO3\Headless\Tests\Unit\Utility; +use FriendsOfTYPO3\Headless\Tests\Unit\HeadlessUnitTestCase; use FriendsOfTYPO3\Headless\Utility\HeadlessMode; use FriendsOfTYPO3\Headless\Utility\HeadlessModeInterface; use FriendsOfTYPO3\Headless\Utility\PluginUtility; use FriendsOfTYPO3\Headless\Utility\UrlUtility; -use PHPUnit\Framework\TestCase; -use ReflectionProperty; use Symfony\Component\DependencyInjection\Container; -use TYPO3\CMS\Core\Configuration\Features; use TYPO3\CMS\Core\ExpressionLanguage\Resolver; use TYPO3\CMS\Core\Http\PropagateResponseException; use TYPO3\CMS\Core\Http\ServerRequest; @@ -29,7 +27,7 @@ use function json_decode; -class PluginUtilityTest extends TestCase +class PluginUtilityTest extends HeadlessUnitTestCase { protected function setUp(): void { @@ -40,15 +38,9 @@ protected function setUp(): void GeneralUtility::setContainer($container); } - protected function tearDown(): void - { - (new ReflectionProperty(GeneralUtility::class, 'container'))->setValue(null, null); - parent::tearDown(); - } - public function testProperException(): void { - $urlUtility = new UrlUtility(new Features(), $this->createMock(Resolver::class), $this->createMock(SiteFinder::class), new HeadlessMode()); + $urlUtility = new UrlUtility($this->createMock(Resolver::class), $this->createMock(SiteFinder::class), new HeadlessMode()); $pluginRedirect = new PluginUtility($urlUtility); @@ -59,7 +51,7 @@ public function testProperException(): void public function testResponse(): void { - $urlUtility = new UrlUtility(new Features(), $this->createMock(Resolver::class), $this->createMock(SiteFinder::class), new HeadlessMode()); + $urlUtility = new UrlUtility($this->createMock(Resolver::class), $this->createMock(SiteFinder::class), new HeadlessMode()); $pluginRedirect = new PluginUtility($urlUtility); diff --git a/Tests/Unit/Utility/UrlUtilityTest.php b/Tests/Unit/Utility/UrlUtilityTest.php index 25769db8..b8863997 100644 --- a/Tests/Unit/Utility/UrlUtilityTest.php +++ b/Tests/Unit/Utility/UrlUtilityTest.php @@ -18,7 +18,6 @@ use ReflectionProperty; use Symfony\Component\DependencyInjection\Container; use Symfony\Component\ExpressionLanguage\SyntaxError; -use TYPO3\CMS\Core\Configuration\Features; use TYPO3\CMS\Core\Exception\SiteNotFoundException; use TYPO3\CMS\Core\ExpressionLanguage\Resolver; use TYPO3\CMS\Core\Http\ServerRequest; @@ -27,9 +26,8 @@ use TYPO3\CMS\Core\Site\Entity\SiteLanguage; use TYPO3\CMS\Core\Site\SiteFinder; use TYPO3\CMS\Core\Utility\GeneralUtility; -use TYPO3\TestingFramework\Core\Unit\UnitTestCase; -class UrlUtilityTest extends UnitTestCase +class UrlUtilityTest extends \FriendsOfTYPO3\Headless\Tests\Unit\HeadlessUnitTestCase { protected function setUp(): void { @@ -40,12 +38,6 @@ protected function setUp(): void GeneralUtility::setContainer($container); } - protected function tearDown(): void - { - (new ReflectionProperty(GeneralUtility::class, 'container'))->setValue(null, null); - parent::tearDown(); - } - public function testFrontendUrls(): void { $headlessMode = $this->createHeadlessMode(); @@ -96,7 +88,7 @@ public function testFrontendUrls(): void $siteFinder = $this->createMock(SiteFinder::class); - $urlUtility = new UrlUtility(new Features(), $resolver, $siteFinder, $headlessMode); + $urlUtility = new UrlUtility($resolver, $siteFinder, $headlessMode); $urlUtility = $urlUtility->withSite($site); self::assertSame('https://test-frontend.tld', $urlUtility->getFrontendUrl()); @@ -117,7 +109,7 @@ public function testFrontendUrls(): void $siteFinder = $this->createMock(SiteFinder::class); - $urlUtility = new UrlUtility(new Features(), $resolver, $siteFinder, $headlessMode); + $urlUtility = new UrlUtility($resolver, $siteFinder, $headlessMode); $urlUtility = $urlUtility->withSite($site); self::assertSame('https://test-frontend2.tld', $urlUtility->getFrontendUrl()); @@ -144,7 +136,7 @@ public function testFrontendUrls(): void $site->method('getBase')->willReturn(new Uri('https://test-backend3-api.tld/')); $site->method('getLanguages')->willReturn([]); - $urlUtility = new UrlUtility(new Features(), $resolver, $siteFinder, $headlessMode); + $urlUtility = new UrlUtility($resolver, $siteFinder, $headlessMode); $urlUtility = $urlUtility->withSite($site); self::assertSame('https://test-frontend3.tld', $urlUtility->getFrontendUrl()); @@ -180,7 +172,7 @@ public function testFrontendUrlsWithDifferentPaths(): void $siteFinder = $this->createMock(SiteFinder::class); - $urlUtility = new UrlUtility(new Features(), $resolver, $siteFinder, $headlessMode); + $urlUtility = new UrlUtility($resolver, $siteFinder, $headlessMode); $urlUtility = $urlUtility->withSite($site); self::assertSame('https://test-frontend.tld/frontend/content-page', $urlUtility->getFrontendUrlWithSite('https://test-backend-api.tld/dev-path/content-page', $site)); @@ -216,7 +208,7 @@ public function testFrontendUrlsWithBaseProductionAndLocalOverride(): void $headlessMode = $this->createHeadlessMode(); - $urlUtility = new UrlUtility(new Features(), $resolver, $siteFinder, $headlessMode); + $urlUtility = new UrlUtility($resolver, $siteFinder, $headlessMode); $urlUtility = $urlUtility->withSite($site); self::assertSame('https://test-frontend.tld', $urlUtility->getFrontendUrl()); @@ -228,7 +220,7 @@ public function testFrontendUrlsWithBaseProductionAndLocalOverride(): void $resolver = $this->createMock(Resolver::class); $resolver->method('evaluate')->with(self::stringContains('Development'))->willReturn(false); - $urlUtility = new UrlUtility(new Features(), $resolver, $siteFinder, $headlessMode); + $urlUtility = new UrlUtility($resolver, $siteFinder, $headlessMode); $urlUtility = $urlUtility->withSite($site); self::assertSame('https://www.typo3.org', $urlUtility->getFrontendUrl()); @@ -276,7 +268,7 @@ public function testOptimizedUrlsForFrontendApp(): void $siteFinder = $this->createMock(SiteFinder::class); - $urlUtility = new UrlUtility(new Features(), $resolver, $siteFinder, $headlessMode); + $urlUtility = new UrlUtility($resolver, $siteFinder, $headlessMode); $urlUtility = $urlUtility->withSite($site); // same page, so we make it relative @@ -298,6 +290,18 @@ public function testOptimizedUrlsForFrontendApp(): void $urlUtility->prepareRelativeUrlIfPossible('https://test-second-frontend.tld/test-page') ); + // do not touch already relative links + self::assertSame( + '/test-page', + $urlUtility->getFrontendUrlWithSite('/test-page', $site) + ); + + // do not touch external links + self::assertSame( + 'https://typo3.org/headless', + $urlUtility->getFrontendUrlWithSite('https://typo3.org/headless', $site) + ); + // test reversed = "Testing" condition $resolver = $this->createMock(Resolver::class); $resolver->method('evaluate')->willReturnCallback(static function ($_arg) { @@ -312,7 +316,7 @@ public function testOptimizedUrlsForFrontendApp(): void $siteFinder = $this->createMock(SiteFinder::class); - $urlUtility = new UrlUtility(new Features(), $resolver, $siteFinder, $headlessMode); + $urlUtility = new UrlUtility($resolver, $siteFinder, $headlessMode); $urlUtility = $urlUtility->withSite($site); // same page, so we make it relative @@ -386,7 +390,7 @@ public function testLanguageResolver(): void $siteFinder = $this->createMock(SiteFinder::class); - $urlUtility = new UrlUtility(new Features(), $resolver, $siteFinder, $headlessMode); + $urlUtility = new UrlUtility($resolver, $siteFinder, $headlessMode); $urlUtility = $urlUtility->withSite($site); $urlUtility = $urlUtility->withLanguage(new SiteLanguage(0, 'en', new Uri('/'), [ 'title' => 'English', @@ -435,7 +439,7 @@ public function testLanguageResolver(): void self::assertSame('https://test-frontend-from-lang.tld/headless/fileadmin', $urlUtility->getStorageProxyUrl()); // not overlay site variants if language has not defined variants - $urlUtility = new UrlUtility(new Features(), $resolver, $siteFinder, $this->createHeadlessMode()); + $urlUtility = new UrlUtility($resolver, $siteFinder, $this->createHeadlessMode()); $urlUtility = $urlUtility->withSite($site); $urlUtility = $urlUtility->withLanguage(new SiteLanguage(0, 'en', new Uri('/'), [ 'title' => 'English', @@ -467,7 +471,7 @@ public function testLanguageResolver(): void $siteFinder = $this->createMock(SiteFinder::class); - $urlUtility = new UrlUtility(new Features(), $resolver, $siteFinder, $headlessMode); + $urlUtility = new UrlUtility($resolver, $siteFinder, $headlessMode); $urlUtility = $urlUtility->withSite($site); $urlUtility = $urlUtility->withLanguage(new SiteLanguage(0, 'en', new Uri('/'), [ 'title' => 'English', @@ -530,7 +534,7 @@ public function testFrontendUrlForPage(): void $siteFinder->method('getSiteByPageId')->willReturn($site); $headlessMode = $this->createHeadlessMode(HeadlessMode::NONE); - $urlUtility = new UrlUtility(new Features(), $resolver, $siteFinder, $headlessMode); + $urlUtility = new UrlUtility($resolver, $siteFinder, $headlessMode); $urlUtility = $urlUtility->withSite($site); // flag is not existing/disabled @@ -541,7 +545,7 @@ public function testFrontendUrlForPage(): void $headlessMode = $this->createHeadlessMode(HeadlessMode::FULL); - $urlUtility = new UrlUtility(new Features(), $resolver, $siteFinder, $headlessMode); + $urlUtility = new UrlUtility($resolver, $siteFinder, $headlessMode); $urlUtility = $urlUtility->withSite($site); self::assertSame( 'https://test-frontend.tld/test-page', @@ -578,7 +582,7 @@ public function testFrontendUrlForPageWithAlreadyFrontendUrlResolved(): void $headlessMode = $this->createHeadlessMode(); - $urlUtility = new UrlUtility(new Features(), $resolver, $siteFinder, $headlessMode); + $urlUtility = new UrlUtility($resolver, $siteFinder, $headlessMode); $urlUtility = $urlUtility->withSite($site); self::assertSame( @@ -614,7 +618,7 @@ public function testFrontendUrlForPageWithPortsOnFrontendSide(): void $siteFinder->method('getSiteByPageId')->willReturn($site); $headlessMode = $this->createHeadlessMode(HeadlessMode::NONE); - $urlUtility = new UrlUtility(new Features(), $resolver, $siteFinder, $headlessMode); + $urlUtility = new UrlUtility($resolver, $siteFinder, $headlessMode); $urlUtility = $urlUtility->withSite($site); // flag is not existing/disabled @@ -625,7 +629,7 @@ public function testFrontendUrlForPageWithPortsOnFrontendSide(): void // flag is enabled $headlessMode = $this->createHeadlessMode(); - $urlUtility = new UrlUtility(new Features(), $resolver, $siteFinder, $headlessMode); + $urlUtility = new UrlUtility($resolver, $siteFinder, $headlessMode); $urlUtility = $urlUtility->withSite($site); self::assertSame( 'https://test-frontend.tld:3000/test-page', @@ -660,7 +664,7 @@ public function testFrontendUrlForPageWithPortsOnBothSides(): void $siteFinder = $this->createPartialMock(SiteFinder::class, ['getSiteByPageId']); $siteFinder->method('getSiteByPageId')->willReturn($site); - $urlUtility = new UrlUtility(new Features(), $resolver, $siteFinder, $headlessMode); + $urlUtility = new UrlUtility($resolver, $siteFinder, $headlessMode); $urlUtility = $urlUtility->withSite($site); // flag is not existing/disabled @@ -671,7 +675,7 @@ public function testFrontendUrlForPageWithPortsOnBothSides(): void // flag is enabled $headlessMode = $this->createHeadlessMode(); - $urlUtility = new UrlUtility(new Features(), $resolver, $siteFinder, $headlessMode); + $urlUtility = new UrlUtility($resolver, $siteFinder, $headlessMode); $urlUtility = $urlUtility->withSite($site); self::assertSame( 'https://test-frontend.tld:3000/test-page', @@ -679,6 +683,38 @@ public function testFrontendUrlForPageWithPortsOnBothSides(): void ); } + public function testBackendPortDoesNotLeakIntoFrontendUrl(): void + { + $headlessMode = $this->createHeadlessMode(); + $site = $this->createMock(Site::class); + $site->method('getBase')->willReturn(new Uri('https://test-backend-api.tld:8000')); + $site->method('getLanguages')->willReturn([]); + $site->method('getConfiguration')->willReturn([ + 'base' => 'https://www.typo3.org', + 'languages' => [], + 'baseVariants' => [ + [ + 'base' => 'https://test-backend-api.tld:8000', + 'condition' => 'applicationContext == "Development"', + 'frontendBase' => 'https://test-frontend.tld', + ], + ], + ]); + + $resolver = $this->createMock(Resolver::class); + $resolver->method('evaluate')->willReturn(true); + + $siteFinder = $this->createPartialMock(SiteFinder::class, ['getSiteByPageId']); + $siteFinder->method('getSiteByPageId')->willReturn($site); + + $urlUtility = (new UrlUtility($resolver, $siteFinder, $headlessMode))->withSite($site); + + self::assertSame( + 'https://test-frontend.tld/test-page', + $urlUtility->getFrontendUrlForPage('https://test-backend-api.tld:8000/test-page', 1) + ); + } + public function testEdgeCases() { $headlessMode = $this->createHeadlessMode(); @@ -704,7 +740,7 @@ public function testEdgeCases() $siteFinder = $this->createPartialMock(SiteFinder::class, ['getSiteByPageId']); $siteFinder->method('getSiteByPageId')->willReturn($site); - $urlUtility = (new UrlUtility(new Features(), $resolver, $siteFinder, $headlessMode))->withRequest($request); + $urlUtility = (new UrlUtility($resolver, $siteFinder, $headlessMode))->withRequest($request); self::assertSame( 'https://test-backend-api.tld:8000/test-page', @@ -713,7 +749,7 @@ public function testEdgeCases() $siteFinder = $this->createPartialMock(SiteFinder::class, ['getSiteByPageId']); $siteFinder->method('getSiteByPageId')->willThrowException(new SiteNotFoundException('test')); - $urlUtility = (new UrlUtility(new Features(), $resolver, $siteFinder, $this->createHeadlessMode()))->withRequest($request); + $urlUtility = (new UrlUtility($resolver, $siteFinder, $this->createHeadlessMode()))->withRequest($request); self::assertSame( 'https://test-backend-api.tld:8000/test-page', @@ -723,7 +759,7 @@ public function testEdgeCases() $resolver = $this->createPartialMock(Resolver::class, ['evaluate']); $resolver->method('evaluate')->willThrowException(new SyntaxError('test')); - $urlUtility = (new UrlUtility(new Features(), $resolver, $siteFinder, $headlessMode))->withRequest($request); + $urlUtility = (new UrlUtility($resolver, $siteFinder, $headlessMode))->withRequest($request); self::assertSame('', $urlUtility->getFrontendUrl()); $urlUtility = $urlUtility->withSite($this->createMockSite('https://test-frontend.tld', '', [])); @@ -765,7 +801,7 @@ public function testEdgeCases() $resolver = $this->createMock(Resolver::class); $resolver->method('evaluate')->willReturn(true); - $urlUtility = (new UrlUtility(new Features(), $resolver, $siteFinder, $headlessMode))->withRequest($request); + $urlUtility = (new UrlUtility($resolver, $siteFinder, $headlessMode))->withRequest($request); self::assertSame('https://test-frontend-from-lang.tld', $urlUtility->getFrontendUrl()); $request = $this->createMock(ServerRequest::class); @@ -795,7 +831,7 @@ public function testEdgeCases() $resolver = $this->createMock(Resolver::class); $resolver->method('evaluate')->willReturn(true); - $urlUtility = (new UrlUtility(new Features(), $resolver, $siteFinder, $headlessMode))->withRequest($request); + $urlUtility = (new UrlUtility($resolver, $siteFinder, $headlessMode))->withRequest($request); self::assertSame('', $urlUtility->getFrontendUrl()); // configuration on language lvl without variants @@ -830,7 +866,7 @@ public function testEdgeCases() $resolver = $this->createMock(Resolver::class); $resolver->method('evaluate')->willReturn(true); - $urlUtility = (new UrlUtility(new Features(), $resolver, $siteFinder, $headlessMode))->withRequest($request); + $urlUtility = (new UrlUtility($resolver, $siteFinder, $headlessMode))->withRequest($request); self::assertSame('https://frontend-domain-from-lang.tld', $urlUtility->getFrontendUrl()); self::assertSame('https://frontend-domain-from-lang.tld/headless', $urlUtility->getProxyUrl()); self::assertSame('https://frontend-domain-from-lang.tld/headless/fileadmin', $urlUtility->getStorageProxyUrl()); @@ -877,7 +913,7 @@ public function testEdgeCases() $resolver = $this->createMock(Resolver::class); $resolver->method('evaluate')->willReturn(true); - $urlUtility = (new UrlUtility(new Features(), $resolver, $siteFinder, $headlessMode))->withRequest($request); + $urlUtility = (new UrlUtility($resolver, $siteFinder, $headlessMode))->withRequest($request); self::assertSame('https://test-frontend-from-when-develop-lang.tld', $urlUtility->getFrontendUrl()); self::assertSame('https://test-frontend-from-when-develop-lang.tld/headless', $urlUtility->getProxyUrl()); self::assertSame('https://test-frontend-from-when-develop-lang.tld/headless/fileadmin', $urlUtility->getStorageProxyUrl()); @@ -919,7 +955,7 @@ public function testEdgeCases() return null; }); - $urlUtility = new UrlUtility(new Features(), $resolver, $siteFinder, $headlessMode); + $urlUtility = new UrlUtility($resolver, $siteFinder, $headlessMode); $urlUtilityWithRequest = $urlUtility->withRequest($manualRequest); self::assertSame('https://test-frontend-from-from-request-lang.tld', $urlUtilityWithRequest->getFrontendUrl()); self::assertSame('https://test-frontend-from-from-request-lang.tld/headless', $urlUtilityWithRequest->getProxyUrl()); @@ -971,7 +1007,7 @@ public function testGetFrontendUrlWithSiteDoesNotLeakStateBetweenCalls(): void $siteFinder = $this->createMock(SiteFinder::class); - $urlUtility = new UrlUtility(new Features(), $resolver, $siteFinder, $headlessMode); + $urlUtility = new UrlUtility($resolver, $siteFinder, $headlessMode); $url1 = $urlUtility->getFrontendUrlWithSite('https://backend1.example.com/page1', $site1); self::assertSame('https://site1.example.com/page1', $url1); @@ -990,6 +1026,45 @@ public function testGetFrontendUrlWithSiteDoesNotLeakStateBetweenCalls(): void self::assertSame([], $domains, 'getFrontendUrlWithSite must not mutate $this->frontendDomains'); } + public function testVariantConditionsEvaluatedAtMostOncePerCondition(): void + { + $headlessMode = $this->createHeadlessMode(); + $site = $this->createMockSite('https://backend.example.com', 'https://frontend.example.com'); + + $resolver = $this->createMock(Resolver::class); + $resolver->expects(self::once()) + ->method('evaluate') + ->with('applicationContext == "Development"') + ->willReturn(true); + + $urlUtility = (new UrlUtility($resolver, $this->createMock(SiteFinder::class), $headlessMode)) + ->withSite($site); + + // Three lookups share the same variant condition. Second/third must hit the cache. + self::assertSame('https://frontend.example.com', $urlUtility->getFrontendUrl()); + self::assertSame('https://frontend.example.com/headless', $urlUtility->getProxyUrl()); + self::assertSame('https://frontend.example.com/headless/fileadmin', $urlUtility->getStorageProxyUrl()); + } + + public function testVariantConditionCacheIsInvalidatedOnWithSite(): void + { + $headlessMode = $this->createHeadlessMode(); + $siteA = $this->createMockSite('https://a-backend.example.com', 'https://a-frontend.example.com'); + $siteB = $this->createMockSite('https://b-backend.example.com', 'https://b-frontend.example.com'); + + $resolver = $this->createMock(Resolver::class); + // One eval per site visit — without invalidation the second withSite would reuse the first cache. + $resolver->expects(self::exactly(2)) + ->method('evaluate') + ->with('applicationContext == "Development"') + ->willReturn(true); + + $urlUtility = new UrlUtility($resolver, $this->createMock(SiteFinder::class), $headlessMode); + + self::assertSame('https://a-frontend.example.com', $urlUtility->withSite($siteA)->getFrontendUrl()); + self::assertSame('https://b-frontend.example.com', $urlUtility->withSite($siteB)->getFrontendUrl()); + } + protected function createMockSite(string $backendUrl, string $frontendUrl = '', ?array $variants = null) { $site = $this->createMock(Site::class); diff --git a/Tests/Unit/View/HeadlessPhpViewTest.php b/Tests/Unit/View/HeadlessPhpViewTest.php new file mode 100644 index 00000000..4031b4d5 --- /dev/null +++ b/Tests/Unit/View/HeadlessPhpViewTest.php @@ -0,0 +1,174 @@ +tempFiles as $file) { + if (is_file($file)) { + @unlink($file); + } + $dir = dirname($file); + if (is_dir($dir) && str_starts_with($dir, Environment::getPublicPath() . '/typo3temp/')) { + @rmdir($dir); + } + } + $this->tempFiles = []; + parent::tearDown(); + } + + public function testRendersAssignedVariablesFromTemplateRoot(): void + { + $root = $this->createTemplateRoot(); + $this->writeTemplate($root, 'Greeting.php', 'assign('name', 'world'); + + self::assertSame('hello world', $view->render('Greeting')); + } + + public function testAssignMultipleMergesVariables(): void + { + $root = $this->createTemplateRoot(); + $this->writeTemplate($root, 'Pair.php', 'assign('a', 'first'); + $view->assignMultiple(['b' => 'second']); + + self::assertSame('first|second', $view->render('Pair')); + } + + public function testRendersDirectTemplatePathAndFilename(): void + { + $root = $this->createTemplateRoot(); + $file = $this->writeTemplate($root, 'Direct.php', 'render()); + } + + public function testLastTemplateRootWins(): void + { + $primary = $this->createTemplateRoot(); + $override = $this->createTemplateRoot(); + $this->writeTemplate($primary, 'Same.php', 'writeTemplate($override, 'Same.php', 'render('Same')); + } + + #[DataProvider('unsafeNamesProvider')] + public function testRejectsPathTraversalAttempts(string $unsafeName): void + { + $root = $this->createTemplateRoot(); + // A file that an attacker would target if traversal worked. + // For "../escape" the resolver would build "{root}/../escape.php". + $outsideDir = dirname(rtrim($root, '/')); + $outsideFile = $outsideDir . '/escape.php'; + file_put_contents($outsideFile, 'tempFiles[] = $outsideFile; + + $view = new HeadlessPhpView(new ViewFactoryData(templateRootPaths: [$root])); + + $this->expectException(RuntimeException::class); + $this->expectExceptionCode(1747300000); + try { + $view->render($unsafeName); + } finally { + @unlink($outsideFile); + } + } + + /** + * @return array + */ + public static function unsafeNamesProvider(): array + { + return [ + 'parent segment' => ['../escape'], + 'embedded parent segment' => ['foo/../../escape'], + 'absolute path' => ['/etc/passwd'], + 'backslash separator' => ['..\\escape'], + 'NUL byte' => ["escape\0"], + 'stream wrapper' => ['phar://attacker.phar/payload'], + 'file wrapper' => ['file:///etc/passwd'], + 'double slash' => ['foo//../escape'], + ]; + } + + public function testThrowsWhenTemplateMissing(): void + { + $view = new HeadlessPhpView(new ViewFactoryData( + templateRootPaths: [$this->createTemplateRoot()], + )); + + $this->expectException(RuntimeException::class); + $this->expectExceptionCode(1747300000); + $view->render('Does/Not/Exist'); + } + + public function testCleansOutputBufferWhenTemplateThrows(): void + { + $root = $this->createTemplateRoot(); + $this->writeTemplate( + $root, + 'Broken.php', + 'render('Broken'); + self::fail('Expected RuntimeException'); + } catch (RuntimeException $e) { + self::assertSame('boom', $e->getMessage()); + self::assertSame($initialObLevel, ob_get_level(), 'output buffer must be cleaned up'); + } + } + + private function createTemplateRoot(): string + { + // Must live under publicPath/typo3temp/ so it satisfies + // GeneralUtility::isAllowedAbsPath (publicPath / projectPath / lockRootPath). + $dir = Environment::getPublicPath() . '/typo3temp/var/tests/headless_view_' . uniqid('', true); + mkdir($dir, 0777, true); + return $dir . '/'; + } + + private function writeTemplate(string $root, string $name, string $contents): string + { + $file = $root . $name; + file_put_contents($file, $contents); + $this->tempFiles[] = $file; + return $file; + } +} diff --git a/Tests/Unit/View/HeadlessViewFactoryTest.php b/Tests/Unit/View/HeadlessViewFactoryTest.php new file mode 100644 index 00000000..2b196451 --- /dev/null +++ b/Tests/Unit/View/HeadlessViewFactoryTest.php @@ -0,0 +1,158 @@ +createMock(ViewFactoryInterface::class); + $expected = $this->createMock(ViewInterface::class); + $inner->expects(self::once())->method('create')->willReturn($expected); + + $factory = new HeadlessViewFactory( + $inner, + $this->featuresWith(true), + $this->headlessModeEnabled(true), + ); + + $data = new ViewFactoryData(format: 'html', request: $this->frontendRequest()); + + self::assertSame($expected, $factory->create($data)); + } + + public function testFallsThroughWhenFeatureFlagDisabled(): void + { + $inner = $this->createMock(ViewFactoryInterface::class); + $expected = $this->createMock(ViewInterface::class); + $inner->expects(self::once())->method('create')->willReturn($expected); + + $factory = new HeadlessViewFactory( + $inner, + $this->featuresWith(false), + $this->headlessModeEnabled(true), + ); + + $data = new ViewFactoryData(format: 'php', request: $this->frontendRequest()); + + self::assertSame($expected, $factory->create($data)); + } + + public function testFallsThroughWhenRequestMissing(): void + { + $inner = $this->createMock(ViewFactoryInterface::class); + $expected = $this->createMock(ViewInterface::class); + $inner->expects(self::once())->method('create')->willReturn($expected); + + $factory = new HeadlessViewFactory( + $inner, + $this->featuresWith(true), + $this->headlessModeEnabled(true), + ); + + $data = new ViewFactoryData(format: 'php', request: null); + + self::assertSame($expected, $factory->create($data)); + } + + public function testFallsThroughForBackendRequest(): void + { + $inner = $this->createMock(ViewFactoryInterface::class); + $expected = $this->createMock(ViewInterface::class); + $inner->expects(self::once())->method('create')->willReturn($expected); + + $factory = new HeadlessViewFactory( + $inner, + $this->featuresWith(true), + $this->headlessModeEnabled(true), + ); + + $data = new ViewFactoryData(format: 'php', request: $this->backendRequest()); + + self::assertSame($expected, $factory->create($data)); + } + + public function testFallsThroughWhenHeadlessDisabled(): void + { + $inner = $this->createMock(ViewFactoryInterface::class); + $expected = $this->createMock(ViewInterface::class); + $inner->expects(self::once())->method('create')->willReturn($expected); + + $factory = new HeadlessViewFactory( + $inner, + $this->featuresWith(true), + $this->headlessModeEnabled(false), + ); + + $data = new ViewFactoryData(format: 'php', request: $this->frontendRequest()); + + self::assertSame($expected, $factory->create($data)); + } + + public function testReturnsHeadlessPhpViewWhenOptedIn(): void + { + $inner = $this->createMock(ViewFactoryInterface::class); + $inner->expects(self::never())->method('create'); + + $factory = new HeadlessViewFactory( + $inner, + $this->featuresWith(true), + $this->headlessModeEnabled(true), + ); + + $data = new ViewFactoryData(format: 'php', request: $this->frontendRequest()); + + self::assertInstanceOf(HeadlessPhpView::class, $factory->create($data)); + } + + private function featuresWith(bool $enabled): Features + { + $features = $this->createMock(Features::class); + $features->method('isFeatureEnabled') + ->with('headless.overrideFluidTemplates') + ->willReturn($enabled); + return $features; + } + + private function headlessModeEnabled(bool $enabled): HeadlessModeInterface + { + $mode = $this->createMock(HeadlessModeInterface::class); + $mode->method('withRequest')->willReturnSelf(); + $mode->method('isEnabled')->willReturn($enabled); + $mode->method('isEnabledFor')->willReturn($enabled); + return $mode; + } + + private function frontendRequest(): ServerRequestInterface + { + return (new ServerRequest()) + ->withAttribute('applicationType', SystemEnvironmentBuilder::REQUESTTYPE_FE); + } + + private function backendRequest(): ServerRequestInterface + { + return (new ServerRequest()) + ->withAttribute('applicationType', SystemEnvironmentBuilder::REQUESTTYPE_BE); + } +} diff --git a/Tests/Unit/XClass/Controller/LoginControllerTest.php b/Tests/Unit/XClass/Controller/LoginControllerTest.php new file mode 100644 index 00000000..d3ea0892 --- /dev/null +++ b/Tests/Unit/XClass/Controller/LoginControllerTest.php @@ -0,0 +1,136 @@ +bindHeadlessMode(HeadlessModeInterface::FULL); + $controller = $this->buildController($this->createMock(EventDispatcherInterface::class)); + + self::setProtected($controller, 'redirectUrl', ''); + + self::assertNull(self::callHandleRedirect($controller)); + } + + public function testHandleRedirectReturnsNullWhenListenerClearsTheUrl(): void + { + $this->bindHeadlessMode(HeadlessModeInterface::FULL); + + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $eventDispatcher->expects(self::once()) + ->method('dispatch') + ->willReturnCallback(static function (BeforeRedirectEvent $event): BeforeRedirectEvent { + $event->setRedirectUrl(''); + return $event; + }); + + $controller = $this->buildController($eventDispatcher); + self::setProtected($controller, 'redirectUrl', '/dashboard'); + + self::assertNull( + self::callHandleRedirect($controller), + 'event veto must short-circuit the redirect so loginAction can render the form view' + ); + } + + public function testHandleRedirectReturnsJsonResponseWithStatusAndUrl(): void + { + $this->bindHeadlessMode(HeadlessModeInterface::FULL); + + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $eventDispatcher->method('dispatch')->willReturnArgument(0); + + $controller = $this->buildController($eventDispatcher); + self::setProtected($controller, 'redirectUrl', '/dashboard'); + + $response = self::callHandleRedirect($controller, 'failure'); + + self::assertInstanceOf(ResponseInterface::class, $response); + self::assertSame('application/json; charset=utf-8', $response->getHeaderLine('Content-Type')); + self::assertSame( + ['redirectUrl' => '/dashboard', 'statusCode' => 303, 'status' => 'failure'], + json_decode((string)$response->getBody(), true) + ); + } + + private function bindHeadlessMode(int $mode): void + { + $request = (new \TYPO3\CMS\Core\Http\ServerRequest('https://website.local/')) + ->withAttribute('headless', new Headless($mode)); + + $container = new Container(); + $container->set(HeadlessModeInterface::class, (new HeadlessMode())->withRequest($request)); + GeneralUtility::setContainer($container); + } + + private function buildController(EventDispatcherInterface $eventDispatcher): LoginController + { + $controller = new LoginController( + $this->createMock(RedirectHandler::class), + $this->createMock(Context::class), + $this->createMock(PageRepository::class), + ); + + $httpFactory = new HttpFactory(); + $controller->injectResponseFactory($httpFactory); + $controller->injectStreamFactory($httpFactory); + $controller->injectEventDispatcher($eventDispatcher); + + $request = new Request( + (new \TYPO3\CMS\Core\Http\ServerRequest('https://website.local/')) + ->withAttribute('headless', new Headless(HeadlessModeInterface::FULL)) + ->withAttribute('extbase', new ExtbaseRequestParameters()) + ); + self::setProtected($controller, 'request', $request); + self::setProtected($controller, 'loginType', 'login'); + + return $controller; + } + + private static function callHandleRedirect(LoginController $controller, string $status = 'success'): ?ResponseInterface + { + $method = new ReflectionMethod($controller, 'handleRedirect'); + return $method->invoke($controller, $status); + } + + private static function setProtected(object $object, string $property, mixed $value): void + { + (new ReflectionProperty($object, $property))->setValue($object, $value); + } +} diff --git a/Tests/Unit/XClass/Fixtures/Templates/Default/Default.php b/Tests/Unit/XClass/Fixtures/Templates/Default/Default.php deleted file mode 100644 index 1a6f43a3..00000000 --- a/Tests/Unit/XClass/Fixtures/Templates/Default/Default.php +++ /dev/null @@ -1,12 +0,0 @@ - $testValue, -]); diff --git a/Tests/Unit/XClass/Fixtures/Templates/Default/DefaultException.php b/Tests/Unit/XClass/Fixtures/Templates/Default/DefaultException.php deleted file mode 100644 index 2daffcbb..00000000 --- a/Tests/Unit/XClass/Fixtures/Templates/Default/DefaultException.php +++ /dev/null @@ -1,10 +0,0 @@ -set(HeadlessModeInterface::class, new HeadlessMode()); - GeneralUtility::setContainer($container); - } + /** @var list */ + private array $tempFiles = []; protected function tearDown(): void { - (new ReflectionProperty(GeneralUtility::class, 'container'))->setValue(null, null); + foreach ($this->tempFiles as $file) { + if (is_file($file)) { + @unlink($file); + } + $dir = dirname($file); + if (is_dir($dir) && str_starts_with($dir, Environment::getPublicPath() . '/typo3temp/')) { + @rmdir($dir); + } + } + $this->tempFiles = []; parent::tearDown(); } - public function testTemplateNotFoundRender(): void + public function testRendersFixtureWithAssignedVariables(): void { - $this->expectException(InvalidTemplateResourceException::class); - - $GLOBALS['TYPO3_REQUEST'] = (new ServerRequest())->withAttribute('applicationType', 1) // fe request - ->withAttribute('headless', new Headless(HeadlessModeInterface::FULL)); - - $templatePaths = $this->createMock(TemplatePaths::class); - $context = new RenderingContext($this->createMock(ViewHelperResolver::class), $this->createMock(FluidCacheInterface::class), [], [], $templatePaths, $this->createMock(ArgumentProcessorInterface::class)); + $root = $this->createTemplateRoot(); + $this->writeTemplate($root, 'Default.php', <<<'PHP' + $testValue]); + PHP); - $variableProvider = new StandardVariableProvider(); - $variableProvider->add('settings', ['phpTemplate' => 1]); + $view = new HeadlessPhpView(new ViewFactoryData(templateRootPaths: [$root])); + $view->assign('testValue', 'TestingJsonValue'); - $context->setVariableProvider($variableProvider); - $view = new TemplateView($context); - $view->render(); + self::assertSame(json_encode(['testKey' => 'TestingJsonValue']), $view->render('Default')); } - public function testTemplateRender(): void + public function testThrowsWhenTemplateFileMissing(): void { - $GLOBALS['TYPO3_REQUEST'] = (new ServerRequest())->withAttribute('applicationType', 1) // fe request - ->withAttribute('headless', new Headless(HeadlessModeInterface::FULL)); - - $templatePaths = $this->createMock(TemplatePaths::class); - $templatePaths->method('resolveTemplateFileForControllerAndActionAndFormat')->willReturn(__DIR__ . '/Fixtures/Templates/Default/Default.php'); - - $context = new RenderingContext($this->createMock(ViewHelperResolver::class), $this->createMock(FluidCacheInterface::class), [], [], $templatePaths, $this->createMock(ArgumentProcessorInterface::class)); + $view = new HeadlessPhpView(new ViewFactoryData( + templateRootPaths: [$this->createTemplateRoot()], + )); - $variableProvider = new StandardVariableProvider(); - $variableProvider->add('settings', ['phpTemplate' => 1]); - $variableProvider->add('testValue', 'TestingJsonValue'); - - $context->setVariableProvider($variableProvider); - - $view = new TemplateView($context); - - self::assertSame(json_encode(['testKey' => 'TestingJsonValue']), $view->render()); + $this->expectException(RuntimeException::class); + $this->expectExceptionCode(1747300000); + $view->render('Default'); } - public function testTemplateFoundRender(): void + public function testThrowsWhenNoTemplateRootIsConfigured(): void { - $GLOBALS['TYPO3_REQUEST'] = (new ServerRequest())->withAttribute('applicationType', 1) // fe request - ->withAttribute('headless', new Headless(HeadlessModeInterface::FULL)); - - $templatePaths = $this->createMock(TemplatePaths::class); - - $context = new RenderingContext($this->createMock(ViewHelperResolver::class), $this->createMock(FluidCacheInterface::class), [], [], $templatePaths, $this->createMock(ArgumentProcessorInterface::class)); - - $variableProvider = new StandardVariableProvider(); - $variableProvider->add('settings', ['phpTemplate' => 1]); - $variableProvider->add('testValue', 'TestingJsonValue'); - - $context->setVariableProvider($variableProvider); - - $templatePaths = $this->createMock(TemplatePaths::class); - $templatePaths->method('resolveTemplateFileForControllerAndActionAndFormat')->willReturn(null); + $view = new HeadlessPhpView(new ViewFactoryData()); - $context->setTemplatePaths($templatePaths); - - $this->expectException(InvalidTemplateResourceException::class); - - $view = new TemplateView($context); - $view->render(); + $this->expectException(RuntimeException::class); + $this->expectExceptionCode(1747300000); + $view->render('Default'); } - public function testChangingAction(): void + public function testExceptionRaisedByTemplateBodyPropagates(): void { - $GLOBALS['TYPO3_REQUEST'] = (new ServerRequest())->withAttribute('applicationType', 1) // fe request - ->withAttribute('headless', new Headless(HeadlessModeInterface::FULL)); - - $templatePaths = $this->createMock(TemplatePaths::class); - $context = new RenderingContext($this->createMock(ViewHelperResolver::class), $this->createMock(FluidCacheInterface::class), [], [], $templatePaths, $this->createMock(ArgumentProcessorInterface::class)); - - $variableProvider = new StandardVariableProvider(); - $variableProvider->add('settings', ['phpTemplate' => 1]); - $variableProvider->add('testValue', 'TestingJsonValue'); + $root = $this->createTemplateRoot(); + $this->writeTemplate($root, 'DefaultException.php', <<<'PHP' + setVariableProvider($variableProvider); + $view = new HeadlessPhpView(new ViewFactoryData(templateRootPaths: [$root])); - $templatePaths = $this->createMock(TemplatePaths::class); - $templatePaths->method('resolveTemplateFileForControllerAndActionAndFormat')->willReturn(__DIR__ . '/Fixtures/Templates/Default/Default.php'); + $initialObLevel = ob_get_level(); - $context->setTemplatePaths($templatePaths); - - self::assertSame('Default', $context->getControllerAction()); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Example exception in template'); - $view = new TemplateView($context); - $view->render('test'); - self::assertSame('Test', $context->getControllerAction()); + try { + $view->render('DefaultException'); + } finally { + self::assertSame($initialObLevel, ob_get_level(), 'output buffer must be cleaned up after a template exception'); + } } - public function testExceptionInTemplate(): void + private function createTemplateRoot(): string { - $GLOBALS['TYPO3_REQUEST'] = (new ServerRequest())->withAttribute('applicationType', 1) // fe request - ->withAttribute('headless', new Headless(HeadlessModeInterface::FULL)); - - $templatePaths = $this->createMock(TemplatePaths::class); - $context = new RenderingContext($this->createMock(ViewHelperResolver::class), $this->createMock(FluidCacheInterface::class), [], [], $templatePaths, $this->createMock(ArgumentProcessorInterface::class)); - - $variableProvider = new StandardVariableProvider(); - $variableProvider->add('settings', ['phpTemplate' => 1]); - $variableProvider->add('testValue', 'TestingJsonValue'); - - $context->setVariableProvider($variableProvider); - - $templatePaths = $this->createMock(TemplatePaths::class); - $templatePaths->method('resolveTemplateFileForControllerAndActionAndFormat')->willReturn(__DIR__ . '/Fixtures/Templates/Default/DefaultException.php'); - - $context->setTemplatePaths($templatePaths); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Example exception in template'); + // Must live under publicPath/typo3temp/ to satisfy + // GeneralUtility::isAllowedAbsPath used by HeadlessPhpView's resolver. + $dir = Environment::getPublicPath() . '/typo3temp/var/tests/headless_xclass_view_' . uniqid('', true); + mkdir($dir, 0777, true); + return $dir . '/'; + } - $view = new TemplateView($context); - $view->render(); + private function writeTemplate(string $root, string $name, string $contents): string + { + $file = $root . $name; + file_put_contents($file, $contents); + $this->tempFiles[] = $file; + return $file; } } diff --git a/composer.json b/composer.json index 073ddc3a..27d780ed 100644 --- a/composer.json +++ b/composer.json @@ -38,11 +38,14 @@ "helmich/typo3-typoscript-lint": "^v3", "justinrainbow/json-schema": "^5", "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.12", + "phpstan/phpstan": "^2", "phpunit/phpcov": "^8 || ^9 || ^10", "seld/jsonlint": "^1.11", "symfony/yaml": "^6.1 || ^7.1", + "typo3/cms-felogin": "^14.3", "typo3/cms-form": "^14.0", + "typo3/cms-redirects": "^14.3", + "typo3/cms-seo": "^14.3", "typo3/cms-workspaces": "^14.3", "typo3/coding-standards": "^0.8", "typo3/testing-framework": "^9.0" @@ -66,8 +69,12 @@ }, "extra": { "typo3/cms": { + "Package": { + "providesPackages": {} + }, "app-dir": ".Build", "extension-key": "headless", + "version": "5.0.0-rc1", "web-dir": ".Build/public" } }, diff --git a/ext_emconf.php b/ext_emconf.php deleted file mode 100644 index 773f0527..00000000 --- a/ext_emconf.php +++ /dev/null @@ -1,27 +0,0 @@ - 'TYPO3 Headless API', - 'description' => 'Makes TYPO3 a headless CMS. Content and pages available in JSON format. Supports multilanguage, multidomain, forms, frontend login, workspaces and more. For JS frontend app see nuxt-typo3 package', - 'state' => 'stable', - 'author' => 'Łukasz Uznański', - 'author_email' => 'extensions@macopedia.pl', - 'author_company' => 'Macopedia Sp. z o.o.', - 'category' => 'fe', - 'version' => '5.0.0-rc1', - 'constraints' => [ - 'depends' => [ - 'frontend' => '14.0.0-14.99.99', - 'typo3' => '14.0.0-14.99.99' - ], - 'conflicts' => [], - 'suggests' => [], - ], -]; diff --git a/ext_localconf.php b/ext_localconf.php index a61aed53..00e114a9 100644 --- a/ext_localconf.php +++ b/ext_localconf.php @@ -7,7 +7,7 @@ * LICENSE.md file that was distributed with this source code. */ -use FriendsOfTYPO3\Headless\Hooks\FileOrFolderLinkBuilder; +use FriendsOfTYPO3\Headless\Typolink\FileOrFolderLinkBuilder; use FriendsOfTYPO3\Headless\Seo\MetaTag\EdgeMetaTagManager; use FriendsOfTYPO3\Headless\Seo\MetaTag\Html5MetaTagManager; use FriendsOfTYPO3\Headless\Seo\MetaTag\OpenGraphMetaTagManager; @@ -17,19 +17,13 @@ use FriendsOfTYPO3\Headless\Resource\Rendering\VimeoRenderer; use FriendsOfTYPO3\Headless\Resource\Rendering\YouTubeRenderer; use FriendsOfTYPO3\Headless\Seo\CanonicalGenerator; -use FriendsOfTYPO3\Headless\XClass\ResourceLocalDriver; -use TYPO3\CMS\Core\Configuration\Features; use TYPO3\CMS\Core\MetaTag\MetaTagManagerRegistry; -use TYPO3\CMS\Core\Resource\Driver\LocalDriver; use TYPO3\CMS\Core\Resource\Rendering\RendererRegistry; use TYPO3\CMS\Core\Utility\ExtensionManagementUtility; use TYPO3\CMS\Core\Utility\GeneralUtility; -use TYPO3\CMS\Extbase\Service\ImageService; use TYPO3\CMS\Form\Controller\FormFrontendController; use TYPO3\CMS\Form\Domain\Runtime\FormRuntime; use TYPO3\CMS\FrontendLogin\Controller\LoginController; -use TYPO3\CMS\Workspaces\Controller\PreviewController; -use TYPO3\CMS\Workspaces\Preview\PreviewUriBuilder; defined('TYPO3') || die(); @@ -42,18 +36,6 @@ static function () { 'FriendsOfTYPO3\Headless\ViewHelpers' ]; - $features = GeneralUtility::makeInstance(Features::class); - - if ($features->isFeatureEnabled('headless.storageProxy')) { - $GLOBALS['TYPO3_CONF_VARS']['SYS']['Objects'][LocalDriver::class] = [ - 'className' => ResourceLocalDriver::class - ]; - - $GLOBALS['TYPO3_CONF_VARS']['SYS']['Objects'][ImageService::class] = [ - 'className' => FriendsOfTYPO3\Headless\XClass\ImageService::class - ]; - } - if (ExtensionManagementUtility::isLoaded('form')) { $GLOBALS['TYPO3_CONF_VARS']['SYS']['Objects'][FormFrontendController::class] = [ 'className' => FriendsOfTYPO3\Headless\XClass\Controller\FormFrontendController::class @@ -70,12 +52,6 @@ static function () { ]; } - if (ExtensionManagementUtility::isLoaded('workspaces')) { - $GLOBALS['TYPO3_CONF_VARS']['SYS']['Objects'][PreviewUriBuilder::class] = [ - 'className' => FriendsOfTYPO3\Headless\XClass\Preview\PreviewUriBuilder::class - ]; - } - if (ExtensionManagementUtility::isLoaded('seo')) { $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['TYPO3\CMS\Frontend\Page\PageGenerator']['generateMetaTags']['canonical'] = CanonicalGenerator::class . '->handle';