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