diff --git a/includes/class-health-check.php b/includes/class-health-check.php new file mode 100644 index 0000000..fa45994 --- /dev/null +++ b/includes/class-health-check.php @@ -0,0 +1,380 @@ +check_core_updates(); + $warnings = array( + 'closed' => array(), + 'stale' => array(), + 'untested' => array(), + ); + + foreach ( $plugins as $key => $plugin ) { + $slug = $plugin_report->get_plugin_slug( $key ); + $report = $plugin_report->assemble_plugin_report( $slug ); + + if ( ! $report ) { + continue; + } + + $name = isset( $report['local_info']['Name'] ) ? $report['local_info']['Name'] : $slug; + + if ( self::is_closed( $report ) ) { + $warnings['closed'][] = $name; + } + if ( self::is_stale( $report ) ) { + $warnings['stale'][] = $name; + } + if ( self::is_untested( $report, $wp_latest ) ) { + $warnings['untested'][] = $name; + } + } + + return $warnings; + } + + + /** + * Checks if a plugin report indicates a closed plugin. + * + * @param array $report Plugin report data. + * + * @return bool + */ + public static function is_closed( $report ) { + return isset( $report['repo_error_code'] ) + && 'plugins_api_failed' === $report['repo_error_code'] + && isset( $report['exists_in_svn'] ) + && true === $report['exists_in_svn']; + } + + + /** + * Checks if a plugin report indicates a stale plugin (no update in 2+ years). + * + * @param array $report Plugin report data. + * + * @return bool + */ + public static function is_stale( $report ) { + if ( ! isset( $report['repo_info'] ) || ! isset( $report['repo_info']->last_updated ) ) { + return false; + } + + $time_update = new DateTime( $report['repo_info']->last_updated ); + $days_since = ( current_time( 'timestamp' ) - $time_update->getTimestamp() ) / DAY_IN_SECONDS; + + return $days_since > self::STALE_DAYS; + } + + + /** + * Checks if a plugin report indicates an untested plugin. + * + * @param array $report Plugin report data. + * @param string $wp_latest Latest WP version string. + * + * @return bool + */ + public static function is_untested( $report, $wp_latest ) { + if ( ! isset( $report['repo_info'] ) || ! isset( $report['repo_info']->tested ) || empty( $report['repo_info']->tested ) ) { + return false; + } + + return version_compare( + self::get_major_version( $report['repo_info']->tested ), + self::get_major_version( $wp_latest ), + '<' + ); + } + + + /** + * Returns the major version (first two segments) of a version string. + * + * @param string $version Full version string. + * + * @return string Major version (e.g. "6.8" from "6.8.2"). + */ + private static function get_major_version( $version ) { + $parts = explode( '.', $version ); + array_splice( $parts, 2 ); + return implode( '.', $parts ); + } + + + /** + * Checks if the current warning set was already notified. + * + * @param array $warnings Warning arrays. + * + * @return bool + */ + public static function already_notified( $warnings ) { + return get_option( self::OPTION_NOTIFIED ) === self::hash_warnings( $warnings ); + } + + + /** + * Generates a hash for a warning set. + * + * @param array $warnings Warning arrays. + * + * @return string MD5 hash. + */ + public static function hash_warnings( $warnings ) { + return md5( wp_json_encode( $warnings ) ); + } + + + /** + * Builds the email data array. + * + * @param array $warnings Warning arrays. + * + * @return array { to, subject, body, headers } + */ + public static function build_email( $warnings ) { + $total = count( $warnings['closed'] ) + count( $warnings['stale'] ) + count( $warnings['untested'] ); + + return array( + 'to' => get_site_option( 'admin_email' ), + 'subject' => self::build_email_subject( $total ), + 'body' => self::build_email_body( $warnings ), + 'headers' => '', + ); + } + + + /** + * Builds the email subject line. + * + * @param int $total Total number of warnings. + * + * @return string + */ + private static function build_email_subject( $total ) { + $site_title = get_option( 'blogname' ); + if ( empty( $site_title ) ) { + $site_title = wp_parse_url( home_url(), PHP_URL_HOST ); + } else { + $site_title = wp_specialchars_decode( $site_title, ENT_QUOTES ); + } + + return sprintf( + /* translators: 1: Site title, 2: Number of plugins with issues */ + __( '[%1$s] Plugin Report: %2$d plugin(s) need attention', 'plugin-report' ), + $site_title, + $total + ); + } + + + /** + * Builds the email body text. + * + * @param array $warnings Warning arrays. + * + * @return string + */ + private static function build_email_body( $warnings ) { + $lines = array(); + + $lines[] = __( 'Plugin Report has detected the following issues with your installed plugins:', 'plugin-report' ); + $lines[] = ''; + + if ( ! empty( $warnings['closed'] ) ) { + $lines[] = __( 'Closed on wordpress.org (no longer receiving updates):', 'plugin-report' ); + $lines = array_merge( $lines, self::format_plugin_list( $warnings['closed'] ) ); + $lines[] = ''; + } + + if ( ! empty( $warnings['stale'] ) ) { + $lines[] = __( 'Not updated in over 2 years:', 'plugin-report' ); + $lines = array_merge( $lines, self::format_plugin_list( $warnings['stale'] ) ); + $lines[] = ''; + } + + if ( ! empty( $warnings['untested'] ) ) { + $lines[] = __( 'Not tested with the current major WordPress version:', 'plugin-report' ); + $lines = array_merge( $lines, self::format_plugin_list( $warnings['untested'] ) ); + $lines[] = ''; + } + + $lines[] = sprintf( + /* translators: %s: URL to the Plugin Report page */ + __( 'View the full report: %s', 'plugin-report' ), + self::get_report_url() + ); + + return implode( "\n", $lines ); + } + + + /** + * Formats a list of plugin names as bullet points. + * + * @param array $names Plugin names. + * + * @return array Lines with "- " prefix. + */ + private static function format_plugin_list( $names ) { + $lines = array(); + foreach ( $names as $name ) { + $lines[] = '- ' . $name; + } + return $lines; + } + + + /** + * Gets the URL to the Plugin Report admin page. + * + * @return string + */ + private static function get_report_url() { + if ( is_multisite() ) { + return network_admin_url( 'plugins.php?page=plugin_report' ); + } + return admin_url( 'plugins.php?page=plugin_report' ); + } + + + /** + * Clears the notification flag so the next health check re-evaluates. + */ + public static function clear_notification_flag() { + delete_option( self::OPTION_NOTIFIED ); + } + + + /** + * Schedules the cron event. + */ + public static function schedule() { + if ( ! wp_next_scheduled( self::CRON_HOOK ) ) { + wp_schedule_event( time(), 'daily', self::CRON_HOOK ); + } + } + + + /** + * Unschedules the cron event and cleans up. + */ + public static function unschedule() { + wp_clear_scheduled_hook( self::CRON_HOOK ); + delete_option( self::OPTION_NOTIFIED ); + } +} diff --git a/phpstan.neon b/phpstan.neon index 22ed929..e07dbc6 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -5,5 +5,8 @@ parameters: level: 5 paths: - rt-plugin-report.php + - includes/ ignoreErrors: - - '#Path in require_once\(\) "\./wp-admin/includes/plugin-install\.php" is not a file or it does not exist#' + - + message: '#Path in require_once\(\) "\./wp-admin/includes/plugin-install\.php" is not a file or it does not exist#' + reportUnmatched: false diff --git a/rt-plugin-report.php b/rt-plugin-report.php index 6b76f78..ff04eab 100644 --- a/rt-plugin-report.php +++ b/rt-plugin-report.php @@ -18,7 +18,11 @@ } -if ( is_admin() && ! class_exists( 'RT_Plugin_Report' ) ) { +define( 'RT_PLUGIN_REPORT_FILE', __FILE__ ); + +require_once __DIR__ . '/includes/class-health-check.php'; + +if ( ! class_exists( 'RT_Plugin_Report' ) ) { /** * Plugin Report main class. @@ -60,6 +64,8 @@ public function init() { add_action( 'wp_ajax_rt_get_plugin_info', array( $this, 'get_plugin_info' ) ); // Hook into the WP Upgrader to selectively delete cache items. add_action( 'upgrader_process_complete', array( $this, 'upgrade_delete_cache_items' ), 10, 2 ); + // Initialize periodic health check. + RT_Plugin_Report_Health_Check::init( $this ); } @@ -215,7 +221,7 @@ private function get_plugin_slugs() { * * @param string $file Plugin file path. */ - private function get_plugin_slug( $file ) { + public function get_plugin_slug( $file ) { if ( strpos( $file, '/' ) !== false ) { $parts = explode( '/', $file ); } else { @@ -278,7 +284,7 @@ public function get_plugin_info() { * * @param string $slug Plugin slug. */ - private function assemble_plugin_report( $slug ) { + public function assemble_plugin_report( $slug ) { if ( ! empty( $slug ) ) { $report = array(); $cache_key = $this->create_cache_key( $slug ); @@ -661,7 +667,7 @@ private function get_timediff_risk_classname( $time_diff ) { * Get the latest available WordPress version using WP core functions * This way, we don't need to do any API calls. WP check this periodically anyway. */ - private function check_core_updates() { + public function check_core_updates() { global $wp_version; $update = get_preferred_from_update_core(); // Bail out of no valid response, or false. @@ -745,6 +751,8 @@ private function clear_cache() { $slug = $this->get_plugin_slug( $key ); $this->clear_cache_item( $slug ); } + // Signal that cached plugin data has changed. + do_action( 'plugin_report_cache_cleared' ); } @@ -770,6 +778,8 @@ public function upgrade_delete_cache_items( $upgrader, $data ) { $slug = $this->get_plugin_slug( $value ); $this->clear_cache_item( $slug ); } + // Signal that cached plugin data has changed. + do_action( 'plugin_report_cache_cleared' ); } }