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