PluginProbe
Stream – Activity Log & Audit Trail / 4.0.2
Stream – Activity Log & Audit Trail v4.0.2
4.4.0 4.3.0 4.2.2 4.2.1 trunk 2.0.1 2.0.2 2.0.3 2.0.4 2.0.5 3.0.0 3.0.1 3.0.2 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.1 3.1.1 3.10.0 3.2.0 3.2.1 3.2.2 3.2.3 All 50 releases
← All changes | classes/class-admin.php +201 -1041 trunk4.0.2 View file →
@@ -18,66 +18,8 @@
18 18 */
19 19 class Admin {
20 20
21 21 /**
22 - * The async deletion action for large sites.
23 - *
24 - * @const string
25 - */
26 - const ASYNC_DELETION_ACTION = 'stream_erase_large_records_action';
27 -
28 - /**
29 - * Recurring Action Scheduler action that drives the TTL-based auto-purge.
30 - *
31 - * @const string
32 - */
33 - const AUTO_PURGE_ACTION = 'stream_auto_purge_action';
34 -
35 - /**
36 - * Async batch worker scheduled by the recurring auto-purge action.
37 - *
38 - * @const string
39 - */
40 - const AUTO_PURGE_BATCH_ACTION = 'stream_auto_purge_batch_action';
41 -
42 - /**
43 - * Terminal action that runs the orphan-meta reaper once per chain.
44 - *
45 - * @const string
46 - */
47 - const AUTO_PURGE_REAPER_ACTION = 'stream_auto_purge_reaper_action';
48 -
49 - /**
50 - * Action Scheduler group string for all auto-purge actions.
51 - *
52 - * @const string
53 - */
54 - const AUTO_PURGE_GROUP = 'stream-auto-purge';
55 -
56 - /**
57 - * Option storing which scheduler backend last registered the recurring
58 - * auto-purge action ('action_scheduler' | 'wp_cron'), or 'disabled' when
59 - * the `wp_stream_enable_auto_purge` filter has torn scheduling down. Used
60 - * to detect a backend switch (or a disable/re-enable cycle) so the stale
61 - * recurring action is cleared exactly once, instead of probing for it on
62 - * every page load.
63 - *
64 - * @const string
65 - */
66 - const SCHEDULER_BACKEND_OPTION = 'wp_stream_scheduler_backend';
67 -
68 - /**
69 - * Option persisting the "large batched operation queued to WP-Cron"
70 - * warning between requests. The contexts that queue the warning (the
71 - * recurring purge under DOING_CRON, the reset handler just before its
72 - * redirect) never render their own output, so the message is stored here
73 - * and displayed on the next admin page load instead. Deleted on render.
74 - *
75 - * @const string
76 - */
77 - const LARGE_TABLE_CRON_NOTICE_OPTION = 'wp_stream_large_table_cron_notice';
78 -
79 - /**
80 22 * Holds Instance of plugin object
81 23 *
82 24 * @var Plugin
83 25 */
@@ -160,13 +102,13 @@
160 102 */
161 103 public $view_cap = 'view_stream';
162 104
163 105 /**
164 - * Capability name for managing settings
106 + * Capability name for viewing settings
165 107 *
166 108 * @var string
167 109 */
168 - public $settings_cap = WP_STREAM_SETTINGS_CAPABILITY;
110 + public $settings_cap = 'manage_options';
169 111
170 112 /**
171 113 * Total amount of authors to pre-load
172 114 *
@@ -199,9 +141,9 @@
199 141 // User and role caps.
200 142 add_filter( 'user_has_cap', array( $this, 'filter_user_caps' ), 10, 4 );
201 143 add_filter( 'role_has_cap', array( $this, 'filter_role_caps' ), 10, 3 );
202 144
203 - if ( $this->plugin->is_multisite_network_activated() && ! is_network_admin() ) {
145 + if ( is_multisite() && $plugin->is_network_activated() && ! is_network_admin() ) {
204 146 $options = (array) get_site_option( 'wp_stream_network', array() );
205 147 $option = isset( $options['general_site_access'] ) ? absint( $options['general_site_access'] ) : 1;
206 148
207 149 $this->disable_access = ( $option ) ? false : true;
@@ -215,11 +157,8 @@
215 157 // Admin notices.
216 158 add_action( 'admin_notices', array( $this, 'prepare_admin_notices' ) );
217 159 add_action( 'shutdown', array( $this, 'admin_notices' ) );
218 160
219 - // Feature request notice.
220 - add_action( 'admin_notices', array( $this, 'display_feature_request_notice' ) );
221 -
222 161 // Add admin body class.
223 162 add_filter( 'admin_body_class', array( $this, 'admin_body_class' ) );
224 163
225 164 // Plugin action links.
@@ -251,41 +190,17 @@
251 190 'wp_ajax_reset',
252 191 )
253 192 );
254 193
255 - // Manual "Clean orphaned meta now" action (Settings → Advanced).
256 - add_action(
257 - 'wp_ajax_wp_stream_clean_orphan_meta',
258 - array( $this, 'wp_ajax_clean_orphan_meta' )
259 - );
260 -
261 - // Render confirmation notices keyed by the wp_stream_message query
262 - // arg set on post-action redirects (e.g. orphan_meta_cleanup_scheduled).
263 - add_action( 'admin_notices', array( $this, 'maybe_display_message' ) );
264 - add_action( 'network_admin_notices', array( $this, 'maybe_display_message' ) );
265 -
266 - // Render the persisted "large batched operation queued to WP-Cron"
267 - // warning on the next admin page load (see
268 - // maybe_warn_large_table_without_action_scheduler()).
269 - add_action( 'admin_notices', array( $this, 'display_large_table_cron_notice' ) );
270 - add_action( 'network_admin_notices', array( $this, 'display_large_table_cron_notice' ) );
271 -
272 - // Auto purge setup (Action Scheduler).
194 + // Auto purge setup.
273 195 add_action( 'wp_loaded', array( $this, 'purge_schedule_setup' ) );
274 196 add_action(
275 - self::AUTO_PURGE_ACTION,
276 - array( $this, 'purge_scheduled_action' )
197 + 'wp_stream_auto_purge',
198 + array(
199 + $this,
200 + 'purge_scheduled_action',
201 + )
277 202 );
278 - add_action(
279 - self::AUTO_PURGE_BATCH_ACTION,
280 - array( $this, 'auto_purge_batch' ),
281 - 10,
282 - 3
283 - );
284 - add_action(
285 - self::AUTO_PURGE_REAPER_ACTION,
286 - array( $this, 'auto_purge_reaper' )
287 - );
288 203
289 204 // Ajax users list.
290 205 add_action(
291 206 'wp_ajax_wp_stream_filters',
@@ -293,19 +208,8 @@
293 208 $this,
294 209 'ajax_filters',
295 210 )
296 211 );
297 -
298 - // Async action for erasing large log tables.
299 - add_action(
300 - self::ASYNC_DELETION_ACTION,
301 - array(
302 - $this,
303 - 'erase_large_records',
304 - ),
305 - 10,
306 - 4
307 - );
308 212 }
309 213
310 214 /**
311 215 * Load admin classes
@@ -398,28 +302,8 @@
398 302 }
399 303 }
400 304
401 305 /**
402 - * Display a feature request notice.
403 - *
404 - * @return void
405 - */
406 - public function display_feature_request_notice() {
407 - $screen = get_current_screen();
408 -
409 - // Display the notice only on the Stream settings page.
410 - if ( empty( $this->screen_id['settings'] ) || $this->screen_id['settings'] !== $screen->id ) {
411 - return;
412 - }
413 -
414 - printf(
415 - '<div class="notice notice-info notice-stream-feature-request"><p>%1$s <a href="https://github.com/xwp/stream/issues/new/choose" target="_blank">%2$s <span class="dashicons dashicons-external"></span></a></p></div>',
416 - esc_html__( 'Have suggestions or found a bug?', 'stream' ),
417 - esc_html__( 'Click here to let us know!', 'stream' )
418 - );
419 - }
420 -
421 - /**
422 306 * Register menu page
423 307 *
424 308 * @action admin_menu
425 309 *
@@ -454,9 +338,9 @@
454 338 $main_menu_title,
455 339 $this->view_cap,
456 340 $this->records_page_slug,
457 341 array( $this, 'render_list_table' ),
458 - 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDI0IDEwMjQiIGZpbGw9IjAwMCI+Cgk8cGF0aCBkPSJNOTAzLjExNSA1MTUuNDEzYy00OS4zOTIgMC05MS40NzQgMzEuMzM3LTEwNy40NiA3NS4yMDNsLTEyNC40MTEtMS41MzJjLTExLjM3Ny0uMzQ2LTIyLjc1MS0uNjg5LTM0LjEyOS0uOTk4bC0uMjQxLjU3NC0yMi40MzYtLjI3OC0uMTUzLS45Mi0xNS4wNTYtODIuOTMzLTIwLjE0Ni0xMDguNDA1LTIwLjU0NC0xMDguMzM3TDUwMy45ODIgMGwtNTMuMTQxIDQyOS4wMTMtMTYuMjE0IDEzNy45MzUtMTIuMDE2IDEwNi45MjQtMTE3LjI4Ni0yODUuMjItMTguMzUzIDIwMi44MWMtNDIuNTYyIDEuNDU0LTg1LjEyNyAyLjkzNC0xMjcuNjg4IDQuNzM4LTUzLjA5NyAyLjI5Mi0xMDYuMTg3IDQuNDczLTE1OS4yODQgNy41MzZ2NDIuMDQyYzUzLjA5NyAzLjA2IDEwNi4xODcgNS4yNDcgMTU5LjI4NCA3LjUzMyA1My4wOTMgMi4yNDUgMTA2LjE4IDQuMTk0IDE1OS4yNzMgNS45MDNsMTQuMjQuNDY1IDE3LjM1MSA0OC4zOWMxOC44NDIgNTEuODc0IDM3LjU0MiAxMDMuODA2IDU2Ljc2NSAxNTUuNTQxTDQ2Ni41MiAxMDI0bDQxLjUxMi0zMDguMjkzIDE3LjYzMy0xMzYuNjg1IDEwLjc3NiA1MC4zMjkgNTQuODE1IDI0OC41NDQgNzIuNTE2LTIxNy4yMTdoMTI5LjI2MWMxMy40OTMgNDguMTIxIDU3LjY1NSA4My40MjkgMTEwLjA3NSA4My40MjkgNjMuMTYgMCAxMTQuMzUyLTUxLjIwNSAxMTQuMzUyLTExNC4zNDggMC02My4xMzktNTEuMTg5LTExNC4zNDUtMTE0LjM0OS0xMTQuMzQ1bC4wMDQtLjAwMVoiIC8+Cjwvc3ZnPgo=',
342 + 'div',
459 343 $main_menu_position
460 344 );
461 345
462 346 /**
@@ -511,50 +395,102 @@
511 395 *
512 396 * @return void
513 397 */
514 398 public function admin_enqueue_scripts( $hook ) {
515 - if ( in_array( $hook, $this->screen_id, true ) ) {
516 - $this->plugin->enqueue_asset(
517 - 'admin',
399 + wp_register_script( 'wp-stream-select2', $this->plugin->locations['url'] . 'ui/lib/select2/js/select2.full.min.js', array( 'jquery' ), '4.0.13', true );
400 + wp_register_style( 'wp-stream-select2', $this->plugin->locations['url'] . 'ui/lib/select2/css/select2.min.css', array(), '4.0.13' );
401 + wp_register_script( 'wp-stream-timeago', $this->plugin->locations['url'] . 'ui/lib/timeago/jquery.timeago.js', array(), '1.4.1', true );
402 +
403 + $locale = strtolower( substr( get_locale(), 0, 2 ) );
404 + $file_tmpl = 'ui/lib/timeago/locales/jquery.timeago.%s.js';
405 +
406 + if ( file_exists( $this->plugin->locations['dir'] . sprintf( $file_tmpl, $locale ) ) ) {
407 + wp_register_script(
408 + 'wp-stream-timeago-locale',
409 + $this->plugin->locations['url'] . sprintf( $file_tmpl, $locale ),
410 + array( 'wp-stream-timeago' ),
411 + '1',
412 + false
413 + );
414 + } else {
415 + wp_register_script(
416 + 'wp-stream-timeago-locale',
417 + $this->plugin->locations['url'] . sprintf( $file_tmpl, 'en' ),
418 + array( 'wp-stream-timeago' ),
419 + '1',
420 + false
421 + );
422 + }
423 +
424 + $min = wp_stream_min_suffix();
425 + wp_enqueue_style( 'wp-stream-admin', $this->plugin->locations['url'] . 'ui/css/admin.' . $min . 'css', array(), $this->plugin->get_version() );
426 +
427 + $script_screens = array( 'plugins.php' );
428 +
429 + if ( in_array( $hook, $this->screen_id, true ) || in_array( $hook, $script_screens, true ) ) {
430 + wp_enqueue_script( 'wp-stream-select2' );
431 + wp_enqueue_style( 'wp-stream-select2' );
432 +
433 + wp_enqueue_script( 'wp-stream-timeago' );
434 + wp_enqueue_script( 'wp-stream-timeago-locale' );
435 +
436 + wp_enqueue_script(
437 + 'wp-stream-admin',
438 + $this->plugin->locations['url'] . 'ui/js/admin.' . $min . 'js',
518 439 array(
519 - $this->plugin->with_select2(),
520 - $this->plugin->with_jquery_timeago(),
440 + 'jquery',
441 + 'wp-stream-select2',
521 442 ),
443 + $this->plugin->get_version(),
444 + false
445 + );
446 + wp_enqueue_script(
447 + 'wp-stream-admin-exclude',
448 + $this->plugin->locations['url'] . 'ui/js/exclude.' . $min . 'js',
522 449 array(
523 - 'i18n' => array(
524 - 'confirm_purge' => __( 'Are you sure you want to delete all Stream activity records from the database? This cannot be undone.', 'stream' ),
525 - 'confirm_defaults' => __( 'Are you sure you want to reset all site settings to default? This cannot be undone.', 'stream' ),
526 - ),
527 - 'locale' => strtolower( substr( get_locale(), 0, 2 ) ),
528 - 'gmt_offset' => get_option( 'gmt_offset' ),
529 - )
450 + 'jquery',
451 + 'wp-stream-select2',
452 + ),
453 + $this->plugin->get_version(),
454 + false
530 455 );
531 -
532 - $this->plugin->enqueue_asset(
533 - 'admin-exclude',
456 + wp_enqueue_script(
457 + 'wp-stream-live-updates',
458 + $this->plugin->locations['url'] . 'ui/js/live-updates.' . $min . 'js',
534 459 array(
535 - $this->plugin->with_select2(),
460 + 'jquery',
461 + 'heartbeat',
536 462 ),
463 + $this->plugin->get_version(),
464 + false
465 + );
466 +
467 + wp_localize_script(
468 + 'wp-stream-admin',
469 + 'wp_stream',
537 470 array(
538 - 'getActionsNonce' => wp_create_nonce( 'stream_get_actions' ),
471 + 'i18n' => array(
472 + 'confirm_purge' => esc_html__( 'Are you sure you want to delete all Stream activity records from the database? This cannot be undone.', 'stream' ),
473 + 'confirm_defaults' => esc_html__( 'Are you sure you want to reset all site settings to default? This cannot be undone.', 'stream' ),
474 + ),
475 + 'locale' => esc_js( $locale ),
476 + 'gmt_offset' => get_option( 'gmt_offset' ),
539 477 )
540 478 );
541 479
542 - $current_order = isset( $_GET['order'] ) ? sanitize_key( wp_unslash( $_GET['order'] ) ) : 'desc'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
543 - if ( ! in_array( $current_order, array( 'asc', 'desc' ), true ) ) {
544 - $current_order = 'desc';
545 - }
546 - $current_query = map_deep( wp_unslash( $_GET ), 'sanitize_text_field' ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
480 + $order_types = array( 'asc', 'desc' );
547 481
548 - $this->plugin->enqueue_asset(
549 - 'live-updates',
550 - array( 'heartbeat' ),
482 + wp_localize_script(
483 + 'wp-stream-live-updates',
484 + 'wp_stream_live_updates',
551 485 array(
552 486 'current_screen' => $hook,
553 487 'current_page' => isset( $_GET['paged'] ) ? absint( wp_unslash( $_GET['paged'] ) ) : '1', // phpcs:ignore WordPress.Security.NonceVerification.Recommended
554 - 'current_order' => $current_order,
555 - 'current_query' => wp_json_encode( $current_query ),
556 - 'current_query_count' => count( $current_query ),
488 + 'current_order' => isset( $_GET['order'] ) && in_array( strtolower( $_GET['order'] ), $order_types, true ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
489 + ? esc_js( $_GET['order'] ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
490 + : 'desc',
491 + 'current_query' => wp_json_encode( $_GET ), // phpcs:ignore WordPress.Security.NonceVerification.Recommended
492 + 'current_query_count' => count( $_GET ), // phpcs:ignore WordPress.Security.NonceVerification.Recommended
557 493 )
558 494 );
559 495 }
560 496
@@ -571,16 +507,24 @@
571 507 * @return int
572 508 */
573 509 $bulk_actions_threshold = apply_filters( 'wp_stream_bulk_actions_threshold', 100 );
574 510
575 - $this->plugin->enqueue_asset(
576 - 'global',
577 - array(),
511 + wp_enqueue_script(
512 + 'wp-stream-global',
513 + $this->plugin->locations['url'] . 'ui/js/global.' . $min . 'js',
514 + array( 'jquery' ),
515 + $this->plugin->get_version(),
516 + false
517 + );
518 +
519 + wp_localize_script(
520 + 'wp-stream-global',
521 + 'wp_stream_global',
578 522 array(
579 523 'bulk_actions' => array(
580 524 'i18n' => array(
581 525 /* translators: %s: a number of items (e.g. "1,742") */
582 - 'confirm_action' => sprintf( __( 'Are you sure you want to perform bulk actions on over %s items? This process could take a while to complete.', 'stream' ), number_format( absint( $bulk_actions_threshold ) ) ),
526 + 'confirm_action' => sprintf( esc_html__( 'Are you sure you want to perform bulk actions on over %s items? This process could take a while to complete.', 'stream' ), number_format( absint( $bulk_actions_threshold ) ) ),
583 527 ),
584 528 'threshold' => absint( $bulk_actions_threshold ),
585 529 ),
586 530 'plugins_screen_url' => self_admin_url( 'plugins.php#stream' ),
@@ -643,13 +587,19 @@
643 587 return sprintf( '%s %s ', $classes, $stream_classes );
644 588 }
645 589
646 590 /**
647 - * Add menu styles for various WP Admin skins.
591 + * Add menu styles for various WP Admin skins
648 592 *
593 + * @uses \wp_add_inline_style()
594 + *
649 595 * @action admin_enqueue_scripts
650 596 */
651 597 public function admin_menu_css() {
598 + $min = wp_stream_min_suffix();
599 + wp_register_style( 'wp-stream-datepicker', $this->plugin->locations['url'] . 'ui/css/datepicker.' . $min . 'css', array(), $this->plugin->get_version() );
600 + wp_register_style( 'wp-stream-icons', $this->plugin->locations['url'] . 'ui/stream-icons/style.css', array(), $this->plugin->get_version() );
601 +
652 602 // Make sure we're working off a clean version.
653 603 if ( ! file_exists( ABSPATH . WPINC . '/version.php' ) ) {
654 604 return;
655 605 }
@@ -658,29 +608,61 @@
658 608 if ( ! isset( $wp_version ) ) {
659 609 return;
660 610 }
661 611
662 - $css = "
663 - body.{$this->admin_body_class} #wpbody-content .wrap h1:nth-child(1):before {
664 - content: '';
665 - display: inline-block;
666 - width: 24px;
667 - height: 24px;
668 - margin-right: 8px;
669 - vertical-align: text-bottom;
670 - background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDI0IDEwMjQiIGZpbGw9ImN1cnJlbnRjb2xvciI+Cgk8cGF0aCBkPSJNOTAzLjExNSA1MTUuNDEzYy00OS4zOTIgMC05MS40NzQgMzEuMzM3LTEwNy40NiA3NS4yMDNsLTEyNC40MTEtMS41MzJjLTExLjM3Ny0uMzQ2LTIyLjc1MS0uNjg5LTM0LjEyOS0uOTk4bC0uMjQxLjU3NC0yMi40MzYtLjI3OC0uMTUzLS45Mi0xNS4wNTYtODIuOTMzLTIwLjE0Ni0xMDguNDA1LTIwLjU0NC0xMDguMzM3TDUwMy45ODIgMGwtNTMuMTQxIDQyOS4wMTMtMTYuMjE0IDEzNy45MzUtMTIuMDE2IDEwNi45MjQtMTE3LjI4Ni0yODUuMjItMTguMzUzIDIwMi44MWMtNDIuNTYyIDEuNDU0LTg1LjEyNyAyLjkzNC0xMjcuNjg4IDQuNzM4LTUzLjA5NyAyLjI5Mi0xMDYuMTg3IDQuNDczLTE1OS4yODQgNy41MzZ2NDIuMDQyYzUzLjA5NyAzLjA2IDEwNi4xODcgNS4yNDcgMTU5LjI4NCA3LjUzMyA1My4wOTMgMi4yNDUgMTA2LjE4IDQuMTk0IDE1OS4yNzMgNS45MDNsMTQuMjQuNDY1IDE3LjM1MSA0OC4zOWMxOC44NDIgNTEuODc0IDM3LjU0MiAxMDMuODA2IDU2Ljc2NSAxNTUuNTQxTDQ2Ni41MiAxMDI0bDQxLjUxMi0zMDguMjkzIDE3LjYzMy0xMzYuNjg1IDEwLjc3NiA1MC4zMjkgNTQuODE1IDI0OC41NDQgNzIuNTE2LTIxNy4yMTdoMTI5LjI2MWMxMy40OTMgNDguMTIxIDU3LjY1NSA4My40MjkgMTEwLjA3NSA4My40MjkgNjMuMTYgMCAxMTQuMzUyLTUxLjIwNSAxMTQuMzUyLTExNC4zNDggMC02My4xMzktNTEuMTg5LTExNC4zNDUtMTE0LjM0OS0xMTQuMzQ1bC4wMDQtLjAwMVoiIC8+Cjwvc3ZnPgo=');
671 - }
672 - #menu-posts-feedback .wp-menu-image:before {
673 - font-family: dashicons !important;
674 - content: '\\f175';
675 - }
676 - #adminmenu #menu-posts-feedback div.wp-menu-image {
677 - background: none !important;
678 - background-repeat: no-repeat;
679 - }
680 - ";
612 + $body_class = $this->admin_body_class;
613 + $records_page = $this->records_page_slug;
614 + $stream_url = $this->plugin->locations['url'];
681 615
682 - wp_add_inline_style( 'wp-admin', $css );
616 + if ( version_compare( $wp_version, '3.8-alpha', '>=' ) ) {
617 + wp_enqueue_style( 'wp-stream-icons' );
618 +
619 + $css = "
620 + #toplevel_page_{$records_page} .wp-menu-image:before {
621 + font-family: 'WP Stream' !important;
622 + content: '\\73' !important;
623 + }
624 + #toplevel_page_{$records_page} .wp-menu-image {
625 + background-repeat: no-repeat;
626 + }
627 + #menu-posts-feedback .wp-menu-image:before {
628 + font-family: dashicons !important;
629 + content: '\\f175';
630 + }
631 + #adminmenu #menu-posts-feedback div.wp-menu-image {
632 + background: none !important;
633 + background-repeat: no-repeat;
634 + }
635 + body.{$body_class} #wpbody-content .wrap h1:nth-child(1):before {
636 + font-family: 'WP Stream' !important;
637 + content: '\\73';
638 + padding: 0 8px 0 0;
639 + }
640 + ";
641 + } else {
642 + $css = "
643 + #toplevel_page_{$records_page} .wp-menu-image {
644 + background: url( {$stream_url}ui/stream-icons/menuicon-sprite.png ) 0 90% no-repeat;
645 + }
646 + /* Retina Stream Menu Icon */
647 + @media only screen and (-moz-min-device-pixel-ratio: 1.5),
648 + only screen and (-o-min-device-pixel-ratio: 3/2),
649 + only screen and (-webkit-min-device-pixel-ratio: 1.5),
650 + only screen and (min-device-pixel-ratio: 1.5) {
651 + #toplevel_page_{$records_page} .wp-menu-image {
652 + background: url( {$stream_url}ui/stream-icons/menuicon-sprite-2x.png ) 0 90% no-repeat;
653 + background-size:30px 64px;
654 + }
655 + }
656 + #toplevel_page_{$records_page}.current .wp-menu-image,
657 + #toplevel_page_{$records_page}.wp-has-current-submenu .wp-menu-image,
658 + #toplevel_page_{$records_page}:hover .wp-menu-image {
659 + background-position: top left;
660 + }
661 + ";
662 + }
663 +
664 + \wp_add_inline_style( 'wp-admin', $css );
683 665 }
684 666
685 667 /**
686 668 * Handle the reset AJAX request to reset logs.
@@ -695,14 +677,8 @@
695 677 esc_html__( "You don't have sufficient privileges to do this action.", 'stream' )
696 678 );
697 679 }
698 680
699 - // Ensure the database tables exist before attempting to clear records.
700 - // Install::check() short-circuits on DOING_AJAX, so call install()
701 - // directly. dbDelta is idempotent and safe to run when tables already
702 - // exist.
703 - $this->plugin->install->install( $this->plugin->get_version() );
704 -
705 681 $this->erase_stream_records();
706 682
707 683 if ( defined( 'WP_STREAM_TESTS' ) && WP_STREAM_TESTS ) {
708 684 return true;
@@ -728,891 +704,87 @@
728 704 */
729 705 private function erase_stream_records() {
730 706 global $wpdb;
731 707
732 - // If this is a multisite and it's not network activated,
733 - // only delete the entries from the blog which made the request.
734 - if ( $this->plugin->is_multisite_not_network_activated() ) {
708 + $where = '';
735 709
736 - // First check the log size.
737 - $stream_log_size = self::get_blog_record_table_size();
738 -
739 - // If this is a large log and we need to delete only the entries
740 - // pertaining to an individual site, we will need to do those in batches.
741 - if ( $this->plugin->is_large_records_table( $stream_log_size ) ) {
742 - $this->schedule_erase_large_records( $stream_log_size );
743 - return;
744 - }
745 -
746 - $wpdb->query(
747 - $wpdb->prepare(
748 - "DELETE `stream`, `meta`
749 - FROM {$wpdb->stream} AS `stream`
750 - LEFT JOIN {$wpdb->streammeta} AS `meta`
751 - ON `meta`.`record_id` = `stream`.`ID`
752 - WHERE `blog_id`=%d;",
753 - get_current_blog_id()
754 - )
755 - );
756 - } else {
757 - // If we are deleting all the entries, we can truncate the tables.
758 - $wpdb->query( "TRUNCATE {$wpdb->streammeta};" );
759 - $wpdb->query( "TRUNCATE {$wpdb->stream};" );
760 - // Tidy up any meta which may have been added in between the two truncations.
761 - $this->delete_orphaned_meta();
710 + if ( is_multisite() && ! $this->plugin->is_network_activated() ) {
711 + $where .= $wpdb->prepare( ' AND `blog_id` = %d', get_current_blog_id() );
762 712 }
763 - }
764 713
765 - /**
766 - * Schedule the initial event to start erasing the logs from now.
767 - *
768 - * @param int $log_size The number of rows which will be affected.
769 - * @return void
770 - */
771 - private function schedule_erase_large_records( int $log_size ) {
772 - global $wpdb;
773 -
774 - $last_entry = $wpdb->get_var(
775 - $wpdb->prepare(
776 - "SELECT ID FROM {$wpdb->stream} WHERE `blog_id`=%d ORDER BY ID DESC LIMIT 1",
777 - get_current_blog_id()
778 - )
779 - );
780 -
781 - // If there are no entries to erase, don't try to erase them.
782 - if ( empty( $last_entry ) ) {
783 - return;
784 - }
785 -
786 - // We are going to delete this many and this many only.
787 - // This is to avoid the situation where rows keep getting added
788 - // between the Action Scheduler runs and they never stop.
789 - $args = array(
790 - 'total' => (int) $log_size,
791 - 'done' => 0,
792 - 'last_entry' => (int) $last_entry,
793 - 'blog_id' => (int) get_current_blog_id(),
794 - );
795 -
796 - $this->plugin->scheduler->enqueue_async( self::ASYNC_DELETION_ACTION, $args );
797 -
798 - $this->maybe_warn_large_table_without_action_scheduler(
799 - (int) $log_size,
800 - __( 'reset the Stream database (delete all records for this site)', 'stream' )
801 - );
802 - }
803 -
804 - /**
805 - * Warn when a large-table batched operation has to lean on WP-Cron.
806 - *
807 - * Action Scheduler is purpose-built to drain long self-chaining batch
808 - * jobs reliably; default WP-Cron fires opportunistically on traffic and
809 - * can stall a multi-hour chain on a low-traffic site. When Stream is
810 - * running the WP-Cron fallback (the `wp_stream_use_action_scheduler`
811 - * filter returned false, or the bundled AS library is absent) against a
812 - * table over the large-table threshold, surface a notice pointing the
813 - * operator at a deterministic WP-CLI drain instead of failing silently.
814 - *
815 - * Delivery depends on context. Under WP-CLI the warning is emitted
816 - * immediately via {@see Admin::notice()} (WP_CLI::warning) — scheduling
817 - * the batch chain onto WP-Cron does not drain it, so a headless /
818 - * low-traffic site is exactly where the chain can stall. Outside WP-CLI
819 - * neither call site renders its own output (the recurring purge runs
820 - * under DOING_CRON; the manual reset redirects and exits before its
821 - * shutdown hook output reaches the browser), so the message is persisted
822 - * to {@see Admin::LARGE_TABLE_CRON_NOTICE_OPTION} and rendered on the
823 - * next admin page load by {@see Admin::display_large_table_cron_notice()}.
824 - *
825 - * No-op when Action Scheduler is the active backend (built to drain long
826 - * chains). The `wp_stream_enable_auto_purge` filter deliberately does NOT
827 - * gate this helper: it governs TTL retention purging only, while this
828 - * warning also covers the manual database reset — an operator who manages
829 - * retention externally can still click "Reset Stream Database" and needs
830 - * the stall warning. The auto-purge call site is already gated by the
831 - * filter's early return in {@see Admin::purge_scheduled_action()}.
832 - *
833 - * @param int $record_count Number of rows the operation will touch.
834 - * @param string $operation Human-readable, translated description of what the
835 - * batched work does (e.g. "delete records older than
836 - * the retention period"), interpolated into the notice.
837 - * @return void
838 - */
839 - private function maybe_warn_large_table_without_action_scheduler( int $record_count, string $operation ) {
840 - if ( $this->plugin->scheduler instanceof AS_Scheduler ) {
841 - return;
842 - }
843 -
844 - if ( ! $this->plugin->is_large_records_table( $record_count ) ) {
845 - return;
846 - }
847 -
848 - $message = sprintf(
849 - /* translators: 1: operation description (e.g. "delete records older than the retention period"), 2: number of records, 3: WP-CLI command. */
850 - __( 'Stream queued a large batched operation to %1$s (%2$s records) to WP-Cron because Action Scheduler is disabled. The records are removed in chained batches as WP-Cron runs. This completes on its own where reliable cron is configured (a Linux crontab or third-party cron service triggering wp-cron.php on a fixed interval, without an execution timeout). On sites relying on default traffic-triggered WP-Cron the chain may stall before it finishes, leaving records only partly removed; to run it to completion deterministically, use WP-CLI: %3$s', 'stream' ),
851 - $operation,
852 - number_format_i18n( $record_count ),
853 - '<code>wp cron event run --due-now</code>'
854 - );
855 -
856 - if ( defined( 'WP_CLI' ) && WP_CLI ) {
857 - // Immediate WP_CLI::warning — the operator is watching the terminal.
858 - $this->notice( $message );
859 - return;
860 - }
861 -
862 - // Persist for the next admin page load. Neither call site can render
863 - // output itself: the recurring purge runs under DOING_CRON (response
864 - // discarded) and the manual reset redirects + exits before shutdown
865 - // output reaches the browser. No autoload — this is set rarely and
866 - // read only in the admin.
867 - update_option( self::LARGE_TABLE_CRON_NOTICE_OPTION, $message, false );
868 - }
869 -
870 - /**
871 - * Render (and clear) the persisted large-table WP-Cron warning.
872 - *
873 - * Counterpart to {@see Admin::maybe_warn_large_table_without_action_scheduler()}:
874 - * displays the stored warning on the first admin page an operator with
875 - * the Stream settings capability loads after a large batched operation
876 - * was queued onto WP-Cron.
877 - *
878 - * @action admin_notices
879 - * @action network_admin_notices
880 - *
881 - * @return void
882 - */
883 - public function display_large_table_cron_notice() {
884 - if ( ! current_user_can( $this->settings_cap ) ) {
885 - return;
886 - }
887 -
888 - $message = get_option( self::LARGE_TABLE_CRON_NOTICE_OPTION );
889 - if ( empty( $message ) ) {
890 - return;
891 - }
892 -
893 - delete_option( self::LARGE_TABLE_CRON_NOTICE_OPTION );
894 -
895 - printf(
896 - '<div class="notice notice-warning">%s</div>',
897 - wp_kses_post( wpautop( $message ) )
898 - );
899 - }
900 -
901 - /**
902 - * Checks if the async deletion process is running.
903 - *
904 - * Checks pending AND in-flight state, mirroring
905 - * {@see Admin::is_running_auto_purge()}. Under WP-Cron the event is
906 - * removed from the cron array before its callback runs, so a
907 - * pending-only probe would momentarily read idle mid-chain and briefly
908 - * re-expose the reset link in Settings. The batch worker keeps the
909 - * best-effort running marker set for that window (see
910 - * {@see Admin::erase_large_records()}). The marker transient is shared
911 - * with the auto-purge chain, which only makes both guards more
912 - * conservative — never less safe.
913 - *
914 - * @return bool True if the async deletion process is running, false otherwise.
915 - */
916 - public static function is_running_async_deletion() {
917 - $plugin = wp_stream_get_instance();
918 - if ( empty( $plugin->scheduler ) ) {
919 - return false;
920 - }
921 - return $plugin->scheduler->any_pending_or_running( array( self::ASYNC_DELETION_ACTION ) );
922 - }
923 -
924 - /**
925 - * Checks if any auto-purge action is currently scheduled or in-flight.
926 - *
927 - * Returns true when either the batched chain worker or the terminal
928 - * orphan reaper is pending OR running. The recurring scheduler is
929 - * intentionally excluded — it is always pending under normal operation,
930 - * so including it here would make the probe useless. Used by the
931 - * Settings → Advanced UI to render an "Auto-purge currently running"
932 - * notice and by the recurring callback as an overlap guard.
933 - *
934 - * Checks both PENDING and IN-PROGRESS statuses so a chain that is
935 - * mid-execution (e.g. the batch worker is currently running and has not
936 - * yet enqueued the next batch) still reports as running. Without the
937 - * RUNNING check the overlap guard can let a second parallel chain stack
938 - * against the same rows.
939 - *
940 - * @return bool
941 - */
942 - public static function is_running_auto_purge() {
943 - $plugin = wp_stream_get_instance();
944 - if ( empty( $plugin->scheduler ) ) {
945 - return false;
946 - }
947 -
948 - return $plugin->scheduler->any_pending_or_running(
949 - array( self::AUTO_PURGE_BATCH_ACTION, self::AUTO_PURGE_REAPER_ACTION )
950 - );
951 - }
952 -
953 - /**
954 - * Erases large records from the stream table.
955 - *
956 - * This function deletes records from the stream table in batches, starting from a given entry ID.
957 - * It deletes records in reverse chronological order, starting from the largest ID and going back.
958 - * The number of records deleted in each batch is determined by the batch size, which can be filtered
959 - * using the 'wp_stream_batch_size' hook.
960 - *
961 - * @param int $total The total number of records to be deleted.
962 - * @param int $done The number of records that have already been deleted.
963 - * @param int $last_entry The ID of the last entry that was deleted.
964 - * @param int $blog_id The ID of the blog for which the records should be deleted.
965 - * @return void
966 - */
967 - public function erase_large_records( int $total, int $done, int $last_entry, int $blog_id ) {
968 - global $wpdb;
969 -
970 - // Best-effort "running" marker, mirroring auto_purge_batch(). Under
971 - // WP-Cron the event is dequeued before this callback runs, so without
972 - // the marker is_running_async_deletion() would momentarily read idle
973 - // between batches and briefly re-expose the reset link in Settings.
974 - // No-op under Action Scheduler; self-expires on a fatal.
975 - $this->plugin->scheduler->mark_running( 'async_deletion' );
976 -
977 - $start_from = $wpdb->get_var(
978 - $wpdb->prepare(
979 - "SELECT ID FROM {$wpdb->stream} WHERE ID < %d AND `blog_id`=%d ORDER BY ID DESC LIMIT 1",
980 - $last_entry + 1, // A tweak to get it correct the first time through.
981 - get_current_blog_id()
982 - )
983 - );
984 -
985 - if ( empty( $start_from ) ) {
986 - // Terminal batch: nothing left to delete, no further event will
987 - // be chained, and no work follows within this callback — safe to
988 - // clear the marker immediately (unlike the auto-purge chain,
989 - // whose terminal batch hands off to the reaper).
990 - $this->plugin->scheduler->mark_done( 'async_deletion' );
991 - return;
992 - }
993 -
994 - /**
995 - * Filters the number of records in the {$wpdb->stream} table to do at a time.
996 - *
997 - * @since 4.1.0
998 - *
999 - * @param int $batch_size The batch size, default 250000.
1000 - */
1001 - $batch_size = apply_filters( 'wp_stream_batch_size', 250000 );
1002 -
1003 - // This will tend to erase them in reverse chronological order,
1004 - // ie it will start from the largest ID and go back from there.
1005 714 $wpdb->query(
1006 - $wpdb->prepare(
1007 - "DELETE `stream`, `meta`
1008 - FROM {$wpdb->stream} AS `stream`
1009 - LEFT JOIN {$wpdb->streammeta} AS `meta`
1010 - ON `meta`.`record_id` = `stream`.`ID`
1011 - WHERE ID <= %d AND ID >= %d AND `blog_id`=%d;",
1012 - $start_from,
1013 - $start_from - $batch_size,
1014 - get_current_blog_id()
1015 - )
715 + "DELETE `stream`, `meta`
716 + FROM {$wpdb->stream} AS `stream`
717 + LEFT JOIN {$wpdb->streammeta} AS `meta`
718 + ON `meta`.`record_id` = `stream`.`ID`
719 + WHERE 1=1 {$where};" // @codingStandardsIgnoreLine $where already prepared
1016 720 );
1017 -
1018 - $remaining = $wpdb->get_var(
1019 - $wpdb->prepare( "SELECT COUNT(ID) FROM {$wpdb->stream} WHERE `blog_id`=%d", $blog_id )
1020 - );
1021 -
1022 - $done = $total - $remaining;
1023 -
1024 - $this->plugin->scheduler->enqueue_async(
1025 - self::ASYNC_DELETION_ACTION,
1026 - array(
1027 - 'total' => (int) $total,
1028 - 'done' => (int) $done,
1029 - 'last_entry' => (int) $start_from - $batch_size, // The last ID checked.
1030 - 'blog_id' => (int) $blog_id,
1031 - )
1032 - );
1033 721 }
1034 722
1035 723 /**
1036 - * Retrieves the size of the blog record table for a specific blog.
1037 - *
1038 - * @param int|null $blog_id The ID of the blog. If not provided, the current blog ID will be used.
1039 - * @return int The size of the blog record table.
1040 - */
1041 - public static function get_blog_record_table_size( $blog_id = null ): int {
1042 - global $wpdb;
1043 -
1044 - $blog_id = empty( $blog_id ) ? get_current_blog_id() : $blog_id;
1045 -
1046 - $blog_size = $wpdb->get_var(
1047 - $wpdb->prepare(
1048 - "SELECT COUNT(ID) FROM {$wpdb->stream} WHERE `blog_id`=%d",
1049 - $blog_id
1050 - )
1051 - );
1052 -
1053 - return (int) $blog_size;
1054 - }
1055 -
1056 - /**
1057 724 * Schedules a purge of records.
1058 725 *
1059 726 * @return void
1060 727 */
1061 728 public function purge_schedule_setup() {
1062 - // Clear the legacy WP-Cron event scheduled by Stream <= 4.1.x so it
1063 - // cannot double-fire alongside the new recurring action.
1064 - if ( wp_next_scheduled( 'wp_stream_auto_purge' ) ) {
1065 - wp_clear_scheduled_hook( 'wp_stream_auto_purge' );
729 + if ( ! wp_next_scheduled( 'wp_stream_auto_purge' ) ) {
730 + wp_schedule_event( time(), 'twicedaily', 'wp_stream_auto_purge' );
1066 731 }
1067 -
1068 - $scheduler = $this->plugin->scheduler;
1069 -
1070 - /**
1071 - * Filter whether Stream schedules its TTL record auto-purge at all.
1072 - *
1073 - * Custom storage drivers that manage retention externally (TTL
1074 - * indexes, partition rotation, a warehouse job, etc.) can return
1075 - * false to disable all TTL purge scheduling regardless of the
1076 - * scheduler backend. Any already-registered recurring purge is
1077 - * unscheduled from both backends so it cannot keep firing.
1078 - *
1079 - * @param bool $enabled Whether auto-purge scheduling is enabled.
1080 - */
1081 - if ( ! apply_filters( 'wp_stream_enable_auto_purge', true ) ) {
1082 - // Tear down only once, then record the 'disabled' sentinel in the
1083 - // backend marker. This runs on every wp_loaded, so without the
1084 - // guard a permanently-disabled site would pay the unschedule
1085 - // probes on every request; with it, steady state is a single
1086 - // in-memory compare (the marker is autoloaded). The sentinel also
1087 - // covers a site upgrading with the filter already active (no
1088 - // marker yet, but a recurring action left by a previous version).
1089 - // The executing path is independently gated by the same filter in
1090 - // purge_scheduled_action(), so a stray entry that somehow survives
1091 - // cannot purge anything anyway.
1092 - if ( 'disabled' !== get_option( self::SCHEDULER_BACKEND_OPTION ) ) {
1093 - $scheduler->unschedule_all( self::AUTO_PURGE_ACTION );
1094 - wp_unschedule_hook( self::AUTO_PURGE_ACTION );
1095 -
1096 - // Also clear the Action Scheduler store when its API is
1097 - // available but AS is not the active backend (e.g. the cron
1098 - // backend is selected while WooCommerce provides AS). The
1099 - // active-backend unschedule above cannot see AS's store, and
1100 - // this filter promises teardown from BOTH backends. When AS
1101 - // is entirely absent this is skipped — a stray AS entry
1102 - // cannot execute (no AS runner), and if AS appears later the
1103 - // action fires as a no-op thanks to the execute-path gate.
1104 - if ( ! $scheduler instanceof AS_Scheduler && function_exists( 'as_unschedule_all_actions' ) ) {
1105 - ( new AS_Scheduler() )->unschedule_all( self::AUTO_PURGE_ACTION );
1106 - }
1107 -
1108 - update_option( self::SCHEDULER_BACKEND_OPTION, 'disabled' );
1109 - }
1110 - return;
1111 - }
1112 -
1113 - $backend = $scheduler instanceof AS_Scheduler ? 'action_scheduler' : 'wp_cron';
1114 -
1115 - // Detect a backend switch and clear the inactive backend's copy of the
1116 - // recurring action exactly once. A site that switched schedulers (via
1117 - // the wp_stream_use_action_scheduler filter) would otherwise keep
1118 - // firing the purge from BOTH backends — the two stores are independent
1119 - // and neither overlap guard can see the other. The marker is an
1120 - // autoloaded option, so the steady-state cost on every wp_loaded is a
1121 - // single in-memory compare; the cleanup query runs only on the first
1122 - // page load after a switch. Idempotent and self-healing. No data is
1123 - // affected — only the redundant schedule entry.
1124 - if ( get_option( self::SCHEDULER_BACKEND_OPTION ) !== $backend ) {
1125 - $cleanup_done = true;
1126 -
1127 - if ( 'action_scheduler' === $backend ) {
1128 - // Drop any leftover WP-Cron recurring event.
1129 - wp_unschedule_hook( self::AUTO_PURGE_ACTION );
1130 - } elseif ( function_exists( 'as_unschedule_all_actions' ) ) {
1131 - // Drop any leftover Action Scheduler recurring action. Routed
1132 - // through AS_Scheduler so the as_*() call stays contained there.
1133 - ( new AS_Scheduler() )->unschedule_all( self::AUTO_PURGE_ACTION );
1134 - } else {
1135 - // Action Scheduler is not loaded (cron backend selected and no
1136 - // other plugin provides AS), so its store cannot be cleaned
1137 - // right now. Do NOT write the marker: if an AS-providing
1138 - // plugin (e.g. WooCommerce) is installed later, the stray
1139 - // Stream recurring action in the AS store would resume firing
1140 - // alongside the cron one — and the cron overlap guard cannot
1141 - // see it. Leaving the marker stale retries this cleanup on a
1142 - // later request once as_unschedule_all_actions() exists.
1143 - $cleanup_done = false;
1144 - }
1145 -
1146 - if ( $cleanup_done ) {
1147 - update_option( self::SCHEDULER_BACKEND_OPTION, $backend );
1148 - }
1149 - }
1150 -
1151 - // 12 hours == old `twicedaily` interval. The scheduler only schedules
1152 - // a fresh recurring action when one is not already registered.
1153 - $scheduler->schedule_recurring(
1154 - time(),
1155 - 12 * HOUR_IN_SECONDS,
1156 - self::AUTO_PURGE_ACTION,
1157 - array(),
1158 - self::AUTO_PURGE_GROUP
1159 - );
1160 732 }
1161 733
1162 734 /**
1163 - * Deletes orphaned meta records from the database.
1164 - *
1165 - * Deletes meta records from the stream meta table where the corresponding
1166 - * stream record no longer exists.
1167 - *
1168 - * @global wpdb $wpdb The WordPress database object.
1169 - */
1170 - protected function delete_orphaned_meta() {
1171 - global $wpdb;
1172 -
1173 - $wpdb->query(
1174 - "DELETE `meta` FROM {$wpdb->streammeta} as `meta` LEFT JOIN {$wpdb->stream} as `stream` ON `stream`.`ID`=`meta`.`record_id` WHERE `stream`.`ID` IS NULL"
1175 - );
1176 - }
1177 -
1178 - /**
1179 735 * Executes a scheduled purge
1180 736 *
1181 737 * @return void
1182 738 */
1183 739 public function purge_scheduled_action() {
1184 - // Respect the auto-purge master switch on the executing path too, not
1185 - // just at scheduling time. A recurring action already in flight when
1186 - // the filter flips to false (or an args-specific entry the unschedule
1187 - // missed) would otherwise still run a purge cycle the operator opted
1188 - // out of. This filter is documented in Admin::purge_schedule_setup().
1189 - if ( ! apply_filters( 'wp_stream_enable_auto_purge', true ) ) {
1190 - return;
1191 - }
740 + global $wpdb;
1192 741
1193 742 // Don't purge when in Network Admin unless Stream is network activated.
1194 743 if (
1195 - $this->plugin->is_multisite_not_network_activated()
744 + is_multisite()
1196 745 &&
1197 746 is_network_admin()
747 + &&
748 + ! $this->plugin->is_network_activated()
1198 749 ) {
1199 750 return;
1200 751 }
1201 752
1202 753 $defaults = $this->plugin->settings->get_defaults();
1203 - if ( $this->plugin->is_multisite_network_activated() ) {
1204 - $options = wp_parse_args( (array) get_site_option( 'wp_stream_network', array() ), $defaults );
754 + if ( is_multisite() && $this->plugin->is_network_activated() ) {
755 + $options = (array) get_site_option( 'wp_stream_network', $defaults );
1205 756 } else {
1206 - $options = wp_parse_args( (array) get_option( 'wp_stream', array() ), $defaults );
757 + $options = (array) get_option( 'wp_stream', $defaults );
1207 758 }
1208 759
1209 - // TTL fallback. Settings::get_defaults() runs every settings field
1210 - // through the `wp_stream_settings_option_fields` filter, which
1211 - // Network::get_network_admin_fields() uses to strip the `records_ttl`
1212 - // field from the per-site option's defaults set. When this callback runs
1213 - // outside any admin context (Action Scheduler, WP-CLI, system cron), the
1214 - // per-site option_key is in effect, so the filtered defaults array does
1215 - // not contain general_records_ttl at all. Apply the documented 30-day
1216 - // default (classes/class-settings.php, `records_ttl` field) only when
1217 - // the key is genuinely missing, so an operator who set the value via
1218 - // CLI/SQL keeps their explicit choice.
1219 - if ( ! isset( $options['general_records_ttl'] ) ) {
1220 - $options['general_records_ttl'] = 30;
1221 - }
1222 -
1223 - if ( ! empty( $options['general_keep_records_indefinitely'] ) ) {
760 + if ( ! empty( $options['general_keep_records_indefinitely'] ) || ! isset( $options['general_records_ttl'] ) ) {
1224 761 return;
1225 762 }
1226 763
1227 - // Refuse to purge with a non-positive TTL. The UI enforces min=1, but
1228 - // CLI/SQL can set 0 or a negative integer. Honoring those would mean
1229 - // "delete every record on every cycle", which has no legitimate use
1230 - // case (keep_records_indefinitely covers the opposite extreme).
1231 - // Bailing out makes operator error visible (records stop being purged)
1232 - // instead of catastrophic (records get wiped repeatedly).
1233 - if ( (int) $options['general_records_ttl'] < 1 ) {
1234 - return;
1235 - }
764 + $days = $options['general_records_ttl'];
765 + $timezone = new DateTimeZone( 'UTC' );
766 + $date = new DateTime( 'now', $timezone );
1236 767
1237 - // Overlap guard: if any auto-purge action (batch worker or reaper) is
1238 - // pending or in-progress, don't stack a new chain. Reuses the same
1239 - // probe used by the Settings UI so the two views of "running" agree.
1240 - if ( self::is_running_auto_purge() ) {
1241 - return;
1242 - }
768 + $date->sub( DateInterval::createFromDateString( "$days days" ) );
1243 769
1244 - /**
1245 - * Fires once per auto-purge cycle, after all bail-out checks pass and
1246 - * immediately before deletion work is enqueued.
1247 - *
1248 - * Preserved for backward compatibility with consumers that hooked the
1249 - * legacy WP-Cron event of the same name in Stream <= 4.1.x. Note that
1250 - * since 4.2.0 this fires only when a purge is actually about to run —
1251 - * it no longer fires on every cron tick regardless of whether work
1252 - * happens. Hook into the recurring AS action (Admin::AUTO_PURGE_ACTION)
1253 - * directly if you need the older "every tick" semantics.
1254 - */
1255 - do_action( 'wp_stream_auto_purge' );
770 + $where = $wpdb->prepare( ' AND `stream`.`created` < %s', $date->format( 'Y-m-d H:i:s' ) );
1256 771
1257 - // Snapshot the UTC cutoff once per recurring tick. Each batch in this
1258 - // chain operates against this fixed cutoff so the chain is finite.
1259 - $days = (int) $options['general_records_ttl'];
1260 - $cutoff = ( new DateTime( 'now', new DateTimeZone( 'UTC' ) ) )
1261 - ->sub( DateInterval::createFromDateString( $days . ' days' ) )
1262 - ->format( 'Y-m-d H:i:s' );
1263 -
1264 - // blog_id = 0 means "all blogs" (network-activated path).
1265 - $blog_id = $this->plugin->is_multisite_not_network_activated() ? (int) get_current_blog_id() : 0;
1266 -
1267 - global $wpdb;
1268 -
1269 - // "Is this a large table?" decision matches the manual reset path
1270 - // (Admin::erase_stream_records()). When the table is small the cost
1271 - // of scheduling a chain (and waiting for AS to drain it on the next
1272 - // runner tick) exceeds the cost of a single inline DELETE. Only fall
1273 - // through to the batched chain when the filter says "yes, large".
1274 - if ( $blog_id > 0 ) {
1275 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
1276 - $record_count = (int) $wpdb->get_var(
1277 - $wpdb->prepare( "SELECT COUNT(ID) FROM {$wpdb->stream} WHERE `blog_id` = %d", $blog_id )
1278 - );
1279 - } else {
1280 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
1281 - $record_count = (int) $wpdb->get_var( "SELECT COUNT(ID) FROM {$wpdb->stream}" );
772 + // Multisite but NOT network activated, only purge the current blog.
773 + if ( is_multisite() && ! $this->plugin->is_network_activated() ) {
774 + $where .= $wpdb->prepare( ' AND `blog_id` = %d', get_current_blog_id() );
1282 775 }
1283 776
1284 - if ( ! $this->plugin->is_large_records_table( $record_count ) ) {
1285 - // Small-table fast path: one inline multi-table DELETE, then enqueue
1286 - // the orphan reaper as a one-shot async action so the heal step is
1287 - // still observable in Tools → Scheduled Actions.
1288 - if ( $blog_id > 0 ) {
1289 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
1290 - $wpdb->query(
1291 - $wpdb->prepare(
1292 - "DELETE `stream`, `meta`
1293 - FROM {$wpdb->stream} AS `stream`
1294 - LEFT JOIN {$wpdb->streammeta} AS `meta`
1295 - ON `meta`.`record_id` = `stream`.`ID`
1296 - WHERE `stream`.`created` < %s AND `stream`.`blog_id` = %d;",
1297 - $cutoff,
1298 - $blog_id
1299 - )
1300 - );
1301 - } else {
1302 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
1303 - $wpdb->query(
1304 - $wpdb->prepare(
1305 - "DELETE `stream`, `meta`
1306 - FROM {$wpdb->stream} AS `stream`
1307 - LEFT JOIN {$wpdb->streammeta} AS `meta`
1308 - ON `meta`.`record_id` = `stream`.`ID`
1309 - WHERE `stream`.`created` < %s;",
1310 - $cutoff
1311 - )
1312 - );
1313 - }
1314 -
1315 - $this->plugin->scheduler->enqueue_async( self::AUTO_PURGE_REAPER_ACTION, array(), self::AUTO_PURGE_GROUP );
1316 - return;
1317 - }
1318 -
1319 - // Large-table path: batched chain.
1320 - $this->plugin->scheduler->enqueue_async(
1321 - self::AUTO_PURGE_BATCH_ACTION,
1322 - array(
1323 - 'cutoff' => $cutoff,
1324 - 'blog_id' => $blog_id,
1325 - ),
1326 - self::AUTO_PURGE_GROUP
777 + $wpdb->query(
778 + "DELETE `stream`, `meta`
779 + FROM {$wpdb->stream} AS `stream`
780 + LEFT JOIN {$wpdb->streammeta} AS `meta`
781 + ON `meta`.`record_id` = `stream`.`ID`
782 + WHERE 1=1 {$where};" // @codingStandardsIgnoreLine $where already prepared
1327 783 );
1328 -
1329 - $this->maybe_warn_large_table_without_action_scheduler(
1330 - $record_count,
1331 - __( 'delete records older than the retention period', 'stream' )
1332 - );
1333 784 }
1334 785
1335 786 /**
1336 - * Async Action Scheduler callback: delete one batch of records eligible
1337 - * under the snapshotted UTC cutoff, then chain the next batch (or the
1338 - * orphan reaper when nothing remains).
1339 - *
1340 - * Window-based deletion mirrors {@see Admin::erase_large_records()} so the
1341 - * InnoDB lock footprint is bounded and predictable on bloated tables.
1342 - *
1343 - * @param string $cutoff MySQL DATETIME string in UTC.
1344 - * @param int $blog_id Blog to scope to, or 0 for all blogs (network-activated).
1345 - * @param int $last_entry The lower-bound ID of the previous batch's window; 0 on the
1346 - * first batch in a chain. The next SELECT uses `ID < last_entry`
1347 - * when non-zero, guaranteeing forward progress even on tables
1348 - * that grow rapidly during the chain. Trade-off: any eligible
1349 - * row that lands inside the already-touched ID range
1350 - * [window_low, start_from] after that batch ran is skipped
1351 - * by the current chain and picked up on the next recurring
1352 - * tick (or small-table fast path). Possible sources: dev/test
1353 - * seeders, importer/migration plugins replaying historical
1354 - * rows, or PHP/MySQL clock skew on `created`. Steady-state
1355 - * logging via Log::log() uses monotonic IDs and current UTC,
1356 - * so this is a no-op for normal production traffic.
1357 - * @throws \InvalidArgumentException When $cutoff is empty (signals AS to mark the action as failed).
1358 - * @return void
1359 - */
1360 - public function auto_purge_batch( $cutoff, $blog_id = 0, $last_entry = 0 ) {
1361 - global $wpdb;
1362 -
1363 - $cutoff = (string) $cutoff;
1364 - $blog_id = (int) $blog_id;
1365 - $last_entry = (int) $last_entry;
1366 -
1367 - // Defensive: a malformed cutoff would otherwise translate to a no-op
1368 - // DELETE that still busies the DB. Throw so Action Scheduler marks
1369 - // the action as failed (and visible in Tools → Scheduled Actions)
1370 - // rather than silently completing. In practice this is unreachable
1371 - // because purge_scheduled_action() always populates the cutoff arg
1372 - // and AS args are immutable; the guard exists for third-party code
1373 - // that may enqueue the action with bad input.
1374 - if ( '' === $cutoff ) {
1375 - throw new \InvalidArgumentException( 'auto_purge_batch requires a non-empty cutoff.' );
1376 - }
1377 -
1378 - // Best-effort "running" marker for schedulers without a native RUNNING
1379 - // store (cron). Bridges the gap between this batch starting and the
1380 - // next chained event being enqueued; self-expires on a fatal. No-op
1381 - // under Action Scheduler. Cleared when the chain reaches its terminal
1382 - // reaper (see the empty-$start_from branch below).
1383 - $this->plugin->scheduler->mark_running( 'auto_purge' );
1384 -
1385 - /**
1386 - * Filters the number of records to delete per batch.
1387 - *
1388 - * Shared with the manual reset path (see {@see Admin::erase_large_records()})
1389 - * so site owners only need to tune one knob.
1390 - *
1391 - * @since 4.1.0
1392 - *
1393 - * @param int $batch_size Default 250000.
1394 - */
1395 - $batch_size = (int) apply_filters( 'wp_stream_batch_size', 250000 );
1396 - if ( $batch_size < 1 ) {
1397 - $batch_size = 250000;
1398 - }
1399 -
1400 - // Find the highest-ID record still eligible under the snapshotted cutoff
1401 - // that lies strictly below the previous window's lower bound (when set).
1402 - // $last_entry=0 means "first batch in chain" — search from the top.
1403 - if ( $blog_id > 0 && $last_entry > 0 ) {
1404 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
1405 - $start_from = $wpdb->get_var(
1406 - $wpdb->prepare(
1407 - "SELECT ID FROM {$wpdb->stream} WHERE `created` < %s AND `blog_id` = %d AND `ID` < %d ORDER BY ID DESC LIMIT 1",
1408 - $cutoff,
1409 - $blog_id,
1410 - $last_entry
1411 - )
1412 - );
1413 - } elseif ( $blog_id > 0 ) {
1414 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
1415 - $start_from = $wpdb->get_var(
1416 - $wpdb->prepare(
1417 - "SELECT ID FROM {$wpdb->stream} WHERE `created` < %s AND `blog_id` = %d ORDER BY ID DESC LIMIT 1",
1418 - $cutoff,
1419 - $blog_id
1420 - )
1421 - );
1422 - } elseif ( $last_entry > 0 ) {
1423 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
1424 - $start_from = $wpdb->get_var(
1425 - $wpdb->prepare(
1426 - "SELECT ID FROM {$wpdb->stream} WHERE `created` < %s AND `ID` < %d ORDER BY ID DESC LIMIT 1",
1427 - $cutoff,
1428 - $last_entry
1429 - )
1430 - );
1431 - } else {
1432 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
1433 - $start_from = $wpdb->get_var(
1434 - $wpdb->prepare(
1435 - "SELECT ID FROM {$wpdb->stream} WHERE `created` < %s ORDER BY ID DESC LIMIT 1",
1436 - $cutoff
1437 - )
1438 - );
1439 - }
1440 -
1441 - if ( empty( $start_from ) ) {
1442 - // Chain is done. Schedule the orphan reaper as the terminal step.
1443 - // The running marker is NOT cleared here: under WP-Cron the reaper
1444 - // event is removed from the cron array before its callback runs,
1445 - // so clearing now would let the overlap guard read "idle" while
1446 - // the reaper's orphan-meta DELETE is still executing. The reaper
1447 - // clears the marker itself when it finishes.
1448 - $this->plugin->scheduler->enqueue_async( self::AUTO_PURGE_REAPER_ACTION, array(), self::AUTO_PURGE_GROUP );
1449 - return;
1450 - }
1451 -
1452 - $start_from = (int) $start_from;
1453 - $window_low = max( 0, $start_from - $batch_size );
1454 -
1455 - // Multi-table DELETE: parent + meta in one statement. Mirrors
1456 - // Admin::erase_large_records().
1457 - if ( $blog_id > 0 ) {
1458 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
1459 - $wpdb->query(
1460 - $wpdb->prepare(
1461 - "DELETE `stream`, `meta`
1462 - FROM {$wpdb->stream} AS `stream`
1463 - LEFT JOIN {$wpdb->streammeta} AS `meta`
1464 - ON `meta`.`record_id` = `stream`.`ID`
1465 - WHERE `stream`.`ID` <= %d
1466 - AND `stream`.`ID` >= %d
1467 - AND `stream`.`created` < %s
1468 - AND `stream`.`blog_id` = %d;",
1469 - $start_from,
1470 - $window_low,
1471 - $cutoff,
1472 - $blog_id
1473 - )
1474 - );
1475 - } else {
1476 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
1477 - $wpdb->query(
1478 - $wpdb->prepare(
1479 - "DELETE `stream`, `meta`
1480 - FROM {$wpdb->stream} AS `stream`
1481 - LEFT JOIN {$wpdb->streammeta} AS `meta`
1482 - ON `meta`.`record_id` = `stream`.`ID`
1483 - WHERE `stream`.`ID` <= %d
1484 - AND `stream`.`ID` >= %d
1485 - AND `stream`.`created` < %s;",
1486 - $start_from,
1487 - $window_low,
1488 - $cutoff
1489 - )
1490 - );
1491 - }
1492 -
1493 - // Chain the next batch. Pass $window_low as the new upper bound so the
1494 - // next SELECT cannot pick up rows in or above the window we just touched.
1495 - $this->plugin->scheduler->enqueue_async(
1496 - self::AUTO_PURGE_BATCH_ACTION,
1497 - array(
1498 - 'cutoff' => $cutoff,
1499 - 'blog_id' => $blog_id,
1500 - 'last_entry' => $window_low,
1501 - ),
1502 - self::AUTO_PURGE_GROUP
1503 - );
1504 - }
1505 -
1506 - /**
1507 - * Terminal Action Scheduler callback for the auto-purge chain.
1508 - *
1509 - * Runs once per chain (after the last batch) and once when the manual
1510 - * "Clean orphaned meta now" button is used. Cleans up meta rows whose
1511 - * parent stream row is already gone — i.e. residue from historical
1512 - * unbatched purges and from any logger races during a chain.
1513 - *
1514 - * @return void
1515 - */
1516 - public function auto_purge_reaper() {
1517 - // Keep the overlap guard reading "busy" while the orphan-meta DELETE
1518 - // runs. Under WP-Cron the event is removed from the cron array before
1519 - // this callback executes, so without the marker a recurring purge
1520 - // tick or a manual "clean orphaned meta" click could stack parallel
1521 - // work against the same rows. No-op under Action Scheduler, which
1522 - // tracks RUNNING state natively. Self-expires on a fatal.
1523 - $this->plugin->scheduler->mark_running( 'auto_purge' );
1524 -
1525 - $this->delete_orphaned_meta();
1526 -
1527 - $this->plugin->scheduler->mark_done( 'auto_purge' );
1528 - }
1529 -
1530 - /**
1531 - * Ajax handler for the "Clean orphaned meta now" button on
1532 - * Settings → Advanced.
1533 - *
1534 - * Schedules an immediate async run of the orphan reaper. Idempotent:
1535 - * if a reaper is already scheduled, returns without enqueuing a second.
1536 - *
1537 - * Returns true under WP_STREAM_TESTS so PHPUnit can call this directly
1538 - * without exiting the worker.
1539 - *
1540 - * @return bool|void True under tests; otherwise redirects and exits.
1541 - */
1542 - public function wp_ajax_clean_orphan_meta() {
1543 - if ( ! current_user_can( $this->settings_cap ) ) {
1544 - wp_die( esc_html__( 'You do not have permission to do this.', 'stream' ), 403 );
1545 - }
1546 -
1547 - check_ajax_referer( 'stream_nonce_clean_orphan_meta', 'wp_stream_nonce_clean_orphan_meta' );
1548 -
1549 - if ( empty( $this->plugin->scheduler ) ) {
1550 - wp_die( esc_html__( 'No scheduler is available.', 'stream' ), 500 );
1551 - }
1552 -
1553 - // Idempotency: skip enqueue when any auto-purge action is already
1554 - // pending or running. is_running_auto_purge() checks PENDING + RUNNING
1555 - // across the batch worker and the reaper, so a chain that will run
1556 - // its own terminal reaper is not duplicated by a manual click landing
1557 - // in the small CSRF/stale-URL window where the UI link is hidden.
1558 - if ( ! self::is_running_auto_purge() ) {
1559 - $this->plugin->scheduler->enqueue_async( self::AUTO_PURGE_REAPER_ACTION, array(), self::AUTO_PURGE_GROUP );
1560 - }
1561 -
1562 - if ( defined( 'WP_STREAM_TESTS' ) && WP_STREAM_TESTS ) {
1563 - return true;
1564 - }
1565 -
1566 - $is_network = $this->plugin->is_multisite_network_activated();
1567 - $page_slug = $is_network ? $this->network->network_settings_page_slug : $this->settings_page_slug;
1568 - $base_url = $is_network ? network_admin_url( $this->admin_parent_page ) : admin_url( $this->admin_parent_page );
1569 -
1570 - wp_safe_redirect(
1571 - add_query_arg(
1572 - array(
1573 - 'page' => $page_slug,
1574 - 'wp_stream_message' => 'orphan_meta_cleanup_scheduled',
1575 - ),
1576 - $base_url
1577 - )
1578 - );
1579 - exit;
1580 - }
1581 -
1582 - /**
1583 - * Render admin notices for post-action redirects.
1584 - *
1585 - * Reads `wp_stream_message` from the query string and renders a matching
1586 - * notice. Used to surface "Clean Orphaned Meta" confirmation after the
1587 - * Ajax handler redirects back to Settings → Advanced.
1588 - *
1589 - * @return void
1590 - */
1591 - public function maybe_display_message() {
1592 - $message = wp_stream_filter_input( INPUT_GET, 'wp_stream_message' );
1593 - if ( empty( $message ) ) {
1594 - return;
1595 - }
1596 -
1597 - $notices = array(
1598 - 'orphan_meta_cleanup_scheduled' => __(
1599 - 'Orphaned meta cleanup scheduled. Progress is visible under Tools → Scheduled Actions.',
1600 - 'stream'
1601 - ),
1602 - );
1603 -
1604 - if ( ! isset( $notices[ $message ] ) ) {
1605 - return;
1606 - }
1607 -
1608 - printf(
1609 - '<div class="notice notice-success is-dismissible"><p>%s</p></div>',
1610 - esc_html( $notices[ $message ] )
1611 - );
1612 - }
1613 -
1614 - /**
1615 787 * Returns the admin action links.
1616 788 *
1617 789 * @filter plugin_action_links
1618 790 *
@@ -1626,9 +798,9 @@
1626 798 return $links;
1627 799 }
1628 800
1629 801 // Also don't show links in Network Admin if Stream isn't network enabled.
1630 - if ( is_network_admin() && $this->plugin->is_multisite_not_network_activated() ) {
802 + if ( is_network_admin() && is_multisite() && ! $this->plugin->is_network_activated() ) {
1631 803 return $links;
1632 804 }
1633 805
1634 806 if ( is_network_admin() ) {
@@ -1646,9 +818,9 @@
1646 818 admin_url( $this->admin_parent_page )
1647 819 );
1648 820 }
1649 821
1650 - $links[] = sprintf( '<a href="%s">%s</a>', esc_url( $admin_page_url ), esc_html__( 'Settings', 'stream' ) );
822 + $links[] = sprintf( '<a href="%s">%s</a>', esc_url( $admin_page_url ), esc_html__( 'Settings', 'default' ) );
1651 823
1652 824 return $links;
1653 825 }
1654 826
@@ -1675,18 +847,10 @@
1675 847 $page_description = apply_filters( 'wp_stream_settings_form_description', '' );
1676 848
1677 849 $sections = $this->plugin->settings->get_fields();
1678 850 $active_tab = wp_stream_filter_input( INPUT_GET, 'tab' );
1679 -
1680 - $this->plugin->enqueue_asset(
1681 - 'settings',
1682 - array(),
1683 - array(
1684 - 'i18n' => array(
1685 - 'confirm_purge' => __( 'Are you sure you want to delete all Stream activity records from the database? This cannot be undone.', 'stream' ),
1686 - ),
1687 - )
1688 - );
851 + $min = wp_stream_min_suffix();
852 + wp_enqueue_script( 'wp-stream-settings', $this->plugin->locations['url'] . 'ui/js/settings.' . $min . 'js', array( 'jquery' ), $this->plugin->get_version(), true );
1689 853 ?>
1690 854 <div class="wrap">
1691 855 <h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
1692 856
@@ -1747,22 +911,18 @@
1747 911
1748 912 /**
1749 913 * Check if a particular role has access
1750 914 *
1751 - * The user_has_cap/role_has_cap filters that call this are registered in the
1752 - * constructor, but the Settings object is not constructed until init priority 9.
1753 - * A capability check fired before then (e.g. by a security plugin evaluating
1754 - * firewall rules on plugins_loaded) must be denied rather than fatal on the
1755 - * null options chain.
1756 - *
1757 915 * @param string $role User role.
1758 916 *
1759 917 * @return bool
1760 918 */
1761 919 private function role_can_view( $role ) {
1762 - $allowed_roles = $this->plugin->settings->options['general_role_access'] ?? array();
920 + if ( in_array( $role, $this->plugin->settings->options['general_role_access'], true ) ) {
921 + return true;
922 + }
1763 923
1764 - return in_array( $role, (array) $allowed_roles, true );
924 + return false;
1765 925 }
1766 926
1767 927 /**
1768 928 * Filter user caps to dynamically grant our view cap based on allowed roles