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