| 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 |
|