PluginProbe ʕ •ᴥ•ʔ
Matomo Analytics – Powerful, Privacy-First Insights for WordPress / trunk
Matomo Analytics – Powerful, Privacy-First Insights for WordPress vtrunk
5.11.1 5.11.0 5.10.2 5.10.1 trunk 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.1.0 1.1.1 1.1.2 1.1.3 1.2.0 1.3.0 1.3.1 1.3.2 4.0.0 4.0.1 4.0.2 4.0.3 4.0.4 4.1.0 4.1.1 4.1.2 4.1.3 4.10.0 4.11.0 4.12.0 4.13.0 4.13.2 4.13.3 4.13.4 4.13.5 4.14.0 4.14.1 4.14.2 4.15.0 4.15.1 4.15.2 4.15.3 4.2.0 4.3.0 4.3.1 4.4.1 4.4.2 4.5.0 4.6.0 5.0.1 5.0.2 5.0.3 5.0.4 5.0.5 5.0.6 5.0.7 5.0.8 5.1.0 5.1.1 5.1.2 5.1.3 5.1.4 5.1.5 5.1.6 5.1.7 5.10.0 5.2.0 5.2.1 5.2.2 5.3.0 5.3.1 5.3.2 5.3.3 5.6.0 5.6.1 5.7.0 5.7.1 5.8.0 5.8.1 5.8.2
matomo / app / core / Config / IniFileChain.php
matomo / app / core / Config Last commit date
Cache.php 1 year ago ConfigNotFoundException.php 2 years ago DatabaseConfig.php 1 year ago GeneralConfig.php 2 years ago IniFileChain.php 1 month ago SectionConfig.php 1 month ago
IniFileChain.php
491 lines
1 <?php
2
3 /**
4 * Matomo - free/libre analytics platform
5 *
6 * @link https://matomo.org
7 * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
8 */
9 namespace Piwik\Config;
10
11 use Piwik\Common;
12 use Matomo\Ini\IniReader;
13 use Matomo\Ini\IniReadingException;
14 use Matomo\Ini\IniWriter;
15 use Piwik\Piwik;
16 /**
17 * Manages a list of INI files where the settings in each INI file merge with or override the
18 * settings in the previous INI file.
19 *
20 * The IniFileChain class manages two types of INI files: multiple default setting files and one
21 * user settings file.
22 *
23 * The default setting files (for example, global.ini.php & common.ini.php) hold the default setting values.
24 * The settings in these files are merged recursively, however, array settings in one file will still
25 * overwrite settings in the previous file.
26 *
27 * Default settings files cannot be modified through the IniFileChain class.
28 *
29 * The user settings file (for example, config.ini.php) holds the actual setting values. Settings in the
30 * user settings files overwrite other settings. So array settings will not merge w/ previous values.
31 *
32 * HTML characters and dollar signs are stored as encoded HTML entities in INI files. This prevents
33 * several `parse_ini_file` issues, including one where parse_ini_file tries to insert a variable
34 * into a setting value if a string like `"$varname" is present.
35 */
36 class IniFileChain
37 {
38 public const CONFIG_CACHE_KEY = 'config.ini';
39 /**
40 * Maps INI file names with their parsed contents. The order of the files signifies the order
41 * in the chain. Files with lower index are overwritten/merged with files w/ a higher index.
42 *
43 * @var array
44 */
45 protected $settingsChain = [];
46 /**
47 * The merged INI settings.
48 *
49 * @var array
50 */
51 protected $mergedSettings = [];
52 /**
53 * @param string[] $defaultSettingsFiles The list of paths to INI files w/ the default setting values.
54 * @param string|null $userSettingsFile The path to the user settings file.
55 */
56 public function __construct(array $defaultSettingsFiles = [], $userSettingsFile = null)
57 {
58 $this->reload($defaultSettingsFiles, $userSettingsFile);
59 }
60 /**
61 * Return setting section by reference.
62 *
63 * @param string $name
64 * @return mixed
65 */
66 public function &get($name)
67 {
68 if (!isset($this->mergedSettings[$name])) {
69 $this->mergedSettings[$name] = [];
70 }
71 $result =& $this->mergedSettings[$name];
72 return $result;
73 }
74 /**
75 * Return setting section from a specific file, rather than the current merged settings.
76 *
77 * @param string $file The path of the file. Should be the path used in construction or reload().
78 * @param string $name The name of the section to access.
79 */
80 public function getFrom($file, $name)
81 {
82 return @$this->settingsChain[$file][$name];
83 }
84 /**
85 * Sets a setting value.
86 *
87 * @param string $name
88 * @param mixed $value
89 */
90 public function set($name, $value)
91 {
92 $name = $this->replaceSectionInvalidChars($name);
93 if ($value !== null) {
94 $value = $this->replaceInvalidChars($value);
95 }
96 $this->mergedSettings[$name] = $value;
97 }
98 /**
99 * Returns all settings. Changes made to the array result will be reflected in the
100 * IniFileChain instance.
101 *
102 * @return array
103 */
104 public function &getAll()
105 {
106 return $this->mergedSettings;
107 }
108 /**
109 * Dumps the current in-memory setting values to a string in INI format and returns it.
110 *
111 * @param string $header The header of the output INI file.
112 * @return string The dumped INI contents.
113 */
114 public function dump($header = '')
115 {
116 return $this->dumpSettings($this->mergedSettings, $header);
117 }
118 /**
119 * Writes the difference of the in-memory setting values and the on-disk user settings file setting
120 * values to a string in INI format, and returns it.
121 *
122 * If a config section is identical to the default settings section (as computed by merging
123 * all default setting files), it is not written to the user settings file.
124 *
125 * @param string $header The header of the INI output.
126 * @return string The dumped INI contents.
127 */
128 public function dumpChanges($header = '')
129 {
130 $userSettingsFile = $this->getUserSettingsFile();
131 $defaultSettings = $this->getMergedDefaultSettings();
132 $existingMutableSettings = $this->settingsChain[$userSettingsFile];
133 $dirty = \false;
134 $configToWrite = [];
135 foreach ($this->mergedSettings as $sectionName => $changedSection) {
136 if (isset($existingMutableSettings[$sectionName])) {
137 $existingMutableSection = $existingMutableSettings[$sectionName];
138 } else {
139 $existingMutableSection = [];
140 }
141 // remove default values from both (they should not get written to local)
142 if (isset($defaultSettings[$sectionName])) {
143 $changedSection = $this->arrayUnmerge($defaultSettings[$sectionName], $changedSection);
144 $existingMutableSection = $this->arrayUnmerge($defaultSettings[$sectionName], $existingMutableSection);
145 }
146 // if either local/config have non-default values and the other doesn't,
147 // OR both have values, but different values, we must write to config.ini.php
148 if ((empty($changedSection) xor empty($existingMutableSection)) || self::compareElements($changedSection, $existingMutableSection)) {
149 $dirty = \true;
150 }
151 $configToWrite[$sectionName] = $changedSection;
152 }
153 if ($dirty) {
154 // sort config sections by how early they appear in the file chain
155 $self = $this;
156 uksort($configToWrite, function ($sectionNameLhs, $sectionNameRhs) use($self) {
157 $lhsIndex = $self->findIndexOfFirstFileWithSection($sectionNameLhs);
158 $rhsIndex = $self->findIndexOfFirstFileWithSection($sectionNameRhs);
159 if ($lhsIndex == $rhsIndex) {
160 $lhsIndexInFile = $self->getIndexOfSectionInFile($lhsIndex, $sectionNameLhs);
161 $rhsIndexInFile = $self->getIndexOfSectionInFile($rhsIndex, $sectionNameRhs);
162 if ($lhsIndexInFile == $rhsIndexInFile) {
163 return 0;
164 } elseif ($lhsIndexInFile < $rhsIndexInFile) {
165 return -1;
166 } else {
167 return 1;
168 }
169 } elseif ($lhsIndex < $rhsIndex) {
170 return -1;
171 } else {
172 return 1;
173 }
174 });
175 return $this->dumpSettings($configToWrite, $header);
176 } else {
177 return null;
178 }
179 }
180 /**
181 * Reloads settings from disk.
182 */
183 public function reload($defaultSettingsFiles = [], $userSettingsFile = null)
184 {
185 if (!empty($defaultSettingsFiles) || !empty($userSettingsFile)) {
186 $this->resetSettingsChain($defaultSettingsFiles, $userSettingsFile);
187 }
188 $hasAbsoluteConfigFile = !empty($userSettingsFile) && strpos($userSettingsFile, \DIRECTORY_SEPARATOR) === 0;
189 $useConfigCache = !empty($GLOBALS['ENABLE_CONFIG_PHP_CACHE']) && $hasAbsoluteConfigFile;
190 if ($useConfigCache && is_file($userSettingsFile)) {
191 $cache = new \Piwik\Config\Cache();
192 $values = $cache->doFetch(self::CONFIG_CACHE_KEY);
193 if (!empty($values) && isset($values['mergedSettings']) && isset($values['settingsChain'][$userSettingsFile])) {
194 $this->mergedSettings = $values['mergedSettings'];
195 $this->settingsChain = $values['settingsChain'];
196 return;
197 }
198 }
199 $reader = new IniReader();
200 foreach ($this->settingsChain as $file => $ignore) {
201 if (is_readable($file)) {
202 try {
203 $contents = $reader->readFile($file);
204 $this->settingsChain[$file] = $this->decodeValues($contents);
205 } catch (IniReadingException $ex) {
206 throw new IniReadingException('Unable to read INI file {' . $file . '}: ' . $ex->getMessage() . "\n Your host may have disabled parse_ini_file().");
207 }
208 $this->decodeValues($this->settingsChain[$file]);
209 }
210 }
211 $merged = $this->mergeFileSettings();
212 // remove reference to $this->settingsChain... otherwise dump() or compareElements() will never notice a difference
213 // on PHP 7+ as they would be always equal
214 $this->mergedSettings = $this->copy($merged);
215 if (!empty($GLOBALS['MATOMO_MODIFY_CONFIG_SETTINGS']) && !empty($this->mergedSettings)) {
216 $this->mergedSettings = call_user_func($GLOBALS['MATOMO_MODIFY_CONFIG_SETTINGS'], $this->mergedSettings);
217 }
218 if ($useConfigCache && !empty($this->mergedSettings) && !empty($this->settingsChain) && \Piwik\Config\Cache::hasHostConfig($this->mergedSettings)) {
219 $ttlOneHour = 3600;
220 $cache = new \Piwik\Config\Cache();
221 if ($cache->isValidHost($this->mergedSettings)) {
222 // we make sure to save the config only if the host is valid...
223 $data = ['mergedSettings' => $this->mergedSettings, 'settingsChain' => $this->settingsChain];
224 $cache->doSave(self::CONFIG_CACHE_KEY, $data, $ttlOneHour);
225 }
226 }
227 }
228 public function deleteConfigCache()
229 {
230 if (!empty($GLOBALS['ENABLE_CONFIG_PHP_CACHE'])) {
231 $cache = new \Piwik\Config\Cache();
232 $cache->doDelete(\Piwik\Config\IniFileChain::CONFIG_CACHE_KEY);
233 }
234 }
235 private function copy($merged)
236 {
237 $copy = [];
238 foreach ($merged as $index => $value) {
239 if (is_array($value)) {
240 $copy[$index] = $this->copy($value);
241 } else {
242 $copy[$index] = $value;
243 }
244 }
245 return $copy;
246 }
247 private function resetSettingsChain($defaultSettingsFiles, $userSettingsFile)
248 {
249 $this->settingsChain = [];
250 if (!empty($defaultSettingsFiles)) {
251 foreach ($defaultSettingsFiles as $file) {
252 $this->settingsChain[$file] = null;
253 }
254 }
255 if (!empty($userSettingsFile)) {
256 $this->settingsChain[$userSettingsFile] = null;
257 }
258 }
259 protected function mergeFileSettings()
260 {
261 $mergedSettings = $this->getMergedDefaultSettings();
262 $userSettings = end($this->settingsChain) ?: [];
263 foreach ($userSettings as $sectionName => $section) {
264 if (!isset($mergedSettings[$sectionName])) {
265 $mergedSettings[$sectionName] = $section;
266 } else {
267 // the last user settings file completely overwrites INI sections. the other files in the chain
268 // can add to array options
269 $mergedSettings[$sectionName] = array_merge($mergedSettings[$sectionName], $section);
270 }
271 }
272 return $mergedSettings;
273 }
274 protected function getMergedDefaultSettings()
275 {
276 $userSettingsFile = $this->getUserSettingsFile();
277 $mergedSettings = [];
278 foreach ($this->settingsChain as $file => $settings) {
279 if ($file == $userSettingsFile || empty($settings)) {
280 continue;
281 }
282 foreach ($settings as $sectionName => $section) {
283 if (!isset($mergedSettings[$sectionName])) {
284 $mergedSettings[$sectionName] = $section;
285 } else {
286 $mergedSettings[$sectionName] = $this->array_merge_recursive_distinct($mergedSettings[$sectionName], $section);
287 }
288 }
289 }
290 return $mergedSettings;
291 }
292 protected function getUserSettingsFile()
293 {
294 // the user settings file is the last key in $settingsChain
295 end($this->settingsChain);
296 return key($this->settingsChain);
297 }
298 /**
299 * Comparison function
300 *
301 * @param mixed $elem1
302 * @param mixed $elem2
303 * @return int;
304 */
305 public static function compareElements($elem1, $elem2)
306 {
307 if (is_array($elem1)) {
308 if (is_array($elem2)) {
309 return strcmp(serialize($elem1), serialize($elem2));
310 }
311 return 1;
312 }
313 if (is_array($elem2)) {
314 return -1;
315 }
316 if ((string) $elem1 === (string) $elem2) {
317 return 0;
318 }
319 return (string) $elem1 > (string) $elem2 ? 1 : -1;
320 }
321 /**
322 * Compare arrays and return difference, such that:
323 *
324 * $modified = array_merge($original, $difference);
325 *
326 * @param array $original original array
327 * @param array $modified modified array
328 * @return array differences between original and modified
329 */
330 public function arrayUnmerge($original, $modified)
331 {
332 // return key/value pairs for keys in $modified but not in $original
333 // return key/value pairs for keys in both $modified and $original, but values differ
334 // ignore keys that are in $original but not in $modified
335 if (empty($original) || !is_array($original)) {
336 $original = [];
337 }
338 if (empty($modified) || !is_array($modified)) {
339 $modified = [];
340 }
341 return array_udiff_assoc($modified, $original, [__CLASS__, 'compareElements']);
342 }
343 /**
344 * array_merge_recursive does indeed merge arrays, but it converts values with duplicate
345 * keys to arrays rather than overwriting the value in the first array with the duplicate
346 * value in the second array, as array_merge does. I.e., with array_merge_recursive,
347 * this happens (documented behavior):
348 *
349 * array_merge_recursive(array('key' => 'org value'), array('key' => 'new value'));
350 * => array('key' => array('org value', 'new value'));
351 *
352 * array_merge_recursive_distinct does not change the datatypes of the values in the arrays.
353 * Matching keys' values in the second array overwrite those in the first array, as is the
354 * case with array_merge, i.e.:
355 *
356 * array_merge_recursive_distinct(array('key' => 'org value'), array('key' => 'new value'));
357 * => array('key' => array('new value'));
358 *
359 * Parameters are passed by reference, though only for performance reasons. They're not
360 * altered by this function.
361 *
362 * @param array $array1
363 * @param array $array2
364 * @return array
365 * @author Daniel <daniel (at) danielsmedegaardbuus (dot) dk>
366 * @author Gabriel Sobrinho <gabriel (dot) sobrinho (at) gmail (dot) com>
367 */
368 private function array_merge_recursive_distinct(array &$array1, array &$array2)
369 {
370 $merged = $array1;
371 foreach ($array2 as $key => &$value) {
372 if (is_array($value) && isset($merged[$key]) && is_array($merged[$key])) {
373 $merged[$key] = $this->array_merge_recursive_distinct($merged[$key], $value);
374 } else {
375 $merged[$key] = $value;
376 }
377 }
378 return $merged;
379 }
380 /**
381 * public for use in closure.
382 */
383 public function findIndexOfFirstFileWithSection($sectionName)
384 {
385 $count = 0;
386 foreach ($this->settingsChain as $file => $settings) {
387 if (isset($settings[$sectionName])) {
388 break;
389 }
390 ++$count;
391 }
392 return $count;
393 }
394 /**
395 * public for use in closure.
396 */
397 public function getIndexOfSectionInFile($fileIndex, $sectionName)
398 {
399 reset($this->settingsChain);
400 for ($i = 0; $i != $fileIndex; ++$i) {
401 next($this->settingsChain);
402 }
403 $settingsData = current($this->settingsChain);
404 if (empty($settingsData)) {
405 return -1;
406 }
407 $settingsDataSectionNames = array_keys($settingsData);
408 return array_search($sectionName, $settingsDataSectionNames);
409 }
410 /**
411 * Encode HTML entities
412 *
413 * @param mixed $values
414 * @return mixed
415 */
416 protected function encodeValues(&$values)
417 {
418 if (is_array($values)) {
419 foreach ($values as &$value) {
420 $value = $this->encodeValues($value);
421 }
422 } elseif (is_float($values)) {
423 $values = Common::forceDotAsSeparatorForDecimalPoint($values);
424 } elseif (is_string($values)) {
425 $values = htmlentities($values, \ENT_COMPAT, 'UTF-8');
426 $values = str_replace('$', '&#36;', $values);
427 }
428 return $values;
429 }
430 /**
431 * Decode HTML entities
432 *
433 * @param mixed $values
434 * @return mixed
435 */
436 protected function decodeValues(&$values)
437 {
438 if (is_array($values)) {
439 foreach ($values as &$value) {
440 $value = $this->decodeValues($value);
441 }
442 return $values;
443 } elseif (is_string($values)) {
444 return html_entity_decode($values, \ENT_COMPAT, 'UTF-8');
445 }
446 return $values;
447 }
448 private function dumpSettings($values, $header)
449 {
450 /**
451 * Triggered before a config is being written / saved on the local file system.
452 *
453 * A plugin can listen to it and modify which settings will be saved on the file system. This allows you
454 * to prevent saving config values that a plugin sets on demand. Say you configure the database password in the
455 * config on demand in your plugin, then you could prevent that the password is saved in the actual config file
456 * by listening to this event like this:
457 *
458 * **Example**
459 * function doNotSaveDbPassword (&$values) {
460 * unset($values['database']['password']);
461 * }
462 *
463 * @param array &$values Config values that will be saved
464 */
465 Piwik::postEvent('Config.beforeSave', [&$values]);
466 $values = $this->encodeValues($values);
467 $writer = new IniWriter();
468 return $writer->writeToString($values, $header);
469 }
470 private function replaceInvalidChars($value)
471 {
472 if (is_array($value)) {
473 $result = [];
474 foreach ($value as $key => $arrayValue) {
475 $key = $this->replaceInvalidChars($key);
476 if (is_array($arrayValue)) {
477 $arrayValue = $this->replaceInvalidChars($arrayValue);
478 }
479 $result[$key] = $arrayValue;
480 }
481 return $result;
482 } else {
483 return preg_replace('/[^a-zA-Z0-9_\\[\\]-]/', '', $value);
484 }
485 }
486 private function replaceSectionInvalidChars($value)
487 {
488 return preg_replace('/[^a-zA-Z0-9_-]/', '', $value);
489 }
490 }
491