PluginProbe
Filter Everything — WordPress & WooCommerce Filters / trunk
Filter Everything — WordPress & WooCommerce Filters vtrunk
1.9.6 1.9.5 1.9.4 1.9.3 1.9.2.2 1.9.2.1 trunk 1.2.1 1.2.3 1.2.4 1.2.5 1.3.0 1.3.1 1.3.2 1.4.1 1.4.4 1.4.5 1.4.8 1.4.9 1.5.0 1.5.1 1.6.0 1.6.1 1.6.2 1.6.3 All 51 releases
filter-everything / src / Admin / AdminNotices.php

AdminNotices.php in Filter Everything — WordPress & WooCommerce Filters trunk, at src/Admin/AdminNotices.php

394 lines 15.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3
4 namespace FilterEverything\Filter;
5
6 if ( ! defined('ABSPATH') ) {
7 exit;
8 }
9
10 /**
11 * Lightweight, reusable admin-notice manager.
12 *
13 * To add or change a notice, edit self::notices() only — each entry fully
14 * describes its text, type and the event it is tied to. The rest (free/PRO
15 * gating, capability, rendering, persistent dismissal) is handled generically.
16 *
17 * Notice fields:
18 * id unique slug, chars [a-z0-9_-] (dismissal key + CSS class + AJAX scope)
19 * type info | success | warning | error
20 * message string (pre-escaped) OR a callable returning the escaped HTML
21 * trigger 'always' | 'update' | callable returning bool
22 * free_only bool — hide in the PRO build (default false)
23 * capability capability required to see/dismiss it (default flrt_plugin_user_caps())
24 * dismissible bool — show the "X" and remember the dismissal permanently, per id
25 *
26 * The 'update' trigger fires only after an existing install is updated (never
27 * on a fresh install): Plugin::activate() stamps the version on fresh installs,
28 * so a missing/older stamp means an in-place update happened.
29 */
30 class AdminNotices
31 {
32 /** Last-seen plugin version on this site. */
33 const VERSION_OPTION = 'flrt_version';
34
35 /** Version the site was most recently updated to (drives the 'update' trigger). */
36 const UPDATED_OPTION = 'flrt_updated_to';
37
38 /** Unix time of that update (lets WhatsNew stop its badge after a while). */
39 const UPDATED_AT_OPTION = 'flrt_updated_at';
40
41 /** Array of permanently dismissed notice ids. */
42 const DISMISSED_OPTION = 'flrt_dismissed_notices';
43
44 /** Map of notice id => unix time when first shown (drives 'expires_after'). */
45 const STARTED_OPTION = 'flrt_notice_started';
46
47 /** Shared AJAX action / nonce for dismissing any notice. */
48 const DISMISS_ACTION = 'flrt_dismiss_notice';
49
50 /**
51 * Preview mode — for fine-tuning notice text and appearance during development.
52 *
53 * When true: every notice is shown on every admin page (trigger, expiry and
54 * dismissal state are ignored) and the "X" only hides it client-side, so it
55 * reappears on reload. Keep false in production.
56 */
57 const PREVIEW_MODE = false;
58
59 public function __construct()
60 {
61 // admin_menu fires BEFORE admin_init in wp-admin/admin.php; stamping here
62 // too lets the WhatsNew menu badge appear on the very first request after
63 // an update (the method is idempotent).
64 add_action( 'admin_menu', [ $this, 'detectUpdate' ], 1 );
65 add_action( 'admin_init', [ $this, 'detectUpdate' ] );
66 add_action( 'admin_init', [ $this, 'maybeAutoDismiss' ] );
67 add_action( 'admin_notices', [ $this, 'renderAll' ] );
68 add_action( 'wp_ajax_' . self::DISMISS_ACTION, [ $this, 'ajaxDismiss' ] );
69 }
70
71 /**
72 * Single source of truth for admin notices. Add one array entry per notice.
73 *
74 * @return array[]
75 */
76 protected function notices()
77 {
78 return [
79 [
80 // 1.9.6: the crawler protections are ON for fresh free installs but
81 // OFF on updated ones (we never change an existing site's markup
82 // silently) — tell those owners once where to switch them on.
83 'id' => 'crawler-protection-196',
84 'type' => 'info',
85 'free_only' => true,
86 'trigger' => function () {
87 // 'update' semantics, but only while the option is still off
88 return get_option( self::UPDATED_OPTION ) === FLRT_PLUGIN_VER
89 && flrt_get_option( 'disable_filter_links_for_bots' ) !== 'on';
90 },
91 'expires_after' => 30 * DAY_IN_SECONDS,
92 'auto_dismiss' => function () {
93 // Gone as soon as the user opens any plugin admin page (Filter
94 // Sets list/editor or any Settings tab) — they have seen the
95 // new section by then.
96 $post_type = isset( $_GET['post_type'] ) ? sanitize_key( wp_unslash( $_GET['post_type'] ) ) : '';
97 $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '';
98 $typenow = ! empty( $GLOBALS['typenow'] ) ? $GLOBALS['typenow'] : '';
99
100 return $post_type === FLRT_FILTERS_SET_POST_TYPE
101 || $typenow === FLRT_FILTERS_SET_POST_TYPE
102 || $page === 'filters-settings';
103 },
104 'message' => function () {
105 $settings_url = admin_url( 'edit.php?post_type=' . FLRT_FILTERS_SET_POST_TYPE . '&page=filters-settings' );
106
107 return sprintf(
108 /* translators: 1: opening <a> tag to the plugin settings page, 2: closing </a> tag. */
109 wp_kses(
110 __( 'Thank you for updating Filter Everything! Bots and AI crawlers increasingly overload websites by requesting endless filter combinations. This version adds two protections — <strong>«Disable filter links for crawlers»</strong> and <strong>«Block filter URLs in robots.txt»</strong>. They stay off on existing sites so that nothing changes without your consent; we recommend enabling both on %1$sthe settings page%2$s.', 'filter-everything' ),
111 [ 'strong' => [], 'a' => [ 'href' => [] ] ]
112 ),
113 '<a href="' . esc_url( $settings_url ) . '">',
114 '</a>'
115 );
116 },
117 ],
118 [
119 'id' => 'security-1922',
120 'type' => 'warning',
121 'free_only' => false, // true = Free only; false = show in both Free and PRO
122 'trigger' => 'update',
123 'expires_after' => DAY_IN_SECONDS,
124 'auto_dismiss' => function () {
125 // Auto-hide once the user opens the Color Swatches (Experimental) settings tab.
126 return isset( $_GET['page'], $_GET['tab'] )
127 && sanitize_key( wp_unslash( $_GET['page'] ) ) === 'filters-settings'
128 && sanitize_key( wp_unslash( $_GET['tab'] ) ) === 'experimental';
129 },
130 'message' => function () {
131 $settings_url = admin_url( 'edit.php?post_type=' . FLRT_FILTERS_SET_POST_TYPE . '&page=filters-settings&tab=experimental' );
132
133 return sprintf(
134 /* translators: 1: opening <a> tag to the plugin settings page, 2: closing </a> tag. */
135 wp_kses(
136 __( 'Thank you for updating Filter Everything! This release includes a security update related to how <strong>Color swatches</strong> are rendered. Everything should work fine, but just in case, please check how your Color swatches look in the filters on your site\'s pages. You can see the list of Color swatches you use on %1$s<strong>this settings page</strong>%2$s.', 'filter-everything' ),
137 [ 'strong' => [], 'a' => [ 'href' => [] ] ]
138 ),
139 '<a href="' . esc_url( $settings_url ) . '">',
140 '</a>'
141 );
142 },
143 ],
144 ];
145 }
146
147 /**
148 * Records that an existing install was updated, so 'update' notices fire.
149 * Fresh installs are pre-stamped in Plugin::activate() and match here.
150 */
151 public function detectUpdate()
152 {
153 if ( self::PREVIEW_MODE ) {
154 return;
155 }
156
157 $stored = get_option( self::VERSION_OPTION, false );
158
159 if ( $stored === FLRT_PLUGIN_VER ) {
160 return;
161 }
162
163 update_option( self::UPDATED_OPTION, FLRT_PLUGIN_VER );
164 update_option( self::UPDATED_AT_OPTION, time() );
165 update_option( self::VERSION_OPTION, FLRT_PLUGIN_VER );
166 }
167
168 /**
169 * Runs each notice's optional 'auto_dismiss' condition (e.g. "the user opened
170 * the relevant settings page") and dismisses it permanently when it matches.
171 */
172 public function maybeAutoDismiss()
173 {
174 if ( self::PREVIEW_MODE ) {
175 return;
176 }
177
178 foreach ( $this->notices() as $notice ) {
179 if ( empty( $notice['id'] ) || empty( $notice['auto_dismiss'] ) || ! is_callable( $notice['auto_dismiss'] ) ) {
180 continue;
181 }
182 if ( $this->isDismissed( $notice['id'] ) ) {
183 continue;
184 }
185 if ( call_user_func( $notice['auto_dismiss'] ) ) {
186 $this->markDismissed( $notice['id'] );
187 }
188 }
189 }
190
191 public function renderAll()
192 {
193 foreach ( $this->notices() as $notice ) {
194 $this->maybeRender( $notice );
195 }
196 }
197
198 protected function maybeRender( array $notice )
199 {
200 $notice = array_merge(
201 [
202 'id' => '',
203 'type' => 'info',
204 'message' => '',
205 'trigger' => 'always',
206 'free_only' => false,
207 'capability' => flrt_plugin_user_caps(),
208 'dismissible' => true,
209 ],
210 $notice
211 );
212
213 if ( $notice['id'] === '' ) {
214 return;
215 }
216
217 if ( $notice['free_only'] && defined( 'FLRT_FILTERS_PRO' ) && FLRT_FILTERS_PRO ) {
218 return;
219 }
220
221 if ( $notice['capability'] && ! current_user_can( $notice['capability'] ) ) {
222 return;
223 }
224
225 if ( ! self::PREVIEW_MODE ) {
226 if ( $this->isDismissed( $notice['id'] ) ) {
227 return;
228 }
229 if ( ! $this->triggerPasses( $notice['trigger'] ) ) {
230 return;
231 }
232 if ( $this->hasExpired( $notice ) ) {
233 return;
234 }
235 }
236
237 $message = is_callable( $notice['message'] ) ? call_user_func( $notice['message'] ) : $notice['message'];
238 if ( $message === '' ) {
239 return;
240 }
241
242 $notice_class = 'flrt-notice-' . sanitize_html_class( $notice['id'] );
243
244 if ( function_exists( 'wp_admin_notice' ) ) {
245 // Modern WordPress notice API (WP 6.4+).
246 wp_admin_notice(
247 $message,
248 [
249 'type' => $notice['type'],
250 'dismissible' => (bool) $notice['dismissible'],
251 'additional_classes' => [ 'flrt-admin-notice', $notice_class ],
252 ]
253 );
254 } else {
255 // Fallback for WordPress < 6.4 ($message is already escaped above).
256 printf(
257 '<div class="notice notice-%1$s%2$s flrt-admin-notice %3$s"><p>%4$s</p></div>',
258 esc_attr( $notice['type'] ),
259 $notice['dismissible'] ? ' is-dismissible' : '',
260 esc_attr( $notice_class ),
261 $message // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built with wp_kses()/esc_url() above
262 );
263 }
264
265 // Persist the dismissal only in live mode; in preview the "X" is client-side only.
266 if ( ! self::PREVIEW_MODE && $notice['dismissible'] ) {
267 $this->printDismissScript( $notice['id'], $notice_class );
268 }
269 }
270
271 protected function triggerPasses( $trigger )
272 {
273 if ( $trigger === 'always' ) {
274 return true;
275 }
276
277 if ( $trigger === 'update' ) {
278 return get_option( self::UPDATED_OPTION ) === FLRT_PLUGIN_VER;
279 }
280
281 if ( is_callable( $trigger ) ) {
282 return (bool) call_user_func( $trigger );
283 }
284
285 return false;
286 }
287
288 protected function isDismissed( $id )
289 {
290 return in_array( $id, (array) get_option( self::DISMISSED_OPTION, [] ), true );
291 }
292
293 protected function markDismissed( $id )
294 {
295 $dismissed = (array) get_option( self::DISMISSED_OPTION, [] );
296 if ( ! in_array( $id, $dismissed, true ) ) {
297 $dismissed[] = $id;
298 update_option( self::DISMISSED_OPTION, $dismissed );
299 }
300 }
301
302 /**
303 * True once a notice with an 'expires_after' (seconds) has been visible that
304 * long. The first-shown time is stamped per id on the first eligible render.
305 */
306 protected function hasExpired( array $notice )
307 {
308 if ( empty( $notice['expires_after'] ) ) {
309 return false;
310 }
311
312 $started = (array) get_option( self::STARTED_OPTION, [] );
313 if ( ! isset( $started[ $notice['id'] ] ) ) {
314 $started[ $notice['id'] ] = time();
315 update_option( self::STARTED_OPTION, $started );
316 }
317
318 return ( time() - (int) $started[ $notice['id'] ] ) >= (int) $notice['expires_after'];
319 }
320
321 /**
322 * Persists the dismissal of a specific notice when its "X" is clicked.
323 */
324 protected function printDismissScript( $id, $notice_class )
325 {
326 ?>
327 <script>
328 ( function () {
329 var notice = document.querySelector( <?php echo wp_json_encode( '.' . $notice_class ); ?> );
330 if ( ! notice ) {
331 return;
332 }
333 notice.addEventListener( 'click', function ( e ) {
334 if ( ! e.target.closest( '.notice-dismiss' ) ) {
335 return;
336 }
337 var body = new URLSearchParams();
338 body.append( 'action', <?php echo wp_json_encode( self::DISMISS_ACTION ); ?> );
339 body.append( 'id', <?php echo wp_json_encode( $id ); ?> );
340 body.append( 'nonce', <?php echo wp_json_encode( wp_create_nonce( self::DISMISS_ACTION ) ); ?> );
341 fetch( <?php echo wp_json_encode( admin_url( 'admin-ajax.php' ) ); ?>, {
342 method: 'POST',
343 credentials: 'same-origin',
344 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
345 body: body.toString()
346 } );
347 } );
348 } )();
349 </script>
350 <?php
351 }
352
353 /**
354 * AJAX handler — permanently marks a notice as dismissed.
355 */
356 public function ajaxDismiss()
357 {
358 check_ajax_referer( self::DISMISS_ACTION, 'nonce' );
359
360 $id = isset( $_POST['id'] ) ? sanitize_key( wp_unslash( $_POST['id'] ) ) : '';
361 $notice = $this->findNotice( $id );
362
363 if ( ! $notice ) {
364 wp_send_json_error( null, 400 );
365 }
366
367 $capability = ! empty( $notice['capability'] ) ? $notice['capability'] : flrt_plugin_user_caps();
368 if ( ! current_user_can( $capability ) ) {
369 wp_send_json_error( null, 403 );
370 }
371
372 $this->markDismissed( $id );
373
374 wp_send_json_success();
375 }
376
377 protected function findNotice( $id )
378 {
379 if ( $id === '' ) {
380 return null;
381 }
382
383 foreach ( $this->notices() as $notice ) {
384 if ( isset( $notice['id'] ) && $notice['id'] === $id ) {
385 return $notice;
386 }
387 }
388
389 return null;
390 }
391 }
392
393 new AdminNotices();
394