1<?php 2 3/* 4 * This file is part of the TYPO3 CMS project. 5 * 6 * It is free software; you can redistribute it and/or modify it under 7 * the terms of the GNU General Public License, either version 2 8 * of the License, or any later version. 9 * 10 * For the full copyright and license information, please read the 11 * LICENSE.txt file that was distributed with this source code. 12 * 13 * The TYPO3 project - inspiring people to share! 14 */ 15 16namespace TYPO3\CMS\Core\Configuration\Loader; 17 18use Psr\Log\LoggerAwareInterface; 19use Psr\Log\LoggerAwareTrait; 20use Symfony\Component\Yaml\Exception\ParseException; 21use Symfony\Component\Yaml\Yaml; 22use TYPO3\CMS\Core\Configuration\Features; 23use TYPO3\CMS\Core\Configuration\Loader\Exception\YamlFileLoadingException; 24use TYPO3\CMS\Core\Configuration\Loader\Exception\YamlParseException; 25use TYPO3\CMS\Core\Configuration\Processor\PlaceholderProcessorList; 26use TYPO3\CMS\Core\Utility\ArrayUtility; 27use TYPO3\CMS\Core\Utility\GeneralUtility; 28use TYPO3\CMS\Core\Utility\PathUtility; 29 30/** 31 * A YAML file loader that allows to load YAML files, based on the Symfony/Yaml component 32 * 33 * In addition to just load a YAML file, it adds some special functionality. 34 * 35 * - A special "imports" key in the YAML file allows to include other YAML files recursively. 36 * The actual YAML file gets loaded after the import statements, which are interpreted first, 37 * at the very beginning. Imports can be referenced with a relative path. 38 * 39 * - Merging configuration options of import files when having simple "lists" will add items to the list instead 40 * of overwriting them. 41 * 42 * - Special placeholder values set via %optionA.suboptionB% replace the value with the named path of the configuration 43 * The placeholders will act as a full replacement of this value. 44 * 45 * - Environment placeholder values set via %env(option)% will be replaced by env variables of the same name 46 */ 47class YamlFileLoader implements LoggerAwareInterface 48{ 49 use LoggerAwareTrait; 50 51 public const PROCESS_PLACEHOLDERS = 1; 52 public const PROCESS_IMPORTS = 2; 53 54 /** 55 * @var int 56 */ 57 private $flags; 58 59 /** 60 * Loads and parses a YAML file, and returns an array with the found data 61 * 62 * @param string $fileName either relative to TYPO3's base project folder or prefixed with EXT:... 63 * @param int $flags Flags to configure behaviour of the loader: see public PROCESS_ constants above 64 * @return array the configuration as array 65 */ 66 public function load(string $fileName, int $flags = self::PROCESS_PLACEHOLDERS | self::PROCESS_IMPORTS): array 67 { 68 $this->flags = $flags; 69 return $this->loadAndParse($fileName, null); 70 } 71 72 /** 73 * Internal method which does all the logic. Built so it can be re-used recursively. 74 * 75 * @param string $fileName either relative to TYPO3's base project folder or prefixed with EXT:... 76 * @param string|null $currentFileName when called recursively 77 * @return array the configuration as array 78 */ 79 protected function loadAndParse(string $fileName, ?string $currentFileName): array 80 { 81 $sanitizedFileName = $this->getStreamlinedFileName($fileName, $currentFileName); 82 $content = $this->getFileContents($sanitizedFileName); 83 $content = Yaml::parse($content); 84 85 if (!is_array($content)) { 86 throw new YamlParseException( 87 'YAML file "' . $fileName . '" could not be parsed into valid syntax, probably empty?', 88 1497332874 89 ); 90 } 91 92 if ($this->hasFlag(self::PROCESS_IMPORTS)) { 93 $content = $this->processImports($content, $sanitizedFileName); 94 } 95 if ($this->hasFlag(self::PROCESS_PLACEHOLDERS)) { 96 // Check for "%" placeholders 97 $content = $this->processPlaceholders($content, $content); 98 } 99 return $content; 100 } 101 102 /** 103 * Put into a separate method to ease the pains with unit tests 104 * 105 * @param string $fileName 106 * @return string the contents 107 */ 108 protected function getFileContents(string $fileName): string 109 { 110 return file_get_contents($fileName); 111 } 112 113 /** 114 * Fetches the absolute file name, but if a different file name is given, it is built relative to that. 115 * 116 * @param string $fileName either relative to TYPO3's base project folder or prefixed with EXT:... 117 * @param string|null $currentFileName when called recursively this contains the absolute file name of the file that included this file 118 * @return string the contents of the file 119 * @throws YamlFileLoadingException when the file was not accessible 120 */ 121 protected function getStreamlinedFileName(string $fileName, ?string $currentFileName): string 122 { 123 if (!empty($currentFileName)) { 124 if (PathUtility::isExtensionPath($fileName) || PathUtility::isAbsolutePath($fileName)) { 125 $streamlinedFileName = GeneralUtility::getFileAbsFileName($fileName); 126 } else { 127 // Now this path is considered to be relative the current file name 128 $streamlinedFileName = PathUtility::getAbsolutePathOfRelativeReferencedFileOrPath( 129 $currentFileName, 130 $fileName 131 ); 132 if (!GeneralUtility::isAllowedAbsPath($streamlinedFileName)) { 133 throw new YamlFileLoadingException( 134 'Referencing a file which is outside of TYPO3s main folder', 135 1560319866 136 ); 137 } 138 } 139 } else { 140 $streamlinedFileName = GeneralUtility::getFileAbsFileName($fileName); 141 } 142 if (!$streamlinedFileName) { 143 throw new YamlFileLoadingException('YAML File "' . $fileName . '" could not be loaded', 1485784246); 144 } 145 return $streamlinedFileName; 146 } 147 148 /** 149 * Checks for the special "imports" key on the main level of a file, 150 * which calls "load" recursively. 151 * @param array $content 152 * @param string|null $fileName 153 * @return array 154 */ 155 protected function processImports(array $content, ?string $fileName): array 156 { 157 if (isset($content['imports']) && is_array($content['imports'])) { 158 if (GeneralUtility::makeInstance(Features::class)->isFeatureEnabled('yamlImportsFollowDeclarationOrder')) { 159 $content['imports'] = array_reverse($content['imports']); 160 } 161 foreach ($content['imports'] as $import) { 162 try { 163 $import = $this->processPlaceholders($import, $import); 164 $importedContent = $this->loadAndParse($import['resource'], $fileName); 165 // override the imported content with the one from the current file 166 $content = ArrayUtility::replaceAndAppendScalarValuesRecursive($importedContent, $content); 167 } catch (ParseException|YamlParseException|YamlFileLoadingException $exception) { 168 $this->logger->error($exception->getMessage(), ['exception' => $exception]); 169 } 170 } 171 unset($content['imports']); 172 } 173 return $content; 174 } 175 176 /** 177 * Main function that gets called recursively to check for %...% placeholders 178 * inside the array 179 * 180 * @param array $content the current sub-level content array 181 * @param array $referenceArray the global configuration array 182 * 183 * @return array the modified sub-level content array 184 */ 185 protected function processPlaceholders(array $content, array $referenceArray): array 186 { 187 foreach ($content as $k => $v) { 188 if (is_array($v)) { 189 $content[$k] = $this->processPlaceholders($v, $referenceArray); 190 } elseif ($this->containsPlaceholder($v)) { 191 $content[$k] = $this->processPlaceholderLine($v, $referenceArray); 192 } 193 } 194 return $content; 195 } 196 197 /** 198 * @param string $line 199 * @param array $referenceArray 200 * @return mixed 201 */ 202 protected function processPlaceholderLine(string $line, array $referenceArray) 203 { 204 $parts = $this->getParts($line); 205 foreach ($parts as $partKey => $part) { 206 $result = $this->processSinglePlaceholder($partKey, $part, $referenceArray); 207 // Replace whole content if placeholder is the only thing in this line 208 if ($line === $partKey) { 209 $line = $result; 210 } elseif (is_string($result) || is_numeric($result)) { 211 $line = str_replace($partKey, $result, $line); 212 } else { 213 throw new \UnexpectedValueException( 214 'Placeholder can not be substituted if result is not string or numeric', 215 1581502783 216 ); 217 } 218 if ($result !== $partKey && $this->containsPlaceholder($line)) { 219 $line = $this->processPlaceholderLine($line, $referenceArray); 220 } 221 } 222 return $line; 223 } 224 225 /** 226 * @param string $placeholder 227 * @param string $value 228 * @param array $referenceArray 229 * @return mixed 230 */ 231 protected function processSinglePlaceholder(string $placeholder, string $value, array $referenceArray) 232 { 233 $processorList = GeneralUtility::makeInstance( 234 PlaceholderProcessorList::class, 235 $GLOBALS['TYPO3_CONF_VARS']['SYS']['yamlLoader']['placeholderProcessors'] 236 ); 237 foreach ($processorList->compile() as $processor) { 238 if ($processor->canProcess($placeholder, $referenceArray)) { 239 try { 240 $result = $processor->process($value, $referenceArray); 241 } catch (\UnexpectedValueException $e) { 242 $result = $placeholder; 243 } 244 if (is_array($result)) { 245 $result = $this->processPlaceholders($result, $referenceArray); 246 } 247 break; 248 } 249 } 250 return $result ?? $placeholder; 251 } 252 253 /** 254 * @param string $placeholders 255 * @return array 256 */ 257 protected function getParts(string $placeholders): array 258 { 259 // find occurrences of placeholders like %some()% and %array.access%. 260 // Only find the innermost ones, so we can nest them. 261 preg_match_all( 262 '/%[^(%]+?\([\'"]?([^(]*?)[\'"]?\)%|%([^%()]*?)%/', 263 $placeholders, 264 $parts, 265 PREG_UNMATCHED_AS_NULL 266 ); 267 $matches = array_filter( 268 array_merge($parts[1], $parts[2]) 269 ); 270 return array_combine($parts[0], $matches); 271 } 272 273 /** 274 * Finds possible placeholders. 275 * May find false positives for complexer structures, but they will be sorted later on. 276 * @param mixed $value 277 */ 278 protected function containsPlaceholder($value): bool 279 { 280 return is_string($value) && substr_count($value, '%') >= 2; 281 } 282 283 protected function hasFlag(int $flag): bool 284 { 285 return ($this->flags & $flag) === $flag; 286 } 287} 288