1<?php
2
3declare(strict_types=1);
4
5/*
6 * This file is part of the TYPO3 CMS project.
7 *
8 * It is free software; you can redistribute it and/or modify it under
9 * the terms of the GNU General Public License, either version 2
10 * of the License, or any later version.
11 *
12 * For the full copyright and license information, please read the
13 * LICENSE.txt file that was distributed with this source code.
14 *
15 * The TYPO3 project - inspiring people to share!
16 */
17
18namespace TYPO3\CMS\Core\Configuration;
19
20use TYPO3\CMS\Core\Configuration\Exception\ExtensionConfigurationExtensionNotConfiguredException;
21use TYPO3\CMS\Core\Configuration\Exception\ExtensionConfigurationPathDoesNotExistException;
22use TYPO3\CMS\Core\Package\PackageManager;
23use TYPO3\CMS\Core\TypoScript\Parser\TypoScriptParser;
24use TYPO3\CMS\Core\Utility\ArrayUtility;
25use TYPO3\CMS\Core\Utility\GeneralUtility;
26
27/**
28 * API to get() instance specific extension configuration options.
29 *
30 * Extension authors are encouraged to use this API - it is currently a simple
31 * wrapper to access TYPO3_CONF_VARS['EXTENSIONS'] but could later become something
32 * different in case core decides to store extension configuration elsewhere.
33 *
34 * Extension authors must not access TYPO3_CONF_VARS['EXTENSIONS'] on their own.
35 *
36 * Extension configurations are often 'feature flags' currently defined by
37 * ext_conf_template.txt files. The core (more specifically the install tool)
38 * takes care default values and overridden values are properly prepared upon
39 * loading or updating an extension.
40 *
41 * Note only ->get() is official API and other public methods are low level
42 * core internal API that is usually only used by extension manager and install tool.
43 */
44class ExtensionConfiguration
45{
46    /**
47     * Get a single configuration value, a sub array or the whole configuration.
48     *
49     * Examples:
50     * // Simple and typical usage: Get a single config value, or an array if the key is a "TypoScript"
51     * // a-like sub-path in ext_conf_template.txt "foo.bar = defaultValue"
52     * ->get('myExtension', 'aConfigKey');
53     *
54     * // Get all current configuration values, always an array
55     * ->get('myExtension');
56     *
57     * // Get a nested config value if the path is a "TypoScript" a-like sub-path
58     * // in ext_conf_template.txt "topLevelKey.subLevelKey = defaultValue"
59     * ->get('myExtension', 'topLevelKey/subLevelKey')
60     *
61     * Notes:
62     * - If a configuration or configuration path of an extension is not found, the
63     *   code tries to synchronize configuration with ext_conf_template.txt first, only
64     *   if still not found, it will throw exceptions.
65     * - Return values are NOT type safe: A boolean false could be returned as string 0.
66     *   Cast accordingly.
67     * - This API throws exceptions if the path does not exist or the extension
68     *   configuration is not available. The install tool takes care any new
69     *   ext_conf_template.txt values are available TYPO3_CONF_VARS['EXTENSIONS'],
70     *   a thrown exception indicates a programming error on developer side
71     *   and should not be caught.
72     * - It is not checked if the extension in question is loaded at all,
73     *   it's just checked the extension configuration path exists.
74     * - Extensions should typically not get configuration of a different extension.
75     *
76     * @param string $extension Extension name
77     * @param string $path Configuration path - e.g. "featureCategory/coolThingIsEnabled"
78     * @return mixed The value. Can be a sub array or a single value.
79     * @throws ExtensionConfigurationExtensionNotConfiguredException If the extension configuration does not exist
80     * @throws ExtensionConfigurationPathDoesNotExistException If a requested path in the extension configuration does not exist
81     */
82    public function get(string $extension, string $path = '')
83    {
84        $hasBeenSynchronized = false;
85        if (!$this->hasConfiguration($extension)) {
86            // This if() should not be hit at "casual" runtime, but only in early setup phases
87            $this->synchronizeExtConfTemplateWithLocalConfigurationOfAllExtensions(true);
88            $hasBeenSynchronized = true;
89            if (!$this->hasConfiguration($extension)) {
90                // If there is still no such entry, even after sync -> throw
91                throw new ExtensionConfigurationExtensionNotConfiguredException(
92                    'No extension configuration for extension ' . $extension . ' found. Either this extension'
93                    . ' has no extension configuration or the configuration is not up to date. Execute the'
94                    . ' install tool to update configuration.',
95                    1509654728
96                );
97            }
98        }
99        if (empty($path)) {
100            return $GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS'][$extension];
101        }
102        if (!ArrayUtility::isValidPath($GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS'], $extension . '/' . $path)) {
103            // This if() should not be hit at "casual" runtime, but only in early setup phases
104            if (!$hasBeenSynchronized) {
105                $this->synchronizeExtConfTemplateWithLocalConfigurationOfAllExtensions(true);
106            }
107            // If there is still no such entry, even after sync -> throw
108            if (!ArrayUtility::isValidPath($GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS'], $extension . '/' . $path)) {
109                throw new ExtensionConfigurationPathDoesNotExistException(
110                    'Path ' . $path . ' does not exist in extension configuration',
111                    1509977699
112                );
113            }
114        }
115        return ArrayUtility::getValueByPath($GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS'], $extension . '/' . $path);
116    }
117
118    /**
119     * Store a new or overwrite an existing configuration value.
120     *
121     * This is typically used by core internal low level tasks like the install
122     * tool but may become handy if an extension needs to update extension configuration
123     * on the fly for whatever reason.
124     *
125     * Examples:
126     * // Set a full extension configuration ($value could be a nested array, too)
127     * ->set('myExtension', ['aFeature' => 'true', 'aCustomClass' => 'css-foo'])
128     *
129     * // Unset a whole extension configuration
130     * ->set('myExtension')
131     *
132     * Notes:
133     * - Do NOT call this at arbitrary places during runtime (eg. NOT in ext_localconf.php or
134     *   similar). ->set() is not supposed to be called each request since it writes LocalConfiguration
135     *   each time. This API is however OK to be called from extension manager hooks.
136     * - Values are not type safe, if the install tool wrote them,
137     *   boolean true could become string 1 on ->get()
138     * - It is not possible to store 'null' as value, giving $value=null
139     *   or no value at all will unset the path
140     * - Setting a value and calling ->get() afterwards will still return the new value.
141     * - Warning on AdditionalConfiguration.php: If this file overwrites settings, it spoils the
142     *   ->set() call and values may not end up as expected.
143     *
144     * @param string $extension Extension name
145     * @param mixed|null $value The value. If null, unset the path
146     * @internal
147     */
148    public function set(string $extension, $value = null): void
149    {
150        if (empty($extension)) {
151            throw new \RuntimeException('extension name must not be empty', 1509715852);
152        }
153        $configurationManager = GeneralUtility::makeInstance(ConfigurationManager::class);
154        if ($value === null) {
155            // Remove whole extension config
156            $configurationManager->removeLocalConfigurationKeysByPath(['EXTENSIONS/' . $extension]);
157            if (isset($GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS'][$extension])) {
158                unset($GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS'][$extension]);
159            }
160        } else {
161            // Set full extension config
162            $configurationManager->setLocalConfigurationValueByPath('EXTENSIONS/' . $extension, $value);
163            $GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS'][$extension] = $value;
164        }
165    }
166
167    /**
168     * Set new configuration of all extensions and reload TYPO3_CONF_VARS.
169     * This is a "do all" variant of set() for all extensions that prevents
170     * writing and loading LocalConfiguration many times.
171     *
172     * @param array $configuration Configuration of all extensions
173     * @param bool $skipWriteIfLocalConfiguationDoesNotExist
174     * @internal
175     */
176    public function setAll(array $configuration, bool $skipWriteIfLocalConfiguationDoesNotExist = false): void
177    {
178        $configurationManager = GeneralUtility::makeInstance(ConfigurationManager::class);
179        if ($skipWriteIfLocalConfiguationDoesNotExist === false || @file_exists($configurationManager->getLocalConfigurationFileLocation())) {
180            $configurationManager->setLocalConfigurationValueByPath('EXTENSIONS', $configuration);
181        }
182        $GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS'] = $configuration;
183    }
184
185    /**
186     * If there are new config settings in ext_conf_template of an extension,
187     * they are found here and synchronized to LocalConfiguration['EXTENSIONS'].
188     *
189     * Used when entering the install tool, during installation and if calling ->get()
190     * with an extension or path that is not yet found in LocalConfiguration
191     *
192     * @param bool $skipWriteIfLocalConfiguationDoesNotExist
193     * @internal
194     */
195    public function synchronizeExtConfTemplateWithLocalConfigurationOfAllExtensions(bool $skipWriteIfLocalConfiguationDoesNotExist = false): void
196    {
197        $activePackages = GeneralUtility::makeInstance(PackageManager::class)->getActivePackages();
198        $fullConfiguration = [];
199        $currentLocalConfiguration = $GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS'] ?? [];
200        foreach ($activePackages as $package) {
201            if (!@is_file($package->getPackagePath() . 'ext_conf_template.txt')) {
202                continue;
203            }
204            $extensionKey = $package->getPackageKey();
205            $currentExtensionConfig = $currentLocalConfiguration[$extensionKey] ?? [];
206            $extConfTemplateConfiguration = $this->getExtConfTablesWithoutCommentsAsNestedArrayWithoutDots($extensionKey);
207            ArrayUtility::mergeRecursiveWithOverrule($extConfTemplateConfiguration, $currentExtensionConfig);
208            if (!empty($extConfTemplateConfiguration)) {
209                $fullConfiguration[$extensionKey] = $extConfTemplateConfiguration;
210            }
211        }
212        // Write new config if changed. Loose array comparison to not write if only array key order is different
213        if ($fullConfiguration != $currentLocalConfiguration) {
214            $this->setAll($fullConfiguration, $skipWriteIfLocalConfiguationDoesNotExist);
215        }
216    }
217
218    /**
219     * Read values from ext_conf_template, verify if they are in LocalConfiguration.php
220     * already and if not, add them.
221     *
222     * Used public by extension manager when updating extension
223     *
224     * @param string $extensionKey The extension to sync
225     * @internal
226     */
227    public function synchronizeExtConfTemplateWithLocalConfiguration(string $extensionKey): void
228    {
229        $package = GeneralUtility::makeInstance(PackageManager::class)->getPackage($extensionKey);
230        if (!@is_file($package->getPackagePath() . 'ext_conf_template.txt')) {
231            return;
232        }
233        $currentLocalConfiguration = $GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS'][$extensionKey] ?? [];
234        $extConfTemplateConfiguration = $this->getExtConfTablesWithoutCommentsAsNestedArrayWithoutDots($extensionKey);
235        ArrayUtility::mergeRecursiveWithOverrule($extConfTemplateConfiguration, $currentLocalConfiguration);
236        // Write new config if changed. Loose array comparison to not write if only array key order is different
237        if ($extConfTemplateConfiguration != $currentLocalConfiguration) {
238            $this->set($extensionKey, $extConfTemplateConfiguration);
239        }
240    }
241
242    /**
243     * Helper method of ext_conf_template.txt parsing.
244     *
245     * Poor man version of getDefaultConfigurationFromExtConfTemplateAsValuedArray() which ignores
246     * comments and returns ext_conf_template as array where nested keys have no dots.
247     *
248     * @param string $extensionKey
249     * @return array
250     */
251    protected function getExtConfTablesWithoutCommentsAsNestedArrayWithoutDots(string $extensionKey): array
252    {
253        $rawConfigurationString = $this->getDefaultConfigurationRawString($extensionKey);
254        $typoScriptParser = GeneralUtility::makeInstance(TypoScriptParser::class);
255        // we are parsing constants, so we need the instructions from comments
256        $typoScriptParser->regComments = true;
257        $typoScriptParser->parse($rawConfigurationString);
258        // setup contains the parsed constants string
259        $parsedTemplate = $typoScriptParser->setup;
260        return $this->removeCommentsAndDotsRecursive($parsedTemplate);
261    }
262
263    /**
264     * Helper method of ext_conf_template.txt parsing.
265     *
266     * Return content of an extensions ext_conf_template.txt file if
267     * the file exists, empty string if file does not exist.
268     *
269     * @param string $extensionKey Extension key
270     * @return string
271     */
272    public function getDefaultConfigurationRawString(string $extensionKey): string
273    {
274        $rawString = '';
275        $extConfTemplateFileLocation = GeneralUtility::getFileAbsFileName(
276            'EXT:' . $extensionKey . '/ext_conf_template.txt'
277        );
278        if (file_exists($extConfTemplateFileLocation)) {
279            $rawString = (string)file_get_contents($extConfTemplateFileLocation);
280        }
281        return $rawString;
282    }
283
284    /**
285     * @param string $extension
286     * @return bool
287     */
288    protected function hasConfiguration(string $extension): bool
289    {
290        return isset($GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS'][$extension]) && is_array($GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS'][$extension]);
291    }
292
293    /**
294     * Helper method of ext_conf_template.txt parsing.
295     *
296     * "Comments" from the "TypoScript" parser below are identified by two (!) dots at the end of array keys
297     * and all array keys have a single dot at the end, if they have sub arrays. This is cleaned here.
298     *
299     * Incoming array:
300     * [
301     *  'automaticInstallation' => '1',
302     *  'automaticInstallation..' => '# cat=basic/enabled; ...'
303     *  'FE.' => [
304     *      'enabled' = '1',
305     *      'enabled..' => '# cat=basic/enabled; ...'
306     *  ]
307     * ]
308     * Output array:
309     * [
310     *  'automaticInstallation' => '1',
311     *  'FE' => [
312     *      'enabled' => '1',
313     * ]
314     *
315     * @param array $config Incoming configuration
316     * @return array
317     */
318    protected function removeCommentsAndDotsRecursive(array $config): array
319    {
320        $cleanedConfig = [];
321        foreach ($config as $key => $value) {
322            if (substr($key, -2) === '..') {
323                continue;
324            }
325            if (substr($key, -1) === '.') {
326                $cleanedConfig[rtrim($key, '.')] = $this->removeCommentsAndDotsRecursive($value);
327            } else {
328                $cleanedConfig[$key] = $value;
329            }
330        }
331        return $cleanedConfig;
332    }
333}
334