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