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