RedundantPlugins.php
4 days ago
ReviewNotice.php
6 days ago
Settings.php
4 days ago
UI.php
2 months ago
RedundantPlugins.php
312 lines
| 1 | <?php |
| 2 | /** |
| 3 | * Redundant Plugins Notice |
| 4 | * |
| 5 | * @package FrontBlocks |
| 6 | * @author Closemarketing |
| 7 | * @copyright 2026 Closemarketing |
| 8 | * @version 1.0.0 |
| 9 | */ |
| 10 | |
| 11 | namespace FrontBlocks\Admin; |
| 12 | |
| 13 | defined( 'ABSPATH' ) || exit; |
| 14 | |
| 15 | /** |
| 16 | * Detects third-party plugins that duplicate a FrontBlocks feature and shows |
| 17 | * a persistent admin notice recommending the site owner deactivate them. |
| 18 | * |
| 19 | * This class is intentionally generic: it does not hardcode any plugin |
| 20 | * knowledge itself. Every "FrontBlocks feature X makes plugin Y redundant" |
| 21 | * pairing lives in an entry returned by the `frontblocks_redundant_plugins` |
| 22 | * filter, so new pairings (e.g. a future image compression feature flagging |
| 23 | * Imagify/Smush/ShortPixel) can be added without touching this file. |
| 24 | * |
| 25 | * See docs/REDUNDANT-PLUGINS.md for the entry format and examples. |
| 26 | * |
| 27 | * @since 1.0.0 |
| 28 | */ |
| 29 | class RedundantPlugins { |
| 30 | |
| 31 | /** |
| 32 | * User meta key storing, per entry id, a hash of the redundant-plugin |
| 33 | * state (which plugins + versions) that was last dismissed. |
| 34 | * |
| 35 | * @var string |
| 36 | */ |
| 37 | const DISMISSED_META_KEY = 'frbl_redundant_plugins_dismissed'; |
| 38 | |
| 39 | /** |
| 40 | * Nonce action used to protect the dismissal AJAX endpoint. |
| 41 | * |
| 42 | * @var string |
| 43 | */ |
| 44 | const NONCE_ACTION = 'frbl_dismiss_redundant_plugin'; |
| 45 | |
| 46 | /** |
| 47 | * Constructor. |
| 48 | */ |
| 49 | public function __construct() { |
| 50 | add_action( 'admin_notices', array( $this, 'render_notices' ) ); |
| 51 | add_action( 'wp_ajax_frbl_dismiss_redundant_plugin_notice', array( $this, 'dismiss_notice_callback' ) ); |
| 52 | } |
| 53 | |
| 54 | /** |
| 55 | * Get the registered redundant-plugin entries. |
| 56 | * |
| 57 | * Any FrontBlocks feature (or third-party code) can add its own entry by |
| 58 | * hooking the `frontblocks_redundant_plugins` filter. See |
| 59 | * docs/REDUNDANT-PLUGINS.md for the entry format. |
| 60 | * |
| 61 | * @return array<string, array{feature: string, enabled: bool, plugins: array<string, string>, doc_url?: string}> |
| 62 | */ |
| 63 | public static function get_entries() { |
| 64 | /** |
| 65 | * Filters the list of "FrontBlocks feature -> redundant plugin(s)" entries. |
| 66 | * |
| 67 | * Each entry is keyed by a unique, stable id and must provide: |
| 68 | * - feature (string) Human-readable name of the FrontBlocks feature. |
| 69 | * - enabled (bool) Whether that feature is currently active on this site. |
| 70 | * - plugins (array<string,string>) Map of plugin basename (as used by is_plugin_active()) |
| 71 | * to a human-readable plugin name. |
| 72 | * - doc_url (string, optional) Link with more information about the FrontBlocks feature. |
| 73 | * |
| 74 | * @since 1.0.0 |
| 75 | * |
| 76 | * @param array $entries Redundant-plugin entries, keyed by id. |
| 77 | */ |
| 78 | return (array) apply_filters( 'frontblocks_redundant_plugins', self::get_default_entries() ); |
| 79 | } |
| 80 | |
| 81 | /** |
| 82 | * Default entries shipped with FrontBlocks core. |
| 83 | * |
| 84 | * These double as a reference implementation for anyone adding new |
| 85 | * entries via the `frontblocks_redundant_plugins` filter. |
| 86 | * |
| 87 | * @return array |
| 88 | */ |
| 89 | private static function get_default_entries() { |
| 90 | $settings = get_option( 'frontblocks_settings', array() ); |
| 91 | |
| 92 | return array( |
| 93 | 'svg-upload' => array( |
| 94 | 'feature' => __( 'SVG Upload', 'frontblocks' ), |
| 95 | // Always active: FrontBlocks enables sanitized SVG uploads in the media library out of the box. |
| 96 | 'enabled' => true, |
| 97 | 'plugins' => array( |
| 98 | 'safe-svg/safe-svg.php' => 'Safe SVG', |
| 99 | 'svg-support/svg-support.php' => 'SVG Support', |
| 100 | ), |
| 101 | 'doc_url' => admin_url( 'themes.php?page=frontblocks-settings' ), |
| 102 | ), |
| 103 | 'cookie-notice' => array( |
| 104 | 'feature' => __( 'Cookie Notice', 'frontblocks' ), |
| 105 | 'enabled' => (bool) ( $settings['enable_cookie_notice'] ?? false ), |
| 106 | 'plugins' => array( |
| 107 | 'gdpr-cookie-compliance/moove-gdpr.php' => 'GDPR Cookie Compliance', |
| 108 | 'cookie-law-info/cookie-law-info.php' => 'CookieYes', |
| 109 | ), |
| 110 | 'doc_url' => admin_url( 'themes.php?page=frontblocks-settings' ), |
| 111 | ), |
| 112 | ); |
| 113 | } |
| 114 | |
| 115 | /** |
| 116 | * Render one persistent admin notice per redundant plugin that is |
| 117 | * currently detected and not already dismissed for its current state. |
| 118 | * |
| 119 | * @return void |
| 120 | */ |
| 121 | public function render_notices() { |
| 122 | if ( ! current_user_can( 'manage_options' ) ) { |
| 123 | return; |
| 124 | } |
| 125 | |
| 126 | $screen = get_current_screen(); |
| 127 | if ( ! $screen ) { |
| 128 | return; |
| 129 | } |
| 130 | |
| 131 | $allowed_screens = array( 'appearance_page_frontblocks-settings', 'dashboard', 'plugins' ); |
| 132 | if ( ! in_array( $screen->id, $allowed_screens, true ) && false === strpos( $screen->id, 'frontblocks' ) ) { |
| 133 | return; |
| 134 | } |
| 135 | |
| 136 | if ( ! function_exists( 'get_plugins' ) ) { |
| 137 | require_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| 138 | } |
| 139 | |
| 140 | $dismissed = get_user_meta( get_current_user_id(), self::DISMISSED_META_KEY, true ); |
| 141 | $dismissed = is_array( $dismissed ) ? $dismissed : array(); |
| 142 | $rendered_any = false; |
| 143 | |
| 144 | foreach ( self::get_entries() as $entry_id => $entry ) { |
| 145 | if ( ! $this->is_valid_entry( $entry ) || ! $entry['enabled'] ) { |
| 146 | continue; |
| 147 | } |
| 148 | |
| 149 | $matched = $this->get_matched_plugins( $entry['plugins'] ); |
| 150 | if ( empty( $matched ) ) { |
| 151 | continue; |
| 152 | } |
| 153 | |
| 154 | $state_hash = $this->get_state_hash( $matched ); |
| 155 | if ( isset( $dismissed[ $entry_id ] ) && $dismissed[ $entry_id ] === $state_hash ) { |
| 156 | continue; |
| 157 | } |
| 158 | |
| 159 | $this->render_notice( $entry_id, $entry, $matched, $state_hash ); |
| 160 | $rendered_any = true; |
| 161 | } |
| 162 | |
| 163 | if ( $rendered_any ) { |
| 164 | $this->enqueue_script(); |
| 165 | } |
| 166 | } |
| 167 | |
| 168 | /** |
| 169 | * Check that an entry has the minimum required shape. |
| 170 | * |
| 171 | * @param mixed $entry Entry to validate. |
| 172 | * @return bool |
| 173 | */ |
| 174 | private function is_valid_entry( $entry ) { |
| 175 | return is_array( $entry ) |
| 176 | && ! empty( $entry['feature'] ) |
| 177 | && array_key_exists( 'enabled', $entry ) |
| 178 | && ! empty( $entry['plugins'] ) |
| 179 | && is_array( $entry['plugins'] ); |
| 180 | } |
| 181 | |
| 182 | /** |
| 183 | * Find which of an entry's candidate plugins are currently active. |
| 184 | * |
| 185 | * @param array $plugins Map of plugin basename to human-readable name. |
| 186 | * @return array<string, array{name: string, version: string}> Matched plugins, keyed by basename. |
| 187 | */ |
| 188 | private function get_matched_plugins( $plugins ) { |
| 189 | $installed = get_plugins(); |
| 190 | $matched = array(); |
| 191 | |
| 192 | foreach ( $plugins as $basename => $name ) { |
| 193 | if ( ! is_plugin_active( $basename ) ) { |
| 194 | continue; |
| 195 | } |
| 196 | |
| 197 | $matched[ $basename ] = array( |
| 198 | 'name' => $name, |
| 199 | 'version' => isset( $installed[ $basename ]['Version'] ) ? $installed[ $basename ]['Version'] : '', |
| 200 | ); |
| 201 | } |
| 202 | |
| 203 | return $matched; |
| 204 | } |
| 205 | |
| 206 | /** |
| 207 | * Build a hash representing the current "which redundant plugins, at |
| 208 | * which versions, are active" state for an entry. |
| 209 | * |
| 210 | * Storing this (instead of a plain dismissed flag) is what makes the |
| 211 | * notice persistent: if the plugin is deactivated and later reactivated, |
| 212 | * or updated, the hash changes and the notice comes back. |
| 213 | * |
| 214 | * @param array $matched Matched plugins as returned by get_matched_plugins(). |
| 215 | * @return string |
| 216 | */ |
| 217 | private function get_state_hash( $matched ) { |
| 218 | $parts = array(); |
| 219 | foreach ( $matched as $basename => $data ) { |
| 220 | $parts[] = $basename . '@' . $data['version']; |
| 221 | } |
| 222 | sort( $parts ); |
| 223 | |
| 224 | return md5( implode( '|', $parts ) ); |
| 225 | } |
| 226 | |
| 227 | /** |
| 228 | * Output a single notice for one entry. |
| 229 | * |
| 230 | * @param string $entry_id Entry id. |
| 231 | * @param array $entry Entry data. |
| 232 | * @param array $matched Matched plugins, as returned by get_matched_plugins(). |
| 233 | * @param string $state_hash Current state hash for this entry. |
| 234 | * @return void |
| 235 | */ |
| 236 | private function render_notice( $entry_id, $entry, $matched, $state_hash ) { |
| 237 | $plugin_names = wp_list_pluck( $matched, 'name' ); |
| 238 | |
| 239 | $notice_message = sprintf( |
| 240 | /* translators: 1: FrontBlocks feature name, 2: comma-separated list of redundant plugin names. */ |
| 241 | __( 'FrontBlocks already includes %1$s. The following active plugin(s) may no longer be necessary: %2$s.', 'frontblocks' ), |
| 242 | '<strong>' . esc_html( $entry['feature'] ) . '</strong>', |
| 243 | esc_html( implode( ', ', $plugin_names ) ) |
| 244 | ); |
| 245 | |
| 246 | echo '<div id="frbl-redundant-plugin-notice-' . esc_attr( $entry_id ) . '" class="notice notice-warning frbl-redundant-plugin-notice" data-entry-id="' . esc_attr( $entry_id ) . '" data-state-hash="' . esc_attr( $state_hash ) . '">'; |
| 247 | echo '<p><strong>' . esc_html__( 'FrontBlocks: possibly unnecessary plugin', 'frontblocks' ) . '</strong></p>'; |
| 248 | echo '<p>' . wp_kses_post( $notice_message ) . '</p>'; |
| 249 | echo '<p>'; |
| 250 | echo '<a href="' . esc_url( admin_url( 'plugins.php' ) ) . '" class="button button-secondary">' . esc_html__( 'Review Plugins', 'frontblocks' ) . '</a>'; |
| 251 | if ( ! empty( $entry['doc_url'] ) ) { |
| 252 | echo ' <a href="' . esc_url( $entry['doc_url'] ) . '" class="button button-secondary">' . esc_html__( 'Learn More', 'frontblocks' ) . '</a>'; |
| 253 | } |
| 254 | echo ' <a href="#" class="button-link frbl-dismiss-redundant-plugin">' . esc_html__( 'Dismiss for now', 'frontblocks' ) . '</a>'; |
| 255 | echo '</p>'; |
| 256 | echo '</div>'; |
| 257 | } |
| 258 | |
| 259 | /** |
| 260 | * Enqueue the JS needed to handle dismissal. |
| 261 | * |
| 262 | * @return void |
| 263 | */ |
| 264 | private function enqueue_script() { |
| 265 | wp_enqueue_script( |
| 266 | 'frbl-redundant-plugins-notice', |
| 267 | FRBL_PLUGIN_URL . 'assets/admin/redundant-plugins-notice.js', |
| 268 | array(), |
| 269 | FRBL_VERSION, |
| 270 | true |
| 271 | ); |
| 272 | |
| 273 | wp_localize_script( |
| 274 | 'frbl-redundant-plugins-notice', |
| 275 | 'frblRedundantPluginsNotice', |
| 276 | array( |
| 277 | 'ajaxurl' => admin_url( 'admin-ajax.php' ), |
| 278 | 'nonce' => wp_create_nonce( self::NONCE_ACTION ), |
| 279 | ) |
| 280 | ); |
| 281 | } |
| 282 | |
| 283 | /** |
| 284 | * AJAX handler to persist a notice dismissal for the current user. |
| 285 | * |
| 286 | * @return void |
| 287 | */ |
| 288 | public function dismiss_notice_callback() { |
| 289 | if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), self::NONCE_ACTION ) ) { |
| 290 | wp_die( esc_html__( 'Security check failed.', 'frontblocks' ), '', array( 'response' => 403 ) ); |
| 291 | } |
| 292 | |
| 293 | if ( ! current_user_can( 'manage_options' ) ) { |
| 294 | wp_die( esc_html__( 'You do not have permission to do this.', 'frontblocks' ), '', array( 'response' => 403 ) ); |
| 295 | } |
| 296 | |
| 297 | $entry_id = isset( $_POST['entry_id'] ) ? sanitize_key( wp_unslash( $_POST['entry_id'] ) ) : ''; |
| 298 | $state_hash = isset( $_POST['state_hash'] ) ? sanitize_text_field( wp_unslash( $_POST['state_hash'] ) ) : ''; |
| 299 | |
| 300 | if ( '' === $entry_id || '' === $state_hash ) { |
| 301 | wp_die( esc_html__( 'Invalid request.', 'frontblocks' ), '', array( 'response' => 400 ) ); |
| 302 | } |
| 303 | |
| 304 | $dismissed = get_user_meta( get_current_user_id(), self::DISMISSED_META_KEY, true ); |
| 305 | $dismissed = is_array( $dismissed ) ? $dismissed : array(); |
| 306 | $dismissed[ $entry_id ] = $state_hash; |
| 307 | |
| 308 | update_user_meta( get_current_user_id(), self::DISMISSED_META_KEY, $dismissed ); |
| 309 | wp_die(); |
| 310 | } |
| 311 | } |
| 312 |