PluginProbe ʕ •ᴥ•ʔ
FrontBlocks for Gutenberg/GeneratePress / 1.5.2
FrontBlocks for Gutenberg/GeneratePress v1.5.2
1.5.2 1.5.1 1.4.0 1.5.0 trunk 0.2.0 0.2.1 0.2.2 0.2.3 0.2.4 0.2.5 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.1.0 1.2.0 1.2.1 1.3.0 1.3.1 1.3.2 1.3.3 1.3.4 1.3.5 1.3.6 ci-artifacts
frontblocks / includes / Admin / RedundantPlugins.php
frontblocks / includes / Admin Last commit date
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 '&nbsp;&nbsp;<a href="' . esc_url( $entry['doc_url'] ) . '" class="button button-secondary">' . esc_html__( 'Learn More', 'frontblocks' ) . '</a>';
253 }
254 echo '&nbsp;&nbsp;<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