Linux webm002.cluster126.gra.hosting.ovh.net 5.15.206-ovh-vps-grsec-zfs-classid #1 SMP Fri May 15 02:41:25 UTC 2026 x86_64
/
home
/
a
/
r
/
i
/
ariannadhf
/
www
/
wp-content
/
plugins
/
simple-history
/
loggers
/
/home/a/r/i/ariannadhf/www/wp-content/plugins/simple-history/loggers/class-plugin-logger.php
<?php namespace Simple_History\Loggers; use Simple_History\Event_Details\Event_Details_Container; use Simple_History\Event_Details\Event_Details_Group; use Simple_History\Event_Details\Event_Details_Item; use Simple_History\Helpers; use Simple_History\Log_Initiators; /** * Logs plugin related things, for example installs, updates, and deletions. */ class Plugin_Logger extends Logger { /** * The logger slug. * * @var string $slug */ public $slug = 'SimplePluginLogger'; /** * This variable is set if a plugins has been disabled due to an error, * like when the plugin file does not exist. We need to store this in this * weird way because there is no other way for us to get the reason. * * @var array $latest_plugin_deactivation_because_of_error_reason */ public $latest_plugin_deactivation_because_of_error_reason = []; /** * Array with package results. * The result can contain errors, like if the old plugin could not be removed. * These errors are not added in the final upgrader class response when using bulk update. * So this is the only way to get them. * Used to detect rollback scenarios (WordPress 6.3+ feature). * * Structure: [ * 'plugin-slug/plugin-file.php' => [ * 'result' => array|WP_Error, * 'hook_extra' => array, * 'rollback_will_occur' => bool, * 'rollback_info' => [ * 'backup_slug' => string, * 'backup_dir' => string, * 'error_code' => string, * 'error_message' => string, * ] * ] * ] * * @var array $package_results */ public $package_results = []; /** * Used to collect information about a plugin (using get_plugin_data()) before it is deleted. * Plugin info is stored with plugin file as the key. * * @var array */ protected $plugins_data = array(); /** * Get array with information about this logger * * @return array */ public function get_info() { return array( 'name' => __( 'Plugin Logger', 'simple-history' ), 'description' => __( 'Logs plugin installs, uninstalls and updates', 'simple-history' ), 'capability' => 'activate_plugins', 'messages' => array( 'plugin_activated' => _x( 'Activated plugin "{plugin_name}"', 'Plugin was non-silently activated by a user', 'simple-history' ), 'plugin_deactivated' => _x( 'Deactivated plugin "{plugin_name}"', 'Plugin was non-silently deactivated by a user', 'simple-history' ), 'plugin_installed' => _x( 'Installed plugin "{plugin_name}"', 'Plugin was installed', 'simple-history' ), 'plugin_installed_failed' => _x( 'Failed to install plugin "{plugin_name}"', 'Plugin failed to install', 'simple-history' ), 'plugin_updated' => _x( 'Updated plugin "{plugin_name}" to version {plugin_version} from {plugin_prev_version}', 'Plugin was updated', 'simple-history' ), 'plugin_update_failed' => _x( 'Failed to update plugin "{plugin_name}"', 'Plugin update failed', 'simple-history' ), 'plugin_update_rolled_back' => _x( 'Plugin "{plugin_name}" was rolled back to version {plugin_prev_version} after update to {plugin_version} failed', 'Plugin update was rolled back', 'simple-history' ), 'plugin_deleted' => _x( 'Deleted plugin "{plugin_name}"', 'Plugin files was deleted', 'simple-history' ), // Bulk versions. 'plugin_bulk_updated' => _x( 'Updated plugin "{plugin_name}" to {plugin_version} from {plugin_prev_version}', 'Plugin was updated in bulk', 'simple-history' ), 'plugin_bulk_updated_failed' => _x( 'Failed to update plugin "{plugin_name}"', 'Plugin failed to update in bulk', 'simple-history' ), // Plugin disabled due to some error. 'plugin_disabled_because_error' => _x( 'Deactivated plugin "{plugin_slug}" because of an error ("{deactivation_reason}").', 'Plugin was disabled because of an error', 'simple-history' ), 'plugin_auto_updates_enabled' => _x( 'Enabled auto-updates for plugin "{plugin_name}"', 'Plugin was enabled for auto-updates', 'simple-history' ), 'plugin_auto_updates_disabled' => _x( 'Disabled auto-updates for plugin "{plugin_name}"', 'Plugin was enabled for auto-updates', 'simple-history' ), ), // Messages. 'labels' => array( 'search' => array( 'label' => _x( 'Plugins', 'Plugin logger: search', 'simple-history' ), 'label_all' => _x( 'All plugin activity', 'Plugin logger: search', 'simple-history' ), 'options' => array( _x( 'Activated plugins', 'Plugin logger: search', 'simple-history' ) => array( 'plugin_activated', ), _x( 'Deactivated plugins', 'Plugin logger: search', 'simple-history' ) => array( 'plugin_deactivated', 'plugin_disabled_because_error', ), _x( 'Installed plugins', 'Plugin logger: search', 'simple-history' ) => array( 'plugin_installed', ), _x( 'Failed plugin installs', 'Plugin logger: search', 'simple-history' ) => array( 'plugin_installed_failed', ), _x( 'Updated plugins', 'Plugin logger: search', 'simple-history' ) => array( 'plugin_updated', 'plugin_bulk_updated', ), _x( 'Failed plugin updates', 'Plugin logger: search', 'simple-history' ) => array( 'plugin_update_failed', 'plugin_bulk_updated_failed', ), _x( 'Deleted plugins', 'Plugin logger: search', 'simple-history' ) => array( 'plugin_deleted', ), ), ), // search array. ), // labels. ); } /** * Plugin loaded. */ public function loaded() { /** * At least the plugin bulk upgrades fires this action before upgrade * We use it to fetch the current version of all plugins, before they are upgraded */ add_filter( 'upgrader_pre_install', array( $this, 'save_versions_before_update' ), 10, 2 ); // Fires after a plugin has been activated. // If a plugin is silently activated (such as during an update), // this hook does not fire. add_action( 'activated_plugin', array( $this, 'on_activated_plugin' ), 10, 2 ); // Fires after a plugin is deactivated. // If a plugin is silently deactivated (such as during an update), // this hook does not fire. add_action( 'deactivated_plugin', array( $this, 'on_deactivated_plugin' ), 10, 1 ); // Fires after plugin install/update completes (but BEFORE health check for auto-updates). // Logs: plugin_installed, plugin_updated, plugin_bulk_updated, etc. // Also adds plugin_update_type context: forced_security, user_enabled, or manual. add_action( 'upgrader_process_complete', array( $this, 'on_upgrader_process_complete' ), 10, 2 ); // Fires after automatic updates complete, including any rollbacks (WP 6.3+). // This hook fires AFTER health check, so we can detect rollbacks here. // Logs: plugin_update_rolled_back when fatal error caused rollback. add_action( 'automatic_updates_complete', array( $this, 'on_automatic_updates_complete' ), 10, 1 ); // Ajax function to get info from GitHub repo. Used by "View plugin info"-link for plugin installs. add_action( 'wp_ajax_SimplePluginLogger_GetGitHubPluginInfo', array( $this, 'ajax_GetGitHubPluginInfo' ) ); // If the Github Update plugin is not installed we need to get extra fields used by it. // So need to hook filter "extra_plugin_headers" ourself. add_filter( 'extra_plugin_headers', function ( $arr_headers ) { $arr_headers[] = 'GitHub Plugin URI'; return $arr_headers; } ); // There is no way to use a filter and detect a plugin that is disabled because it can't be found or similar error. // So we hook into gettext and look for the usage of the error that is returned when this happens. // Only register gettext filters and auto-update hooks when on plugins.php page for better performance. add_action( 'load-plugins.php', array( $this, 'on_load_plugins_page' ) ); add_action( 'wp_ajax_toggle-auto-updates', array( $this, 'handle_auto_update_change' ), 1, 1 ); // Log plugin deletions, i.e. when a user click "Delete" in the plugins listing // or choose plugin(s) and select Bulk actions -> Delete. // Since WordPress 4.4 filters exists that are fired before and after plugin deletion. add_action( 'delete_plugin', array( $this, 'on_action_delete_plugin' ), 10, 1 ); add_action( 'deleted_plugin', array( $this, 'on_action_deleted_plugin' ), 10, 2 ); // Capture individual plugin install/update errors. These errors are not included in the final // upgrader response during bulk updates, so this is the only way to get them. add_filter( 'upgrader_install_package_result', [ $this, 'on_upgrader_install_package_result' ], 10, 2 ); } /** * Register gettext filters and handle auto-update when loading the plugins.php page for performance optimization. * Only register these filters and run functionality when they're actually needed. */ public function on_load_plugins_page() { add_filter( 'gettext', array( $this, 'on_gettext_detect_plugin_error_deactivation_reason' ), 10, 3 ); add_filter( 'gettext', array( $this, 'on_gettext' ), 10, 3 ); // Handle auto-update change detection. $this->handle_auto_update_change(); } /** * Filters the result of WP_Upgrader::install_package(). * Fired from WP_Upgrader class (or a subclass like Plugin_Upgrader in the run() function. * Here we can get access to any errors that happen during plugin install/update. * This hook is fired once for each plugin. * * @param array|\WP_Error $result Result from WP_Upgrader::install_package(). * @param array $hook_extra Extra arguments passed to hooked filters. */ public function on_upgrader_install_package_result( $result, $hook_extra ) { /* $result example: WP_Error Object ( [errors] => Array ( [remove_old_failed] => Array ( [0] => Could not remove the old plugin. ) ) [error_data] => Array ( ) [additional_data:protected] => Array ( ) ) $hook_extra example: Array ( [plugin] => classic-widgets/classic-widgets.php [temp_backup] => Array ( [slug] => classic-widgets [src] => /var/www/html/wp-content/plugins [dir] => plugins ) ) */ $plugin_main_file_path = $hook_extra['plugin'] ?? null; if ( ! $plugin_main_file_path ) { return $result; } $this->package_results[ $plugin_main_file_path ] = [ 'result' => $result, 'hook_extra' => $hook_extra, ]; // Detect if rollback will occur (WordPress 6.3+ feature). // Rollback happens when: // 1. This is an update (temp_backup exists in hook_extra) // 2. The update failed (result is WP_Error) // When both conditions are true, WordPress will automatically restore // the previous version from the temporary backup on shutdown. $is_update = isset( $hook_extra['temp_backup'] ); $has_error = is_wp_error( $result ); if ( $is_update && $has_error ) { $this->package_results[ $plugin_main_file_path ]['rollback_will_occur'] = true; $this->package_results[ $plugin_main_file_path ]['rollback_info'] = [ 'backup_slug' => $hook_extra['temp_backup']['slug'] ?? '', 'backup_dir' => $hook_extra['temp_backup']['dir'] ?? '', 'error_code' => $result->get_error_code(), 'error_message' => $result->get_error_message(), ]; } return $result; } /** * Store information about a plugin before it gets deleted. * Called from action `deleted_plugin` that is fired just before the plugin will be deleted. * * @param string $plugin_file Path to the plugin file relative to the plugins directory. * @return void */ public function on_action_delete_plugin( $plugin_file ) { $this->plugins_data[ $plugin_file ] = get_plugin_data( WP_PLUGIN_DIR . '/' . $plugin_file, true, false ); } /** * Log plugin deletion. * Called from action `deleted_plugin` that is fired just after a plugin has been deleted. * * @param string $plugin_file Path to the plugin file relative to the plugins directory. * @param bool $deleted Whether the plugin deletion was successful. * @return void */ public function on_action_deleted_plugin( $plugin_file, $deleted ) { if ( ! $deleted ) { return; } if ( empty( $this->plugins_data[ $plugin_file ] ) ) { return; } $plugin_data = $this->plugins_data[ $plugin_file ]; $context = array( 'plugin' => $plugin_file, 'plugin_name' => $plugin_data['Name'], 'plugin_title' => $plugin_data['Title'], 'plugin_description' => $plugin_data['Description'], 'plugin_author' => $plugin_data['Author'], 'plugin_version' => $plugin_data['Version'], 'plugin_url' => $plugin_data['PluginURI'], ); $this->info_message( 'plugin_deleted', $context ); } /** * Detect when a plugin is enable or disabled to be auto updated. * This can be changed from the plugins-page, either from a GET request to * the plugins-page or via an AJAX call. * * The result of the action is stored in * site_option 'auto_update_plugins'. * Check the value of that option after the option is updated. */ public function handle_auto_update_change() { $option = 'auto_update_plugins'; add_action( "update_option_{$option}", // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed function ( $old_value, $value, $option ) { /** * Option contains array with plugin that are set to be auto updated. * Example: * Array * ( * [1] => query-monitor/query-monitor.php * [2] => akismet/akismet.php * [3] => wp-crontrol/wp-crontrol.php * [4] => redirection/redirection.php * ) * * $_GET when opening single item enable/disable auto update link in plugin list in new window * Array * ( * [action] => disable-auto-update | enable-auto-update * [plugin] => akismet/akismet.php * ) * * * $_POST from ajax call when clicking single item enable/disable link in plugin list * [action] => toggle-auto-updates * [state] => disable | enable * [type] => plugin * [asset] => redirection/redirection.php * * * $_POST when selecting multiple plugins and choosing Enable auto updates or Disable auto updates * [action] => enable-auto-update-selected | disable-auto-update-selected * [checked] => Array * ( * [0] => query-monitor/query-monitor.php * [1] => redirection/redirection.php * ) */ // phpcs:ignore WordPress.Security.NonceVerification.Recommended $action = sanitize_text_field( wp_unslash( $_GET['action'] ?? '' ) ); if ( ! $action ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing $action = sanitize_text_field( wp_unslash( $_POST['action'] ?? '' ) ); } // Bail if doing ajax and // - action is not toggle-auto-updates. // - type is not plugin. if ( wp_doing_ajax() ) { if ( $action !== 'toggle-auto-updates' ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Missing $type = sanitize_text_field( wp_unslash( $_POST['type'] ?? '' ) ); if ( $type !== 'plugin' ) { return; } } // Bail if screen and not plugin screen. $current_screen = get_current_screen(); if ( is_a( $current_screen, 'WP_Screen' ) && ( $current_screen->base !== 'plugins' ) ) { return; } // Enable or disable, string "enable" or "disable". $enableOrDisable = null; // Plugin slugs that actions are performed against. $plugins = array(); if ( in_array( $action, array( 'enable-auto-update', 'disable-auto-update' ), true ) ) { // Opening single item enable/disable auto update link in plugin list in new window. // phpcs:ignore WordPress.Security.NonceVerification.Recommended $plugin = sanitize_text_field( wp_unslash( $_GET['plugin'] ?? '' ) ); if ( $plugin ) { $plugins[] = sanitize_text_field( urldecode( $plugin ) ); } if ( $action === 'enable-auto-update' ) { $enableOrDisable = 'enable'; } elseif ( $action === 'disable-auto-update' ) { $enableOrDisable = 'disable'; } } elseif ( $action === 'toggle-auto-updates' ) { // Ajax post call when clicking single item enable/disable link in plugin list. // * [action] => toggle-auto-updates // * [state] => disable | enable // * [type] => plugin // * [asset] => redirection/redirection.php. // phpcs:ignore WordPress.Security.NonceVerification.Missing $state = sanitize_text_field( wp_unslash( $_POST['state'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing $asset = sanitize_text_field( wp_unslash( $_POST['asset'] ?? '' ) ); if ( $state === 'enable' ) { $enableOrDisable = 'enable'; } elseif ( $state === 'disable' ) { $enableOrDisable = 'disable'; } if ( $asset ) { $plugins[] = sanitize_text_field( urldecode( $asset ) ); } } elseif ( in_array( $action, array( 'enable-auto-update-selected', 'disable-auto-update-selected' ), true ) ) { // $_POST when checking multiple plugins and choosing Enable auto updates or Disable auto updates. // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $checked = wp_unslash( $_POST['checked'] ?? null ); if ( $checked ) { $plugins = (array) $checked; } if ( $action === 'enable-auto-update-selected' ) { $enableOrDisable = 'enable'; } elseif ( $action === 'disable-auto-update-selected' ) { $enableOrDisable = 'disable'; } } // Now we have: // - an array of plugin slugs in $plugins. // - if plugin auto updates is to be enabled or disabled din $enableOrDisable. // Bail if required values not set. if ( ! $plugins || ! $enableOrDisable ) { return; } // Finally log each plugin. foreach ( $plugins as $onePluginSlug ) { $this->logPluginAutoUpdateEnableOrDisable( $onePluginSlug, $enableOrDisable ); } }, 10, 3 ); } /** * Log plugin that is enable or disabled for auto updates. * * @param string $onePluginSlug slug of plugin, i.e. "hello-dolly/hello.php". * @param string $enableOrDisable String "enable" or "disable". */ public function logPluginAutoUpdateEnableOrDisable( $onePluginSlug, $enableOrDisable ) { $pluginFile = WP_PLUGIN_DIR . '/' . $onePluginSlug; $pluginData = get_plugin_data( $pluginFile, true, false ); $context = array( 'plugin_slug' => $onePluginSlug, 'plugin_name' => $pluginData['Name'] ?? null, 'plugin_version' => $pluginData['Version'] ?? null, ); if ( $enableOrDisable === 'enable' ) { $this->info_message( 'plugin_auto_updates_enabled', $context ); } elseif ( $enableOrDisable === 'disable' ) { $this->info_message( 'plugin_auto_updates_disabled', $context ); } } /** * Detect when a plugin is deactivated due to an error, like if the plugin file has been deleted. * * @param string $translation Translation. * @param string $text Text. * @param string $domain Domain. */ public function on_gettext_detect_plugin_error_deactivation_reason( $translation, $text, $domain ) { global $pagenow; // We only act on page plugins.php. if ( ! isset( $pagenow ) || $pagenow !== 'plugins.php' ) { return $translation; } // We only act if the untranslated text is among the following ones // (Literally these, no translation). $untranslated_texts = array( 'Plugin file does not exist.', 'Invalid plugin path.', 'The plugin does not have a valid header.', ); if ( ! in_array( $text, $untranslated_texts, true ) ) { return $translation; } // Text was among our wanted texts. switch ( $text ) { case 'Plugin file does not exist.': $this->latest_plugin_deactivation_because_of_error_reason[] = 'file_does_not_exist'; break; case 'Invalid plugin path.': $this->latest_plugin_deactivation_because_of_error_reason[] = 'invalid_path'; break; case 'The plugin does not have a valid header.': $this->latest_plugin_deactivation_because_of_error_reason[] = 'no_valid_header'; break; } return $translation; } /** * There is no way to use a filter and detect a plugin that is disabled because it can't be found or similar error. * we hook into gettext and look for the usage of the error that is returned when this happens. * * A plugin gets deactivated when plugins.php is visited function validate_active_plugins() * return new WP_Error('plugin_not_found', __('Plugin file does not exist.')); * and if invalid plugin is found then this is outputted * printf( * /* translators: 1: plugin file 2: error message * __( 'The plugin %1$s has been <strong>deactivated</strong> due to an error: %2$s' ), * '<code>' . esc_html( $plugin_file ) . '</code>', * $error->get_error_message() ); * * @param string $translation Translation. * @param string $text Text. * @param string $domain Domain. */ public function on_gettext( $translation, $text, $domain ) { global $pagenow; // We only act on page plugins.php. if ( ! isset( $pagenow ) || $pagenow !== 'plugins.php' ) { return $translation; } // We only act if the untranslated text is among the following ones // (Literally these, no translation). $untranslated_texts = array( // This string is called later than the above. 'The plugin %1$s has been <strong>deactivated</strong> due to an error: %2$s', ); if ( ! in_array( $text, $untranslated_texts, true ) ) { return $translation; } // Directly after the string is translated 'esc_html' is called with the plugin name. // This is one of the few ways we can get the name of the plugin. // The esc_html filter is used pretty much but we make sure we only do our. // stuff the first time it's called (directly after the gettet for the plugin disabled-error..). $logger_instance = $this; add_filter( 'esc_html', function ( $safe_text, $text ) use ( $logger_instance ) { static $is_called = false; if ( $is_called === false ) { $is_called = true; $deactivation_reason = array_shift( $logger_instance->latest_plugin_deactivation_because_of_error_reason ); // We don't know what plugin that was that got this error and currently there does not seem to be a way to determine that. // So that's why we use such generic log messages. $logger_instance->warning_message( 'plugin_disabled_because_error', array( '_initiator' => Log_Initiators::WORDPRESS, 'plugin_slug' => $text, 'deactivation_reason' => $deactivation_reason, ) ); } return $safe_text; }, 10, 2 ); return $translation; } /** * Show readme from github in a modal win */ public function ajax_GetGitHubPluginInfo() { check_admin_referer( 'simple-history-github-plugin-info' ); if ( ! current_user_can( 'install_plugins' ) ) { wp_die( esc_html__( "You don't have access to this page.", 'simple-history' ) ); } $repo = isset( $_GET['repo'] ) ? (string) sanitize_text_field( wp_unslash( $_GET['repo'] ) ) : ''; if ( $repo === '' ) { wp_die( esc_html__( 'Could not find GitHub repository.', 'simple-history' ) ); } $repo_parts = explode( '/', rtrim( $repo, '/' ) ); if ( count( $repo_parts ) !== 5 ) { wp_die( esc_html__( 'Could not find GitHub repository.', 'simple-history' ) ); } $repo_username = $repo_parts[3]; $repo_repo = $repo_parts[4]; // https://developer.github.com/v3/repos/contents/. // https://api.github.com/repos/<username>/<repo>/readme. $api_url = sprintf( 'https://api.github.com/repos/%1$s/%2$s/readme', rawurlencode( $repo_username ), rawurlencode( $repo_repo ) ); // Get file. Use accept-header to get file as HTML instead of JSON. // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.wp_remote_get_wp_remote_get $response = wp_remote_get( $api_url, array( 'headers' => array( 'accept' => 'application/vnd.github.VERSION.html', ), ) ); $response_body = wp_remote_retrieve_body( $response ); $repo_info = '<p>' . sprintf( // translators: %1$s is a link to the repo, %2$s is the repo name. __( 'Viewing <code>readme</code> from repository <code><a target="_blank" href="%1$s">%2$s</a></code>.', 'simple-history' ), esc_url( $repo ), esc_html( $repo ) ) . '</p>'; $github_markdown_css_path = SIMPLE_HISTORY_PATH . '/css/github-markdown.css'; $escaped_response_body = wp_kses( $response_body, array( 'p' => array(), 'div' => array(), 'h1' => array(), 'h2' => array(), 'h3' => array(), 'code' => array(), 'a' => array( 'href' => array(), ), 'img' => array( 'src' => array(), ), 'ul' => array(), 'li' => array(), ) ); printf( ' <!doctype html> <style> body { font-family: sans-serif; font-size: 16px; } .repo-info { padding: 1.25em 1em; background: #fafafa; line-height: 1; } .repo-info p { margin: 0; } .markdown-body { min-width: 200px; max-width: 790px; margin: 0 auto; padding: 30px; } @import url("%3$s"); </style> <base href="%4$s/raw/master/"> <base target="_blank"> <header class="repo-info"> %1$s </header> <div class="markdown-body readme-contents"> %2$s </div> ', $repo_info, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped $escaped_response_body, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped esc_url( $github_markdown_css_path ), // 3 esc_url( $repo ) // 4 ); exit; } /** * Saves info about all installed plugins to an option. * When we are done logging then we remove the option. * Fired from filter `upgrader_pre_install`. * * @param bool|\WP_Error $bool_or_error Default null. * @param array $hook_extra Default null. */ public function save_versions_before_update( $bool_or_error = null, $hook_extra = null ) { update_option( $this->get_slug() . '_plugin_info_before_update', Helpers::json_encode( get_plugins() ), false ); return $bool_or_error; } /** * Called when plugins is updated or installed * Called from class-wp-upgrader.php * * @param \Plugin_Upgrader $plugin_upgrader_instance Plugin_Upgrader instance. In other contexts, $this, might * be a Theme_Upgrader or Core_Upgrade instance. * @param array $arr_data Array of bulk item update data. */ public function on_upgrader_process_complete( $plugin_upgrader_instance, $arr_data ) { // Bail if not plugin update data. if ( ! isset( $arr_data['type'] ) || $arr_data['type'] !== 'plugin' ) { return; } $this->on_upgrader_process_complete_log_single_plugin_install( $plugin_upgrader_instance, $arr_data ); $this->on_upgrader_process_complete_log_single_plugin_update( $plugin_upgrader_instance, $arr_data ); $this->on_upgrader_process_complete_log_bulk_plugin_update( $plugin_upgrader_instance, $arr_data ); } /** * Log single, non bulk, plugin update or update failure. * Not sure when this happens anymore, I always get bulk results, no matter where I * update from. * * @param \Plugin_Upgrader $plugin_upgrader_instance Plugin_Upgrader instance. * @param array $arr_data Array of bulk item update data. */ protected function on_upgrader_process_complete_log_single_plugin_update( $plugin_upgrader_instance, $arr_data ) { // Bail if not single plugin non bulk update. if ( ! isset( $arr_data['action'] ) || $arr_data['action'] !== 'update' || $plugin_upgrader_instance->bulk ) { return; } // No plugin info in instance, so get it ourself. $plugin_data = []; if ( file_exists( WP_PLUGIN_DIR . '/' . $arr_data['plugin'] ) ) { $plugin_data = get_plugin_data( WP_PLUGIN_DIR . '/' . $arr_data['plugin'], true, false ); } // Fall back to pre-update stored data when get_plugin_data() returns empty Name. // Custom updaters (like Code Profiler Pro) may not have the file in place yet. $plugins_before_update = json_decode( get_option( $this->get_slug() . '_plugin_info_before_update', false ), true ); if ( empty( $plugin_data['Name'] ) && is_array( $plugins_before_update ) && isset( $plugins_before_update[ $arr_data['plugin'] ] ) ) { $plugin_data = $plugins_before_update[ $arr_data['plugin'] ]; } // autoptimize/autoptimize.php. $plugin_slug = dirname( $arr_data['plugin'] ); $context = [ 'plugin_slug' => $plugin_slug, 'request' => Helpers::json_encode( $_REQUEST ), // phpcs:ignore WordPress.Security.NonceVerification.Recommended 'plugin_name' => $plugin_data['Name'] ?? '', 'plugin_title' => $plugin_data['Title'] ?? '', 'plugin_description' => $plugin_data['Description'] ?? '', 'plugin_author' => $plugin_data['Author'] ?? '', 'plugin_version' => $plugin_data['Version'] ?? '', 'plugin_url' => $plugin_data['PluginURI'] ?? '', 'plugin_was_active' => is_plugin_active( $arr_data['plugin'] ) ? '1' : '0', ]; // Add Update URI if it is set. Available since WP 5.8. if ( isset( $plugin_data['UpdateURI'] ) ) { $context['plugin_update_uri'] = $plugin_data['UpdateURI']; } // update status for plugins are in response // plugin folder + index file = key // use transient to get url and package. $update_plugins = get_site_transient( 'update_plugins' ); if ( $update_plugins && isset( $update_plugins->response[ $arr_data['plugin'] ] ) ) { $plugin_update_info = $update_plugins->response[ $arr_data['plugin'] ]; // autoptimize/autoptimize.php. if ( isset( $plugin_update_info->plugin ) ) { $context['plugin_update_info_plugin'] = $plugin_update_info->plugin; } // https://downloads.wordpress.org/plugin/autoptimize.1.9.1.zip. if ( isset( $plugin_update_info->package ) ) { $context['plugin_update_info_package'] = $plugin_update_info->package; } // Upgrade notice from WordPress.org, e.g. "Version X contains security fixes...". if ( ! empty( $plugin_update_info->upgrade_notice ) ) { $context['plugin_upgrade_notice'] = $plugin_update_info->upgrade_notice; } } // To get old version we use the pre-update data (already fetched above for name fallback). if ( is_array( $plugins_before_update ) && isset( $plugins_before_update[ $arr_data['plugin'] ] ) ) { $context['plugin_prev_version'] = $plugins_before_update[ $arr_data['plugin'] ]['Version']; } // Add update type context (forced_security, user_enabled, manual). $context = $this->add_update_type_context( $context, $arr_data['plugin'], $update_plugins ); if ( is_a( $plugin_upgrader_instance->skin->result, 'WP_Error' ) ) { // Add errors // Errors is in original wp admin language. // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode $context['error_messages'] = json_encode( $plugin_upgrader_instance->skin->result->errors ); // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode $context['error_data'] = json_encode( $plugin_upgrader_instance->skin->result->error_data ); // Add rollback context if rollback will occur. $context = $this->add_rollback_context( $context, $arr_data['plugin'] ); $this->info_message( 'plugin_update_failed', $context ); } elseif ( ( $context['plugin_update_type'] ?? '' ) === 'forced_security' ) { // Use notice level for forced security updates (WordPress.org override). $this->notice_message( 'plugin_updated', $context ); } else { $this->info_message( 'plugin_updated', $context ); } } /** * Log bulk plugin update. * * @param \Plugin_Upgrader $plugin_upgrader_instance Plugin_Upgrader instance. * @param array $arr_data { * Array of bulk item update data. * * @type string $action Type of action. Default 'update'. * @type string $type Type of update process. Accepts 'plugin', 'theme', 'translation', or 'core'. * @type bool $bulk Whether the update process is a bulk update. Default true. * @type array $plugins Array of the basename paths of the plugins' main files. * @type array $themes The theme slugs. * @type array $translations { * Array of translations update data. * * @type string $language The locale the translation is for. * @type string $type Type of translation. Accepts 'plugin', 'theme', or 'core'. * @type string $slug Text domain the translation is for. The slug of a theme/plugin or * 'default' for core translations. * @type string $version The version of a theme, plugin, or core. * } * } */ protected function on_upgrader_process_complete_log_bulk_plugin_update( $plugin_upgrader_instance, $arr_data ) { // Bail if not bulk plugin update. if ( ! isset( $arr_data['bulk'] ) || ! $arr_data['bulk'] || ! isset( $arr_data['action'] ) || $arr_data['action'] !== 'update' ) { return; } $plugins_updated = isset( $arr_data['plugins'] ) ? (array) $arr_data['plugins'] : []; $plugins_before_update = json_decode( get_option( $this->get_slug() . '_plugin_info_before_update', false ), true ); /** @var string $plugin_main_file_path Plugin folder and main file, i.e. classic-widgets/classic-widgets.php */ foreach ( $plugins_updated as $plugin_main_file_path ) { $plugin_data = []; if ( file_exists( WP_PLUGIN_DIR . '/' . $plugin_main_file_path ) ) { $plugin_data = get_plugin_data( WP_PLUGIN_DIR . '/' . $plugin_main_file_path, true, false ); } // Fall back to pre-update stored data when get_plugin_data() returns empty Name. // Custom updaters (like Code Profiler Pro) may not have the file in place yet. if ( empty( $plugin_data['Name'] ) && is_array( $plugins_before_update ) && isset( $plugins_before_update[ $plugin_main_file_path ] ) ) { $plugin_data = $plugins_before_update[ $plugin_main_file_path ]; } $plugin_slug = dirname( $plugin_main_file_path ); $context = [ 'plugin_main_file_path' => $plugin_main_file_path, 'plugin_slug' => $plugin_slug, 'plugin_name' => $plugin_data['Name'] ?? '', 'plugin_title' => $plugin_data['Title'] ?? '', 'plugin_description' => $plugin_data['Description'] ?? '', 'plugin_author' => $plugin_data['Author'] ?? '', 'plugin_version' => $plugin_data['Version'] ?? '', 'plugin_url' => $plugin_data['PluginURI'] ?? '', 'plugin_was_active' => is_plugin_active( $plugin_main_file_path ) ? '1' : '0', ]; // Add Update URI if it is set. Available since WP 5.8. if ( isset( $plugin_data['UpdateURI'] ) ) { $context['plugin_update_uri'] = $plugin_data['UpdateURI']; } // Get url and package. $update_plugins = get_site_transient( 'update_plugins' ); if ( $update_plugins && isset( $update_plugins->response[ $plugin_main_file_path ] ) ) { // phpcs:ignore Squiz.PHP.CommentedOutCode.Found, Squiz.Commenting.BlockComment.NoEmptyLineBefore /* $update_plugins[plugin_path/slug]: { "id": "8986", "slug": "autoptimize", "plugin": "autoptimize/autoptimize.php", "new_version": "1.9.1", "url": "https://wordpress.org/plugins/autoptimize/", "package": "https://downloads.wordpress.org/plugin/autoptimize.1.9.1.zip" } */ $plugin_update_info = $update_plugins->response[ $plugin_main_file_path ]; // autoptimize/autoptimize.php. if ( isset( $plugin_update_info->plugin ) ) { $context['plugin_update_info_plugin'] = $plugin_update_info->plugin; } // https://downloads.wordpress.org/plugin/autoptimize.1.9.1.zip. if ( isset( $plugin_update_info->package ) ) { $context['plugin_update_info_package'] = $plugin_update_info->package; } // Upgrade notice from WordPress.org, e.g. "Version X contains security fixes...". if ( ! empty( $plugin_update_info->upgrade_notice ) ) { $context['plugin_upgrade_notice'] = $plugin_update_info->upgrade_notice; } } // To get old version we use the pre-update data (already fetched above for name fallback). if ( is_array( $plugins_before_update ) && isset( $plugins_before_update[ $plugin_main_file_path ] ) ) { $context['plugin_prev_version'] = $plugins_before_update[ $plugin_main_file_path ]['Version']; } // Add update type context (forced_security, user_enabled, manual). $context = $this->add_update_type_context( $context, $plugin_main_file_path, $update_plugins ); $plugin_errors = $this->package_results[ $plugin_main_file_path ]['result']->errors ?? []; if ( count( $plugin_errors ) > 0 ) { $context['plugin_errors'] = $plugin_errors; // Add rollback context if rollback will occur. $context = $this->add_rollback_context( $context, $plugin_main_file_path ); $this->warning_message( 'plugin_bulk_updated_failed', $context ); } elseif ( ( $context['plugin_update_type'] ?? '' ) === 'forced_security' ) { // Use notice level for forced security updates (WordPress.org override). $this->notice_message( 'plugin_bulk_updated', $context ); } else { $this->info_message( 'plugin_bulk_updated', $context ); } } } /** * Log single, non bulk, plugin install or install failure. * * @param \Plugin_Upgrader $plugin_upgrader_instance Plugin_Upgrader instance. * @param array $arr_data Array of bulk item update data. */ protected function on_upgrader_process_complete_log_single_plugin_install( $plugin_upgrader_instance, $arr_data ) { // Bail if not single plugin install data. if ( ! isset( $arr_data['action'] ) || $arr_data['action'] !== 'install' || $plugin_upgrader_instance->bulk ) { return; } $upgrader_skin_options = isset( $plugin_upgrader_instance->skin->options ) && is_array( $plugin_upgrader_instance->skin->options ) ? $plugin_upgrader_instance->skin->options : array(); $upgrader_skin_result = isset( $plugin_upgrader_instance->skin->result ) && is_array( $plugin_upgrader_instance->skin->result ) ? $plugin_upgrader_instance->skin->result : array(); $new_plugin_data = $plugin_upgrader_instance->new_plugin_data ?? array(); $plugin_slug = $upgrader_skin_result['destination_name'] ?? ''; $context = [ 'plugin_slug' => $plugin_slug, 'plugin_name' => $new_plugin_data['Name'] ?? '', 'plugin_url' => $new_plugin_data['PluginURI'] ?? '', 'plugin_version' => $new_plugin_data['Version'] ?? '', 'plugin_author' => $new_plugin_data['Author'] ?? '', 'plugin_requires_wp' => $new_plugin_data['RequiresWP'] ?? '', 'plugin_requires_php' => $new_plugin_data['RequiresPHP'] ?? '', ]; // Add Update URI if it is set. Available since WP 5.8. if ( isset( $new_plugin_data['UpdateURI'] ) ) { $context['plugin_update_uri'] = $new_plugin_data['UpdateURI']; } /* Detect install plugin from wordpress.org - options[type] = "web" - options[api] contains all we need Detect install from upload ZIP - options[type] = "upload" Also: plugins hosted at GitHub have a de-facto standard field of "GitHub Plugin URI" */ $install_source = 'web'; if ( isset( $upgrader_skin_options['type'] ) ) { $install_source = (string) $upgrader_skin_options['type']; } $context['plugin_install_source'] = $install_source; // If uploaded plugin store name of ZIP. // phpcs:ignore WordPress.Security.NonceVerification.Missing if ( $install_source === 'upload' && isset( $_FILES['pluginzip']['name'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing $plugin_upload_name = sanitize_text_field( $_FILES['pluginzip']['name'] ); $context['plugin_upload_name'] = $plugin_upload_name; } if ( is_a( $plugin_upgrader_instance->skin->result, 'WP_Error' ) ) { // Add errors // Errors is in original wp admin language. $context['error_messages'] = Helpers::json_encode( $plugin_upgrader_instance->skin->result->errors ); $context['error_data'] = Helpers::json_encode( $plugin_upgrader_instance->skin->result->error_data ); $this->info_message( 'plugin_installed_failed', $context ); } else { // Plugin was successfully installed // Try to grab more info from the readme // Would be nice to grab a screenshot, but that is difficult since they often are stored remotely. $plugin_destination = $plugin_upgrader_instance->result['destination'] ?? null; if ( $plugin_destination ) { $plugin_info = $plugin_upgrader_instance->plugin_info(); $plugin_data = array(); if ( file_exists( WP_PLUGIN_DIR . '/' . $plugin_info ) ) { $plugin_data = get_plugin_data( WP_PLUGIN_DIR . '/' . $plugin_info, true, false ); } $context['plugin_name'] = $plugin_data['Name'] ?? ''; $context['plugin_description'] = $plugin_data['Description'] ?? ''; $context['plugin_url'] = $plugin_data['PluginURI'] ?? ''; $context['plugin_version'] = $plugin_data['Version'] ?? ''; $context['plugin_author'] = $plugin_data['AuthorName'] ?? ''; if ( ! empty( $plugin_data['GitHub Plugin URI'] ) ) { $context['plugin_github_url'] = $plugin_data['GitHub Plugin URI']; // @phpstan-ignore-line offsetAccess.notFound } } $this->info_message( 'plugin_installed', $context ); } } /** * Log plugin updates that were rolled back. * * WordPress 6.3+ has automatic rollback for plugin updates that cause fatal errors. * The upgrader_process_complete hook fires BEFORE the health check, so we need * to hook into automatic_updates_complete to detect rollbacks that happen AFTER. * * Timeline of a failed update with rollback: * 1. Plugin files updated to new version * 2. upgrader_process_complete fires → Simple History logs "Updated to X.X.X" * 3. WordPress performs loopback health check * 4. Health check fails (fatal error detected) * 5. WordPress restores from temp backup (rollback) * 6. automatic_updates_complete fires → This method logs the rollback * * WordPress uses these error codes for rollbacks (defined in class-wp-automatic-updater.php): * - plugin_update_fatal_error_rollback_successful: Update caused fatal, rollback succeeded * - plugin_update_fatal_error_rollback_failed: Update caused fatal, rollback also failed * * @see readme.issue-608-alerts.md for investigation details and testing instructions. * @see wp-admin/includes/class-wp-automatic-updater.php lines 566-619 for rollback code. * * @param array $update_results Results of all update attempts. */ public function on_automatic_updates_complete( $update_results ) { if ( empty( $update_results['plugin'] ) ) { return; } $rollback_error_codes = [ 'plugin_update_fatal_error_rollback_successful', 'plugin_update_fatal_error_rollback_failed', ]; foreach ( $update_results['plugin'] as $update ) { if ( ! is_wp_error( $update->result ) ) { continue; } $error_code = $update->result->get_error_code(); if ( ! in_array( $error_code, $rollback_error_codes, true ) ) { continue; } $plugin_file = $update->item->plugin ?? ''; $plugin_data = []; if ( $plugin_file && file_exists( WP_PLUGIN_DIR . '/' . $plugin_file ) ) { $plugin_data = get_plugin_data( WP_PLUGIN_DIR . '/' . $plugin_file, true, false ); } $rollback_successful = ( $error_code === 'plugin_update_fatal_error_rollback_successful' ); $context = [ 'plugin_slug' => dirname( $plugin_file ), 'plugin_name' => $plugin_data['Name'] ?? $plugin_file, 'plugin_prev_version' => $plugin_data['Version'] ?? '', 'plugin_version' => $update->item->new_version ?? '', 'rollback_successful' => $rollback_successful ? '1' : '0', 'error_message' => $update->result->get_error_message(), ]; $this->warning_message( 'plugin_update_rolled_back', $context ); } } /** * Plugin is activated * plugin_name is like admin-menu-tree-page-view/index.php * * @param string $plugin_name Plugin name. * @param bool $network_wide Network wide. */ public function on_activated_plugin( $plugin_name, $network_wide ) { $plugin_data = get_plugin_data( WP_PLUGIN_DIR . '/' . $plugin_name, true, false ); $plugin_slug = dirname( $plugin_name ); $context = array( 'plugin_name' => $plugin_data['Name'], 'plugin_slug' => $plugin_slug, 'plugin_title' => $plugin_data['Title'], 'plugin_description' => $plugin_data['Description'], 'plugin_author' => $plugin_data['Author'], 'plugin_version' => $plugin_data['Version'], 'plugin_url' => $plugin_data['PluginURI'], ); if ( ! empty( $plugin_data['GitHub Plugin URI'] ) ) { $context['plugin_github_url'] = $plugin_data['GitHub Plugin URI']; // @phpstan-ignore-line offsetAccess.notFound } $this->info_message( 'plugin_activated', $context ); } /** * Plugin is deactivated * plugin_name is like admin-menu-tree-page-view/index.php * * @param string $plugin_name Plugin name. */ public function on_deactivated_plugin( $plugin_name ) { $plugin_data = get_plugin_data( WP_PLUGIN_DIR . '/' . $plugin_name, true, false ); $plugin_slug = dirname( $plugin_name ); $context = array( 'plugin_name' => $plugin_data['Name'], 'plugin_slug' => $plugin_slug, 'plugin_title' => $plugin_data['Title'], 'plugin_description' => $plugin_data['Description'], 'plugin_author' => $plugin_data['Author'], 'plugin_version' => $plugin_data['Version'], 'plugin_url' => $plugin_data['PluginURI'], ); if ( ! empty( $plugin_data['GitHub Plugin URI'] ) ) { $context['plugin_github_url'] = $plugin_data['GitHub Plugin URI']; // @phpstan-ignore-line offsetAccess.notFound } $this->info_message( 'plugin_deactivated', $context ); } /** * Get output for detailed log section * * @param object $row Log row. */ public function get_log_row_details_output( $row ) { $context = $row->context; $message_key = $context['_message_key']; switch ( $message_key ) { case 'plugin_installed': return $this->get_plugin_installed_details_group( $context ); case 'plugin_bulk_updated': case 'plugin_updated': return $this->get_plugin_updated_details_group( $context ); case 'plugin_activated': case 'plugin_deactivated': return ''; } return ''; } /** * Get action links for a log row. * * Shows "View plugin info" or "View changelog" thickbox links. * * @param object $row Log row object. * @return array Array of action link arrays. */ /** * Failure-path events store the underlying error in context (error_code, * error_message, plus the raw upgrader package_result for rollbacks). * Surface a "Show error message" link so users can open the modal and * see what actually went wrong without hunting through the row dropdown. * * @param object $row Log row object. * @return string|false */ public function event_has_more_details( $row ) { $context = $row->context; $message_key = $context['_message_key'] ?? ''; $failure_message_keys = array( 'plugin_installed_failed', 'plugin_update_failed', 'plugin_bulk_updated_failed', 'plugin_disabled_because_error', ); if ( ! in_array( $message_key, $failure_message_keys, true ) ) { return false; } // Only show the link when there's actually an error payload to read. // Older failure events sometimes lack these keys, in which case the // modal wouldn't reveal anything useful. $has_error_payload = ! empty( $context['error_message'] ) || ! empty( $context['error_code'] ) || ! empty( $context['package_result_errors'] ) || ! empty( $context['deactivation_reason'] ); if ( ! $has_error_payload ) { return false; } return __( 'Show error message', 'simple-history' ); } public function get_action_links( $row ) { $context = $row->context; $message_key = $context['_message_key'] ?? ''; $plugin_slug = empty( $context['plugin_slug'] ) ? '' : $context['plugin_slug']; // GitHub-hosted plugins use a different thickbox URL. if ( ! empty( $context['plugin_github_url'] ) ) { return [ [ 'url' => wp_nonce_url( admin_url( sprintf( 'admin-ajax.php?action=SimplePluginLogger_GetGitHubPluginInfo&getrepo&repo=%1$s&TB_iframe=true&width=640&height=550', rawurlencode( $context['plugin_github_url'] ) ) ), 'simple-history-github-plugin-info' ), 'label' => _x( 'View plugin info', 'plugin logger: plugin info thickbox title view all info', 'simple-history' ), 'action' => 'view', ], ]; } if ( ! $plugin_slug ) { return []; } if ( in_array( $message_key, [ 'plugin_updated', 'plugin_bulk_updated' ], true ) ) { $url = is_multisite() ? network_admin_url( "plugin-install.php?tab=plugin-information&plugin={$plugin_slug}§ion=changelog&TB_iframe=true&width=772&height=550" ) : admin_url( "plugin-install.php?tab=plugin-information&plugin={$plugin_slug}§ion=changelog&TB_iframe=true&width=772&height=550" ); return [ [ 'url' => $url, 'label' => _x( 'View changelog', 'plugin logger: plugin info thickbox title', 'simple-history' ), 'action' => 'view', ], ]; } if ( in_array( $message_key, [ 'plugin_installed', 'plugin_activated', 'plugin_deactivated' ], true ) ) { return [ [ 'url' => admin_url( "plugin-install.php?tab=plugin-information&plugin={$plugin_slug}§ion=&TB_iframe=true&width=640&height=550" ), 'label' => _x( 'View plugin info', 'plugin logger: plugin info thickbox title view all info', 'simple-history' ), 'action' => 'view', ], ]; } return []; } /** * Get Event_Details_Group for installed plugin details. * * @param array $context Log context. * @return Event_Details_Group|string */ private function get_plugin_installed_details_group( $context ) { if ( ! isset( $context['plugin_description'] ) ) { return ''; } // Description includes a link to author after <cite>; strip it and any HTML. $plugin_description = $context['plugin_description']; $cite_pos = strpos( $plugin_description, '<cite>' ); if ( $cite_pos !== false ) { $plugin_description = substr( $plugin_description, 0, $cite_pos ); } $plugin_description = trim( wp_strip_all_tags( $plugin_description ) ); // Author header is often wrapped in <a href="...">Name</a> by WP — flatten to plain text. $plugin_author = isset( $context['plugin_author'] ) ? trim( wp_strip_all_tags( $context['plugin_author'] ) ) : ''; // Translate install source codes to readable labels. $install_source = $context['plugin_install_source'] ?? ''; $install_source_label = ''; if ( $install_source === 'web' ) { $install_source_label = __( 'WordPress Plugin Repository', 'simple-history' ); } elseif ( $install_source === 'upload' ) { $install_source_label = __( 'Uploaded ZIP archive', 'simple-history' ); } elseif ( $install_source !== '' ) { $install_source_label = $install_source; } // Source filename only meaningful for uploaded archives. $install_source_file = ''; if ( $install_source === 'upload' && ! empty( $context['plugin_upload_name'] ) ) { $install_source_file = $context['plugin_upload_name']; } // Map of context-key => [ label, value ]. Empty values are skipped. $rows = array( 'plugin_description' => array( _x( 'Description', 'plugin logger - detailed output', 'simple-history' ), $plugin_description ), 'plugin_install_source' => array( _x( 'Source', 'plugin logger - detailed output install source', 'simple-history' ), $install_source_label ), 'plugin_install_source_file' => array( _x( 'Source file name', 'plugin logger - detailed output install source', 'simple-history' ), $install_source_file ), 'plugin_version' => array( _x( 'Version', 'plugin logger - detailed output version', 'simple-history' ), $context['plugin_version'] ?? '' ), 'plugin_author' => array( _x( 'Author', 'plugin logger - detailed output author', 'simple-history' ), $plugin_author ), 'plugin_url' => array( _x( 'URL', 'plugin logger - detailed output url', 'simple-history' ), $context['plugin_url'] ?? '' ), ); // Backward-compatible filter: receives map of context-key => label. $arr_plugin_keys = apply_filters( 'simple_history/plugin_logger/row_details_plugin_info_keys', wp_list_pluck( $rows, 0 ) ); $group = new Event_Details_Group(); foreach ( $arr_plugin_keys as $key => $label ) { // Prefer our prepared value; fall back to raw context for keys added via filter. $value = isset( $rows[ $key ][1] ) ? $rows[ $key ][1] : ( $context[ $key ] ?? '' ); if ( trim( (string) $value ) === '' ) { continue; } $group->add_item( ( new Event_Details_Item( $key, $label ) )->set_new_value( $value ) ); } return $group; } /** * Get Event_Details_Group for plugin update details. * * @param array $context Log context. * @return Event_Details_Group|string */ private function get_plugin_updated_details_group( $context ) { $plugin_slug = empty( $context['plugin_slug'] ) ? '' : $context['plugin_slug']; $plugin_version = empty( $context['plugin_version'] ) ? '' : $context['plugin_version']; $groups = []; /** * Allow plugins (or Simple History itself) to add extra details to the plugin update details output. * * @param string $extra_details Extra HTML to output. Probably empty string. */ $extra_details = apply_filters( "simple_history/pluginlogger/plugin_updated_details/{$plugin_slug}", '' ); /** * Allow plugins (or Simple History itself) to add extra details to the plugin update details output. * * @param string $extra_details Extra HTML to output. Probably empty string. */ $extra_details = apply_filters( "simple_history/pluginlogger/plugin_updated_details/{$plugin_slug}/{$plugin_version}", $extra_details ); // Extra details from filters (raw HTML). if ( ! empty( $extra_details ) ) { $groups[] = Event_Details_Group::create_raw( $extra_details ); } $info_group = new Event_Details_Group(); if ( isset( $context['plugin_was_active'] ) ) { $info_group->add_item( ( new Event_Details_Item( null, _x( 'Status at update', 'plugin logger: plugin active status label', 'simple-history' ) ) ) ->set_new_value( $context['plugin_was_active'] === '1' ? _x( 'Active', 'plugin logger: plugin was active at update', 'simple-history' ) : _x( 'Inactive', 'plugin logger: plugin was inactive at update', 'simple-history' ) ) ); } $plugin_update_type = $context['plugin_update_type'] ?? ''; if ( $plugin_update_type === 'forced_security' ) { $info_group->add_item( ( new Event_Details_Item( null, _x( 'Update method', 'plugin logger: update method label', 'simple-history' ) ) ) ->set_new_value( _x( 'Security auto-update', 'plugin logger: forced security update method', 'simple-history' ) ) ); } if ( ! empty( $context['plugin_upgrade_notice'] ) ) { $upgrade_notice = wp_strip_all_tags( $context['plugin_upgrade_notice'] ); $upgrade_notice = wp_trim_words( $upgrade_notice, 30, '…' ); $info_group->add_item( ( new Event_Details_Item( null, _x( 'Update notice', 'plugin logger: update notice label', 'simple-history' ) ) ) ->set_new_value( $upgrade_notice ) ); } if ( ! empty( $info_group->items ) ) { $groups[] = $info_group; } if ( empty( $groups ) ) { return ''; } return Event_Details_Container::create_from( $groups ); } /** * Add rollback context to event if rollback will occur. * * @param array $context Context array. * @param string $plugin_identifier Plugin main file path. * @return array Modified context array. */ private function add_rollback_context( $context, $plugin_identifier ) { // Check if rollback will occur (WordPress 6.3+ feature). $package_result = $this->package_results[ $plugin_identifier ] ?? null; if ( $package_result && ! empty( $package_result['rollback_will_occur'] ) ) { $context['rollback_will_occur'] = true; if ( ! empty( $package_result['rollback_info'] ) ) { $context['rollback_backup_slug'] = $package_result['rollback_info']['backup_slug']; $context['rollback_error_code'] = $package_result['rollback_info']['error_code']; $context['rollback_error_message'] = $package_result['rollback_info']['error_message']; } } return $context; } /** * Get the update type for a plugin (forced_security, user_enabled, or manual). * * WordPress.org can force security updates via the `autoupdate` flag in the API response, * even when users have NOT enabled auto-updates for that plugin. This is used for critical * security vulnerabilities where the WordPress.org security team decides all sites should * be updated regardless of user preferences. * * How it works: * - WordPress.org API returns `autoupdate => 1` for plugins needing forced security updates * - This flag is checked against `auto_update_plugins` option (user preferences) * - If API says force but user didn't enable → forced_security * - If user enabled auto-updates → user_enabled * - Otherwise → manual * * To test forced security updates: * 1. Find a plugin with known forced update (e.g., WooCommerce 10.4.2 → 10.4.3) * 2. Downgrade: wp plugin install woocommerce --version=10.4.2 --force * 3. Clear cache: wp transient delete update_plugins --network * 4. Trigger: wp eval 'wp_maybe_auto_update();' * 5. Check log for "Security Update" badge * * @see readme.issue-608-alerts.md for detailed testing instructions. * @see https://gist.github.com/bonny/dceab0c8582f08075919e9f760380f50 Real API response with autoupdate=1. * @see https://make.wordpress.org/plugins/2015/03/14/plugin-automatic-security-updates/ * * @param string $plugin_file Plugin main file path (e.g., 'woocommerce/woocommerce.php'). * @param ?object $update_plugins Optional update_plugins transient. Will be fetched if not provided. * @return string Update type: 'forced_security', 'user_enabled', or 'manual'. */ private function get_plugin_update_type( $plugin_file, $update_plugins = null ) { if ( $update_plugins === null ) { $update_plugins = get_site_transient( 'update_plugins' ); } // Check if WordPress.org API flagged this as a forced security update. $api_forced = false; if ( $update_plugins && isset( $update_plugins->response[ $plugin_file ] ) ) { $plugin_update_info = $update_plugins->response[ $plugin_file ]; $api_forced = ! empty( $plugin_update_info->autoupdate ); } // Check if user has enabled auto-updates for this plugin. $auto_update_plugins = (array) get_site_option( 'auto_update_plugins', [] ); $user_enabled = in_array( $plugin_file, $auto_update_plugins, true ); if ( $api_forced && ! $user_enabled ) { // WordPress.org forced this update, user did NOT enable auto-updates. return 'forced_security'; } if ( $user_enabled ) { // User explicitly enabled auto-updates for this plugin. return 'user_enabled'; } // Manual update (user clicked update button). return 'manual'; } /** * Add update type context to event. * * @param array $context Context array. * @param string $plugin_file Plugin main file path. * @param ?object $update_plugins Optional update_plugins transient. * @return array Modified context array. */ private function add_update_type_context( $context, $plugin_file, $update_plugins = null ) { $plugin_update_type = $this->get_plugin_update_type( $plugin_file, $update_plugins ); $context['plugin_update_type'] = $plugin_update_type; return $context; } }