diff --git a/app/bootstrap.php b/app/bootstrap.php
index c7b1a4fe2..cd67a64fa 100644
--- a/app/bootstrap.php
+++ b/app/bootstrap.php
@@ -194,7 +194,6 @@ function matomo_log_message_no_display($message)
}
}
-
if ( matomo_is_app_request() ) {
// pretend we are in the admin... potentially avoiding caching etc
$GLOBALS['hook_suffix'] = '';
@@ -222,45 +221,15 @@ function matomo_log_message_no_display($message)
}
}
-$GLOBALS['MATOMO_MODIFY_CONFIG_SETTINGS'] = function ($settings) {
- $plugins = $settings['Plugins'];
- if (is_array($settings['Plugins'])) {
- $pluginsToRemove = array('Marketplace', 'MultiSites', 'TwoFactorAuth', 'Widgetize', 'Feedback', 'ExamplePlugin', 'ExampleAPI', 'MobileAppMeasurable', 'CustomPiwikJs');
- foreach ($pluginsToRemove as $pluginToRemove) {
- // Marketplace => this is instead done in wordpress
- // MultiSites => doesn't really make sense since we have only one website per installation
- // TwoFactorAuth => not needed as login is being handled by WordPress
- // widgetize for now we don't want to allow widgetizing as it is based on the token_auth authentication
- // Monolog => we use our own logger
- // ProfessionalServices => we advertise in the WP plugin itself instead
- // feedback => we want to hide things like Need help in the admin etc
- // MobileAppMeasurable => for WP mobile apps are not a thing
- // custom variables we don't want to enable as we will deprecate them in Matomo 4 anyway => used to be disabled but we need to make sure the columns get installed otherwise matomo has issues... need to wait to matomo 4 to remove it
- $pos = array_search($pluginToRemove, $plugins['Plugins']);
- if ($pos !== false) {
- array_splice($plugins['Plugins'], $pos, 1);
- }
- }
- if (matomo_has_tag_manager()) {
- $plugins['Plugins'][] = 'TagManager';
- }
- $mustEnable = ['BulkTracking', 'CustomJsTracker'];
- foreach ($mustEnable as $enable) {
- if (!in_array($enable, $plugins['Plugins'])) {
- $plugins['Plugins'][] = $enable;
- }
- }
- }
- if (!empty($GLOBALS['MATOMO_PLUGINS_ENABLED'])) {
- foreach ($GLOBALS['MATOMO_PLUGINS_ENABLED'] as $plugin) {
- if (!in_array($plugin, $plugins['Plugins'])) {
- $plugins['Plugins'][] = $plugin;
- }
- }
- }
- if (!empty($GLOBALS['MATOMO_MARKETPLACE_PLUGINS'])) {
- matomo_filter_incompatible_plugins($plugins['Plugins']);
- }
- $settings['Plugins'] = $plugins;
- return $settings;
-};
+// use a WordPress specific Matomo Environment subclass so the WordPress GlobalSettingsProvider is
+// used. core Matomo entry points are patched to instantiate WordPressEnvironment (see
+// patches/prefixed/wordpress-environment.diff).
+//
+// at this point the composer autoloader is not included yet, so make sure the classes these
+// entry points reference are loaded before they run.
+if (!defined('PIWIK_INCLUDE_PATH')) {
+ define('PIWIK_INCLUDE_PATH', PIWIK_DOCUMENT_ROOT);
+}
+require_once PIWIK_DOCUMENT_ROOT . '/vendor/autoload.php';
+require_once __DIR__ . '/../plugins/WordPress/Overrides/GlobalSettingsProvider.php';
+require_once __DIR__ . '/../plugins/WordPress/WordPressEnvironment.php';
diff --git a/app/core/CliMulti/RequestCommand.php b/app/core/CliMulti/RequestCommand.php
index 1c3886ceb..9ad4275c7 100644
--- a/app/core/CliMulti/RequestCommand.php
+++ b/app/core/CliMulti/RequestCommand.php
@@ -92,7 +92,7 @@ private function recreateContainerWithWebEnvironment()
{
StaticContainer::clearContainer();
Log::unsetInstance();
- $this->environment = new Environment(null);
+ $this->environment = new \Piwik\Plugins\WordPress\WordPressEnvironment(null);
$this->environment->init();
}
private function resetDatabase()
diff --git a/app/core/Console.php b/app/core/Console.php
index f8c3862ff..8527b86b0 100644
--- a/app/core/Console.php
+++ b/app/core/Console.php
@@ -196,7 +196,7 @@ protected function initEnvironment(OutputInterface $output)
{
try {
if ($this->environment === null) {
- $this->environment = new Environment('cli');
+ $this->environment = new \Piwik\Plugins\WordPress\WordPressEnvironment('cli');
$this->environment->init();
}
$config = \Piwik\Config::getInstance();
diff --git a/app/core/dispatch.php b/app/core/dispatch.php
index d4c79ed69..b49179f6f 100644
--- a/app/core/dispatch.php
+++ b/app/core/dispatch.php
@@ -19,7 +19,7 @@
\define('PIWIK_ENABLE_DISPATCH', \true);
}
if (\PIWIK_ENABLE_DISPATCH) {
- $environment = new \Piwik\Application\Environment(null);
+ $environment = new \Piwik\Plugins\WordPress\WordPressEnvironment(null);
$environment->init();
$controller = FrontController::getInstance();
try {
diff --git a/app/js/tracker.php b/app/js/tracker.php
index be82031fd..18b9a6cbb 100644
--- a/app/js/tracker.php
+++ b/app/js/tracker.php
@@ -67,7 +67,7 @@ class Validator {
public function validate() {}
}
$validator = new Validator();
-$environment = new \Piwik\Application\Environment(null, array(
+$environment = new \Piwik\Plugins\WordPress\WordPressEnvironment(null, array(
'Piwik\Application\Kernel\EnvironmentValidator' => $validator
));
$environment->init();
diff --git a/app/piwik.php b/app/piwik.php
index 8fd0e5ad3..ab133815e 100644
--- a/app/piwik.php
+++ b/app/piwik.php
@@ -46,7 +46,7 @@
require_once \PIWIK_INCLUDE_PATH . '/core/Cookie.php';
require_once \PIWIK_INCLUDE_PATH . '/core/API/CORSHandler.php';
SettingsServer::setIsTrackerApiRequest();
- $environment = new \Piwik\Application\Environment('tracker');
+ $environment = new \Piwik\Plugins\WordPress\WordPressEnvironment('tracker');
try {
$environment->init();
} catch (\Piwik\Exception\NotYetInstalledException $e) {
diff --git a/classes/WpMatomo/API.php b/classes/WpMatomo/API.php
index fc51c91d6..9f03238cb 100644
--- a/classes/WpMatomo/API.php
+++ b/classes/WpMatomo/API.php
@@ -55,6 +55,7 @@ public function register_routes() {
$this->register_route( 'CoreAdminHome', 'invalidateArchivedReports' );
$this->register_route( 'CoreAdminHome', 'runScheduledTasks' );
$this->register_route( 'CoreAdminHome', 'runCronArchiving' );
+ $this->register_route( 'CorePluginsAdmin', 'setSystemSettings' );
$this->register_route( 'Dashboard', 'getDashboards' );
$this->register_route( 'ImageGraph', 'get' );
$this->register_route( 'VisitsSummary', 'getVisits' );
@@ -155,9 +156,6 @@ public function to_snake_case( $method ) {
return implode( '_', $snake_case );
}
- /**
- * @api
- */
public function register_route( $api_module, $api_method ) {
$methods = [
'get' => 'GET',
@@ -170,6 +168,7 @@ public function register_route( $api_module, $api_method ) {
'run' => 'POST',
'send' => 'POST',
'publish' => 'POST',
+ 'set' => 'POST',
'delete' => 'DELETE',
'remove' => 'DELETE',
];
diff --git a/classes/WpMatomo/Admin/PluginSuggestions/PluginSuggestions.php b/classes/WpMatomo/Admin/PluginSuggestions/PluginSuggestions.php
index 1a88afde6..324705a15 100644
--- a/classes/WpMatomo/Admin/PluginSuggestions/PluginSuggestions.php
+++ b/classes/WpMatomo/Admin/PluginSuggestions/PluginSuggestions.php
@@ -40,6 +40,10 @@ public function register_hooks() {
add_action( 'admin_enqueue_scripts', [ $this, 'load_scripts' ] );
+ add_action( 'init', [ $this, 'register_suggestion_hooks' ] );
+ }
+
+ public function register_suggestion_hooks() {
foreach ( $this->get_suggestions() as $suggestion ) {
$suggestion->register_hooks();
}
diff --git a/classes/WpMatomo/Admin/SystemReport.php b/classes/WpMatomo/Admin/SystemReport.php
index d43590c76..767bfb3aa 100644
--- a/classes/WpMatomo/Admin/SystemReport.php
+++ b/classes/WpMatomo/Admin/SystemReport.php
@@ -75,8 +75,10 @@ class SystemReport implements MatomoPageContent {
const TROUBLESHOOT_UPDATE_GEOIP_DB = 'matomo_troubleshooting_action_update_geoipdb';
const TROUBLESHOOT_CLEAR_LOGS = 'matomo_troubleshooting_action_clear_logs';
const TROUBLESHOOT_RUN_UPDATER = 'matomo_troubleshooting_action_run_updater';
- const REGENERATE_TRACKING_CODE = 'matomo_troubleshooting_action_regen_tracking_code';
- const RUN_SCHEDULED_TASK = 'matomo_troubleshooting_action_run_task';
+
+ const TROUBLESHOOT_DISMISS_SALT_REGENERATED = 'matomo_troubleshooting_action_dismiss_salt_regenerated';
+ const REGENERATE_TRACKING_CODE = 'matomo_troubleshooting_action_regen_tracking_code';
+ const RUN_SCHEDULED_TASK = 'matomo_troubleshooting_action_run_task';
private $not_compatible_plugins = [
'minify-html-markup',
@@ -196,6 +198,10 @@ private function execute_troubleshoot_if_needed() {
$this->logger->clear_logged_exceptions();
}
+ if ( ! empty( $_POST[ self::TROUBLESHOOT_DISMISS_SALT_REGENERATED ] ) ) {
+ $this->settings->set_time_salt_was_regenerated( 0 );
+ }
+
if ( ! $this->settings->is_network_enabled() || ! is_network_admin() ) {
if ( ! empty( $_POST[ self::TROUBLESHOOT_SYNC_USERS ] ) ) {
$sync = new UserSync();
@@ -366,6 +372,7 @@ public function show() {
$matomo_has_exception_logs = [];
$matomo_has_warning_and_no_errors = false;
$matomo_scheduled_tasks = [];
+ $matomo_salt_was_regenerated = $this->settings->get_time_salt_was_regenerated() > 0;
if ( empty( $matomo_active_tab ) ) { // system report
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.prevent_path_disclosure_error_reporting
@@ -585,6 +592,19 @@ private function get_matomo_info() {
$path_config_file = $paths->get_config_ini_path();
$rows = $this->check_file_exists_and_writable( $rows, $path_config_file, 'Config', true );
+ $salt_regenerated_time = $this->settings->get_time_salt_was_regenerated();
+ if ( $salt_regenerated_time > 0 ) {
+ $rows[] = [
+ 'name' => esc_html__( 'Matomo salt was regenerated during a config file recovery', 'matomo' ),
+ 'value' => $this->convert_time_to_date( $salt_regenerated_time, true, true ),
+ 'is_warning' => true,
+ 'comment' => esc_html__(
+ 'The Matomo config file (config.ini.php) went missing or was corrupted, and was automatically recovered from the backup kept in the WordPress database. The original "salt" setting could not be recovered, however, so a new one was generated. This has the following consequences for your install: visitors who opted out of tracking through the Matomo opt-out feature are being tracked again, because their opt-out cookie is no longer recognized — consider informing your users to renew their opt-out, since being able to refuse tracking may be legally required depending on your jurisdiction (eg. GDPR/ePrivacy). Additionally, visits that were ongoing at the time of the recovery may appear split in two in your reports.',
+ 'matomo'
+ ),
+ ];
+ }
+
$path_tracker_file = $paths->get_matomo_js_upload_path();
$rows = $this->check_file_exists_and_writable( $rows, $path_tracker_file, 'JS Tracker', false );
diff --git a/classes/WpMatomo/Admin/views/systemreport.php b/classes/WpMatomo/Admin/views/systemreport.php
index 6ab5f82eb..a6e51b3ef 100644
--- a/classes/WpMatomo/Admin/views/systemreport.php
+++ b/classes/WpMatomo/Admin/views/systemreport.php
@@ -212,6 +212,14 @@ class='button-primary'
+
+
+
+
+
init();
self::$environment_bootstrapped = true;
diff --git a/classes/WpMatomo/Installer.php b/classes/WpMatomo/Installer.php
index 492f3f44c..311291784 100644
--- a/classes/WpMatomo/Installer.php
+++ b/classes/WpMatomo/Installer.php
@@ -20,6 +20,7 @@
use Piwik\Plugin\API as PluginApi;
use Piwik\Plugin\Manager;
use Piwik\Plugins\SitesManager\Model;
+use Piwik\Plugins\WordPress\Overrides\GlobalSettingsProvider;
use Piwik\SettingsPiwik;
use Piwik\Singleton;
use WpMatomo\Site\Sync;
@@ -114,7 +115,10 @@ public function install() {
Bootstrap::bootstrap_environment();
- if ( ! SettingsPiwik::isMatomoInstalled() || ! $this->looks_like_it_is_installed() ) {
+ if (
+ ! SettingsPiwik::isMatomoInstalled()
+ || ! $this->looks_like_it_is_installed()
+ ) {
throw new NotYetInstalledException( 'Not yet installed' );
}
@@ -148,7 +152,7 @@ public function install() {
if ( ! $this->is_environment_set_up() ) {
try {
$this->logger->log( 'Matomo will now init the environment' );
- $environment = new \Piwik\Application\Environment( null, Bootstrap::get_extra_di_definitions() );
+ $environment = new \Piwik\Plugins\WordPress\WordPressEnvironment( null, Bootstrap::get_extra_di_definitions() );
$environment->init();
} catch ( Exception $e ) {
$this->logger->log( 'Ignoring error environment init' );
@@ -299,18 +303,25 @@ private function create_db() {
return $db_infos;
}
- private function create_config( $db_info ) {
- $this->logger->log( 'Matomo is now creating the config' );
+ public static function get_trusted_host_from_wp_url() {
$home_url = home_url();
- $domain = wp_parse_url( $home_url, PHP_URL_HOST );
- if ( $domain ) {
- $port = wp_parse_url( $home_url, PHP_URL_PORT );
- if ( $port ) {
- $domain .= ':' . $port;
- }
- } else {
- $domain = $home_url;
+
+ $domain = wp_parse_url( $home_url, PHP_URL_HOST );
+ if ( ! $domain ) {
+ return $home_url;
}
+
+ $port = wp_parse_url( $home_url, PHP_URL_PORT );
+ if ( $port ) {
+ $domain .= ':' . $port;
+ }
+
+ return $domain;
+ }
+
+ private function create_config( $db_info ) {
+ $this->logger->log( 'Matomo is now creating the config' );
+ $domain = self::get_trusted_host_from_wp_url();
$general = [
'trusted_hosts' => [ $domain ],
'salt' => Common::generateUniqId(),
@@ -333,6 +344,14 @@ private function create_config( $db_info ) {
$config->database = array_merge( $db_default, $db_info );
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
$config->General = array_merge( $general_default, $general );
+
+ // add the end-of-file marker used to detect interrupted writes to config.ini.php; it must
+ // exist in every config file (see GlobalSettingsProvider). skip it when the config backup
+ // feature is disabled, so the file is not modified for admins managing it themselves.
+ if ( ! Settings::is_config_backup_disabled() ) {
+ GlobalSettingsProvider::addEndOfFileMarkerSectionTo( $config );
+ }
+
$config->forceSave();
$mode = 0664;
diff --git a/classes/WpMatomo/ScheduledTasks.php b/classes/WpMatomo/ScheduledTasks.php
index 8ad6ea4a8..fd8c95807 100644
--- a/classes/WpMatomo/ScheduledTasks.php
+++ b/classes/WpMatomo/ScheduledTasks.php
@@ -19,6 +19,7 @@
use Piwik\Plugins\GeoIp2\LocationProvider\GeoIp2;
use Piwik\Plugins\GeoIp2\LocationProvider\GeoIp2\Php;
use Piwik\Plugins\UserCountry\LocationProvider;
+use Piwik\Plugins\WordPress\Overrides\GlobalSettingsProvider;
use WpMatomo\Admin\Admin;
use WpMatomo\Site\Sync as SiteSync;
use WpMatomo\User\Sync as UserSync;
@@ -33,6 +34,7 @@ class ScheduledTasks extends Feature {
const EVENT_ARCHIVE = 'matomo_scheduled_archive';
const EVENT_GEOIP = 'matomo_scheduled_geoipdb';
const EVENT_UPDATE = 'matomo_update_core';
+ const EVENT_CONFIG_MARKER = 'matomo_scheduled_config_marker';
const KEY_BEFORE_CRON = 'before-cron-';
const KEY_AFTER_CRON = 'after-cron-';
@@ -145,21 +147,26 @@ public function set_last_time_after_cron( $event_name, $time ) {
public function get_all_events() {
$events = [
- self::EVENT_SYNC => [
+ self::EVENT_SYNC => [
'name' => 'Sync users & sites',
'interval' => 'daily',
'method' => 'sync',
],
- self::EVENT_ARCHIVE => [
+ self::EVENT_ARCHIVE => [
'name' => 'Archive',
'interval' => 'hourly',
'method' => 'archive',
],
- self::EVENT_GEOIP => [
+ self::EVENT_GEOIP => [
'name' => 'Update GeoIP DB',
'interval' => 'matomo_monthly',
'method' => 'update_geo_ip2_db',
],
+ self::EVENT_CONFIG_MARKER => [
+ 'name' => 'Add config end of file marker',
+ 'interval' => 'daily',
+ 'method' => 'add_config_end_of_file_marker',
+ ],
];
if ( $this->settings->should_disable_addhandler() ) {
$events[ self::EVENT_DISABLE_ADDHANDLER ] = [
@@ -211,6 +218,26 @@ public function disable_add_handler( $force_undo = false ) {
}
}
+ /**
+ * Adds the eof file marker used by the config backup system (see GlobalSettingsProvider).
+ */
+ public function add_config_end_of_file_marker() {
+ if ( Settings::is_config_backup_disabled() ) {
+ return; // the marker only exists for the config backup feature
+ }
+
+ $this->remove_task_errors( [ 'config_backup' ] );
+
+ try {
+ Bootstrap::do_bootstrap();
+
+ $updater = new Updater( $this->settings );
+ $updater->add_config_end_of_file_marker_if_needed();
+ } catch ( Exception $e ) {
+ $this->on_task_fail( 'config_backup', $e, 'An error occurred when adding the end-of-file marker to config.ini.php.' );
+ }
+ }
+
private function check_try_update() {
try {
$installer = new Installer( $this->settings );
diff --git a/classes/WpMatomo/Settings.php b/classes/WpMatomo/Settings.php
index 00294d225..6f7d2ef34 100644
--- a/classes/WpMatomo/Settings.php
+++ b/classes/WpMatomo/Settings.php
@@ -27,6 +27,9 @@ class Settings {
const GLOBAL_OPTION_PREFIX = 'matomo_global-';
const OPTION = 'matomo-option';
const OPTION_GLOBAL = 'matomo-global-option';
+ const OPTION_CONFIG_BACKUP = 'matomo-global-config-backup';
+ const OPTION_ENCRYPTED_SALT = 'matomo-encrypted-salt';
+ const OPTION_SALT_REGENERATED = 'matomo-salt-regenerated-at';
const OPTION_KEY_CAPS_ACCESS = 'caps_access';
const OPTION_KEY_STEALTH = 'caps_tracking';
const OPTION_LAST_TRACKING_SETTINGS_CHANGE = 'last_tracking_settings_update';
@@ -34,7 +37,7 @@ class Settings {
const SHOW_GET_STARTED_PAGE = 'show_get_started_page';
const DELETE_ALL_DATA_ON_UNINSTALL = 'delete_all_data_uninstall';
const SITE_CURRENCY = 'site_currency';
- const NETWORK_CONFIG_OPTIONS = 'config_options';
+ const CONFIG_OPTIONS = 'config_options';
const DISABLE_ASYNC_ARCHIVING_OPTION_NAME = 'matomo_disable_async_archiving';
const USE_SESSION_VISITOR_ID_OPTION_NAME = 'use_session_visitor_id';
const SERVER_SIDE_TRACKING_DELAY_SECS = 'server_side_tracking_delay_secs';
@@ -46,7 +49,13 @@ class Settings {
// adding an extra get_option call to every WordPress backoffice request.
const INSTANCE_COMPONENTS_INSTALLED = 'instance-components-installed';
+ /**
+ * @deprecated use CONFIG_OPTIONS instead
+ */
+ const NETWORK_CONFIG_OPTIONS = 'config_options';
+
public static $is_doing_action_tracking_related = false;
+
/**
* @internal tests only
* @var bool
@@ -74,7 +83,7 @@ class Settings {
self::OPTION_LAST_TRACKING_SETTINGS_CHANGE => 0,
self::OPTION_KEY_STEALTH => [],
self::OPTION_KEY_CAPS_ACCESS => [],
- self::NETWORK_CONFIG_OPTIONS => [],
+ self::CONFIG_OPTIONS => [],
self::DELETE_ALL_DATA_ON_UNINSTALL => true,
self::SITE_CURRENCY => 'USD',
// User settings: Stats configuration
@@ -143,6 +152,15 @@ class Settings {
private $global_settings = [];
private $blog_settings = [];
+ /**
+ * The blog ID the cached settings were loaded for. Long-lived Settings instances can
+ * be used across switch_to_blog() calls, cached values must be reloaded when a blog
+ * changes.
+ *
+ * @var int|null
+ */
+ private $loaded_for_blog_id = null;
+
private $settings_changed = [];
/**
@@ -175,11 +193,11 @@ public function init_settings() {
$this->global_settings = $global_settings;
}
- $settings = get_option( self::OPTION, [] );
+ $this->load_blog_settings();
+ }
- if ( ! empty( $settings ) && is_array( $settings ) ) {
- $this->blog_settings = $settings;
- }
+ public static function is_config_backup_disabled() {
+ return defined( 'MATOMO_DISABLE_CONFIG_BACKUP' ) && MATOMO_DISABLE_CONFIG_BACKUP;
}
public function get_customised_global_settings() {
@@ -226,6 +244,8 @@ public function is_network_enabled() {
* Save all settings as WordPress options
*/
public function save() {
+ $this->reload_if_blog_switched();
+
if ( empty( $this->settings_changed ) ) {
$this->logger->log( 'No settings changed yet' );
@@ -265,6 +285,8 @@ public function save() {
* @api
*/
public function get_global_option( $key ) {
+ $this->reload_if_blog_switched();
+
if ( isset( $this->global_settings[ $key ] ) ) {
return $this->global_settings[ $key ];
}
@@ -283,6 +305,8 @@ public function get_global_option( $key ) {
* @api
*/
public function get_option( $key ) {
+ $this->reload_if_blog_switched();
+
if ( isset( $this->blog_settings[ $key ] ) ) {
return $this->blog_settings[ $key ];
}
@@ -309,15 +333,25 @@ private function convert_type( $value, $type ) {
* @param string|array $value new option value
*/
public function set_global_option( $key, $value ) {
+ $this->reload_if_blog_switched();
+
if ( isset( $this->default_global_settings[ $key ] ) ) {
$type = gettype( $this->default_global_settings[ $key ] );
$value = $this->convert_type( $value, $type );
}
- if ( ! isset( $this->global_settings[ $key ] )
- || $this->global_settings[ $key ] !== $value ) {
+ if (
+ ! isset( $this->global_settings[ $key ] )
+ || $this->global_settings[ $key ] !== $value
+ ) {
$this->settings_changed[] = $key;
- $this->logger->log( 'Changed global option ' . $key . ': ' . ( is_array( $value ) ? wp_json_encode( $value ) : $value ) );
+
+ // config_options holds network config options to sync across all network sites'
+ // config.ini.php. these can include INI secrets (eg. an SMTP [mail] password);
+ // never write it to the log
+ if ( self::CONFIG_OPTIONS !== $key ) {
+ $this->logger->log( 'Changed global option ' . $key . ': ' . ( is_array( $value ) ? wp_json_encode( $value ) : $value ) );
+ }
$this->global_settings[ $key ] = $value;
}
@@ -330,6 +364,8 @@ public function set_global_option( $key, $value ) {
* @param string $value new option value
*/
public function set_option( $key, $value ) {
+ $this->reload_if_blog_switched();
+
if ( isset( $this->default_blog_settings[ $key ] ) ) {
$type = gettype( $this->default_blog_settings[ $key ] );
$value = $this->convert_type( $value, $type );
@@ -551,4 +587,112 @@ public function get_global_user_agent_exclusions() {
public function is_track_via_esi_enabled() {
return ( (bool) $this->get_global_option( 'track_ai_bots_using_esi' ) ) === true;
}
+
+ /**
+ * Get the backup of the Matomo config file data.
+ *
+ * In network mode the backup is stored network-wide: every blog in the network is supposed
+ * to have the same INI config as the others (SyncConfig keeps the files in sync), so one
+ * backup serves all of them. Blog-specific values ([database], salt, trusted_hosts) are
+ * not part of the backup, they are rebuilt by the restoring blog.
+ *
+ * The option is named so both the "matomo-" wp_options cleanup and the "matomo_global-"
+ * sitemeta cleanup in Uninstaller match it (in SQL LIKE, "_" matches "-").
+ *
+ * @return array
+ */
+ public function get_config_backup() {
+ if ( $this->is_network_enabled() ) {
+ $backup = get_site_option( self::OPTION_CONFIG_BACKUP, [] );
+ } else {
+ $backup = get_option( self::OPTION_CONFIG_BACKUP, [] );
+ }
+ return is_array( $backup ) ? $backup : [];
+ }
+
+ public function update_config_backup( $config_backup ) {
+ if ( $this->is_network_enabled() ) {
+ update_site_option( self::OPTION_CONFIG_BACKUP, $config_backup );
+ } else {
+ // not autoloaded, the backup is only read when Matomo bootstraps
+ update_option( self::OPTION_CONFIG_BACKUP, $config_backup, false );
+ }
+ }
+
+ /**
+ * Get the encrypted copy of the Matomo salt kept for the current blog (see
+ * GlobalSettingsProvider). Stored per blog, since every blog has its own Matomo salt,
+ * and separately from the config backup, which never contains secrets.
+ *
+ * @return array
+ */
+ public function get_encrypted_salt_backup() {
+ $record = get_option( self::OPTION_ENCRYPTED_SALT, [] );
+
+ return is_array( $record ) ? $record : [];
+ }
+
+ /**
+ * @param array $record
+ */
+ public function update_encrypted_salt_backup( array $record ) {
+ // not autoloaded, the record is only read when Matomo bootstraps
+ update_option( self::OPTION_ENCRYPTED_SALT, $record, false );
+ }
+
+ /**
+ * The time a config.ini.php restore had to generate a new salt because the original one
+ * could not be recovered (see GlobalSettingsProvider). Used to inform super admins about
+ * the consequences in the system report. 0 if this never happened.
+ *
+ * @return int
+ */
+ public function get_time_salt_was_regenerated() {
+ return (int) get_option( self::OPTION_SALT_REGENERATED, 0 );
+ }
+
+ /**
+ * @param int $timestamp
+ */
+ public function set_time_salt_was_regenerated( $timestamp ) {
+ update_option( self::OPTION_SALT_REGENERATED, (int) $timestamp, false );
+ }
+
+ public function load_blog_settings() {
+ $settings = get_option( self::OPTION, [] );
+ if ( ! is_array( $settings ) ) {
+ $settings = [];
+ }
+
+ $this->blog_settings = $settings;
+
+ $this->loaded_for_blog_id = get_current_blog_id();
+ }
+
+ /**
+ * Reload cached settings if the current blog changed since they were loaded (eg, via
+ * switch_to_blog()). Cached per-blog settings must not be served for, or saved to, a
+ * different blog. Pending unsaved changes for the previous blog are discarded.
+ */
+ private function reload_if_blog_switched() {
+ if ( ! $this->is_multisite()
+ || null === $this->loaded_for_blog_id
+ || get_current_blog_id() === $this->loaded_for_blog_id
+ ) {
+ return;
+ }
+
+ if ( ! empty( $this->settings_changed ) ) {
+ // blog was switched in code before settings were save()'d
+ $this->logger->log(
+ 'Unsaved Matomo setting changes were discarded after a WP blog switch: '
+ . implode( ', ', array_values( array_unique( $this->settings_changed ) ) )
+ );
+ }
+
+ // reload all settings, including intermediate state like $settings_changed
+ // to ensure that changes intended for the previous blog are not saved to this
+ // blog.
+ $this->init_settings();
+ }
}
diff --git a/classes/WpMatomo/Site/Sync/SyncConfig.php b/classes/WpMatomo/Site/Sync/SyncConfig.php
index 049dec15b..4bd18425b 100644
--- a/classes/WpMatomo/Site/Sync/SyncConfig.php
+++ b/classes/WpMatomo/Site/Sync/SyncConfig.php
@@ -71,7 +71,7 @@ public function sync_config_for_current_site() {
}
private function get_all() {
- $options = $this->settings->get_global_option( Settings::NETWORK_CONFIG_OPTIONS );
+ $options = $this->settings->get_global_option( Settings::CONFIG_OPTIONS );
if ( empty( $options ) || ! is_array( $options ) ) {
$options = [];
@@ -109,12 +109,14 @@ public function set_config_value( $group, $key, $value ) {
$this->settings->apply_changes(
[
- Settings::NETWORK_CONFIG_OPTIONS => $config,
+ Settings::CONFIG_OPTIONS => $config,
]
);
// need to update all config files
wp_schedule_single_event( time() + 5, ScheduledTasks::EVENT_SYNC );
- } elseif ( ! WpMatomo::is_safe_mode() ) {
+ }
+
+ if ( ! WpMatomo::is_safe_mode() ) {
Bootstrap::do_bootstrap();
$config = PiwikConfig::getInstance();
$the_group = $config->{$group};
diff --git a/classes/WpMatomo/Updater.php b/classes/WpMatomo/Updater.php
index 9d92ad832..228101799 100644
--- a/classes/WpMatomo/Updater.php
+++ b/classes/WpMatomo/Updater.php
@@ -17,6 +17,7 @@
use Piwik\Filesystem;
use Piwik\Option;
use Piwik\Plugins\Installation\ServerFilesGenerator;
+use Piwik\Plugins\WordPress\Overrides\GlobalSettingsProvider;
use Piwik\SettingsServer;
use Piwik\Version;
use WP_Upgrader;
@@ -142,6 +143,8 @@ public function update( $update_from_version = null ) {
$this->settings->set_global_option( 'core_version', Version::VERSION );
$this->settings->save();
+ $this->add_config_end_of_file_marker_if_needed();
+
$paths = new Paths();
$paths->clear_cache_dir();
@@ -178,6 +181,8 @@ public function update( $update_from_version = null ) {
\Piwik\Access::doAsSuperUser(
function () {
+ // updating components twice is required for all updates to complete.
+ // unsure why, but suspect it is due to dimension updates.
self::update_components();
self::update_components();
}
@@ -218,6 +223,53 @@ function () {
do_action( 'matomo_update' );
}
+ /**
+ * Make sure config.ini.php ends with the end-of-file marker used to detect interrupted
+ * writes. The marker is kept up to date on every config file write, but config files
+ * created by plugin versions that predate the marker need it added here once (without it,
+ * the config backup is never refreshed).
+ *
+ * (public for tests)
+ */
+ public function add_config_end_of_file_marker_if_needed() {
+ if ( Settings::is_config_backup_disabled() ) {
+ return; // the marker only exists for the config backup feature
+ }
+
+ $config = Config::getInstance();
+ if ( GlobalSettingsProvider::isEndOfFileMarkerPresent( $config ) ) {
+ return;
+ }
+
+ // do not write to the file if we think it may be being written to currently
+ $local_path = $config->getLocalPath();
+ if (
+ is_file( $local_path )
+ && GlobalSettingsProvider::wasConfigFileModifiedRecently( $local_path )
+ ) {
+ return;
+ }
+
+ try {
+ // persist the fully parsed config to the backup option BEFORE the marker write. if
+ // the write below is interrupted (eg. the process is killed mid-write), it leaves a
+ // truncated config.ini.php behind, and with the backup populated the marker-less
+ // self-heal can restore the complete file. without this, a later marker run would
+ // stamp the truncated remnant as complete and silently lose the cut-off sections.
+ $provider = \Piwik\Container\StaticContainer::get( \Piwik\Application\Kernel\GlobalSettingsProvider::class );
+ if ( $provider instanceof GlobalSettingsProvider ) {
+ $provider->persistConfigOption();
+ }
+
+ GlobalSettingsProvider::addEndOfFileMarkerSectionTo( $config );
+ $config->forceSave();
+ } catch ( Exception $e ) {
+ // eg. the config file is not writable; the marker will be added by the next
+ // successful config write instead
+ $this->logger->log_exception( 'config_backup', $e );
+ }
+ }
+
public function is_upgrade_in_progress() {
if ( ! self::load_upgrader() ) {
return 'no upgrader';
diff --git a/matomo.php b/matomo.php
index 3bb7f8feb..c741e9919 100644
--- a/matomo.php
+++ b/matomo.php
@@ -7,7 +7,7 @@
* Version: 5.11.1
* Domain Path: /languages
* WC requires at least: 2.4.0
- * WC tested up to: 10.9.1
+ * WC tested up to: 10.9.4
*
* Matomo - free/libre analytics platform
*
diff --git a/patches/prefixed/wordpress-environment.diff b/patches/prefixed/wordpress-environment.diff
new file mode 100644
index 000000000..c815f515e
--- /dev/null
+++ b/patches/prefixed/wordpress-environment.diff
@@ -0,0 +1,65 @@
+diff --git a/app/core/CliMulti/RequestCommand.php b/app/core/CliMulti/RequestCommand.php
+index 1c3886ceb..9ad4275c7 100644
+--- a/app/core/CliMulti/RequestCommand.php
++++ b/app/core/CliMulti/RequestCommand.php
+@@ -92,7 +92,7 @@ class RequestCommand extends ConsoleCommand
+ {
+ StaticContainer::clearContainer();
+ Log::unsetInstance();
+- $this->environment = new Environment(null);
++ $this->environment = new \Piwik\Plugins\WordPress\WordPressEnvironment(null);
+ $this->environment->init();
+ }
+ private function resetDatabase()
+diff --git a/app/core/Console.php b/app/core/Console.php
+index f8c3862ff..8527b86b0 100644
+--- a/app/core/Console.php
++++ b/app/core/Console.php
+@@ -196,7 +196,7 @@ class Console extends Application
+ {
+ try {
+ if ($this->environment === null) {
+- $this->environment = new Environment('cli');
++ $this->environment = new \Piwik\Plugins\WordPress\WordPressEnvironment('cli');
+ $this->environment->init();
+ }
+ $config = \Piwik\Config::getInstance();
+diff --git a/app/core/dispatch.php b/app/core/dispatch.php
+index d4c79ed69..b49179f6f 100644
+--- a/app/core/dispatch.php
++++ b/app/core/dispatch.php
+@@ -19,7 +19,7 @@ namespace {
+ \define('PIWIK_ENABLE_DISPATCH', \true);
+ }
+ if (\PIWIK_ENABLE_DISPATCH) {
+- $environment = new \Piwik\Application\Environment(null);
++ $environment = new \Piwik\Plugins\WordPress\WordPressEnvironment(null);
+ $environment->init();
+ $controller = FrontController::getInstance();
+ try {
+diff --git a/app/js/tracker.php b/app/js/tracker.php
+index be82031fd..18b9a6cbb 100644
+--- a/app/js/tracker.php
++++ b/app/js/tracker.php
+@@ -67,7 +67,7 @@ class Validator {
+ public function validate() {}
+ }
+ $validator = new Validator();
+-$environment = new \Piwik\Application\Environment(null, array(
++$environment = new \Piwik\Plugins\WordPress\WordPressEnvironment(null, array(
+ 'Piwik\Application\Kernel\EnvironmentValidator' => $validator
+ ));
+ $environment->init();
+diff --git a/app/piwik.php b/app/piwik.php
+index 8fd0e5ad3..ab133815e 100644
+--- a/app/piwik.php
++++ b/app/piwik.php
+@@ -46,7 +46,7 @@ namespace {
+ require_once \PIWIK_INCLUDE_PATH . '/core/Cookie.php';
+ require_once \PIWIK_INCLUDE_PATH . '/core/API/CORSHandler.php';
+ SettingsServer::setIsTrackerApiRequest();
+- $environment = new \Piwik\Application\Environment('tracker');
++ $environment = new \Piwik\Plugins\WordPress\WordPressEnvironment('tracker');
+ try {
+ $environment->init();
+ } catch (\Piwik\Exception\NotYetInstalledException $e) {
diff --git a/plugins/WordPress/Overrides/GlobalSettingsProvider.php b/plugins/WordPress/Overrides/GlobalSettingsProvider.php
new file mode 100644
index 000000000..2e74e7273
--- /dev/null
+++ b/plugins/WordPress/Overrides/GlobalSettingsProvider.php
@@ -0,0 +1,845 @@
+settings = $settings;
+
+ // before parent::__construct(), which calls reload() and can log via a restore
+ $this->logger = new Logger();
+
+ parent::__construct($pathGlobal, $pathLocal, $pathCommon);
+ }
+
+ public function reload($pathGlobal = null, $pathLocal = null, $pathCommon = null)
+ {
+ if (Settings::is_config_backup_disabled()) {
+ // the config backup/restore feature is turned off, so behave like the default provider
+ // (no corrupt-file recovery, no restore, no backup).
+ parent::reload($pathGlobal, $pathLocal, $pathCommon);
+ $this->detectExtraPluginsToLoad();
+ return;
+ }
+
+ try {
+ parent::reload($pathGlobal, $pathLocal, $pathCommon);
+ } catch (\Exception $ex) {
+ // the config.ini.php file is possibly syntactically corrupted and cannot be loaded (eg. a
+ // write interrupted mid-value or mid-section header) makes the whole INI chain fail
+ // to load, which would otherwise fatal every request forever.
+ //
+ // if the local config file is the culprit, drop it so the missing-file restore path below
+ // can rebuild it from the DB backup, otherwise rethrow.
+ if (!$this->dropLocalConfigFileIfUnparseable()) {
+ throw $ex;
+ }
+
+ parent::reload($pathGlobal, $pathLocal, $pathCommon);
+ }
+
+ $this->syncOrRestoreConfigBackup();
+ $this->detectExtraPluginsToLoad();
+ }
+
+ /**
+ * @return bool true if the local config file was the culprit and was dropped, false if otherwise
+ */
+ private function dropLocalConfigFileIfUnparseable()
+ {
+ $path = $this->getPathLocal();
+ if (empty($path) || !is_file($path) || $this->isParseableIniFile($path)) {
+ // no local file, or it parses fine — the corruption is elsewhere
+ return false;
+ }
+
+ if ($this->wasLocalConfigFileModifiedRecently()) {
+ // the file may still be mid-write by a concurrent request (core's Config::forceSave()
+ // rewrites it in place and readers do not take the lock)
+ $this->logger->log('config.ini.php cannot be parsed but was modified recently; a write may be in progress, leaving the file alone.');
+ return false;
+ }
+
+ if ($this->isConfigBackupEmpty()) {
+ // throwing a fatal error on each request is required here, since there is no backup.
+ // allow the user to see and manually resolve the issue.
+ $this->logger->log('config.ini.php is corrupted and cannot be parsed; config backup does not exist, cannot restore, manual intervention required.');
+ return false;
+ }
+
+ $this->logger->log('config.ini.php is corrupted and cannot be parsed; attempting to restore from the backup.');
+
+ return unlink($path);
+ }
+
+ private function isConfigBackupEmpty()
+ {
+ $backup = $this->getWpMatomoSettings()->get_config_backup();
+
+ // clean the backup just in case the backup option includes values that should not be there
+ $backup = $this->removeValuesExcludedFromBackup($backup);
+
+ return empty($backup);
+ }
+
+ private function isParseableIniFile($path)
+ {
+ if (!is_readable($path)) {
+ return true; // unable to check if it is parseable, play it safe and do not replace
+ }
+
+ $content = file_get_contents($path);
+ if (false === $content) {
+ return true;
+ }
+
+ return $this->isParseableIniString($content);
+ }
+
+ private function isParseableIniString($content)
+ {
+ // swallow parse warnings since we are just trying to detect if it is parseable.
+ // the website owner doesn't need to see the warnings from our test.
+ set_error_handler(static function () {
+ return true;
+ });
+ try {
+ $parsed = parse_ini_string($content, true);
+ } finally {
+ restore_error_handler();
+ }
+
+ return false !== $parsed;
+ }
+
+ public function persistConfigOption()
+ {
+ if (Settings::is_config_backup_disabled()) {
+ return;
+ }
+
+ // only persist the values that differ from the INI default settings (ie, what would go
+ // in config.ini.php), minus anything plugins strip from config writes, secret or
+ // blog-specific
+ $diff = $this->applyConfigBeforeSaveHandlers($this->computeUserConfigDiff());
+ $diff = $this->removeValuesExcludedFromBackup($diff);
+
+ $this->getWpMatomoSettings()->update_config_backup($diff);
+
+ $this->updateEncryptedSaltIfNeeded();
+ }
+
+ /**
+ * Runs the Config.beforeSave handlers over the diff before it is persisted.
+ * Required because Config.beforeSave does not modify in-memory values, just
+ * a copy that is then written to the file. If a handler modifies the config
+ * before persisting, those modifications would be lost when saving a config
+ * backup to memory, unless another Config.beforeSave is posted.
+ *
+ * @param array $diff
+ * @return array
+ */
+ private function applyConfigBeforeSaveHandlers($diff)
+ {
+ try {
+ Piwik::postEvent('Config.beforeSave', [&$diff]);
+ } catch (ContainerDoesNotExistException $ex) {
+ // environment not created, safe not to run the event since
+ // here we are using data read directly from the config file.
+ // we expect modifications to be in place.
+ }
+ return $diff;
+ }
+
+ /**
+ * Keeps an encrypted copy of the salt in a dedicated per-blog option so a restore can
+ * bring the original salt back instead of generating a new one (a new salt would silently
+ * invalidate visitors' signed tracking opt-out cookies — a privacy compliance violation — and
+ * change config_id fingerprints).
+ *
+ * The salt is encrypted with a key derived from the WP auth key (wp_salt('auth'), ie.
+ * AUTH_KEY . AUTH_SALT from wp-config.php, which survives the loss of config.ini.php), so
+ * a wp_options dump alone does not reveal it. Cheap fingerprints of the auth key and of
+ * the salt make this a no-op on the fast path: encryption only runs when the record does
+ * not exist yet, the WP auth key was rotated, or the salt itself changed.
+ */
+ private function updateEncryptedSaltIfNeeded()
+ {
+ if (!$this->isSaltEncryptionSupported()) {
+ return;
+ }
+
+ $general = $this->iniFileChain->get('General');
+ $salt = is_array($general) && !empty($general['salt']) ? $general['salt'] : '';
+ if ('' === $salt || !is_string($salt)) {
+ return;
+ }
+
+ $keyFingerprint = $this->computeAuthKeyFingerprint();
+ $saltFingerprint = $this->computeSaltFingerprint($salt);
+
+ $record = $this->getWpMatomoSettings()->get_encrypted_salt_backup();
+ if (!empty($record['ciphertext'])
+ && isset($record['key_fingerprint'], $record['salt_fingerprint'])
+ && $record['key_fingerprint'] === $keyFingerprint
+ && $record['salt_fingerprint'] === $saltFingerprint
+ ) {
+ return; // up to date; only two cheap hashes were computed
+ }
+
+ $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
+
+ $this->getWpMatomoSettings()->update_encrypted_salt_backup([
+ 'ciphertext' => base64_encode($nonce . sodium_crypto_secretbox($salt, $nonce, $this->deriveSaltEncryptionKey())),
+ 'key_fingerprint' => $keyFingerprint,
+ 'salt_fingerprint' => $saltFingerprint,
+ ]);
+ }
+
+ /**
+ * @return string|null the decrypted salt, or null if there is nothing to decrypt, the WP
+ * auth key was rotated in the meantime, or the record was tampered with
+ */
+ private function decryptSaltFromOption()
+ {
+ if (!$this->isSaltEncryptionSupported()) {
+ return null;
+ }
+
+ $record = $this->getWpMatomoSettings()->get_encrypted_salt_backup();
+ if (empty($record['ciphertext']) || !is_string($record['ciphertext'])) {
+ return null;
+ }
+
+ $decoded = base64_decode($record['ciphertext'], true);
+ if (false === $decoded || strlen($decoded) <= SODIUM_CRYPTO_SECRETBOX_NONCEBYTES) {
+ return null;
+ }
+
+ $nonce = substr($decoded, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
+ $ciphertext = substr($decoded, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
+
+ try {
+ // the auth tag check fails (returns false) when the WP auth key was rotated or the
+ // ciphertext was modified
+ $salt = sodium_crypto_secretbox_open($ciphertext, $nonce, $this->deriveSaltEncryptionKey());
+ } catch (\Exception $ex) {
+ return null;
+ }
+
+ return is_string($salt) && '' !== $salt ? $salt : null;
+ }
+
+ private function isSaltEncryptionSupported()
+ {
+ // sodium is native in PHP 7.2+ and polyfilled by WordPress 5.2+ (via sodium_compat)
+ return function_exists('sodium_crypto_secretbox')
+ && function_exists('sodium_crypto_secretbox_open')
+ && function_exists('wp_salt');
+ }
+
+ private function deriveSaltEncryptionKey()
+ {
+ // derive a key from the WP auth key instead of re-using it directly. only a fixed-length
+ // prefix of the auth key is used, so even a total compromise of the derived key can never
+ // yield the complete WP auth secret.
+ return hash('sha256', substr(wp_salt('auth'), 0, 64) . '|matomo-salt-encryption', true);
+ }
+
+ private function computeAuthKeyFingerprint()
+ {
+ // cheap (~1μs) and secure fingerprint to detect WP auth key rotation without decrypting/encrypting
+ // on every request.
+ return substr(hash('sha256', wp_salt('auth') . '|matomo-salt-key-fingerprint'), 0, 16);
+ }
+
+ private function computeSaltFingerprint($salt)
+ {
+ // detects a manually changed salt in config.ini.php, so the stored ciphertext is updated
+ return substr(hash('sha256', $salt . '|matomo-salt-fingerprint'), 0, 16);
+ }
+
+ private function syncOrRestoreConfigBackup()
+ {
+ if (!$this->localConfigFileExists()) {
+ // if local file does not exist (for example, deleted by hosting provider or another plugin),
+ // restore the contents from the backup
+ $this->restoreConfigFromBackup();
+ return;
+ }
+
+ if ($this->isLocalConfigFileWrittenCompletely()) {
+ // tracking requests need to be as fast as possible, so the backup is never refreshed
+ // there; config changes are picked up by the next non-tracker request instead
+ if (SettingsServer::isTrackerApiRequest()) {
+ return;
+ }
+
+ // if local file exists and was written completely, backup its contents to the WP option
+ // note: in WP, update_option() will not actually write to the database if the existing value
+ // is the same as what's already there, so it's safe to do this on every request.
+ $this->persistConfigOption();
+ return;
+ }
+
+ // the file exists but the end-of-file marker was not read from it: so either a write is in
+ // progress, or a previous write was interrupted.
+
+ if ($this->wasLocalConfigFileModifiedRecently()) {
+ // file was modified recently, assume a write is in progress; do not overwrite the
+ // file with the backup either, the in progress write would be lost
+ return;
+ }
+
+ // the last write to the file was interrupted: self-heal by restoring the
+ // backup over it. this is safe for config files written by plugin versions that
+ // predate the marker (they legitimately have no marker and an old mtime): their
+ // backup option is still empty at that point, so nothing is restored over them
+ // until the plugin update adds the marker (see Updater).
+ $this->restoreConfigFromBackup();
+ }
+
+ private function wasLocalConfigFileModifiedRecently()
+ {
+ return self::wasConfigFileModifiedRecently($this->getPathLocal());
+ }
+
+ public static function wasConfigFileModifiedRecently($path)
+ {
+ // @ suppresses the "stat failed" warning when the file disappears between the caller's
+ // is_file() check and this stat (a concurrent unlink)
+ // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
+ $mtime = @filemtime($path);
+ if (false === $mtime) {
+ // cannot tell (eg. the file just disappeared); err on the side of not touching it
+ return true;
+ }
+
+ return (time() - $mtime) < self::INCOMPLETE_FILE_GRACE_PERIOD_SECONDS;
+ }
+
+ private function localConfigFileExists()
+ {
+ $path = $this->getPathLocal();
+ return !empty($path) && is_file($path);
+ }
+
+ /**
+ * The end-of-file marker is always the last section written to config.ini.php, so if it made
+ * it into the parsed INI data, the whole file was read and the file was written completely.
+ * The parsed data (and not the raw file) is checked on purpose: it is what gets persisted to
+ * the backup, and re-reading the file here could race with a concurrent write to it.
+ */
+ private function isLocalConfigFileWrittenCompletely()
+ {
+ $marker = $this->iniFileChain->getFrom($this->getPathLocal(), self::END_OF_FILE_MARKER_SECTION);
+ return !empty($marker[self::END_OF_FILE_MARKER_KEY]);
+ }
+
+ private function restoreConfigFromBackup()
+ {
+ // manually edited backup options may hold secrets or blog-specific values, so strip
+ // them on the way in too
+ $backup = $this->removeValuesExcludedFromBackup($this->getWpMatomoSettings()->get_config_backup());
+ if (empty($backup)) {
+ // nothing to restore, eg. a fresh install before config.ini.php has been created
+ return;
+ }
+
+ $backup = $this->addUnbackedUpConfigValues($backup);
+
+ $this->applyConfigBackupToIniFileChain($backup);
+ $this->writeLocalConfigFile($this->rebuildConfigViaIniFileChain($backup));
+
+ // edge case: auth key rotated and config.ini.php went missing, update
+ // salt to prevent salt from being regenerated on every request.
+ $this->updateEncryptedSaltIfNeeded();
+ }
+
+ /**
+ * Rebuilds config data through IniFileChain::set() to re-use the sanitization
+ * code in core.
+ *
+ * @param array $config
+ * @return array
+ */
+ private function rebuildConfigViaIniFileChain(array $config)
+ {
+ $scratchChain = new IniFileChain();
+ foreach ($config as $sectionName => $section) {
+ if (!is_array($section)) {
+ continue;
+ }
+ $scratchChain->set($sectionName, $section);
+ }
+ return $scratchChain->getAll();
+ }
+
+ private function removeValuesExcludedFromBackup($config)
+ {
+ // save database keys that are not used to authenticate to the database
+ // (like whether to use SSL) in the DB backup
+ $portableDatabaseValues = [];
+ if (isset($config['database']) && is_array($config['database'])) {
+ $portableDatabaseValues = array_intersect_key(
+ $config['database'],
+ array_flip(self::DATABASE_KEYS_TO_BACKUP)
+ );
+ }
+
+ $config = $this->redactSecrets($config);
+
+ if (!empty($portableDatabaseValues)) {
+ $config['database'] = $portableDatabaseValues;
+ }
+
+ // trusted_hosts is blog-specific (derived from the blog's home URL), so it must not
+ // enter the network-shared backup; it is rebuilt on restore.
+ if (isset($config['General']) && is_array($config['General'])) {
+ unset($config['General']['trusted_hosts']);
+ if (empty($config['General'])) {
+ unset($config['General']);
+ }
+ }
+
+ // the end-of-file marker is file bookkeeping, not user config; it is written fresh
+ // whenever the file is (re)created
+ unset($config[self::END_OF_FILE_MARKER_SECTION]);
+
+ // in MWP the [Plugins] section reflects the runtime-computed plugin list (see
+ // detectExtraPluginsToLoad()), it is built, indirectly, from WordPress' activated
+ // plugins list.
+ unset($config['Plugins']);
+
+ return $config;
+ }
+
+ private function redactSecrets($config)
+ {
+ if (!is_array($config)) {
+ return [];
+ }
+
+ foreach (self::REDACTED_SECTIONS as $sectionName) {
+ unset($config[$sectionName]);
+ }
+
+ foreach ($config as $sectionName => $section) {
+ if (!is_array($section)) {
+ continue;
+ }
+
+ foreach ($section as $key => $value) {
+ if (preg_match(self::SECRET_KEY_PATTERN, $key)) {
+ unset($config[$sectionName][$key]);
+ }
+ }
+
+ if (empty($config[$sectionName])) {
+ unset($config[$sectionName]);
+ }
+ }
+
+ return $config;
+ }
+
+ /**
+ * Fills in the config values that are deliberately not part of the backup:
+ * - the [database] section is rebuilt from the current WordPress credentials
+ * - the salt is restored from the encrypted per-blog option, or regenerated when that is
+ * not possible
+ * - trusted_hosts is derived from the current blog's WordPress home URL
+ *
+ * Restoring the original salt keeps visitors' signed tracking opt-out cookies valid (a
+ * regenerated salt silently invalidates them, re-enabling tracking for visitors who opted
+ * out — a privacy compliance issue) and keeps config_id fingerprints stable. Falling back to
+ * a regenerated salt is otherwise acceptable in MWP since using the API with a Matomo
+ * token_auth is not supported.
+ *
+ * @param array $backup
+ * @return array
+ */
+ private function addUnbackedUpConfigValues($backup)
+ {
+ $portableDatabaseValues = isset($backup['database']) && is_array($backup['database'])
+ ? $backup['database'] : [];
+ $backup['database'] = array_merge(Installer::get_db_infos(), $portableDatabaseValues);
+
+ if (!isset($backup['General']) || !is_array($backup['General'])) {
+ $backup['General'] = [];
+ }
+
+ // for network mode, apply any pending INI config changes to the backup we are about
+ // to restore, to avoid the case when a blog config is restored before a change to another
+ // blog is synced.
+ if ($this->getWpMatomoSettings()->is_network_enabled()) {
+ $toBeSyncedConfig = $this->getWpMatomoSettings()->get_global_option(Settings::CONFIG_OPTIONS);
+ $toBeSyncedConfig = $this->removeValuesExcludedFromBackup($toBeSyncedConfig);
+ foreach ($toBeSyncedConfig as $sectionName => $values) {
+ $existingSection = isset($backup[$sectionName]) && is_array($backup[$sectionName])
+ ? $backup[$sectionName] : [];
+ $backup[$sectionName] = array_merge($existingSection, (array) $values);
+ }
+ }
+
+ $salt = $this->decryptSaltFromOption();
+ if (empty($salt)) {
+ // if there is no salt backup, check if there is a complete looking one
+ // in the existing config.ini.php file. if there is, use it to avoid
+ // invalidating signed cookies.
+ $fileSalt = $this->iniFileChain->get('General')['salt'] ?? null;
+ if (!empty($fileSalt) && is_string($fileSalt) && strlen($fileSalt) >= 32) {
+ $salt = $fileSalt;
+ }
+ }
+ if (empty($salt)) {
+ $salt = Common::generateUniqId();
+
+ // if the salt never existed (because this is a new install), don't raise a false
+ // alarm about the salt being regenerated
+ if (!empty($this->getWpMatomoSettings()->get_option(Settings::INSTANCE_COMPONENTS_INSTALLED))) {
+ // record it, so super admins are informed about the regenerated salt and its
+ // consequences in the system report (most importantly, visitors' signed tracking
+ // opt-out cookies are no longer recognized).
+ $this->getWpMatomoSettings()->set_time_salt_was_regenerated(time());
+ }
+ }
+
+ $backup['General']['salt'] = $salt;
+ $backup['General']['trusted_hosts'] = [Installer::get_trusted_host_from_wp_url()];
+
+ return $backup;
+ }
+
+ private function writeLocalConfigFile(array $userConfig)
+ {
+ $path = $this->getPathLocal();
+ if (empty($path)) {
+ return;
+ }
+
+ $header = "; DO NOT REMOVE THIS LINE\n";
+ $header .= "; file automatically generated or modified by Matomo; you can manually override the default values in global.ini.php by redefining them in this file.\n";
+
+ // the end-of-file marker must be the very last section of the file (see
+ // isLocalConfigFileWrittenCompletely())
+ unset($userConfig[self::END_OF_FILE_MARKER_SECTION]);
+ $userConfig[self::END_OF_FILE_MARKER_SECTION] = self::getEndOfFileMarkerSection();
+
+ // Config::forceSave()/IniFileChain::dumpChanges() cannot be used here: they post events,
+ // which needs the DI container, and a restore runs while the environment is being created,
+ // before the container exists. the install check (Installer::looks_like_it_is_installed())
+ // needs the file back on disk as soon as the environment is created (otherwise it triggers
+ // a full re-install), so the restored user config is dumped directly.
+ //
+ // note: we can do this safely since we only store the diff with global.ini.php/common.config.ini.php
+ // in the db backup.
+
+ try {
+ $writer = new IniWriter();
+ $content = $writer->writeToString($this->encodeIniValues($userConfig), $header);
+ } catch (\Exception $ex) {
+ $this->logger->log_exception('config_backup', new \Exception('Failed to dump the restored Matomo config: ' . $ex->getMessage()));
+ return;
+ }
+
+ // never write a file that cannot be parsed back: it would fail the next reload(), be
+ // dropped as corrupt and be rewritten again on every request, forever.
+ if (!$this->isParseableIniString($content)) { // sanity check
+ $this->logger->log_exception('config_backup', new \Exception('Refusing to restore config.ini.php: the generated content is not parseable INI.'));
+ return;
+ }
+
+ $dir = dirname($path);
+ if (!is_dir($dir)) {
+ wp_mkdir_p($dir);
+ }
+
+ $this->deleteStaleTempConfigFiles($path);
+
+ // atomic write to config.ini.php to keep the file in as consistent a state as possible
+ $tempPath = $this->getTempConfigPath($path);
+ $bytesWritten = @file_put_contents($tempPath, $content, LOCK_EX);
+ if ($bytesWritten !== strlen($content) || !@rename($tempPath, $path)) {
+ @unlink($tempPath);
+ $this->logger->log_exception('config_backup', new \Exception('Failed to restore config.ini.php from the backup option.'));
+ return;
+ }
+
+ // use FS_CHMOD_FILE if a user has defined it (in wp-config.php for example)
+ $mode = defined('FS_CHMOD_FILE') ? FS_CHMOD_FILE : 0664;
+ @chmod($path, $mode);
+ }
+
+ private function deleteStaleTempConfigFiles($path)
+ {
+ $pattern = dirname($path) . '/' . basename($path, '.php') . '.tmp*.php';
+ // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
+ $matches = @glob($pattern, GLOB_NOSORT);
+ if (empty($matches)) {
+ return;
+ }
+
+ foreach ($matches as $match) {
+ // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
+ $mtime = @filemtime($match);
+ if (false !== $mtime && (time() - $mtime) >= self::INCOMPLETE_FILE_GRACE_PERIOD_SECONDS) {
+ // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
+ @unlink($match);
+ }
+ }
+ }
+
+ /**
+ * Same as IniFileChain::encodeValues() (protected, so not callable from here).
+ */
+ private function encodeIniValues($values)
+ {
+ if (is_array($values)) {
+ foreach ($values as $key => $value) {
+ $values[$key] = $this->encodeIniValues($value);
+ }
+ return $values;
+ }
+ if (is_float($values)) {
+ return Common::forceDotAsSeparatorForDecimalPoint($values);
+ }
+ if (is_string($values)) {
+ return str_replace('$', '$', htmlentities($values, ENT_COMPAT, 'UTF-8'));
+ }
+ return $values;
+ }
+
+ private function computeUserConfigDiff()
+ {
+ $diff = [];
+ foreach ($this->iniFileChain->getAll() as $sectionName => $section) {
+ if (!is_array($section)) {
+ continue;
+ }
+
+ $sectionDiff = $this->iniFileChain->arrayUnmerge($this->getDefaultSection($sectionName), $section);
+ if (!empty($sectionDiff)) {
+ $diff[$sectionName] = $sectionDiff;
+ }
+ }
+ return $diff;
+ }
+
+ private function applyConfigBackupToIniFileChain($diff)
+ {
+ foreach ($diff as $sectionName => $section) {
+ if (!is_array($section)) {
+ continue;
+ }
+
+ $existing = $this->iniFileChain->get($sectionName);
+ $existing = is_array($existing) ? $existing : [];
+ $this->iniFileChain->set($sectionName, array_merge($existing, $section));
+ }
+ }
+
+ private function detectExtraPluginsToLoad()
+ {
+ $merged = $this->iniFileChain->getAll();
+ if (empty($merged)) {
+ return;
+ }
+
+ $plugins = isset($merged['Plugins']['Plugins']) ? $merged['Plugins']['Plugins'] : [];
+ if (!is_array($plugins)) {
+ $plugins = [];
+ }
+
+ $modified = $this->getActualPluginsToLoad($plugins);
+ $this->iniFileChain->set('Plugins', [ 'Plugins' => $modified ]);
+ }
+
+ private function getActualPluginsToLoad( $plugins ) { // TODO: cache result of this?
+ $pluginsToRemove = array('Marketplace', 'MultiSites', 'TwoFactorAuth', 'Widgetize', 'Feedback', 'ExamplePlugin', 'ExampleAPI', 'MobileAppMeasurable', 'CustomPiwikJs');
+ foreach ($pluginsToRemove as $pluginToRemove) {
+ // Marketplace => this is instead done in wordpress
+ // MultiSites => doesn't really make sense since we have only one website per installation
+ // TwoFactorAuth => not needed as login is being handled by WordPress
+ // widgetize for now we don't want to allow widgetizing as it is based on the token_auth authentication
+ // Monolog => we use our own logger
+ // ProfessionalServices => we advertise in the WP plugin itself instead
+ // feedback => we want to hide things like Need help in the admin etc
+ // MobileAppMeasurable => for WP mobile apps are not a thing
+ // custom variables we don't want to enable as we will deprecate them in Matomo 4 anyway => used to be disabled but we need to make sure the columns get installed otherwise matomo has issues... need to wait to matomo 4 to remove it
+ $pos = array_search($pluginToRemove, $plugins);
+ if ($pos !== false) {
+ array_splice($plugins, $pos, 1);
+ }
+ }
+ if (matomo_has_tag_manager()) {
+ $plugins[] = 'TagManager';
+ }
+ $mustEnable = ['BulkTracking', 'CustomJsTracker'];
+ foreach ($mustEnable as $enable) {
+ if (!in_array($enable, $plugins)) {
+ $plugins[] = $enable;
+ }
+ }
+ if (!empty($GLOBALS['MATOMO_PLUGINS_ENABLED'])) {
+ foreach ($GLOBALS['MATOMO_PLUGINS_ENABLED'] as $plugin) {
+ if (!in_array($plugin, $plugins)) {
+ $plugins[] = $plugin;
+ }
+ }
+ }
+ if (!empty($GLOBALS['MATOMO_MARKETPLACE_PLUGINS'])) {
+ matomo_filter_incompatible_plugins($plugins);
+ }
+ return $plugins;
+ }
+
+ private function getDefaultSection($sectionName)
+ {
+ $global = $this->iniFileChain->getFrom($this->getPathGlobal(), $sectionName);
+ $common = $this->iniFileChain->getFrom($this->getPathCommon(), $sectionName);
+
+ $global = is_array($global) ? $global : [];
+ $common = is_array($common) ? $common : [];
+
+ return $this->arrayMergeRecursiveDistinct($global, $common);
+ }
+
+ /**
+ * Same as IniFileChain::array_merge_recursive_distinct() (private in core, so not callable
+ * from here).
+ *
+ * @param array $array1
+ * @param array $array2
+ * @return array
+ */
+ private function arrayMergeRecursiveDistinct(array $array1, array $array2)
+ {
+ $merged = $array1;
+ foreach ($array2 as $key => $value) {
+ if (is_array($value) && isset($merged[$key]) && is_array($merged[$key])) {
+ $merged[$key] = $this->arrayMergeRecursiveDistinct($merged[$key], $value);
+ } else {
+ $merged[$key] = $value;
+ }
+ }
+ return $merged;
+ }
+
+ private function getWpMatomoSettings()
+ {
+ if ( empty( $this->settings ) ) {
+ $this->settings = \WpMatomo::$settings ?: new Settings();
+ }
+ return $this->settings;
+ }
+
+ private function getTempConfigPath($path)
+ {
+ return dirname($path) . '/' . basename($path, '.php') . '.' . uniqid('tmp', true) . '.php';
+ }
+
+ public static function addEndOfFileMarkerSectionTo(\Piwik\Config $config)
+ {
+ $marker_section = self::END_OF_FILE_MARKER_SECTION;
+ $config->{$marker_section} = self::getEndOfFileMarkerSection();
+ }
+
+ public static function isEndOfFileMarkerPresent(\Piwik\Config $config)
+ {
+ $markerSection = self::END_OF_FILE_MARKER_SECTION;
+ $markerSection = $config->{$markerSection};
+ return ! empty( $markerSection[self::END_OF_FILE_MARKER_KEY] );
+ }
+
+ public static function getEndOfFileMarkerSection()
+ {
+ return [
+ self::END_OF_FILE_MARKER_KEY => self::END_OF_FILE_MARKER_VALUE,
+ ];
+ }
+}
diff --git a/plugins/WordPress/WordPress.php b/plugins/WordPress/WordPress.php
index dd380749e..f63fba93c 100644
--- a/plugins/WordPress/WordPress.php
+++ b/plugins/WordPress/WordPress.php
@@ -12,10 +12,12 @@
use Exception;
use Piwik\Access;
use Piwik\API\Request;
+use Piwik\Application\Kernel\GlobalSettingsProvider;
use Piwik\Common;
use Piwik\Config;
use Piwik\Container\StaticContainer;
use Piwik\FrontController;
+use Piwik\Log\LoggerInterface;
use Piwik\Option;
use Piwik\Piwik;
use Piwik\Plugin;
@@ -84,9 +86,37 @@ public function registerEvents()
'API.Request.dispatch' => 'onApiRequestDispatch',
'API.Request.dispatch.end' => 'onApiRequestDispatchEnd',
ProcessedReportInnerCallHooks::PROCESSED_REPORT_INNER_END_EVENT => 'afterProcessedReportInner',
+ 'Config.beforeSave' => 'ensureEndOfFileMarkerIsLastConfigSection',
+ 'Core.configFileChanged' => 'configFileChanged',
);
}
+ public function ensureEndOfFileMarkerIsLastConfigSection(&$values) {
+ if (\WpMatomo\Settings::is_config_backup_disabled()) {
+ // the marker only exists for the config backup feature; with it disabled we must not
+ // modify config.ini.php, so it stays byte-identical to what the admin manages.
+ return;
+ }
+
+ // the marker must be the very last section of config.ini.php, so an interrupted or
+ // still running write can be detected by its absence (see
+ // GlobalSettingsProvider::isLocalConfigFileWrittenCompletely())
+ $section = \Piwik\Plugins\WordPress\Overrides\GlobalSettingsProvider::END_OF_FILE_MARKER_SECTION;
+
+ unset($values[$section]);
+ $values[$section] = \Piwik\Plugins\WordPress\Overrides\GlobalSettingsProvider::getEndOfFileMarkerSection();
+ }
+
+ public function configFileChanged() {
+ $globalSettingsProvider = StaticContainer::get(GlobalSettingsProvider::class);
+ if ($globalSettingsProvider instanceof \Piwik\Plugins\WordPress\Overrides\GlobalSettingsProvider) {
+ $globalSettingsProvider->persistConfigOption();
+ } else {
+ $logger = StaticContainer::get(LoggerInterface::class);
+ $logger->warning('Unexpected: overloaded GlobalSettingsProvider not found in DI container.');
+ }
+ }
+
public function onApiRequestDispatch(&$finalParameters, $pluginName, $methodName) {
StaticContainer::get(ProcessedReportInnerCallHooks::class)->onDispatchStart($finalParameters, $pluginName, $methodName);
}
diff --git a/plugins/WordPress/WordPressEnvironment.php b/plugins/WordPress/WordPressEnvironment.php
new file mode 100644
index 000000000..f40c15cab
--- /dev/null
+++ b/plugins/WordPress/WordPressEnvironment.php
@@ -0,0 +1,27 @@
+ v.toString())).toEqual(['1']);
});
diff --git a/tests/e2e/pageobjects/mwp-admin/marketplace.page.ts b/tests/e2e/pageobjects/mwp-admin/marketplace.page.ts
index 09b47fb6f..a65bf7103 100644
--- a/tests/e2e/pageobjects/mwp-admin/marketplace.page.ts
+++ b/tests/e2e/pageobjects/mwp-admin/marketplace.page.ts
@@ -12,6 +12,7 @@ import * as path from 'path';
import * as url from 'url';
import MwpPage from './page.js';
import Website from '../../website.js';
+import MatomoApi from "../../apiobjects/matomo.api";
const dirname = path.dirname(url.fileURLToPath(import.meta.url));
@@ -293,6 +294,13 @@ class MwpMarketplacePage extends MwpPage {
expect(activatedPlugins).toEqual(allPluginsName);
+ // make sure headless browsers are tracked otherwise following tests will fail
+ await MatomoApi.call('POST', 'CorePluginsAdmin.setSystemSettings', new URLSearchParams({
+ 'settingValues[TrackingSpamPrevention][0][name]': 'block_headless',
+ 'settingValues[TrackingSpamPrevention][0][value]': '0',
+ passwordConfirmation: process.env.WORDPRESS_USER_PASS || 'pass',
+ }));
+
await browser.refresh(); // for new nonce values
}
}
diff --git a/tests/e2e/tracking.e2e.ts b/tests/e2e/tracking.e2e.ts
index cae2cc00b..3efb9cd52 100644
--- a/tests/e2e/tracking.e2e.ts
+++ b/tests/e2e/tracking.e2e.ts
@@ -33,12 +33,25 @@ describe('Tracking', () => {
await BlogPostPage.open();
await BlogPostPage.waitForTrackingRequest();
- await browser.pause(3000); // just to make sure everything gets tracked
-
- const counters = await MatomoApi.call('GET', 'Live.getCounters', new URLSearchParams({
- idSite: '1',
- lastMinutes: '60',
- }));
+ let counters;
+ try {
+ await Website.retry(3, async () => {
+ await browser.pause(3000); // just to make sure everything gets tracked
+
+ counters = await MatomoApi.call('GET', 'Live.getCounters', new URLSearchParams({
+ idSite: '1',
+ lastMinutes: '60',
+ }));
+
+ if (parseInt(counters[0]?.actions, 10) !== parseInt(countersBefore[0].actions, 10) + 2) {
+ throw new Error('retry');
+ }
+ });
+ } catch (e) {
+ if ((e as Error).message !== 'retry') {
+ throw e; // ignore retry error so counters assert below will execute
+ }
+ }
expect(counters).toEqual([{
visits: `${parseInt(countersBefore[0].visits, 10) + 1}`,
diff --git a/tests/phpunit/plugins/WordPress/Overrides/test-globalsettingsprovider.php b/tests/phpunit/plugins/WordPress/Overrides/test-globalsettingsprovider.php
new file mode 100644
index 000000000..ffc9cdab6
--- /dev/null
+++ b/tests/phpunit/plugins/WordPress/Overrides/test-globalsettingsprovider.php
@@ -0,0 +1,1523 @@
+settings = new \WpMatomo\Settings();
+ }
+
+ public function tearDown(): void {
+ foreach ( $this->temp_files as $file ) {
+ if ( file_exists( $file ) ) {
+ unlink( $file );
+ }
+ }
+ $this->temp_files = [];
+
+ parent::tearDown();
+ }
+
+ public function test_is_a_global_settings_provider() {
+ $provider = new GlobalSettingsProvider( null, null, null, $this->settings );
+ $this->assertInstanceOf( DefaultGlobalSettingsProvider::class, $provider );
+ }
+
+ public function test_bootstrapped_environment_uses_the_wordpress_global_settings_provider() {
+ \WpMatomo\Bootstrap::do_bootstrap();
+
+ $provider = \Piwik\Container\StaticContainer::get( DefaultGlobalSettingsProvider::class );
+ $this->assertInstanceOf( GlobalSettingsProvider::class, $provider );
+ }
+
+ public function test_construct_backs_up_config_to_option_when_option_has_no_data() {
+ $this->assertEquals( [], $this->get_option_data() );
+
+ $provider = new GlobalSettingsProvider( null, null, null, $this->settings );
+
+ $stored = $this->get_option_data();
+
+ $this->assertIsArray( $stored );
+ $this->assertNotEmpty( $stored );
+
+ // only the diff (values that differ from the INI defaults) is backed up, not the full merged
+ // config. so the stored data must be a strict subset of the merged settings and must not
+ // contain unchanged default-only sections.
+ $merged = $provider->getIniFileChain()->getAll();
+ $this->assertNotEquals( $merged, $stored );
+ foreach ( $stored as $section_name => $section ) {
+ $this->assertArrayHasKey( $section_name, $merged );
+ }
+ }
+
+ public function test_backup_is_not_refreshed_on_tracker_requests() {
+ \Piwik\SettingsServer::setIsTrackerApiRequest();
+
+ try {
+ new GlobalSettingsProvider( null, null, null, $this->settings );
+
+ $this->assertEquals( [], $this->get_option_data() );
+ } finally {
+ \Piwik\SettingsServer::setIsNotTrackerApiRequest();
+ }
+ }
+
+ public function test_construct_backs_up_config_to_option_when_option_holds_an_empty_array() {
+ $this->update_option_data( [] );
+
+ $provider = new GlobalSettingsProvider( null, null, null, $this->settings );
+
+ $stored = $this->get_option_data();
+
+ $this->assertNotEmpty( $stored );
+ $this->assertNotEquals( $provider->getIniFileChain()->getAll(), $stored );
+ }
+
+ public function test_construct_refreshes_backup_option_from_file_when_file_exists() {
+ // the config.ini.php file is the source of truth, so a stale backup option must be overwritten
+ // with the data currently in the file.
+ $this->update_option_data(
+ array(
+ 'ThisSectionIsNotInTheFile' => array( 'foo' => 'bar' ),
+ )
+ );
+
+ new GlobalSettingsProvider( null, null, null, $this->settings );
+
+ $stored = $this->get_option_data();
+ $this->assertArrayNotHasKey( 'ThisSectionIsNotInTheFile', $stored );
+ }
+
+ public function test_construct_does_not_apply_backup_option_when_file_exists() {
+ // the backup option is only a backup: while config.ini.php exists it must not be layered on top
+ // of the file config, so a value present only in the option is ignored.
+ $this->update_option_data(
+ array(
+ 'TestSection' => array( 'test_key' => 'test_value' ),
+ )
+ );
+
+ $provider = new GlobalSettingsProvider( null, null, null, $this->settings );
+
+ $section = (array) $provider->getSection( 'TestSection' );
+ $this->assertArrayNotHasKey( 'test_key', $section );
+ }
+
+ public function test_construct_restores_config_from_backup_when_file_is_missing() {
+ $this->update_option_data(
+ array(
+ 'TestSection' => array( 'test_key' => 'test_value' ),
+ )
+ );
+
+ $provider = new GlobalSettingsProvider( null, $this->non_existent_config_path(), null, $this->settings );
+
+ // the backup is applied on top of the INI config when the file is missing
+ $this->assertSame( 'test_value', $provider->getSection( 'TestSection' )['test_key'] );
+ }
+
+ public function test_restore_does_not_fatal_when_backup_general_section_is_not_an_array() {
+ $this->update_option_data(
+ array(
+ 'General' => 'corrupted',
+ 'TestSection' => array( 'test_key' => 'test_value' ),
+ )
+ );
+
+ $provider = new GlobalSettingsProvider( null, $this->non_existent_config_path(), null, $this->settings );
+
+ $this->assertSame( 'test_value', $provider->getSection( 'TestSection' )['test_key'] );
+
+ // check General was rebuilt into a real array with the regenerated salt/trusted_hosts
+ $general = $provider->getSection( 'General' );
+ $this->assertIsArray( $general );
+ $this->assertNotEmpty( $general['salt'] );
+ }
+
+ public function test_restore_recovers_from_a_syntactically_corrupted_config_file() {
+ $this->update_option_data(
+ array(
+ 'TestSection' => array( 'test_key' => 'test_value' ),
+ )
+ );
+
+ // a write interrupted mid-quoted-string leaves an unparseable config.ini.php: the INI
+ // chain fails to load, which without recovery would fatal every request. the file must
+ // be at rest (older than the grace period) before it is replaced.
+ $path = $this->write_config_file( "[General]\nsalt = \"unterminated\n" );
+ touch( $path, time() - GlobalSettingsProvider::INCOMPLETE_FILE_GRACE_PERIOD_SECONDS - 60 );
+
+ $provider = new GlobalSettingsProvider( null, $path, null, $this->settings );
+
+ // check that it did not fatal: the corrupt file was dropped and rebuilt from the backup
+ $this->assertSame( 'test_value', $provider->getSection( 'TestSection' )['test_key'] );
+ $this->assertTrue( file_exists( $path ) );
+ $this->assert_config_file_ends_with_marker( $path );
+ }
+
+ public function test_recently_modified_corrupt_config_file_is_not_dropped_or_replaced() {
+ $this->update_option_data(
+ array(
+ 'TestSection' => array( 'test_key' => 'test_value' ),
+ )
+ );
+
+ // an unparseable config.ini.php with a fresh mtime: a concurrent Config::forceSave()
+ // may still be rewriting it in place
+ $path = $this->write_config_file( "[General]\nsalt = \"unterminated\n" );
+ $corrupt_contents = file_get_contents( $path );
+
+ $threw = false;
+ try {
+ new GlobalSettingsProvider( null, $path, null, $this->settings );
+ } catch ( \Exception $ex ) {
+ $threw = true;
+ }
+
+ $this->assertTrue( $threw, 'expected the unparseable config to make reload() throw' );
+ $this->assertSame( $corrupt_contents, file_get_contents( $path ) );
+ }
+
+ public function test_corrupt_config_file_is_left_untouched_and_rethrown_when_there_is_no_backup() {
+ // no backup option is set, so there is nothing to restore from. the file is aged past
+ // the grace period so this exercises the empty-backup branch, not the write-in-progress
+ // protection.
+ $path = $this->write_config_file( "[General]\nsalt = \"unterminated\n" );
+ touch( $path, time() - GlobalSettingsProvider::INCOMPLETE_FILE_GRACE_PERIOD_SECONDS - 60 );
+ $corrupt_contents = file_get_contents( $path );
+
+ $threw = false;
+ try {
+ new GlobalSettingsProvider( null, $path, null, $this->settings );
+ } catch ( \Exception $ex ) {
+ $threw = true;
+ }
+
+ // with no backup, dropping the file would lose the config for good, so the load error is
+ // rethrown instead of being swallowed. the corrupt file is left in place, unchanged, so the
+ // site owner can see and manually resolve the issue.
+ $this->assertTrue( $threw, 'expected the unparseable config to make reload() throw' );
+ $this->assertTrue( file_exists( $path ) );
+ $this->assertSame( $corrupt_contents, file_get_contents( $path ) );
+ }
+
+ public function test_construct_recreates_config_file_from_backup_when_file_is_missing() {
+ $this->update_option_data(
+ array(
+ 'TestSection' => array( 'test_key' => 'test_value' ),
+ )
+ );
+
+ $path = $this->non_existent_config_path();
+ $this->assertFalse( file_exists( $path ) );
+
+ new GlobalSettingsProvider( null, $path, null, $this->settings );
+
+ // the missing config.ini.php is written back to disk from the backup
+ $this->assertTrue( file_exists( $path ) );
+ $contents = file_get_contents( $path );
+ $this->assertStringContainsString( '[TestSection]', $contents );
+ $this->assertStringContainsString( 'test_value', $contents );
+ }
+
+ public function test_restore_temp_file_keeps_the_php_extension_so_it_cannot_leak_as_plain_text() {
+ $provider = new GlobalSettingsProvider( null, null, null, $this->settings );
+
+ $method = new ReflectionMethod( GlobalSettingsProvider::class, 'getTempConfigPath' );
+ $method->setAccessible( true );
+
+ $config_path = '/var/www/uploads/matomo/config/config.ini.php';
+ $temp_path = $method->invoke( $provider, $config_path );
+
+ $this->assertStringEndsWith( '.php', $temp_path );
+ $this->assertNotSame( $config_path, $temp_path );
+ // the temp file lands next to the config file and is matched by the stale-temp sweep
+ $sweep_pattern = dirname( $config_path ) . '/' . basename( $config_path, '.php' ) . '.tmp*.php';
+ $this->assertNotEmpty( fnmatch( $sweep_pattern, $temp_path ) );
+ }
+
+ public function test_default_section_merges_global_and_common_per_key_like_core() {
+ $global_path = $this->write_raw_ini_file( "[TestArr]\nlist[] = \"a\"\nlist[] = \"b\"\n" );
+ $common_path = $this->write_raw_ini_file( "[TestArr]\nlist[] = \"c\"\n" );
+ $local_path = $this->write_config_file( "[General]\nsalt = \"" . str_repeat( 'a', 32 ) . "\"\n" );
+
+ $provider = new GlobalSettingsProvider( $global_path, $local_path, $common_path, $this->settings );
+
+ $method = new ReflectionMethod( GlobalSettingsProvider::class, 'getDefaultSection' );
+ $method->setAccessible( true );
+ $default = $method->invoke( $provider, 'TestArr' );
+
+ // per-key recursive merge: index 0 overwritten by common's 'c', index 1 kept from global
+ // ('b') — exactly what core's array_merge_recursive_distinct produces. a plain
+ // array_merge would instead yield ['list' => ['c']] (whole array replaced).
+ $this->assertSame( array( 'list' => array( 'c', 'b' ) ), $default );
+ }
+
+ public function test_restore_deletes_stale_orphaned_temp_files_but_keeps_recent_ones() {
+ $this->update_option_data(
+ array(
+ 'TestSection' => array( 'test_key' => 'test_value' ),
+ )
+ );
+
+ $path = $this->non_existent_config_path();
+ $dir = dirname( $path );
+ $base = basename( $path, '.php' );
+
+ // an orphaned temp file from a long-ago interrupted restore, and one that a concurrent
+ // restore might be writing right now
+ $stale = $dir . '/' . $base . '.tmpstale.php';
+ $recent = $dir . '/' . $base . '.tmprecent.php';
+ file_put_contents( $stale, "; \n[database]\npassword = \"leaked\"\n" );
+ file_put_contents( $recent, "; \n[database]\npassword = \"in-progress\"\n" );
+ $this->temp_files[] = $stale;
+ $this->temp_files[] = $recent;
+ touch( $stale, time() - GlobalSettingsProvider::INCOMPLETE_FILE_GRACE_PERIOD_SECONDS - 60 );
+
+ // a restore runs (missing config file) and sweeps stale temp files before writing
+ new GlobalSettingsProvider( null, $path, null, $this->settings );
+
+ $this->assertFalse( file_exists( $stale ), 'stale orphaned temp file should be deleted' );
+ $this->assertTrue( file_exists( $recent ), 'a recent temp file (possible in-progress write) must be kept' );
+ // the real config was still restored, and the config file itself was not swept
+ $this->assertTrue( file_exists( $path ) );
+ }
+
+ public function test_construct_does_not_write_file_when_backup_is_empty_and_file_is_missing() {
+ $path = $this->non_existent_config_path();
+
+ new GlobalSettingsProvider( null, $path, null, $this->settings );
+
+ // nothing to restore (eg. fresh install), so no bogus config.ini.php is created
+ $this->assertFalse( file_exists( $path ) );
+ }
+
+ public function test_restore_does_not_wipe_default_sections_when_backup_holds_partial_data() {
+ // this guards against the regression where partial stored data (eg. data written by older
+ // plugin versions) replaced the whole config and wiped the INI defaults.
+ $default_general = ( new GlobalSettingsProvider( null, null, null, $this->settings ) )->getSection( 'General' );
+ $this->assertNotEmpty( $default_general, 'precondition: the General section has default values' );
+
+ // trusted_hosts and salt are set by the installer (and generated again on restore),
+ // they're not part of the default section data being compared
+ unset( $default_general['trusted_hosts'] );
+ unset( $default_general['salt'] );
+
+ delete_option( \WpMatomo\Settings::OPTION_GLOBAL );
+ $this->settings = new \WpMatomo\Settings();
+ $this->update_option_data(
+ array(
+ 'TestSection' => array( 'test_key' => 'test_value' ),
+ )
+ );
+
+ $provider = new GlobalSettingsProvider( null, $this->non_existent_config_path(), null, $this->settings );
+
+ // the partial backup is applied...
+ $this->assertSame( 'test_value', $provider->getSection( 'TestSection' )['test_key'] );
+ // ...but the default sections that are not part of the backup are preserved.
+ $restored_general = $provider->getSection( 'General' );
+ unset( $restored_general['trusted_hosts'] );
+ unset( $restored_general['salt'] );
+ $this->assertEquals( $default_general, $restored_general );
+ }
+
+ public function test_activated_plugins_are_filtered_for_wordpress_when_file_exists() {
+ // the WordPress plugin filtering runs regardless of whether the config comes from the file or
+ // the backup, so with the real config.ini.php in place the activated list is still filtered.
+ $activated = $this->get_activated_plugins();
+
+ $this->assertNotContains( 'Marketplace', $activated );
+ $this->assertNotContains( 'MultiSites', $activated );
+ $this->assertContains( 'BulkTracking', $activated );
+ $this->assertContains( 'CustomJsTracker', $activated );
+ }
+
+ public function test_keeps_plugins_section_structure_so_it_can_be_read_by_pluginlist() {
+ // regression test: the [Plugins] section must stay a nested array (['Plugins' => [...]]) so
+ // PluginList::getActivatedPlugins() (and therefore the DI container) can read it. storing a
+ // flat list of plugin names would make the activated plugin list resolve to empty.
+ $provider = new GlobalSettingsProvider( null, null, null, $this->settings );
+
+ $section = $provider->getSection( 'Plugins' );
+
+ $this->assertArrayHasKey( 'Plugins', $section );
+ $this->assertIsArray( $section['Plugins'] );
+ $this->assertNotEmpty( $section['Plugins'] );
+ // the section must not be a flat list of plugin names
+ $this->assertArrayNotHasKey( 0, $section );
+ }
+
+ public function test_restored_plugin_list_is_filtered_for_wordpress() {
+ // a [Plugins] section stored in a backup is discarded on restore: the plugin
+ // list is runtime-computed, so the activated list must come from the defaults plus the
+ // WordPress filtering, never from the stored value
+ $activated = $this->get_activated_plugins_for_missing_file(
+ array(
+ 'Plugins' => array( 'Plugins' => array( 'CoreHome', 'Marketplace', 'MultiSites' ) ),
+ )
+ );
+
+ // activated plugins are read back through the real consumer of the section
+ $this->assertContains( 'CoreHome', $activated );
+ // plugins that do not make sense in WordPress are removed
+ $this->assertNotContains( 'Marketplace', $activated );
+ $this->assertNotContains( 'MultiSites', $activated );
+ // plugins required for WordPress are force enabled
+ $this->assertContains( 'BulkTracking', $activated );
+ $this->assertContains( 'CustomJsTracker', $activated );
+ }
+
+ public function test_restored_plugin_list_adds_globally_enabled_plugins() {
+ $original = isset( $GLOBALS['MATOMO_PLUGINS_ENABLED'] ) ? $GLOBALS['MATOMO_PLUGINS_ENABLED'] : null;
+ $GLOBALS['MATOMO_PLUGINS_ENABLED'] = array( 'MyExtraPlugin' );
+
+ try {
+ $activated = $this->get_activated_plugins_for_missing_file(
+ array(
+ 'Plugins' => array( 'Plugins' => array( 'CoreHome' ) ),
+ )
+ );
+ } finally {
+ if ( null === $original ) {
+ unset( $GLOBALS['MATOMO_PLUGINS_ENABLED'] );
+ } else {
+ $GLOBALS['MATOMO_PLUGINS_ENABLED'] = $original;
+ }
+ }
+
+ $this->assertContains( 'MyExtraPlugin', $activated );
+ }
+
+ public function test_restored_plugin_list_is_not_frozen_to_stored_value() {
+ // a plugin list restored from the backup must not prevent the WordPress filtering from running
+ // again (eg. BulkTracking must always end up enabled even if it is not stored).
+ $activated = $this->get_activated_plugins_for_missing_file(
+ array(
+ 'Plugins' => array( 'Plugins' => array( 'CoreHome' ) ),
+ )
+ );
+
+ $this->assertContains( 'CoreHome', $activated );
+ $this->assertContains( 'BulkTracking', $activated );
+ }
+
+ public function test_persistConfigOption_reflects_config_changes_in_the_option() {
+ $provider = new GlobalSettingsProvider( null, null, null, $this->settings );
+
+ $chain = $provider->getIniFileChain();
+ $section = $chain->get( 'TestSection' );
+ $this->assertArrayNotHasKey( 'changed_key', (array) $section );
+
+ // change the config data the same way Piwik\Config does (through IniFileChain)
+ $section = (array) $section;
+ $section['changed_key'] = 'changed_value';
+ $chain->set( 'TestSection', $section );
+
+ $provider->persistConfigOption();
+
+ $stored = $this->get_option_data();
+
+ $this->assertSame( 'changed_value', $stored['TestSection']['changed_key'] );
+ // only the override is stored, not the full merged config
+ $this->assertNotEquals( $chain->getAll(), $stored );
+ }
+
+ public function test_backup_does_not_contain_database_credentials_salt_or_trusted_hosts() {
+ // the real config.ini.php contains the [database] credentials and the [General] salt
+ // (secrets that must never be copied into the more exposed WordPress options table) as
+ // well as trusted_hosts (blog-specific, must not enter a potentially network-shared backup)
+ new GlobalSettingsProvider( null, null, null, $this->settings );
+
+ $stored = $this->get_option_data();
+
+ $this->assertNotEmpty( $stored );
+ if ( isset( $stored['database'] ) ) {
+ // only portable connection settings may be backed up, never credentials or identity
+ $unexpected_keys = array_diff_key(
+ $stored['database'],
+ array_flip( GlobalSettingsProvider::DATABASE_KEYS_TO_BACKUP )
+ );
+ $this->assertSame( array(), $unexpected_keys );
+ }
+ if ( isset( $stored['General'] ) ) {
+ $this->assertArrayNotHasKey( 'salt', $stored['General'] );
+ $this->assertArrayNotHasKey( 'trusted_hosts', $stored['General'] );
+ }
+ }
+
+ public function test_backup_does_not_contain_values_stripped_by_config_before_save_handlers() {
+ $strip = function ( &$values ) {
+ if ( isset( $values['TestSection'] ) && is_array( $values['TestSection'] ) ) {
+ unset( $values['TestSection']['runtime_only_value'] );
+ }
+ };
+ \Piwik\Piwik::addAction( 'Config.beforeSave', $strip );
+
+ $provider = new GlobalSettingsProvider( null, null, null, $this->settings );
+
+ $chain = $provider->getIniFileChain();
+ $chain->set(
+ 'TestSection',
+ array(
+ 'runtime_only_value' => 'runtime-only',
+ 'kept_value' => 'kept',
+ )
+ );
+
+ $provider->persistConfigOption();
+
+ $stored = $this->get_option_data();
+ $this->assertSame( array( 'kept_value' => 'kept' ), $stored['TestSection'] );
+ }
+
+ public function test_backup_redacts_secret_like_values() {
+ $provider = new GlobalSettingsProvider( null, null, null, $this->settings );
+
+ $chain = $provider->getIniFileChain();
+ $chain->set(
+ 'TestSection',
+ array(
+ 'some_password' => 'secret1',
+ 'api_key' => 'secret2',
+ 'smtpPassword' => 'secret3',
+ 'auth_token' => 'secret4',
+ 'accessToken' => 'secret5',
+ 'passphrase' => 'secret6',
+ 'bearer' => 'secret7',
+ 'credentials' => 'secret8',
+ 'safe_value' => 'kept',
+ )
+ );
+
+ $provider->persistConfigOption();
+
+ $stored = $this->get_option_data();
+
+ $this->assertSame( array( 'safe_value' => 'kept' ), $stored['TestSection'] );
+ }
+
+ public function test_backup_keeps_only_portable_database_values() {
+ $provider = new GlobalSettingsProvider( null, null, null, $this->settings );
+
+ $chain = $provider->getIniFileChain();
+ $database = (array) $chain->get( 'database' );
+ $chain->set(
+ 'database',
+ array_merge(
+ $database,
+ array(
+ 'enable_ssl' => 1,
+ 'ssl_ca' => '/etc/ssl/db-ca.pem',
+ )
+ )
+ );
+
+ $provider->persistConfigOption();
+
+ $stored = $this->get_option_data();
+
+ // hand-added portable connection settings survive into the backup...
+ $this->assertSame( 1, $stored['database']['enable_ssl'] );
+ $this->assertSame( '/etc/ssl/db-ca.pem', $stored['database']['ssl_ca'] );
+ // ...but credentials and identity values never do
+ $this->assertArrayNotHasKey( 'username', $stored['database'] );
+ $this->assertArrayNotHasKey( 'password', $stored['database'] );
+ $this->assertArrayNotHasKey( 'host', $stored['database'] );
+ $this->assertArrayNotHasKey( 'dbname', $stored['database'] );
+ $this->assertArrayNotHasKey( 'tables_prefix', $stored['database'] );
+ }
+
+ public function test_restore_applies_portable_database_values_over_rebuilt_credentials() {
+ $this->update_option_data(
+ array(
+ 'database' => array(
+ 'ssl_ca' => '/etc/ssl/db-ca.pem',
+ 'charset' => 'custom_charset',
+ 'username' => 'stale_user',
+ 'host' => 'stale-host.example.com',
+ ),
+ 'TestSection' => array( 'test_key' => 'test_value' ),
+ )
+ );
+
+ $provider = new GlobalSettingsProvider( null, $this->non_existent_config_path(), null, $this->settings );
+
+ $database = $provider->getSection( 'database' );
+ $this->assertSame( '/etc/ssl/db-ca.pem', $database['ssl_ca'] );
+ $this->assertSame( 'custom_charset', $database['charset'] );
+ $this->assertSame( DB_USER, $database['username'] );
+ $this->assertNotEquals( 'stale-host.example.com', $database['host'] );
+ }
+
+ public function test_backup_does_not_contain_reader_or_tests_database_sections() {
+ $provider = new GlobalSettingsProvider( null, null, null, $this->settings );
+
+ $chain = $provider->getIniFileChain();
+ $chain->set(
+ 'database_reader',
+ array(
+ 'host' => 'reader.example.com',
+ 'username' => 'reader_user',
+ 'password' => 'reader_pass',
+ )
+ );
+ $chain->set( 'database_tests', array( 'dbname' => 'tests_db' ) );
+
+ $provider->persistConfigOption();
+
+ $stored = $this->get_option_data();
+
+ $this->assertArrayNotHasKey( 'database_reader', $stored );
+ $this->assertArrayNotHasKey( 'database_tests', $stored );
+ }
+
+ public function test_restore_does_not_write_a_database_reader_section_from_the_backup() {
+ $this->update_option_data(
+ array(
+ 'database_reader' => array(
+ 'host' => 'reader.example.com',
+ 'username' => 'reader_user',
+ ),
+ 'TestSection' => array( 'test_key' => 'test_value' ),
+ )
+ );
+
+ $path = $this->non_existent_config_path();
+ new GlobalSettingsProvider( null, $path, null, $this->settings );
+
+ $contents = file_get_contents( $path );
+ $this->assertStringNotContainsString( '[database_reader]', $contents );
+ $this->assertStringContainsString( "[TestSection]\n", $contents );
+ }
+
+ public function test_restore_rebuilds_database_settings_from_wordpress() {
+ global $wpdb;
+
+ $this->update_option_data(
+ array(
+ 'TestSection' => array( 'test_key' => 'test_value' ),
+ )
+ );
+
+ $path = $this->non_existent_config_path();
+ $provider = new GlobalSettingsProvider( null, $path, null, $this->settings );
+
+ // the [database] section is not part of the backup, it is rebuilt from the current
+ // WordPress DB credentials (so restores keep working when the credentials change)
+ $database = $provider->getSection( 'database' );
+ $this->assertSame( DB_USER, $database['username'] );
+ $this->assertSame( $wpdb->prefix . MATOMO_DATABASE_PREFIX, $database['tables_prefix'] );
+
+ $contents = file_get_contents( $path );
+ $this->assertStringContainsString( '[database]', $contents );
+ }
+
+ public function test_restore_generates_missing_salt_and_trusted_hosts() {
+ $this->update_option_data(
+ array(
+ 'TestSection' => array( 'test_key' => 'test_value' ),
+ )
+ );
+
+ $provider = new GlobalSettingsProvider( null, $this->non_existent_config_path(), null, $this->settings );
+
+ $general = $provider->getSection( 'General' );
+ $this->assertNotEmpty( $general['salt'] );
+ $this->assertNotEmpty( $general['trusted_hosts'] );
+ }
+
+ public function test_restore_does_not_reuse_a_salt_stored_in_a_backup() {
+ $this->update_option_data(
+ array(
+ 'General' => array(
+ 'salt' => 'stored-salt',
+ 'kept_setting' => 'kept-value',
+ ),
+ 'TestSection' => array( 'test_key' => 'test_value' ),
+ )
+ );
+
+ $provider = new GlobalSettingsProvider( null, $this->non_existent_config_path(), null, $this->settings );
+
+ $general = $provider->getSection( 'General' );
+ $this->assertNotEmpty( $general['salt'] );
+ $this->assertNotSame( 'stored-salt', $general['salt'] );
+ $this->assertSame( 'kept-value', $general['kept_setting'] );
+ }
+
+ public function test_restore_does_not_reuse_trusted_hosts_stored_in_a_backup() {
+ $this->update_option_data(
+ array(
+ 'General' => array( 'trusted_hosts' => array( 'other-blog.example.com' ) ),
+ 'TestSection' => array( 'test_key' => 'test_value' ),
+ )
+ );
+
+ $provider = new GlobalSettingsProvider( null, $this->non_existent_config_path(), null, $this->settings );
+
+ $expected_host = wp_parse_url( home_url(), PHP_URL_HOST );
+ $expected_port = wp_parse_url( home_url(), PHP_URL_PORT );
+ if ( $expected_port ) {
+ $expected_host .= ':' . $expected_port;
+ }
+
+ $trusted_hosts = $provider->getSection( 'General' )['trusted_hosts'];
+ $this->assertNotContains( 'other-blog.example.com', $trusted_hosts );
+ $this->assertSame( array( $expected_host ), $trusted_hosts );
+ }
+
+ public function test_restore_never_uses_the_config_options_option_as_a_backup() {
+ // config_options belongs to SyncConfig (admin-set config overrides, possibly partial
+ // data); it must never be treated as a config backup. SyncConfig re-applies it to
+ // config files through its own sync instead.
+ $this->update_sync_config_options(
+ array(
+ 'TestSection' => array( 'test_key' => 'sync_config_value' ),
+ )
+ );
+
+ $path = $this->non_existent_config_path();
+ $provider = new GlobalSettingsProvider( null, $path, null, $this->settings );
+
+ $section = (array) $provider->getSection( 'TestSection' );
+ $this->assertArrayNotHasKey( 'test_key', $section );
+ $this->assertFalse( file_exists( $path ) );
+ }
+
+ public function test_backup_and_restore_work_when_network_enabled() {
+ $this->settings->set_assume_is_network_enabled_in_tests();
+ $this->update_option_data(
+ array(
+ 'TestSection' => array( 'test_key' => 'network_value' ),
+ )
+ );
+
+ $path = $this->non_existent_config_path();
+ $provider = new GlobalSettingsProvider( null, $path, null, $this->settings );
+
+ $this->assertSame( 'network_value', $provider->getSection( 'TestSection' )['test_key'] );
+ $this->assertTrue( file_exists( $path ) );
+ }
+
+ public function test_stale_markerless_config_keeps_its_salt_when_the_network_backup_is_restored_over_it() {
+ $this->settings->set_assume_is_network_enabled_in_tests();
+
+ // the shared network-wide backup was already populated by another blog
+ $this->update_option_data(
+ array(
+ 'TestSection' => array( 'test_key' => 'network_value' ),
+ )
+ );
+
+ // this blog's config.ini.php: valid and complete, but written by a plugin version that
+ // predates the end-of-file marker, and untouched since long before the grace period. its
+ // per-blog encrypted-salt option was never written, so the file is the only place its
+ // salt (a 32 char id generated by the installer) still exists.
+ $salt = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6';
+ $path = $this->write_config_file( "[General]\nsalt = \"$salt\"\n" );
+ touch( $path, time() - GlobalSettingsProvider::INCOMPLETE_FILE_GRACE_PERIOD_SECONDS - 60 );
+
+ $provider = new GlobalSettingsProvider( null, $path, null, $this->settings );
+
+ // the network backup is applied...
+ $this->assertSame( 'network_value', $provider->getSection( 'TestSection' )['test_key'] );
+ // ...but this blog's salt survives, in memory and in the rewritten file
+ $this->assertSame( $salt, $provider->getSection( 'General' )['salt'] );
+ $this->assertStringContainsString( $salt, file_get_contents( $path ) );
+ }
+
+ public function test_restore_applies_pending_network_config_options_over_a_stale_backup() {
+ $this->settings->set_assume_is_network_enabled_in_tests();
+
+ $this->update_option_data(
+ array(
+ 'TestSection' => array(
+ 'synced_key' => 'stale_value',
+ 'other_key' => 'kept_value',
+ ),
+ )
+ );
+
+ // config_options (the source of truth SyncConfig applies to every blog) already holds
+ // the network admin's new value
+ $this->update_sync_config_options(
+ array(
+ 'TestSection' => array( 'synced_key' => 'new_value' ),
+ )
+ );
+
+ $provider = new GlobalSettingsProvider( null, $this->non_existent_config_path(), null, $this->settings );
+
+ $section = $provider->getSection( 'TestSection' );
+ // the synced value wins over the stale backup...
+ $this->assertSame( 'new_value', $section['synced_key'] );
+ // ...without wiping other backed-up values in the same section
+ $this->assertSame( 'kept_value', $section['other_key'] );
+ }
+
+ public function test_restore_does_not_let_synced_config_options_override_rebuilt_database_credentials() {
+ $this->settings->set_assume_is_network_enabled_in_tests();
+
+ $this->update_option_data(
+ array(
+ 'TestSection' => array( 'test_key' => 'test_value' ),
+ )
+ );
+
+ // a [database] group in config_options (nothing syncs one today, but set_config_value()
+ // accepts any group) must not clobber the credentials rebuilt from WordPress
+ $this->update_sync_config_options(
+ array(
+ 'database' => array( 'host' => 'stale-host.example.com' ),
+ )
+ );
+
+ $provider = new GlobalSettingsProvider( null, $this->non_existent_config_path(), null, $this->settings );
+
+ $database = $provider->getSection( 'database' );
+ $this->assertSame( DB_USER, $database['username'] );
+ $this->assertNotEquals( 'stale-host.example.com', $database['host'] );
+ }
+
+ public function test_restore_does_not_write_ini_injected_through_a_malicious_config_backup() {
+ $this->update_option_data(
+ array(
+ 'TestSection' => array(
+ "x = 1\n[database]\nhost" => 'attacker.example',
+ 'test_key' => 'test_value',
+ ),
+ )
+ );
+
+ $path = $this->non_existent_config_path();
+ $provider = new GlobalSettingsProvider( null, $path, null, $this->settings );
+
+ // the sanitized key ("x1[database]host") still produces unparseable INI (bracket/array
+ // syntax), so the restore must refuse to write the file
+ $this->assertFalse( file_exists( $path ) );
+
+ // in memory, the [database] section is rebuilt from WordPress, not attacker-controlled
+ $this->assertNotEquals( 'attacker.example', $provider->getSection( 'database' )['host'] );
+ }
+
+ public function test_restore_sanitizes_backup_keys_like_core_config_writes_do() {
+ $this->update_option_data(
+ array(
+ "Test\nSection" => array(
+ 'weird key!' => 'kept_value',
+ 'test_key' => 'test_value',
+ ),
+ )
+ );
+
+ $path = $this->non_existent_config_path();
+ new GlobalSettingsProvider( null, $path, null, $this->settings );
+
+ $contents = (string) file_get_contents( $path );
+ $parsed = parse_ini_string( $contents, true );
+
+ $this->assertIsArray( $parsed );
+ $this->assertSame( 'kept_value', $parsed['TestSection']['weirdkey'] );
+ $this->assertSame( 'test_value', $parsed['TestSection']['test_key'] );
+ }
+
+ public function test_restore_does_nothing_when_backup_holds_only_excluded_values() {
+ $this->update_option_data(
+ array(
+ 'database' => array( 'password' => 'stored-password' ),
+ 'General' => array(
+ 'salt' => 'stored-salt',
+ 'trusted_hosts' => array( 'example.com' ),
+ ),
+ )
+ );
+
+ $path = $this->non_existent_config_path();
+ new GlobalSettingsProvider( null, $path, null, $this->settings );
+
+ $this->assertFalse( file_exists( $path ) );
+ }
+
+ public function test_backup_does_not_contain_the_plugins_section() {
+ $provider = new GlobalSettingsProvider( null, null, null, $this->settings );
+
+ // the [Plugins] section holds the runtime-computed plugin list (set on every reload by
+ // the WordPress plugin filtering), it must not be persisted as if it were user config
+ $chain = $provider->getIniFileChain();
+ $chain->set( 'Plugins', array( 'Plugins' => array( 'CoreHome', 'TagManager' ) ) );
+
+ $provider->persistConfigOption();
+
+ $this->assertArrayNotHasKey( 'Plugins', $this->get_option_data() );
+ }
+
+ public function test_restore_does_not_write_a_plugins_section_from_the_backup() {
+ $this->update_option_data(
+ array(
+ 'Plugins' => array( 'Plugins' => array( 'CoreHome', 'Marketplace' ) ),
+ 'TestSection' => array( 'test_key' => 'test_value' ),
+ )
+ );
+
+ $path = $this->non_existent_config_path();
+ new GlobalSettingsProvider( null, $path, null, $this->settings );
+
+ $contents = file_get_contents( $path );
+ $this->assertStringNotContainsString( "[Plugins]\n", $contents );
+ $this->assertStringContainsString( "[TestSection]\n", $contents );
+ }
+
+ public function test_persist_stores_an_encrypted_copy_of_the_salt_in_its_own_option() {
+ $this->skip_if_sodium_is_not_available();
+
+ $this->build_provider_for_config_with_salt( 'stored-test-salt' );
+
+ $record = $this->settings->get_encrypted_salt_backup();
+
+ $this->assertNotEmpty( $record['ciphertext'] );
+ $this->assertNotEmpty( $record['key_fingerprint'] );
+ $this->assertNotEmpty( $record['salt_fingerprint'] );
+
+ // the record must not reveal the salt
+ $this->assertStringNotContainsString( 'stored-test-salt', wp_json_encode( $record ) );
+ // the salt itself must still not be in the config backup either
+ $stored = $this->get_option_data();
+ $this->assertArrayNotHasKey( 'salt', isset( $stored['General'] ) ? $stored['General'] : array() );
+ }
+
+ public function test_restore_uses_the_encrypted_salt_instead_of_generating_a_new_one() {
+ $this->skip_if_sodium_is_not_available();
+
+ // a normal request stores the encrypted salt
+ $this->build_provider_for_config_with_salt( 'stored-test-salt' );
+
+ // config.ini.php is lost and restored from the backup
+ $this->update_option_data(
+ array(
+ 'TestSection' => array( 'test_key' => 'test_value' ),
+ )
+ );
+ $restored = new GlobalSettingsProvider( null, $this->non_existent_config_path(), null, $this->settings );
+
+ $this->assertSame( 'stored-test-salt', $restored->getSection( 'General' )['salt'] );
+ }
+
+ public function test_restore_generates_a_new_salt_when_the_wp_auth_key_was_rotated() {
+ $this->skip_if_sodium_is_not_available();
+
+ $this->build_provider_for_config_with_salt( 'stored-test-salt' );
+
+ $rotate_key = function ( $salt_value ) {
+ return 'rotated-' . $salt_value;
+ };
+ add_filter( 'salt', $rotate_key );
+
+ try {
+ $this->update_option_data(
+ array(
+ 'TestSection' => array( 'test_key' => 'test_value' ),
+ )
+ );
+ $restored = new GlobalSettingsProvider( null, $this->non_existent_config_path(), null, $this->settings );
+
+ // decryption fails under the rotated key, so a fresh salt is generated
+ $restored_salt = $restored->getSection( 'General' )['salt'];
+ $this->assertNotEmpty( $restored_salt );
+ $this->assertNotSame( 'stored-test-salt', $restored_salt );
+ } finally {
+ remove_filter( 'salt', $rotate_key );
+ }
+ }
+
+ public function test_restore_does_not_regenerate_the_salt_on_every_request_when_it_cannot_be_decrypted() {
+ $this->skip_if_sodium_is_not_available();
+
+ // an encrypted salt exists, but it was stored under the pre-rotation WP auth key, so
+ // restores cannot decrypt it and have to fall back to generating a salt
+ $this->build_provider_for_config_with_salt( 'stored-test-salt' );
+ $this->mark_blog_installed( true );
+
+ $rotate_key = function ( $salt_value ) {
+ return 'rotated-' . $salt_value;
+ };
+ add_filter( 'salt', $rotate_key );
+
+ try {
+ $this->update_option_data(
+ array(
+ 'TestSection' => array( 'test_key' => 'test_value' ),
+ )
+ );
+
+ // first restore: decryption fails under the rotated key, so a fresh salt is
+ // generated (once) and re-encrypted under the current key
+ $first = new GlobalSettingsProvider( null, $this->non_existent_config_path(), null, $this->settings );
+ $first_salt = $first->getSection( 'General' )['salt'];
+ $this->assertNotSame( 'stored-test-salt', $first_salt );
+ $this->assertGreaterThan( 0, $this->settings->get_time_salt_was_regenerated() );
+
+ // make any further rewrite of the regeneration timestamp detectable
+ $this->settings->set_time_salt_was_regenerated( 12345 );
+
+ // second restore (eg. the restored file was lost again, or its write keeps
+ // failing): the salt generated by the first restore is decrypted and reused, so
+ // visitors' config_ids and opt-out cookie signatures stay stable across requests
+ $second = new GlobalSettingsProvider( null, $this->non_existent_config_path(), null, $this->settings );
+ $this->assertSame( $first_salt, $second->getSection( 'General' )['salt'] );
+
+ // the regeneration timestamp is not rewritten, so it keeps recording when the
+ // incident actually started
+ $this->assertSame( 12345, $this->settings->get_time_salt_was_regenerated() );
+ } finally {
+ remove_filter( 'salt', $rotate_key );
+ }
+ }
+
+ public function test_persist_reencrypts_the_salt_when_the_wp_auth_key_changes() {
+ $this->skip_if_sodium_is_not_available();
+
+ $path = $this->build_provider_for_config_with_salt( 'stored-test-salt' );
+ $record_before = $this->settings->get_encrypted_salt_backup();
+
+ $rotate_key = function ( $salt_value ) {
+ return 'rotated-' . $salt_value;
+ };
+ add_filter( 'salt', $rotate_key );
+
+ try {
+ // the next non-tracker request notices the rotated key (fingerprint mismatch) and
+ // re-encrypts the salt, which is still available in config.ini.php
+ new GlobalSettingsProvider( null, $path, null, $this->settings );
+
+ $record_after = $this->settings->get_encrypted_salt_backup();
+ $this->assertNotSame( $record_before['key_fingerprint'], $record_after['key_fingerprint'] );
+ $this->assertNotSame( $record_before['ciphertext'], $record_after['ciphertext'] );
+
+ // the re-encrypted salt is restorable under the new key
+ $this->update_option_data(
+ array(
+ 'TestSection' => array( 'test_key' => 'test_value' ),
+ )
+ );
+ $restored = new GlobalSettingsProvider( null, $this->non_existent_config_path(), null, $this->settings );
+ $this->assertSame( 'stored-test-salt', $restored->getSection( 'General' )['salt'] );
+ } finally {
+ remove_filter( 'salt', $rotate_key );
+ }
+ }
+
+ public function test_persist_reencrypts_when_the_salt_in_the_config_file_changes() {
+ $this->skip_if_sodium_is_not_available();
+
+ $provider = $this->build_provider_for_config_with_salt( 'stored-test-salt', $return_provider = true );
+ $record_before = $this->settings->get_encrypted_salt_backup();
+
+ // eg. the user manually changed the salt in config.ini.php
+ $chain = $provider->getIniFileChain();
+ $general = (array) $chain->get( 'General' );
+
+ $general['salt'] = 'manually-changed-salt';
+ $chain->set( 'General', $general );
+
+ $provider->persistConfigOption();
+
+ $record_after = $this->settings->get_encrypted_salt_backup();
+ $this->assertNotSame( $record_before['salt_fingerprint'], $record_after['salt_fingerprint'] );
+ $this->assertNotSame( $record_before['ciphertext'], $record_after['ciphertext'] );
+ }
+
+ public function test_restore_records_the_time_when_the_salt_had_to_be_regenerated() {
+ $this->mark_blog_installed( true );
+ $this->update_option_data(
+ array(
+ 'TestSection' => array( 'test_key' => 'test_value' ),
+ )
+ );
+
+ new GlobalSettingsProvider( null, $this->non_existent_config_path(), null, $this->settings );
+
+ $this->assertGreaterThanOrEqual( time() - 60, $this->settings->get_time_salt_was_regenerated() );
+ }
+
+ public function test_restore_does_not_flag_a_regenerated_salt_on_a_never_installed_blog() {
+ $this->mark_blog_installed( false );
+ $this->update_option_data(
+ array(
+ 'TestSection' => array( 'test_key' => 'test_value' ),
+ )
+ );
+
+ new GlobalSettingsProvider( null, $this->non_existent_config_path(), null, $this->settings );
+
+ $this->assertSame( 0, $this->settings->get_time_salt_was_regenerated() );
+ }
+
+ public function test_restore_does_not_flag_a_regenerated_salt_when_the_salt_was_restored() {
+ $this->skip_if_sodium_is_not_available();
+
+ $this->build_provider_for_config_with_salt( 'stored-test-salt' );
+
+ $this->update_option_data(
+ array(
+ 'TestSection' => array( 'test_key' => 'test_value' ),
+ )
+ );
+ $restored = new GlobalSettingsProvider( null, $this->non_existent_config_path(), null, $this->settings );
+
+ $this->assertSame( 'stored-test-salt', $restored->getSection( 'General' )['salt'] );
+ $this->assertSame( 0, $this->settings->get_time_salt_was_regenerated() );
+ }
+
+ public function test_restore_happens_on_tracker_requests() {
+ // only the persist path is skipped on tracker requests: if config.ini.php goes missing
+ // under tracker-only traffic, it must still be restored so tracking keeps working
+ $this->update_option_data(
+ array(
+ 'TestSection' => array( 'test_key' => 'test_value' ),
+ )
+ );
+
+ \Piwik\SettingsServer::setIsTrackerApiRequest();
+
+ try {
+ $path = $this->non_existent_config_path();
+ $provider = new GlobalSettingsProvider( null, $path, null, $this->settings );
+
+ $this->assertSame( 'test_value', $provider->getSection( 'TestSection' )['test_key'] );
+ $this->assertTrue( file_exists( $path ) );
+ $this->assert_config_file_ends_with_marker( $path );
+ } finally {
+ \Piwik\SettingsServer::setIsNotTrackerApiRequest();
+ }
+ }
+
+ public function test_restore_produces_identical_files_for_identical_state() {
+ $this->skip_if_sodium_is_not_available();
+
+ // concurrent restores (eg. a tracker request storm hitting a deleted config) can never
+ // corrupt anything as long as every restore produces the exact same bytes: the atomic
+ // write-and-rename then makes the race a harmless last-writer-wins of identical content.
+ // this requires the salt to come from the encrypted per-blog backup, not be generated.
+ $this->build_provider_for_config_with_salt( 'stored-test-salt' );
+
+ $this->update_option_data(
+ array(
+ 'TestSection' => array( 'test_key' => 'test_value' ),
+ )
+ );
+
+ $first_path = $this->non_existent_config_path();
+ new GlobalSettingsProvider( null, $first_path, null, $this->settings );
+
+ $second_path = $this->non_existent_config_path();
+ new GlobalSettingsProvider( null, $second_path, null, $this->settings );
+
+ $this->assertNotEmpty( file_get_contents( $first_path ) );
+ $this->assertSame( file_get_contents( $first_path ), file_get_contents( $second_path ) );
+ }
+
+ private function skip_if_sodium_is_not_available() {
+ if ( ! function_exists( 'sodium_crypto_secretbox' ) ) {
+ $this->markTestSkipped( 'sodium is not available' );
+ }
+ }
+
+ /**
+ * Writes a complete (marker-terminated) config file containing the given salt and builds a
+ * provider for it, which persists the encrypted salt to the per-blog option.
+ *
+ * @param string $salt
+ * @param bool $return_provider
+ * @return string|GlobalSettingsProvider the config file path, or the provider itself
+ */
+ private function build_provider_for_config_with_salt( $salt, $return_provider = false ) {
+ $path = $this->write_config_file(
+ "[General]\nsalt = \"$salt\"\n\n"
+ . '[' . GlobalSettingsProvider::END_OF_FILE_MARKER_SECTION . "]\n"
+ . GlobalSettingsProvider::END_OF_FILE_MARKER_KEY . ' = "'
+ . GlobalSettingsProvider::END_OF_FILE_MARKER_VALUE . "\"\n"
+ );
+
+ $provider = new GlobalSettingsProvider( null, $path, null, $this->settings );
+
+ return $return_provider ? $provider : $path;
+ }
+
+ public function test_installed_config_file_ends_with_end_of_file_marker() {
+ // the config.ini.php created by the installer must end with the marker, otherwise it
+ // would never be backed up
+ $provider = new GlobalSettingsProvider( null, null, null, $this->settings );
+
+ $this->assert_config_file_ends_with_marker( $provider->getPathLocal() );
+ }
+
+ public function test_backup_is_not_updated_when_config_file_is_missing_the_end_of_file_marker() {
+ $this->update_option_data(
+ array(
+ 'Preexisting' => array( 'key' => 'value' ),
+ )
+ );
+
+ // a config file whose write did not finish (or that was truncated): no marker at the end
+ $path = $this->write_config_file( "[TestSection]\ntest_key = \"test_value\"\n" );
+
+ $original_contents = file_get_contents( $path );
+
+ new GlobalSettingsProvider( null, $path, null, $this->settings );
+
+ // the incomplete file was not backed up (that would overwrite the good backup)...
+ $this->assertSame( array( 'Preexisting' => array( 'key' => 'value' ) ), $this->get_option_data() );
+ // ...and it was not overwritten with the backup either (a write may be in progress)
+ $this->assertSame( $original_contents, file_get_contents( $path ) );
+ }
+
+ public function test_backup_is_updated_when_config_file_ends_with_the_end_of_file_marker() {
+ $marker_section = GlobalSettingsProvider::END_OF_FILE_MARKER_SECTION;
+
+ $path = $this->write_config_file(
+ "[TestSection]\ntest_key = \"test_value\"\n\n"
+ . '[' . $marker_section . "]\n"
+ . GlobalSettingsProvider::END_OF_FILE_MARKER_KEY . ' = "'
+ . GlobalSettingsProvider::END_OF_FILE_MARKER_VALUE . "\"\n"
+ );
+
+ new GlobalSettingsProvider( null, $path, null, $this->settings );
+
+ $stored = $this->get_option_data();
+
+ $this->assertSame( 'test_value', $stored['TestSection']['test_key'] );
+ // the marker is file bookkeeping, it does not belong in the backup
+ $this->assertArrayNotHasKey( $marker_section, $stored );
+ }
+
+ public function test_stale_incomplete_config_file_is_restored_from_backup() {
+ $this->update_option_data(
+ array(
+ 'TestSection' => array( 'test_key' => 'test_value' ),
+ )
+ );
+
+ // a marker-less file that has not been modified for longer than the grace period: the
+ // write that produced it was interrupted for good, so it is replaced with the backup
+ $path = $this->write_config_file( "[OldSection]\nold_key = \"old_value\"\n" );
+ touch( $path, time() - GlobalSettingsProvider::INCOMPLETE_FILE_GRACE_PERIOD_SECONDS - 60 );
+
+ new GlobalSettingsProvider( null, $path, null, $this->settings );
+
+ $contents = file_get_contents( $path );
+ $this->assertStringContainsString( "[TestSection]\n", $contents );
+ $this->assertStringNotContainsString( '[OldSection]', $contents );
+ $this->assert_config_file_ends_with_marker( $path );
+ }
+
+ public function test_stale_incomplete_config_file_is_not_touched_when_the_backup_is_empty() {
+ $this->update_sync_config_options(
+ array(
+ 'TestSection' => array( 'test_key' => 'sync_config_value' ),
+ )
+ );
+
+ $path = $this->write_config_file( "[OldSection]\nold_key = \"old_value\"\n" );
+ touch( $path, time() - GlobalSettingsProvider::INCOMPLETE_FILE_GRACE_PERIOD_SECONDS - 60 );
+
+ $original_contents = file_get_contents( $path );
+
+ new GlobalSettingsProvider( null, $path, null, $this->settings );
+
+ $this->assertSame( $original_contents, file_get_contents( $path ) );
+ }
+
+ public function test_empty_config_file_is_neither_backed_up_nor_restored_over() {
+ $this->update_option_data(
+ array(
+ 'TestSection' => array( 'test_key' => 'test_value' ),
+ )
+ );
+
+ // eg. a concurrent Config::forceSave() just truncated the file and is about to rewrite it
+ $path = $this->non_existent_config_path();
+ touch( $path );
+
+ new GlobalSettingsProvider( null, $path, null, $this->settings );
+
+ $this->assertSame( '', file_get_contents( $path ) );
+ $this->assertSame( array( 'TestSection' => array( 'test_key' => 'test_value' ) ), $this->get_option_data() );
+ }
+
+ public function test_restored_config_file_ends_with_end_of_file_marker_and_is_backed_up_again() {
+ $this->update_option_data(
+ array(
+ 'TestSection' => array( 'test_key' => 'test_value' ),
+ )
+ );
+
+ $path = $this->non_existent_config_path();
+ new GlobalSettingsProvider( null, $path, null, $this->settings );
+
+ $this->assert_config_file_ends_with_marker( $path );
+
+ // round trip: the restored file passes the completeness check, so it is backed up again
+ $this->update_option_data( array() );
+ new GlobalSettingsProvider( null, $path, null, $this->settings );
+
+ $stored = $this->get_option_data();
+ $this->assertSame( 'test_value', $stored['TestSection']['test_key'] );
+ }
+
+ /**
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ */
+ public function test_backup_is_not_written_when_the_feature_is_disabled() {
+ define( 'MATOMO_DISABLE_CONFIG_BACKUP', true );
+
+ $this->assertSame( array(), $this->get_option_data() );
+
+ new GlobalSettingsProvider( null, null, null, $this->settings );
+
+ // with the feature off, the real config.ini.php is not backed up to the option
+ $this->assertSame( array(), $this->get_option_data() );
+ }
+
+ /**
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ */
+ public function test_config_file_is_not_restored_when_the_feature_is_disabled() {
+ define( 'MATOMO_DISABLE_CONFIG_BACKUP', true );
+
+ $this->update_option_data(
+ array(
+ 'TestSection' => array( 'test_key' => 'test_value' ),
+ )
+ );
+
+ $path = $this->non_existent_config_path();
+ $provider = new GlobalSettingsProvider( null, $path, null, $this->settings );
+
+ // the missing config.ini.php is neither rebuilt from the backup...
+ $this->assertFalse( file_exists( $path ) );
+ // ...nor is the backup layered on top of the INI config
+ $section = (array) $provider->getSection( 'TestSection' );
+ $this->assertArrayNotHasKey( 'test_key', $section );
+ }
+
+ /**
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ */
+ public function test_persistConfigOption_is_a_noop_when_the_feature_is_disabled() {
+ define( 'MATOMO_DISABLE_CONFIG_BACKUP', true );
+
+ $provider = new GlobalSettingsProvider( null, null, null, $this->settings );
+
+ $chain = $provider->getIniFileChain();
+ $section = (array) $chain->get( 'TestSection' );
+ $section['changed_key'] = 'changed_value';
+ $chain->set( 'TestSection', $section );
+
+ $provider->persistConfigOption();
+
+ $this->assertSame( array(), $this->get_option_data() );
+ }
+
+ /**
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ */
+ public function test_corrupt_config_file_is_not_recovered_when_the_feature_is_disabled() {
+ define( 'MATOMO_DISABLE_CONFIG_BACKUP', true );
+
+ $this->update_option_data(
+ array(
+ 'TestSection' => array( 'test_key' => 'test_value' ),
+ )
+ );
+
+ $path = $this->write_config_file( "[General]\nsalt = \"unterminated\n" );
+ $original_contents = file_get_contents( $path );
+
+ $threw = false;
+ try {
+ new GlobalSettingsProvider( null, $path, null, $this->settings );
+ } catch ( \Exception $ex ) {
+ $threw = true;
+ }
+
+ // with the feature off there is no self-heal: the load error propagates instead of being
+ // swallowed, and the corrupt file is left untouched rather than dropped and rebuilt
+ $this->assertTrue( $threw, 'expected the unparseable config to make reload() throw' );
+ $this->assertTrue( file_exists( $path ) );
+ $this->assertSame( $original_contents, file_get_contents( $path ) );
+ }
+
+ /**
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ */
+ public function test_plugin_filtering_still_runs_when_the_feature_is_disabled() {
+ define( 'MATOMO_DISABLE_CONFIG_BACKUP', true );
+
+ $provider = new GlobalSettingsProvider( null, null, null, $this->settings );
+ $plugin_list = new \Piwik\Application\Kernel\PluginList( $provider );
+ $activated = $plugin_list->getActivatedPlugins();
+
+ $this->assertContains( 'BulkTracking', $activated );
+ $this->assertNotContains( 'Marketplace', $activated );
+ }
+
+ public function test_before_save_handler_adds_the_marker_when_the_feature_is_enabled() {
+ $section = GlobalSettingsProvider::END_OF_FILE_MARKER_SECTION;
+ $plugin = new \Piwik\Plugins\WordPress\WordPress();
+
+ $values = array( 'General' => array( 'foo' => 'bar' ) );
+ $plugin->ensureEndOfFileMarkerIsLastConfigSection( $values );
+
+ $this->assertArrayHasKey( $section, $values );
+ // the marker must be the very last section
+ $this->assertSame( $section, array_key_last( $values ) );
+ }
+
+ /**
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ */
+ public function test_before_save_handler_does_not_add_the_marker_when_the_feature_is_disabled() {
+ define( 'MATOMO_DISABLE_CONFIG_BACKUP', true );
+
+ $section = GlobalSettingsProvider::END_OF_FILE_MARKER_SECTION;
+ $plugin = new \Piwik\Plugins\WordPress\WordPress();
+
+ // with the feature disabled the beforeSave handler must not touch config.ini.php, so the
+ // marker section is not injected on every save (config-management drift, R5-3)
+ $values = array( 'General' => array( 'foo' => 'bar' ) );
+ $plugin->ensureEndOfFileMarkerIsLastConfigSection( $values );
+
+ $this->assertArrayNotHasKey( $section, $values );
+ }
+
+ private function mark_blog_installed( $installed ) {
+ $this->settings->set_option(
+ \WpMatomo\Settings::INSTANCE_COMPONENTS_INSTALLED,
+ $installed ? wp_json_encode( array( 'core' => '1.0.0' ) ) : ''
+ );
+ $this->settings->save();
+ }
+
+ private function write_config_file( $ini_content ) {
+ $path = $this->non_existent_config_path();
+ file_put_contents( $path, "; DO NOT REMOVE THIS LINE\n" . $ini_content );
+
+ return $path;
+ }
+
+ private function write_raw_ini_file( $ini_content ) {
+ $path = get_temp_dir() . 'matomo-wp-ini-' . uniqid() . '.ini.php';
+ file_put_contents( $path, "; DO NOT REMOVE THIS LINE\n" . $ini_content );
+ $this->temp_files[] = $path;
+
+ return $path;
+ }
+
+ private function assert_config_file_ends_with_marker( $path ) {
+ $contents = trim( (string) file_get_contents( $path ) );
+
+ // the marker section must be present...
+ $this->assertStringContainsString(
+ '[' . GlobalSettingsProvider::END_OF_FILE_MARKER_SECTION . ']',
+ $contents
+ );
+ // ...and the marker key must be the very last line, so an interrupted write is detectable
+ $expected_tail = GlobalSettingsProvider::END_OF_FILE_MARKER_KEY
+ . ' = "' . GlobalSettingsProvider::END_OF_FILE_MARKER_VALUE . '"';
+ $this->assertSame(
+ $expected_tail,
+ substr( $contents, - strlen( $expected_tail ) ),
+ 'config file does not end with the end-of-file marker, it ends with: ...' . substr( $contents, -200 )
+ );
+ }
+
+ private function get_activated_plugins() {
+ $provider = new GlobalSettingsProvider( null, null, null, $this->settings );
+ $plugin_list = new \Piwik\Application\Kernel\PluginList( $provider );
+
+ return $plugin_list->getActivatedPlugins();
+ }
+
+ /**
+ * Builds a provider whose config.ini.php is missing so the given backup is restored, then returns
+ * the activated plugin list the way the DI container does.
+ *
+ * @param array $backup
+ * @return string[]
+ */
+ private function get_activated_plugins_for_missing_file( array $backup ) {
+ $this->update_option_data( $backup );
+
+ $provider = new GlobalSettingsProvider( null, $this->non_existent_config_path(), null, $this->settings );
+ $plugin_list = new \Piwik\Application\Kernel\PluginList( $provider );
+
+ return $plugin_list->getActivatedPlugins();
+ }
+
+ /**
+ * Returns a path to a config.ini.php that does not exist (used to simulate an accidentally deleted
+ * file).
+ *
+ * @return string
+ */
+ private function non_existent_config_path() {
+ $path = get_temp_dir() . 'matomo-wp-missing-config-' . uniqid() . '.ini.php';
+ if ( file_exists( $path ) ) {
+ unlink( $path );
+ }
+ $this->temp_files[] = $path;
+
+ return $path;
+ }
+
+ private function get_option_data() {
+ return $this->settings->get_config_backup();
+ }
+
+ private function update_option_data( array $data ) {
+ $this->settings->update_config_backup( $data );
+ }
+
+ private function update_sync_config_options( array $data ) {
+ $this->settings->set_global_option( \WpMatomo\Settings::CONFIG_OPTIONS, $data );
+ $this->settings->save();
+ }
+}
diff --git a/tests/phpunit/wpmatomo/admin/test-systemreport.php b/tests/phpunit/wpmatomo/admin/test-systemreport.php
index f2989e6b1..02901bfb1 100644
--- a/tests/phpunit/wpmatomo/admin/test-systemreport.php
+++ b/tests/phpunit/wpmatomo/admin/test-systemreport.php
@@ -39,6 +39,13 @@ class AdminSystemReportTest extends MatomoAnalytics_TestCase {
public function setUp(): void {
parent::setUp();
+
+ // the built report tables are cached in a static property; reset it so every test
+ // renders the report from the current state
+ $matomo_tables_property = new ReflectionProperty( SystemReport::class, 'matomo_tables' );
+ $matomo_tables_property->setAccessible( true );
+ $matomo_tables_property->setValue( null, null );
+
$this->settings = new Settings();
$this->report = new SystemReport( $this->settings );
if ( is_multisite() ) {
@@ -64,6 +71,41 @@ public function test_show_renders_ui() {
$this->assertStringContainsString( 'WordPress Plugins', $output );
}
+ public function test_show_does_not_mention_a_regenerated_salt_by_default() {
+ ob_start();
+ $this->report->show();
+ $output = ob_get_clean();
+
+ $this->assertStringNotContainsString( 'salt was regenerated', $output );
+ }
+
+ public function test_show_warns_about_a_regenerated_salt_after_a_config_recovery() {
+ $this->settings->set_time_salt_was_regenerated( time() );
+
+ ob_start();
+ $this->report->show();
+ $output = ob_get_clean();
+
+ $this->assertStringContainsString( 'Matomo salt was regenerated during a config file recovery', $output );
+ $this->assertStringContainsString( 'opted out of tracking', $output );
+ }
+
+ public function test_show_dismisses_the_salt_regeneration_warning_on_request() {
+ // the troubleshooting actions require super admin permissions
+ $this->create_set_super_admin();
+
+ $this->settings->set_time_salt_was_regenerated( time() );
+
+ $this->fake_request( SystemReport::TROUBLESHOOT_DISMISS_SALT_REGENERATED );
+
+ ob_start();
+ $this->report->show();
+ $output = ob_get_clean();
+
+ $this->assertSame( 0, $this->settings->get_time_salt_was_regenerated() );
+ $this->assertStringNotContainsString( 'salt was regenerated', $output );
+ }
+
/**
* @dataProvider get_trouble_shooting_data
*/
diff --git a/tests/phpunit/wpmatomo/site/sync/test-syncconfig.php b/tests/phpunit/wpmatomo/site/sync/test-syncconfig.php
index 155af6bfc..ecb23b7c4 100644
--- a/tests/phpunit/wpmatomo/site/sync/test-syncconfig.php
+++ b/tests/phpunit/wpmatomo/site/sync/test-syncconfig.php
@@ -125,5 +125,4 @@ public function test_sync_config_for_current_site_when_multiple_values() {
$new_category
);
}
-
}
diff --git a/tests/phpunit/wpmatomo/site/test-sync.php b/tests/phpunit/wpmatomo/site/test-sync.php
index 3e4ede89f..ad84a1917 100644
--- a/tests/phpunit/wpmatomo/site/test-sync.php
+++ b/tests/phpunit/wpmatomo/site/test-sync.php
@@ -147,6 +147,8 @@ public function test_sync_current_site_passes_correct_values_to_sync_site_when_w
$this->mock->sync_current_site();
+ restore_current_blog();
+
wp_delete_site( $blogid1 );
$this->assertEquals(
diff --git a/tests/phpunit/wpmatomo/test-scheduled-tasks.php b/tests/phpunit/wpmatomo/test-scheduled-tasks.php
index ccf38ed0e..83f228d95 100644
--- a/tests/phpunit/wpmatomo/test-scheduled-tasks.php
+++ b/tests/phpunit/wpmatomo/test-scheduled-tasks.php
@@ -79,6 +79,49 @@ public function test_sync_does_not_fail() {
}
}
+ public function test_add_config_end_of_file_marker_adds_marker_and_repopulates_backup() {
+ $marker_section = \Piwik\Plugins\WordPress\Overrides\GlobalSettingsProvider::END_OF_FILE_MARKER_SECTION;
+ $marker_key = \Piwik\Plugins\WordPress\Overrides\GlobalSettingsProvider::END_OF_FILE_MARKER_KEY;
+ $marker_value = \Piwik\Plugins\WordPress\Overrides\GlobalSettingsProvider::END_OF_FILE_MARKER_VALUE;
+
+ Bootstrap::do_bootstrap();
+ $path = \Piwik\Config::getInstance()->getLocalPath();
+
+ // simulate the state after a failed one-time marker write during the plugin update
+ // (eg. the config file was temporarily not writable): the file has no marker, so the
+ // per-request backup refresh never runs and the backup option stays empty
+ $contents = file_get_contents( $path );
+ $this->assertStringContainsString( $marker_section, $contents );
+ $stripped = preg_replace(
+ '/\[' . preg_quote( $marker_section, '/' ) . '\].*$/s',
+ '',
+ $contents
+ );
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_file_put_contents
+ file_put_contents( $path, $stripped );
+ // age the file past the grace period
+ touch( $path, time() - \Piwik\Plugins\WordPress\Overrides\GlobalSettingsProvider::INCOMPLETE_FILE_GRACE_PERIOD_SECONDS - 60 );
+
+ // the backup must be empty before the reload, otherwise the marker-less self-heal
+ // would restore the file during the reload and the task would have nothing to do
+ delete_option( Settings::OPTION_CONFIG_BACKUP );
+ delete_site_option( Settings::OPTION_CONFIG_BACKUP );
+
+ StaticContainer::get( \Piwik\Application\Kernel\GlobalSettingsProvider::class )->reload();
+ $this->assertSame( array(), $this->settings->get_config_backup() );
+
+ $this->tasks->add_config_end_of_file_marker();
+
+ // the marker is back as the very last section of the file...
+ $new_contents = trim( (string) file_get_contents( $path ) );
+ $expected_tail = $marker_key . ' = "' . $marker_value . '"';
+ $this->assertSame( $expected_tail, substr( $new_contents, - strlen( $expected_tail ) ) );
+
+ // ...and the config write repopulated the backup in the same run (via the
+ // Core.configFileChanged event), so the self-heal feature is protective again
+ $this->assertNotEmpty( $this->settings->get_config_backup() );
+ }
+
public function test_disable_add_handler_wontfail_when_addhandler_enabled() {
$this->assertFalse( $this->settings->should_disable_addhandler() );
$this->tasks->disable_add_handler();
diff --git a/tests/phpunit/wpmatomo/test-settings.php b/tests/phpunit/wpmatomo/test-settings.php
index bfbe9574d..3fc887ee0 100644
--- a/tests/phpunit/wpmatomo/test-settings.php
+++ b/tests/phpunit/wpmatomo/test-settings.php
@@ -411,4 +411,36 @@ public function test_excluded_user_agents_in_mwp_are_added_to_tracker_cache_gene
$tracker_cache = \Piwik\Tracker\Cache::getCacheGeneral();
$this->assertEquals( $other_user_agents, $tracker_cache['global_excluded_user_agents'] );
}
+
+ public function test_save_after_blog_switch_does_not_fire_actions_for_discarded_changes() {
+ if ( ! is_multisite() ) {
+ $this->markTestSkipped( 'Not multisite.' );
+ return;
+ }
+
+ $this->settings->set_assume_is_network_enabled_in_tests();
+
+ $blog_id = self::factory()->blog->create();
+
+ $fired = 0;
+ $callback = function () use ( &$fired ) {
+ ++$fired;
+ };
+ add_action( 'matomo_setting_change_noscript_code', $callback );
+
+ try {
+ $this->settings->set_option( 'noscript_code', 'pending change' );
+
+ switch_to_blog( $blog_id );
+ $this->settings->save();
+
+ // check that the setting was not saved after the blog switch, and
+ // the callback was not called
+ $this->assertSame( 0, $fired );
+ $this->assertSame( '', $this->settings->get_option( 'noscript_code' ) );
+ } finally {
+ restore_current_blog();
+ remove_action( 'matomo_setting_change_noscript_code', $callback );
+ }
+ }
}
diff --git a/tests/phpunit/wpmatomo/test-updater.php b/tests/phpunit/wpmatomo/test-updater.php
index 029168f2d..8b8b8fdfb 100644
--- a/tests/phpunit/wpmatomo/test-updater.php
+++ b/tests/phpunit/wpmatomo/test-updater.php
@@ -1,6 +1,8 @@
getLocalPath();
+
+ // simulate a config file written by a plugin version that predates the marker
+ $contents = file_get_contents( $path );
+ $this->assertStringContainsString( $marker_section, $contents );
+ $stripped = preg_replace(
+ '/\[' . preg_quote( $marker_section, '/' ) . '\].*$/s',
+ '',
+ $contents
+ );
+ $this->assertStringNotContainsString( $marker_section, $stripped );
+ file_put_contents( $path, $stripped );
+ // age the file past the grace period: a marker-less file with a fresh mtime is
+ // deliberately not stamped (a write may be in progress)
+ touch( $path, time() - \Piwik\Plugins\WordPress\Overrides\GlobalSettingsProvider::INCOMPLETE_FILE_GRACE_PERIOD_SECONDS - 60 );
+
+ // empty the backup so the reload below does not restore the marker-less file (this test
+ // is about the marker retrofit, not the self-heal)
+ delete_option( Settings::OPTION_CONFIG_BACKUP );
+ delete_site_option( Settings::OPTION_CONFIG_BACKUP );
+
+ // make the in-memory config state match the file on disk, the way the fresh bootstrap
+ // of an update request would see it
+ \Piwik\Container\StaticContainer::get( \Piwik\Application\Kernel\GlobalSettingsProvider::class )->reload();
+
+ $updater = new Updater( new Settings() );
+ $updater->add_config_end_of_file_marker_if_needed();
+
+ $new_contents = trim( (string) file_get_contents( $path ) );
+ $this->assertStringContainsString( '[' . $marker_section . ']', $new_contents );
+ $expected_tail = $marker_key . ' = "' . $marker_value . '"';
+ $this->assertSame( $expected_tail, substr( $new_contents, - strlen( $expected_tail ) ) );
+ }
+
+ public function test_add_config_end_of_file_marker_skips_a_recently_modified_config_file() {
+ $marker_section = \Piwik\Plugins\WordPress\Overrides\GlobalSettingsProvider::END_OF_FILE_MARKER_SECTION;
+
+ $config = \Piwik\Config::getInstance();
+ $path = $config->getLocalPath();
+
+ $contents = file_get_contents( $path );
+ $stripped = preg_replace(
+ '/\[' . preg_quote( $marker_section, '/' ) . '\].*$/s',
+ '',
+ $contents
+ );
+ // the file keeps its fresh mtime: a marker-less file modified moments ago may still be
+ // mid-write by a concurrent request
+ file_put_contents( $path, $stripped );
+
+ delete_option( Settings::OPTION_CONFIG_BACKUP );
+ delete_site_option( Settings::OPTION_CONFIG_BACKUP );
+
+ \Piwik\Container\StaticContainer::get( \Piwik\Application\Kernel\GlobalSettingsProvider::class )->reload();
+
+ $updater = new Updater( new Settings() );
+ $updater->add_config_end_of_file_marker_if_needed();
+
+ // the possibly in-progress file was not stamped (that would canonicalize a partial
+ // write) and nothing was persisted to the backup either
+ $this->assertSame( $stripped, file_get_contents( $path ) );
+ $this->assertSame( array(), ( new Settings() )->get_config_backup() );
+ }
+
+ public function test_add_config_end_of_file_marker_persists_backup_before_writing_the_marker() {
+ $marker_section = \Piwik\Plugins\WordPress\Overrides\GlobalSettingsProvider::END_OF_FILE_MARKER_SECTION;
+
+ $config = \Piwik\Config::getInstance();
+ $path = $config->getLocalPath();
+
+ $contents = file_get_contents( $path );
+ $stripped = preg_replace(
+ '/\[' . preg_quote( $marker_section, '/' ) . '\].*$/s',
+ '',
+ $contents
+ );
+ file_put_contents( $path, $stripped );
+ touch( $path, time() - \Piwik\Plugins\WordPress\Overrides\GlobalSettingsProvider::INCOMPLETE_FILE_GRACE_PERIOD_SECONDS - 60 );
+
+ delete_option( Settings::OPTION_CONFIG_BACKUP );
+ delete_site_option( Settings::OPTION_CONFIG_BACKUP );
+
+ \Piwik\Container\StaticContainer::get( \Piwik\Application\Kernel\GlobalSettingsProvider::class )->reload();
+
+ // observe the state of the file at the moment the backup option is first written: if the
+ // marker is not in the file yet, the backup was persisted before the risky config write,
+ // so an interrupted marker write leaves a complete backup for the self-heal to restore
+ $file_had_marker_at_first_persist = null;
+ $capture = function ( $value ) use ( &$file_had_marker_at_first_persist, $path, $marker_section ) {
+ if ( null === $file_had_marker_at_first_persist ) {
+ $file_had_marker_at_first_persist = false !== strpos( (string) file_get_contents( $path ), $marker_section );
+ }
+ return $value;
+ };
+ add_filter( 'pre_update_option_' . Settings::OPTION_CONFIG_BACKUP, $capture );
+
+ try {
+ $updater = new Updater( new Settings() );
+ $updater->add_config_end_of_file_marker_if_needed();
+ } finally {
+ remove_filter( 'pre_update_option_' . Settings::OPTION_CONFIG_BACKUP, $capture );
+ }
+
+ $this->assertFalse( $file_had_marker_at_first_persist );
+ $this->assertNotEmpty( ( new Settings() )->get_config_backup() );
+ $this->assertStringContainsString( '[' . $marker_section . ']', file_get_contents( $path ) );
+ }
+
+ /**
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ */
+ public function test_add_config_end_of_file_marker_is_skipped_when_the_feature_is_disabled() {
+ define( 'MATOMO_DISABLE_CONFIG_BACKUP', true );
+
+ $marker_section = \Piwik\Plugins\WordPress\Overrides\GlobalSettingsProvider::END_OF_FILE_MARKER_SECTION;
+
+ $config = \Piwik\Config::getInstance();
+ $path = $config->getLocalPath();
+
+ // a marker-less config file, aged past the grace period (so only the kill switch could
+ // stop it from being stamped)
+ $contents = file_get_contents( $path );
+ $stripped = preg_replace(
+ '/\[' . preg_quote( $marker_section, '/' ) . '\].*$/s',
+ '',
+ $contents
+ );
+ file_put_contents( $path, $stripped );
+ touch( $path, time() - \Piwik\Plugins\WordPress\Overrides\GlobalSettingsProvider::INCOMPLETE_FILE_GRACE_PERIOD_SECONDS - 60 );
+
+ $updater = new Updater( new Settings() );
+ $updater->add_config_end_of_file_marker_if_needed();
+
+ // with the feature disabled the marker is not written (the ScheduledTasks path and this
+ // Updater path now agree on the kill switch)
+ $this->assertSame( $stripped, file_get_contents( $path ) );
+ $this->assertStringNotContainsString( $marker_section, file_get_contents( $path ) );
+ }
}