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