PluginProbe
Stream – Activity Log & Audit Trail / 3.9.0
Stream – Activity Log & Audit Trail v3.9.0
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 +238 -799 4.2.13.9.0 View file →
@@ -9,10 +9,10 @@
9 9
10 10 use DateTime;
11 11 use DateTimeZone;
12 12 use DateInterval;
13 -use WP_CLI;
14 -use WP_Roles;
13 +use \WP_CLI;
14 +use \WP_Roles;
15 15
16 16 /**
17 17 * Class - Admin
18 18 */
@@ -18,43 +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 22 * Holds Instance of plugin object
58 23 *
59 24 * @var Plugin
60 25 */
@@ -137,13 +102,13 @@
137 102 */
138 103 public $view_cap = 'view_stream';
139 104
140 105 /**
141 - * Capability name for managing settings
106 + * Capability name for viewing settings
142 107 *
143 108 * @var string
144 109 */
145 - public $settings_cap = WP_STREAM_SETTINGS_CAPABILITY;
110 + public $settings_cap = 'manage_options';
146 111
147 112 /**
148 113 * Total amount of authors to pre-load
149 114 *
@@ -176,9 +141,9 @@
176 141 // User and role caps.
177 142 add_filter( 'user_has_cap', array( $this, 'filter_user_caps' ), 10, 4 );
178 143 add_filter( 'role_has_cap', array( $this, 'filter_role_caps' ), 10, 3 );
179 144
180 - if ( $this->plugin->is_multisite_network_activated() && ! is_network_admin() ) {
145 + if ( is_multisite() && $plugin->is_network_activated() && ! is_network_admin() ) {
181 146 $options = (array) get_site_option( 'wp_stream_network', array() );
182 147 $option = isset( $options['general_site_access'] ) ? absint( $options['general_site_access'] ) : 1;
183 148
184 149 $this->disable_access = ( $option ) ? false : true;
@@ -192,11 +157,8 @@
192 157 // Admin notices.
193 158 add_action( 'admin_notices', array( $this, 'prepare_admin_notices' ) );
194 159 add_action( 'shutdown', array( $this, 'admin_notices' ) );
195 160
196 - // Feature request notice.
197 - add_action( 'admin_notices', array( $this, 'display_feature_request_notice' ) );
198 -
199 161 // Add admin body class.
200 162 add_filter( 'admin_body_class', array( $this, 'admin_body_class' ) );
201 163
202 164 // Plugin action links.
@@ -228,35 +190,24 @@
228 190 'wp_ajax_reset',
229 191 )
230 192 );
231 193
232 - // Manual "Clean orphaned meta now" action (Settings → Advanced).
233 - add_action(
234 - 'wp_ajax_wp_stream_clean_orphan_meta',
235 - array( $this, 'wp_ajax_clean_orphan_meta' )
236 - );
194 + /**
195 + * Uninstall Streams and Deactivate plugin.
196 + *
197 + * @todo Confirm if variable assignment is necessary.
198 + */
199 + $uninstall = $this->plugin->db->driver->purge_storage( $this->plugin );
237 200
238 - // Render confirmation notices keyed by the wp_stream_message query
239 - // arg set on post-action redirects (e.g. orphan_meta_cleanup_scheduled).
240 - add_action( 'admin_notices', array( $this, 'maybe_display_message' ) );
241 - add_action( 'network_admin_notices', array( $this, 'maybe_display_message' ) );
242 -
243 - // Auto purge setup (Action Scheduler).
201 + // Auto purge setup.
244 202 add_action( 'wp_loaded', array( $this, 'purge_schedule_setup' ) );
245 203 add_action(
246 - self::AUTO_PURGE_ACTION,
247 - array( $this, 'purge_scheduled_action' )
204 + 'wp_stream_auto_purge',
205 + array(
206 + $this,
207 + 'purge_scheduled_action',
208 + )
248 209 );
249 - add_action(
250 - self::AUTO_PURGE_BATCH_ACTION,
251 - array( $this, 'auto_purge_batch' ),
252 - 10,
253 - 3
254 - );
255 - add_action(
256 - self::AUTO_PURGE_REAPER_ACTION,
257 - array( $this, 'auto_purge_reaper' )
258 - );
259 210
260 211 // Ajax users list.
261 212 add_action(
262 213 'wp_ajax_wp_stream_filters',
@@ -264,19 +215,8 @@
264 215 $this,
265 216 'ajax_filters',
266 217 )
267 218 );
268 -
269 - // Async action for erasing large log tables.
270 - add_action(
271 - self::ASYNC_DELETION_ACTION,
272 - array(
273 - $this,
274 - 'erase_large_records',
275 - ),
276 - 10,
277 - 4
278 - );
279 219 }
280 220
281 221 /**
282 222 * Load admin classes
@@ -286,14 +226,8 @@
286 226 public function init() {
287 227 $this->network = new Network( $this->plugin );
288 228 $this->live_update = new Live_Update( $this->plugin );
289 229 $this->export = new Export( $this->plugin );
290 -
291 - // Check if the host has configured the `REMOTE_ADDR` correctly.
292 - $client_ip = $this->plugin->get_client_ip_address();
293 - if ( empty( $client_ip ) && $this->is_stream_screen() ) {
294 - $this->notice( __( 'Stream plugin can\'t determine a reliable client IP address! Please update the hosting environment to set the $_SERVER[\'REMOTE_ADDR\'] variable or use the wp_stream_client_ip_address filter to specify the verified client IP address!', 'stream' ) );
295 - }
296 230 }
297 231
298 232 /**
299 233 * Output specific updates passed as URL parameters.
@@ -369,28 +303,8 @@
369 303 }
370 304 }
371 305
372 306 /**
373 - * Display a feature request notice.
374 - *
375 - * @return void
376 - */
377 - public function display_feature_request_notice() {
378 - $screen = get_current_screen();
379 -
380 - // Display the notice only on the Stream settings page.
381 - if ( empty( $this->screen_id['settings'] ) || $this->screen_id['settings'] !== $screen->id ) {
382 - return;
383 - }
384 -
385 - printf(
386 - '<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>',
387 - esc_html__( 'Have suggestions or found a bug?', 'stream' ),
388 - esc_html__( 'Click here to let us know!', 'stream' )
389 - );
390 - }
391 -
392 - /**
393 307 * Register menu page
394 308 *
395 309 * @action admin_menu
396 310 *
@@ -425,9 +339,9 @@
425 339 $main_menu_title,
426 340 $this->view_cap,
427 341 $this->records_page_slug,
428 342 array( $this, 'render_list_table' ),
429 - 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDI0IDEwMjQiIGZpbGw9IjAwMCI+Cgk8cGF0aCBkPSJNOTAzLjExNSA1MTUuNDEzYy00OS4zOTIgMC05MS40NzQgMzEuMzM3LTEwNy40NiA3NS4yMDNsLTEyNC40MTEtMS41MzJjLTExLjM3Ny0uMzQ2LTIyLjc1MS0uNjg5LTM0LjEyOS0uOTk4bC0uMjQxLjU3NC0yMi40MzYtLjI3OC0uMTUzLS45Mi0xNS4wNTYtODIuOTMzLTIwLjE0Ni0xMDguNDA1LTIwLjU0NC0xMDguMzM3TDUwMy45ODIgMGwtNTMuMTQxIDQyOS4wMTMtMTYuMjE0IDEzNy45MzUtMTIuMDE2IDEwNi45MjQtMTE3LjI4Ni0yODUuMjItMTguMzUzIDIwMi44MWMtNDIuNTYyIDEuNDU0LTg1LjEyNyAyLjkzNC0xMjcuNjg4IDQuNzM4LTUzLjA5NyAyLjI5Mi0xMDYuMTg3IDQuNDczLTE1OS4yODQgNy41MzZ2NDIuMDQyYzUzLjA5NyAzLjA2IDEwNi4xODcgNS4yNDcgMTU5LjI4NCA3LjUzMyA1My4wOTMgMi4yNDUgMTA2LjE4IDQuMTk0IDE1OS4yNzMgNS45MDNsMTQuMjQuNDY1IDE3LjM1MSA0OC4zOWMxOC44NDIgNTEuODc0IDM3LjU0MiAxMDMuODA2IDU2Ljc2NSAxNTUuNTQxTDQ2Ni41MiAxMDI0bDQxLjUxMi0zMDguMjkzIDE3LjYzMy0xMzYuNjg1IDEwLjc3NiA1MC4zMjkgNTQuODE1IDI0OC41NDQgNzIuNTE2LTIxNy4yMTdoMTI5LjI2MWMxMy40OTMgNDguMTIxIDU3LjY1NSA4My40MjkgMTEwLjA3NSA4My40MjkgNjMuMTYgMCAxMTQuMzUyLTUxLjIwNSAxMTQuMzUyLTExNC4zNDggMC02My4xMzktNTEuMTg5LTExNC4zNDUtMTE0LjM0OS0xMTQuMzQ1bC4wMDQtLjAwMVoiIC8+Cjwvc3ZnPgo=',
343 + 'div',
430 344 $main_menu_position
431 345 );
432 346
433 347 /**
@@ -482,50 +396,103 @@
482 396 *
483 397 * @return void
484 398 */
485 399 public function admin_enqueue_scripts( $hook ) {
486 - if ( in_array( $hook, $this->screen_id, true ) ) {
487 - $this->plugin->enqueue_asset(
488 - 'admin',
400 + wp_register_script( 'wp-stream-select2', $this->plugin->locations['url'] . 'ui/lib/select2/js/select2.full.min.js', array( 'jquery' ), '3.5.2', true );
401 + wp_register_style( 'wp-stream-select2', $this->plugin->locations['url'] . 'ui/lib/select2/css/select2.min.css', array(), '3.5.2' );
402 + wp_register_script( 'wp-stream-timeago', $this->plugin->locations['url'] . 'ui/lib/timeago/jquery.timeago.js', array(), '1.4.1', true );
403 +
404 + $locale = strtolower( substr( get_locale(), 0, 2 ) );
405 + $file_tmpl = 'ui/lib/timeago/locales/jquery.timeago.%s.js';
406 +
407 + if ( file_exists( $this->plugin->locations['dir'] . sprintf( $file_tmpl, $locale ) ) ) {
408 + wp_register_script(
409 + 'wp-stream-timeago-locale',
410 + $this->plugin->locations['url'] . sprintf( $file_tmpl, $locale ),
411 + array( 'wp-stream-timeago' ),
412 + '1',
413 + false
414 + );
415 + } else {
416 + wp_register_script(
417 + 'wp-stream-timeago-locale',
418 + $this->plugin->locations['url'] . sprintf( $file_tmpl, 'en' ),
419 + array( 'wp-stream-timeago' ),
420 + '1',
421 + false
422 + );
423 + }
424 +
425 + $min = wp_stream_min_suffix();
426 + wp_enqueue_style( 'wp-stream-admin', $this->plugin->locations['url'] . 'ui/css/admin.' . $min . 'css', array(), $this->plugin->get_version() );
427 +
428 + $script_screens = array( 'plugins.php' );
429 +
430 + if ( in_array( $hook, $this->screen_id, true ) || in_array( $hook, $script_screens, true ) ) {
431 + wp_enqueue_script( 'wp-stream-select2' );
432 + wp_enqueue_style( 'wp-stream-select2' );
433 +
434 + wp_enqueue_script( 'wp-stream-timeago' );
435 + wp_enqueue_script( 'wp-stream-timeago-locale' );
436 +
437 + wp_enqueue_script(
438 + 'wp-stream-admin',
439 + $this->plugin->locations['url'] . 'ui/js/admin.' . $min . 'js',
489 440 array(
490 - $this->plugin->with_select2(),
491 - $this->plugin->with_jquery_timeago(),
441 + 'jquery',
442 + 'wp-stream-select2',
492 443 ),
444 + $this->plugin->get_version(),
445 + false
446 + );
447 + wp_enqueue_script(
448 + 'wp-stream-admin-exclude',
449 + $this->plugin->locations['url'] . 'ui/js/exclude.' . $min . 'js',
493 450 array(
494 - 'i18n' => array(
495 - 'confirm_purge' => __( 'Are you sure you want to delete all Stream activity records from the database? This cannot be undone.', 'stream' ),
496 - 'confirm_defaults' => __( 'Are you sure you want to reset all site settings to default? This cannot be undone.', 'stream' ),
497 - ),
498 - 'locale' => strtolower( substr( get_locale(), 0, 2 ) ),
499 - 'gmt_offset' => get_option( 'gmt_offset' ),
500 - )
451 + 'jquery',
452 + 'wp-stream-select2',
453 + ),
454 + $this->plugin->get_version(),
455 + false
501 456 );
502 -
503 - $this->plugin->enqueue_asset(
504 - 'admin-exclude',
457 + wp_enqueue_script(
458 + 'wp-stream-live-updates',
459 + $this->plugin->locations['url'] . 'ui/js/live-updates.' . $min . 'js',
505 460 array(
506 - $this->plugin->with_select2(),
461 + 'jquery',
462 + 'heartbeat',
507 463 ),
464 + $this->plugin->get_version(),
465 + false
466 + );
467 +
468 + wp_localize_script(
469 + 'wp-stream-admin',
470 + 'wp_stream',
508 471 array(
509 - 'getActionsNonce' => wp_create_nonce( 'stream_get_actions' ),
472 + 'i18n' => array(
473 + 'confirm_purge' => esc_html__( 'Are you sure you want to delete all Stream activity records from the database? This cannot be undone.', 'stream' ),
474 + 'confirm_defaults' => esc_html__( 'Are you sure you want to reset all site settings to default? This cannot be undone.', 'stream' ),
475 + 'confirm_uninstall' => esc_html__( 'Are you sure you want to uninstall and deactivate Stream? This will delete all Stream tables from the database and cannot be undone.', 'stream' ),
476 + ),
477 + 'locale' => esc_js( $locale ),
478 + 'gmt_offset' => get_option( 'gmt_offset' ),
510 479 )
511 480 );
512 481
513 - $current_order = isset( $_GET['order'] ) ? sanitize_key( wp_unslash( $_GET['order'] ) ) : 'desc'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
514 - if ( ! in_array( $current_order, array( 'asc', 'desc' ), true ) ) {
515 - $current_order = 'desc';
516 - }
517 - $current_query = map_deep( wp_unslash( $_GET ), 'sanitize_text_field' ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
482 + $order_types = array( 'asc', 'desc' );
518 483
519 - $this->plugin->enqueue_asset(
520 - 'live-updates',
521 - array( 'heartbeat' ),
484 + wp_localize_script(
485 + 'wp-stream-live-updates',
486 + 'wp_stream_live_updates',
522 487 array(
523 488 'current_screen' => $hook,
524 489 'current_page' => isset( $_GET['paged'] ) ? absint( wp_unslash( $_GET['paged'] ) ) : '1', // phpcs:ignore WordPress.Security.NonceVerification.Recommended
525 - 'current_order' => $current_order,
526 - 'current_query' => wp_json_encode( $current_query ),
527 - 'current_query_count' => count( $current_query ),
490 + 'current_order' => isset( $_GET['order'] ) && in_array( strtolower( $_GET['order'] ), $order_types, true ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
491 + ? esc_js( $_GET['order'] ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
492 + : 'desc',
493 + 'current_query' => wp_stream_json_encode( $_GET ), // phpcs:ignore WordPress.Security.NonceVerification.Recommended
494 + 'current_query_count' => count( $_GET ), // phpcs:ignore WordPress.Security.NonceVerification.Recommended
528 495 )
529 496 );
530 497 }
531 498
@@ -542,16 +509,24 @@
542 509 * @return int
543 510 */
544 511 $bulk_actions_threshold = apply_filters( 'wp_stream_bulk_actions_threshold', 100 );
545 512
546 - $this->plugin->enqueue_asset(
547 - 'global',
548 - array(),
513 + wp_enqueue_script(
514 + 'wp-stream-global',
515 + $this->plugin->locations['url'] . 'ui/js/global.' . $min . 'js',
516 + array( 'jquery' ),
517 + $this->plugin->get_version(),
518 + false
519 + );
520 +
521 + wp_localize_script(
522 + 'wp-stream-global',
523 + 'wp_stream_global',
549 524 array(
550 525 'bulk_actions' => array(
551 526 'i18n' => array(
552 527 /* translators: %s: a number of items (e.g. "1,742") */
553 - '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 ) ) ),
528 + '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 ) ) ),
554 529 ),
555 530 'threshold' => absint( $bulk_actions_threshold ),
556 531 ),
557 532 'plugins_screen_url' => self_admin_url( 'plugins.php#stream' ),
@@ -564,23 +539,17 @@
564 539 *
565 540 * @return bool
566 541 */
567 542 public function is_stream_screen() {
568 - if ( ! is_admin() ) {
569 - return false;
543 + if ( is_admin() && false !== strpos( wp_stream_filter_input( INPUT_GET, 'page' ), $this->records_page_slug ) ) {
544 + return true;
570 545 }
571 546
572 - $page = wp_stream_filter_input( INPUT_GET, 'page' );
573 - if ( is_string( $page ) && false !== strpos( $page, $this->records_page_slug ) ) {
547 + $screen = get_current_screen();
548 + if ( is_admin() && Alerts::POST_TYPE === $screen->post_type ) {
574 549 return true;
575 550 }
576 551
577 - if ( is_admin() && function_exists( 'get_current_screen' ) ) {
578 - $screen = get_current_screen();
579 -
580 - return ( Alerts::POST_TYPE === $screen->post_type );
581 - }
582 -
583 552 return false;
584 553 }
585 554
586 555 /**
@@ -614,13 +583,19 @@
614 583 return sprintf( '%s %s ', $classes, $stream_classes );
615 584 }
616 585
617 586 /**
618 - * Add menu styles for various WP Admin skins.
587 + * Add menu styles for various WP Admin skins
619 588 *
589 + * @uses \wp_add_inline_style()
590 + *
620 591 * @action admin_enqueue_scripts
621 592 */
622 593 public function admin_menu_css() {
594 + $min = wp_stream_min_suffix();
595 + wp_register_style( 'wp-stream-datepicker', $this->plugin->locations['url'] . 'ui/css/datepicker.' . $min . 'css', array(), $this->plugin->get_version() );
596 + wp_register_style( 'wp-stream-icons', $this->plugin->locations['url'] . 'ui/stream-icons/style.css', array(), $this->plugin->get_version() );
597 +
623 598 // Make sure we're working off a clean version.
624 599 if ( ! file_exists( ABSPATH . WPINC . '/version.php' ) ) {
625 600 return;
626 601 }
@@ -629,29 +604,61 @@
629 604 if ( ! isset( $wp_version ) ) {
630 605 return;
631 606 }
632 607
633 - $css = "
634 - body.{$this->admin_body_class} #wpbody-content .wrap h1:nth-child(1):before {
635 - content: '';
636 - display: inline-block;
637 - width: 24px;
638 - height: 24px;
639 - margin-right: 8px;
640 - vertical-align: text-bottom;
641 - background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDI0IDEwMjQiIGZpbGw9ImN1cnJlbnRjb2xvciI+Cgk8cGF0aCBkPSJNOTAzLjExNSA1MTUuNDEzYy00OS4zOTIgMC05MS40NzQgMzEuMzM3LTEwNy40NiA3NS4yMDNsLTEyNC40MTEtMS41MzJjLTExLjM3Ny0uMzQ2LTIyLjc1MS0uNjg5LTM0LjEyOS0uOTk4bC0uMjQxLjU3NC0yMi40MzYtLjI3OC0uMTUzLS45Mi0xNS4wNTYtODIuOTMzLTIwLjE0Ni0xMDguNDA1LTIwLjU0NC0xMDguMzM3TDUwMy45ODIgMGwtNTMuMTQxIDQyOS4wMTMtMTYuMjE0IDEzNy45MzUtMTIuMDE2IDEwNi45MjQtMTE3LjI4Ni0yODUuMjItMTguMzUzIDIwMi44MWMtNDIuNTYyIDEuNDU0LTg1LjEyNyAyLjkzNC0xMjcuNjg4IDQuNzM4LTUzLjA5NyAyLjI5Mi0xMDYuMTg3IDQuNDczLTE1OS4yODQgNy41MzZ2NDIuMDQyYzUzLjA5NyAzLjA2IDEwNi4xODcgNS4yNDcgMTU5LjI4NCA3LjUzMyA1My4wOTMgMi4yNDUgMTA2LjE4IDQuMTk0IDE1OS4yNzMgNS45MDNsMTQuMjQuNDY1IDE3LjM1MSA0OC4zOWMxOC44NDIgNTEuODc0IDM3LjU0MiAxMDMuODA2IDU2Ljc2NSAxNTUuNTQxTDQ2Ni41MiAxMDI0bDQxLjUxMi0zMDguMjkzIDE3LjYzMy0xMzYuNjg1IDEwLjc3NiA1MC4zMjkgNTQuODE1IDI0OC41NDQgNzIuNTE2LTIxNy4yMTdoMTI5LjI2MWMxMy40OTMgNDguMTIxIDU3LjY1NSA4My40MjkgMTEwLjA3NSA4My40MjkgNjMuMTYgMCAxMTQuMzUyLTUxLjIwNSAxMTQuMzUyLTExNC4zNDggMC02My4xMzktNTEuMTg5LTExNC4zNDUtMTE0LjM0OS0xMTQuMzQ1bC4wMDQtLjAwMVoiIC8+Cjwvc3ZnPgo=');
642 - }
643 - #menu-posts-feedback .wp-menu-image:before {
644 - font-family: dashicons !important;
645 - content: '\\f175';
646 - }
647 - #adminmenu #menu-posts-feedback div.wp-menu-image {
648 - background: none !important;
649 - background-repeat: no-repeat;
650 - }
651 - ";
608 + $body_class = $this->admin_body_class;
609 + $records_page = $this->records_page_slug;
610 + $stream_url = $this->plugin->locations['url'];
652 611
653 - wp_add_inline_style( 'wp-admin', $css );
612 + if ( version_compare( $wp_version, '3.8-alpha', '>=' ) ) {
613 + wp_enqueue_style( 'wp-stream-icons' );
614 +
615 + $css = "
616 + #toplevel_page_{$records_page} .wp-menu-image:before {
617 + font-family: 'WP Stream' !important;
618 + content: '\\73' !important;
619 + }
620 + #toplevel_page_{$records_page} .wp-menu-image {
621 + background-repeat: no-repeat;
622 + }
623 + #menu-posts-feedback .wp-menu-image:before {
624 + font-family: dashicons !important;
625 + content: '\\f175';
626 + }
627 + #adminmenu #menu-posts-feedback div.wp-menu-image {
628 + background: none !important;
629 + background-repeat: no-repeat;
630 + }
631 + body.{$body_class} #wpbody-content .wrap h1:nth-child(1):before {
632 + font-family: 'WP Stream' !important;
633 + content: '\\73';
634 + padding: 0 8px 0 0;
635 + }
636 + ";
637 + } else {
638 + $css = "
639 + #toplevel_page_{$records_page} .wp-menu-image {
640 + background: url( {$stream_url}ui/stream-icons/menuicon-sprite.png ) 0 90% no-repeat;
641 + }
642 + /* Retina Stream Menu Icon */
643 + @media only screen and (-moz-min-device-pixel-ratio: 1.5),
644 + only screen and (-o-min-device-pixel-ratio: 3/2),
645 + only screen and (-webkit-min-device-pixel-ratio: 1.5),
646 + only screen and (min-device-pixel-ratio: 1.5) {
647 + #toplevel_page_{$records_page} .wp-menu-image {
648 + background: url( {$stream_url}ui/stream-icons/menuicon-sprite-2x.png ) 0 90% no-repeat;
649 + background-size:30px 64px;
650 + }
651 + }
652 + #toplevel_page_{$records_page}.current .wp-menu-image,
653 + #toplevel_page_{$records_page}.wp-has-current-submenu .wp-menu-image,
654 + #toplevel_page_{$records_page}:hover .wp-menu-image {
655 + background-position: top left;
656 + }
657 + ";
658 + }
659 +
660 + \wp_add_inline_style( 'wp-admin', $css );
654 661 }
655 662
656 663 /**
657 664 * Handle the reset AJAX request to reset logs.
@@ -666,14 +673,8 @@
666 673 esc_html__( "You don't have sufficient privileges to do this action.", 'stream' )
667 674 );
668 675 }
669 676
670 - // Ensure the database tables exist before attempting to clear records.
671 - // Install::check() short-circuits on DOING_AJAX, so call install()
672 - // directly. dbDelta is idempotent and safe to run when tables already
673 - // exist.
674 - $this->plugin->install->install( $this->plugin->get_version() );
675 -
676 677 $this->erase_stream_records();
677 678
678 679 if ( defined( 'WP_STREAM_TESTS' ) && WP_STREAM_TESTS ) {
679 680 return true;
@@ -699,665 +700,87 @@
699 700 */
700 701 private function erase_stream_records() {
701 702 global $wpdb;
702 703
703 - // If this is a multisite and it's not network activated,
704 - // only delete the entries from the blog which made the request.
705 - if ( $this->plugin->is_multisite_not_network_activated() ) {
704 + $where = '';
706 705
707 - // First check the log size.
708 - $stream_log_size = self::get_blog_record_table_size();
709 -
710 - // If this is a large log and we need to delete only the entries
711 - // pertaining to an individual site, we will need to do those in batches.
712 - if ( $this->plugin->is_large_records_table( $stream_log_size ) ) {
713 - $this->schedule_erase_large_records( $stream_log_size );
714 - return;
715 - }
716 -
717 - $wpdb->query(
718 - $wpdb->prepare(
719 - "DELETE `stream`, `meta`
720 - FROM {$wpdb->stream} AS `stream`
721 - LEFT JOIN {$wpdb->streammeta} AS `meta`
722 - ON `meta`.`record_id` = `stream`.`ID`
723 - WHERE `blog_id`=%d;",
724 - get_current_blog_id()
725 - )
726 - );
727 - } else {
728 - // If we are deleting all the entries, we can truncate the tables.
729 - $wpdb->query( "TRUNCATE {$wpdb->streammeta};" );
730 - $wpdb->query( "TRUNCATE {$wpdb->stream};" );
731 - // Tidy up any meta which may have been added in between the two truncations.
732 - $this->delete_orphaned_meta();
706 + if ( is_multisite() && ! $this->plugin->is_network_activated() ) {
707 + $where .= $wpdb->prepare( ' AND `blog_id` = %d', get_current_blog_id() );
733 708 }
734 - }
735 709
736 - /**
737 - * Schedule the initial event to start erasing the logs from now.
738 - *
739 - * @param int $log_size The number of rows which will be affected.
740 - * @return void
741 - */
742 - private function schedule_erase_large_records( int $log_size ) {
743 - global $wpdb;
744 -
745 - $last_entry = $wpdb->get_var(
746 - $wpdb->prepare(
747 - "SELECT ID FROM {$wpdb->stream} WHERE `blog_id`=%d ORDER BY ID DESC LIMIT 1",
748 - get_current_blog_id()
749 - )
750 - );
751 -
752 - // If there are no entries to erase, don't try to erase them.
753 - if ( empty( $last_entry ) ) {
754 - return;
755 - }
756 -
757 - // We are going to delete this many and this many only.
758 - // This is to avoid the situation where rows keep getting added
759 - // between the Action Scheduler runs and they never stop.
760 - $args = array(
761 - 'total' => (int) $log_size,
762 - 'done' => 0,
763 - 'last_entry' => (int) $last_entry,
764 - 'blog_id' => (int) get_current_blog_id(),
765 - );
766 -
767 - as_enqueue_async_action( self::ASYNC_DELETION_ACTION, $args );
768 - }
769 -
770 - /**
771 - * Checks if the async deletion process is running.
772 - *
773 - * @return bool True if the async deletion process is running, false otherwise.
774 - */
775 - public static function is_running_async_deletion() {
776 - return as_has_scheduled_action( self::ASYNC_DELETION_ACTION );
777 - }
778 -
779 - /**
780 - * Checks if any auto-purge action is currently scheduled or in-flight.
781 - *
782 - * Returns true when either the batched chain worker or the terminal
783 - * orphan reaper is pending OR running. The recurring scheduler is
784 - * intentionally excluded — it is always pending under normal operation,
785 - * so including it here would make the probe useless. Used by the
786 - * Settings → Advanced UI to render an "Auto-purge currently running"
787 - * notice and by the recurring callback as an overlap guard.
788 - *
789 - * Checks both PENDING and IN-PROGRESS statuses so a chain that is
790 - * mid-execution (e.g. the batch worker is currently running and has not
791 - * yet enqueued the next batch) still reports as running. Without the
792 - * RUNNING check the overlap guard can let a second parallel chain stack
793 - * against the same rows.
794 - *
795 - * @return bool
796 - */
797 - public static function is_running_auto_purge() {
798 - if ( ! function_exists( 'as_get_scheduled_actions' ) ) {
799 - return false;
800 - }
801 -
802 - foreach ( array( self::AUTO_PURGE_BATCH_ACTION, self::AUTO_PURGE_REAPER_ACTION ) as $hook ) {
803 - $found = as_get_scheduled_actions(
804 - array(
805 - 'hook' => $hook,
806 - 'status' => array(
807 - \ActionScheduler_Store::STATUS_PENDING,
808 - \ActionScheduler_Store::STATUS_RUNNING,
809 - ),
810 - 'per_page' => 1,
811 - ),
812 - 'ids'
813 - );
814 - if ( ! empty( $found ) ) {
815 - return true;
816 - }
817 - }
818 -
819 - return false;
820 - }
821 -
822 - /**
823 - * Erases large records from the stream table.
824 - *
825 - * This function deletes records from the stream table in batches, starting from a given entry ID.
826 - * It deletes records in reverse chronological order, starting from the largest ID and going back.
827 - * The number of records deleted in each batch is determined by the batch size, which can be filtered
828 - * using the 'wp_stream_batch_size' hook.
829 - *
830 - * @param int $total The total number of records to be deleted.
831 - * @param int $done The number of records that have already been deleted.
832 - * @param int $last_entry The ID of the last entry that was deleted.
833 - * @param int $blog_id The ID of the blog for which the records should be deleted.
834 - * @return void
835 - */
836 - public function erase_large_records( int $total, int $done, int $last_entry, int $blog_id ) {
837 - global $wpdb;
838 -
839 - $start_from = $wpdb->get_var(
840 - $wpdb->prepare(
841 - "SELECT ID FROM {$wpdb->stream} WHERE ID < %d AND `blog_id`=%d ORDER BY ID DESC LIMIT 1",
842 - $last_entry + 1, // A tweak to get it correct the first time through.
843 - get_current_blog_id()
844 - )
845 - );
846 -
847 - if ( empty( $start_from ) ) {
848 - return;
849 - }
850 -
851 - /**
852 - * Filters the number of records in the {$wpdb->stream} table to do at a time.
853 - *
854 - * @since 4.1.0
855 - *
856 - * @param int $batch_size The batch size, default 250000.
857 - */
858 - $batch_size = apply_filters( 'wp_stream_batch_size', 250000 );
859 -
860 - // This will tend to erase them in reverse chronological order,
861 - // ie it will start from the largest ID and go back from there.
862 710 $wpdb->query(
863 - $wpdb->prepare(
864 - "DELETE `stream`, `meta`
865 - FROM {$wpdb->stream} AS `stream`
866 - LEFT JOIN {$wpdb->streammeta} AS `meta`
867 - ON `meta`.`record_id` = `stream`.`ID`
868 - WHERE ID <= %d AND ID >= %d AND `blog_id`=%d;",
869 - $start_from,
870 - $start_from - $batch_size,
871 - get_current_blog_id()
872 - )
711 + "DELETE `stream`, `meta`
712 + FROM {$wpdb->stream} AS `stream`
713 + LEFT JOIN {$wpdb->streammeta} AS `meta`
714 + ON `meta`.`record_id` = `stream`.`ID`
715 + WHERE 1=1 {$where};" // @codingStandardsIgnoreLine $where already prepared
873 716 );
874 -
875 - $remaining = $wpdb->get_var(
876 - $wpdb->prepare( "SELECT COUNT(ID) FROM {$wpdb->stream} WHERE `blog_id`=%d", $blog_id )
877 - );
878 -
879 - $done = $total - $remaining;
880 -
881 - as_enqueue_async_action(
882 - self::ASYNC_DELETION_ACTION,
883 - array(
884 - 'total' => (int) $total,
885 - 'done' => (int) $done,
886 - 'last_entry' => (int) $start_from - $batch_size, // The last ID checked.
887 - 'blog_id' => (int) $blog_id,
888 - )
889 - );
890 717 }
891 718
892 719 /**
893 - * Retrieves the size of the blog record table for a specific blog.
894 - *
895 - * @param int|null $blog_id The ID of the blog. If not provided, the current blog ID will be used.
896 - * @return int The size of the blog record table.
897 - */
898 - public static function get_blog_record_table_size( $blog_id = null ): int {
899 - global $wpdb;
900 -
901 - $blog_id = empty( $blog_id ) ? get_current_blog_id() : $blog_id;
902 -
903 - $blog_size = $wpdb->get_var(
904 - $wpdb->prepare(
905 - "SELECT COUNT(ID) FROM {$wpdb->stream} WHERE `blog_id`=%d",
906 - $blog_id
907 - )
908 - );
909 -
910 - return (int) $blog_size;
911 - }
912 -
913 - /**
914 720 * Schedules a purge of records.
915 721 *
916 722 * @return void
917 723 */
918 724 public function purge_schedule_setup() {
919 - // Clear the legacy WP-Cron event scheduled by Stream <= 4.1.x so it
920 - // cannot double-fire alongside the new AS recurring action.
921 - if ( wp_next_scheduled( 'wp_stream_auto_purge' ) ) {
922 - wp_clear_scheduled_hook( 'wp_stream_auto_purge' );
725 + if ( ! wp_next_scheduled( 'wp_stream_auto_purge' ) ) {
726 + wp_schedule_event( time(), 'twicedaily', 'wp_stream_auto_purge' );
923 727 }
924 -
925 - if ( ! function_exists( 'as_schedule_recurring_action' ) ) {
926 - // Action Scheduler not yet loaded (e.g. very early hook); bail.
927 - // Plugin::__construct() loads it before init, so this should be unreachable.
928 - return;
929 - }
930 -
931 - if ( false === as_next_scheduled_action( self::AUTO_PURGE_ACTION ) ) {
932 - // 12 hours == old `twicedaily` interval.
933 - as_schedule_recurring_action(
934 - time(),
935 - 12 * HOUR_IN_SECONDS,
936 - self::AUTO_PURGE_ACTION,
937 - array(),
938 - self::AUTO_PURGE_GROUP
939 - );
940 - }
941 728 }
942 729
943 730 /**
944 - * Deletes orphaned meta records from the database.
945 - *
946 - * Deletes meta records from the stream meta table where the corresponding
947 - * stream record no longer exists.
948 - *
949 - * @global wpdb $wpdb The WordPress database object.
950 - */
951 - protected function delete_orphaned_meta() {
952 - global $wpdb;
953 -
954 - $wpdb->query(
955 - "DELETE `meta` FROM {$wpdb->streammeta} as `meta` LEFT JOIN {$wpdb->stream} as `stream` ON `stream`.`ID`=`meta`.`record_id` WHERE `stream`.`ID` IS NULL"
956 - );
957 - }
958 -
959 - /**
960 731 * Executes a scheduled purge
961 732 *
962 733 * @return void
963 734 */
964 735 public function purge_scheduled_action() {
736 + global $wpdb;
737 +
965 738 // Don't purge when in Network Admin unless Stream is network activated.
966 739 if (
967 - $this->plugin->is_multisite_not_network_activated()
740 + is_multisite()
968 741 &&
969 742 is_network_admin()
743 + &&
744 + ! $this->plugin->is_network_activated()
970 745 ) {
971 746 return;
972 747 }
973 748
974 749 $defaults = $this->plugin->settings->get_defaults();
975 - if ( $this->plugin->is_multisite_network_activated() ) {
976 - $options = wp_parse_args( (array) get_site_option( 'wp_stream_network', array() ), $defaults );
750 + if ( is_multisite() && $this->plugin->is_network_activated() ) {
751 + $options = (array) get_site_option( 'wp_stream_network', $defaults );
977 752 } else {
978 - $options = wp_parse_args( (array) get_option( 'wp_stream', array() ), $defaults );
753 + $options = (array) get_option( 'wp_stream', $defaults );
979 754 }
980 755
981 - // TTL fallback. Settings::get_defaults() runs every settings field
982 - // through the `wp_stream_settings_option_fields` filter, which
983 - // Network::get_network_admin_fields() uses to strip the `records_ttl`
984 - // field from the per-site option's defaults set. When this callback runs
985 - // outside any admin context (Action Scheduler, WP-CLI, system cron), the
986 - // per-site option_key is in effect, so the filtered defaults array does
987 - // not contain general_records_ttl at all. Apply the documented 30-day
988 - // default (classes/class-settings.php, `records_ttl` field) only when
989 - // the key is genuinely missing, so an operator who set the value via
990 - // CLI/SQL keeps their explicit choice.
991 - if ( ! isset( $options['general_records_ttl'] ) ) {
992 - $options['general_records_ttl'] = 30;
993 - }
994 -
995 - if ( ! empty( $options['general_keep_records_indefinitely'] ) ) {
756 + if ( ! empty( $options['general_keep_records_indefinitely'] ) || ! isset( $options['general_records_ttl'] ) ) {
996 757 return;
997 758 }
998 759
999 - // Refuse to purge with a non-positive TTL. The UI enforces min=1, but
1000 - // CLI/SQL can set 0 or a negative integer. Honoring those would mean
1001 - // "delete every record on every cycle", which has no legitimate use
1002 - // case (keep_records_indefinitely covers the opposite extreme).
1003 - // Bailing out makes operator error visible (records stop being purged)
1004 - // instead of catastrophic (records get wiped repeatedly).
1005 - if ( (int) $options['general_records_ttl'] < 1 ) {
1006 - return;
1007 - }
760 + $days = $options['general_records_ttl'];
761 + $timezone = new DateTimeZone( 'UTC' );
762 + $date = new DateTime( 'now', $timezone );
1008 763
1009 - // Overlap guard: if any auto-purge action (batch worker or reaper) is
1010 - // pending or in-progress, don't stack a new chain. Reuses the same
1011 - // probe used by the Settings UI so the two views of "running" agree.
1012 - if ( self::is_running_auto_purge() ) {
1013 - return;
1014 - }
764 + $date->sub( DateInterval::createFromDateString( "$days days" ) );
1015 765
1016 - /**
1017 - * Fires once per auto-purge cycle, after all bail-out checks pass and
1018 - * immediately before deletion work is enqueued.
1019 - *
1020 - * Preserved for backward compatibility with consumers that hooked the
1021 - * legacy WP-Cron event of the same name in Stream <= 4.1.x. Note that
1022 - * since 4.2.0 this fires only when a purge is actually about to run —
1023 - * it no longer fires on every cron tick regardless of whether work
1024 - * happens. Hook into the recurring AS action (Admin::AUTO_PURGE_ACTION)
1025 - * directly if you need the older "every tick" semantics.
1026 - */
1027 - do_action( 'wp_stream_auto_purge' );
766 + $where = $wpdb->prepare( ' AND `stream`.`created` < %s', $date->format( 'Y-m-d H:i:s' ) );
1028 767
1029 - // Snapshot the UTC cutoff once per recurring tick. Each batch in this
1030 - // chain operates against this fixed cutoff so the chain is finite.
1031 - $days = (int) $options['general_records_ttl'];
1032 - $cutoff = ( new DateTime( 'now', new DateTimeZone( 'UTC' ) ) )
1033 - ->sub( DateInterval::createFromDateString( $days . ' days' ) )
1034 - ->format( 'Y-m-d H:i:s' );
1035 -
1036 - // blog_id = 0 means "all blogs" (network-activated path).
1037 - $blog_id = $this->plugin->is_multisite_not_network_activated() ? (int) get_current_blog_id() : 0;
1038 -
1039 - global $wpdb;
1040 -
1041 - // "Is this a large table?" decision matches the manual reset path
1042 - // (Admin::erase_stream_records()). When the table is small the cost
1043 - // of scheduling a chain (and waiting for AS to drain it on the next
1044 - // runner tick) exceeds the cost of a single inline DELETE. Only fall
1045 - // through to the batched chain when the filter says "yes, large".
1046 - if ( $blog_id > 0 ) {
1047 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
1048 - $record_count = (int) $wpdb->get_var(
1049 - $wpdb->prepare( "SELECT COUNT(ID) FROM {$wpdb->stream} WHERE `blog_id` = %d", $blog_id )
1050 - );
1051 - } else {
1052 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
1053 - $record_count = (int) $wpdb->get_var( "SELECT COUNT(ID) FROM {$wpdb->stream}" );
768 + // Multisite but NOT network activated, only purge the current blog.
769 + if ( is_multisite() && ! $this->plugin->is_network_activated() ) {
770 + $where .= $wpdb->prepare( ' AND `blog_id` = %d', get_current_blog_id() );
1054 771 }
1055 772
1056 - if ( ! $this->plugin->is_large_records_table( $record_count ) ) {
1057 - // Small-table fast path: one inline multi-table DELETE, then enqueue
1058 - // the orphan reaper as a one-shot async action so the heal step is
1059 - // still observable in Tools → Scheduled Actions.
1060 - if ( $blog_id > 0 ) {
1061 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
1062 - $wpdb->query(
1063 - $wpdb->prepare(
1064 - "DELETE `stream`, `meta`
1065 - FROM {$wpdb->stream} AS `stream`
1066 - LEFT JOIN {$wpdb->streammeta} AS `meta`
1067 - ON `meta`.`record_id` = `stream`.`ID`
1068 - WHERE `stream`.`created` < %s AND `stream`.`blog_id` = %d;",
1069 - $cutoff,
1070 - $blog_id
1071 - )
1072 - );
1073 - } else {
1074 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
1075 - $wpdb->query(
1076 - $wpdb->prepare(
1077 - "DELETE `stream`, `meta`
1078 - FROM {$wpdb->stream} AS `stream`
1079 - LEFT JOIN {$wpdb->streammeta} AS `meta`
1080 - ON `meta`.`record_id` = `stream`.`ID`
1081 - WHERE `stream`.`created` < %s;",
1082 - $cutoff
1083 - )
1084 - );
1085 - }
1086 -
1087 - as_enqueue_async_action( self::AUTO_PURGE_REAPER_ACTION, array(), self::AUTO_PURGE_GROUP );
1088 - return;
1089 - }
1090 -
1091 - // Large-table path: batched chain.
1092 - as_enqueue_async_action(
1093 - self::AUTO_PURGE_BATCH_ACTION,
1094 - array(
1095 - 'cutoff' => $cutoff,
1096 - 'blog_id' => $blog_id,
1097 - ),
1098 - self::AUTO_PURGE_GROUP
773 + $wpdb->query(
774 + "DELETE `stream`, `meta`
775 + FROM {$wpdb->stream} AS `stream`
776 + LEFT JOIN {$wpdb->streammeta} AS `meta`
777 + ON `meta`.`record_id` = `stream`.`ID`
778 + WHERE 1=1 {$where};" // @codingStandardsIgnoreLine $where already prepared
1099 779 );
1100 780 }
1101 781
1102 782 /**
1103 - * Async Action Scheduler callback: delete one batch of records eligible
1104 - * under the snapshotted UTC cutoff, then chain the next batch (or the
1105 - * orphan reaper when nothing remains).
1106 - *
1107 - * Window-based deletion mirrors {@see Admin::erase_large_records()} so the
1108 - * InnoDB lock footprint is bounded and predictable on bloated tables.
1109 - *
1110 - * @param string $cutoff MySQL DATETIME string in UTC.
1111 - * @param int $blog_id Blog to scope to, or 0 for all blogs (network-activated).
1112 - * @param int $last_entry The lower-bound ID of the previous batch's window; 0 on the
1113 - * first batch in a chain. The next SELECT uses `ID < last_entry`
1114 - * when non-zero, guaranteeing forward progress even on tables
1115 - * that grow rapidly during the chain. Trade-off: any eligible
1116 - * row that lands inside the already-touched ID range
1117 - * [window_low, start_from] after that batch ran is skipped
1118 - * by the current chain and picked up on the next recurring
1119 - * tick (or small-table fast path). Possible sources: dev/test
1120 - * seeders, importer/migration plugins replaying historical
1121 - * rows, or PHP/MySQL clock skew on `created`. Steady-state
1122 - * logging via Log::log() uses monotonic IDs and current UTC,
1123 - * so this is a no-op for normal production traffic.
1124 - * @throws \InvalidArgumentException When $cutoff is empty (signals AS to mark the action as failed).
1125 - * @return void
1126 - */
1127 - public function auto_purge_batch( $cutoff, $blog_id = 0, $last_entry = 0 ) {
1128 - global $wpdb;
1129 -
1130 - $cutoff = (string) $cutoff;
1131 - $blog_id = (int) $blog_id;
1132 - $last_entry = (int) $last_entry;
1133 -
1134 - // Defensive: a malformed cutoff would otherwise translate to a no-op
1135 - // DELETE that still busies the DB. Throw so Action Scheduler marks
1136 - // the action as failed (and visible in Tools → Scheduled Actions)
1137 - // rather than silently completing. In practice this is unreachable
1138 - // because purge_scheduled_action() always populates the cutoff arg
1139 - // and AS args are immutable; the guard exists for third-party code
1140 - // that may enqueue the action with bad input.
1141 - if ( '' === $cutoff ) {
1142 - throw new \InvalidArgumentException( 'auto_purge_batch requires a non-empty cutoff.' );
1143 - }
1144 -
1145 - /**
1146 - * Filters the number of records to delete per batch.
1147 - *
1148 - * Shared with the manual reset path (see {@see Admin::erase_large_records()})
1149 - * so site owners only need to tune one knob.
1150 - *
1151 - * @since 4.1.0
1152 - *
1153 - * @param int $batch_size Default 250000.
1154 - */
1155 - $batch_size = (int) apply_filters( 'wp_stream_batch_size', 250000 );
1156 - if ( $batch_size < 1 ) {
1157 - $batch_size = 250000;
1158 - }
1159 -
1160 - // Find the highest-ID record still eligible under the snapshotted cutoff
1161 - // that lies strictly below the previous window's lower bound (when set).
1162 - // $last_entry=0 means "first batch in chain" — search from the top.
1163 - if ( $blog_id > 0 && $last_entry > 0 ) {
1164 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
1165 - $start_from = $wpdb->get_var(
1166 - $wpdb->prepare(
1167 - "SELECT ID FROM {$wpdb->stream} WHERE `created` < %s AND `blog_id` = %d AND `ID` < %d ORDER BY ID DESC LIMIT 1",
1168 - $cutoff,
1169 - $blog_id,
1170 - $last_entry
1171 - )
1172 - );
1173 - } elseif ( $blog_id > 0 ) {
1174 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
1175 - $start_from = $wpdb->get_var(
1176 - $wpdb->prepare(
1177 - "SELECT ID FROM {$wpdb->stream} WHERE `created` < %s AND `blog_id` = %d ORDER BY ID DESC LIMIT 1",
1178 - $cutoff,
1179 - $blog_id
1180 - )
1181 - );
1182 - } elseif ( $last_entry > 0 ) {
1183 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
1184 - $start_from = $wpdb->get_var(
1185 - $wpdb->prepare(
1186 - "SELECT ID FROM {$wpdb->stream} WHERE `created` < %s AND `ID` < %d ORDER BY ID DESC LIMIT 1",
1187 - $cutoff,
1188 - $last_entry
1189 - )
1190 - );
1191 - } else {
1192 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
1193 - $start_from = $wpdb->get_var(
1194 - $wpdb->prepare(
1195 - "SELECT ID FROM {$wpdb->stream} WHERE `created` < %s ORDER BY ID DESC LIMIT 1",
1196 - $cutoff
1197 - )
1198 - );
1199 - }
1200 -
1201 - if ( empty( $start_from ) ) {
1202 - // Chain is done. Schedule the orphan reaper as the terminal step.
1203 - as_enqueue_async_action( self::AUTO_PURGE_REAPER_ACTION, array(), self::AUTO_PURGE_GROUP );
1204 - return;
1205 - }
1206 -
1207 - $start_from = (int) $start_from;
1208 - $window_low = max( 0, $start_from - $batch_size );
1209 -
1210 - // Multi-table DELETE: parent + meta in one statement. Mirrors
1211 - // Admin::erase_large_records().
1212 - if ( $blog_id > 0 ) {
1213 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
1214 - $wpdb->query(
1215 - $wpdb->prepare(
1216 - "DELETE `stream`, `meta`
1217 - FROM {$wpdb->stream} AS `stream`
1218 - LEFT JOIN {$wpdb->streammeta} AS `meta`
1219 - ON `meta`.`record_id` = `stream`.`ID`
1220 - WHERE `stream`.`ID` <= %d
1221 - AND `stream`.`ID` >= %d
1222 - AND `stream`.`created` < %s
1223 - AND `stream`.`blog_id` = %d;",
1224 - $start_from,
1225 - $window_low,
1226 - $cutoff,
1227 - $blog_id
1228 - )
1229 - );
1230 - } else {
1231 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
1232 - $wpdb->query(
1233 - $wpdb->prepare(
1234 - "DELETE `stream`, `meta`
1235 - FROM {$wpdb->stream} AS `stream`
1236 - LEFT JOIN {$wpdb->streammeta} AS `meta`
1237 - ON `meta`.`record_id` = `stream`.`ID`
1238 - WHERE `stream`.`ID` <= %d
1239 - AND `stream`.`ID` >= %d
1240 - AND `stream`.`created` < %s;",
1241 - $start_from,
1242 - $window_low,
1243 - $cutoff
1244 - )
1245 - );
1246 - }
1247 -
1248 - // Chain the next batch. Pass $window_low as the new upper bound so the
1249 - // next SELECT cannot pick up rows in or above the window we just touched.
1250 - as_enqueue_async_action(
1251 - self::AUTO_PURGE_BATCH_ACTION,
1252 - array(
1253 - 'cutoff' => $cutoff,
1254 - 'blog_id' => $blog_id,
1255 - 'last_entry' => $window_low,
1256 - ),
1257 - self::AUTO_PURGE_GROUP
1258 - );
1259 - }
1260 -
1261 - /**
1262 - * Terminal Action Scheduler callback for the auto-purge chain.
1263 - *
1264 - * Runs once per chain (after the last batch) and once when the manual
1265 - * "Clean orphaned meta now" button is used. Cleans up meta rows whose
1266 - * parent stream row is already gone — i.e. residue from historical
1267 - * unbatched purges and from any logger races during a chain.
1268 - *
1269 - * @return void
1270 - */
1271 - public function auto_purge_reaper() {
1272 - $this->delete_orphaned_meta();
1273 - }
1274 -
1275 - /**
1276 - * Ajax handler for the "Clean orphaned meta now" button on
1277 - * Settings → Advanced.
1278 - *
1279 - * Schedules an immediate async run of the orphan reaper. Idempotent:
1280 - * if a reaper is already scheduled, returns without enqueuing a second.
1281 - *
1282 - * Returns true under WP_STREAM_TESTS so PHPUnit can call this directly
1283 - * without exiting the worker.
1284 - *
1285 - * @return bool|void True under tests; otherwise redirects and exits.
1286 - */
1287 - public function wp_ajax_clean_orphan_meta() {
1288 - if ( ! current_user_can( $this->settings_cap ) ) {
1289 - wp_die( esc_html__( 'You do not have permission to do this.', 'stream' ), 403 );
1290 - }
1291 -
1292 - check_ajax_referer( 'stream_nonce_clean_orphan_meta', 'wp_stream_nonce_clean_orphan_meta' );
1293 -
1294 - if ( ! function_exists( 'as_get_scheduled_actions' ) || ! function_exists( 'as_enqueue_async_action' ) ) {
1295 - wp_die( esc_html__( 'Action Scheduler is not available.', 'stream' ), 500 );
1296 - }
1297 -
1298 - // Idempotency: skip enqueue when any auto-purge action is already
1299 - // pending or running. is_running_auto_purge() checks PENDING + RUNNING
1300 - // across the batch worker and the reaper, so a chain that will run
1301 - // its own terminal reaper is not duplicated by a manual click landing
1302 - // in the small CSRF/stale-URL window where the UI link is hidden.
1303 - if ( ! self::is_running_auto_purge() ) {
1304 - as_enqueue_async_action( self::AUTO_PURGE_REAPER_ACTION, array(), self::AUTO_PURGE_GROUP );
1305 - }
1306 -
1307 - if ( defined( 'WP_STREAM_TESTS' ) && WP_STREAM_TESTS ) {
1308 - return true;
1309 - }
1310 -
1311 - $is_network = $this->plugin->is_multisite_network_activated();
1312 - $page_slug = $is_network ? $this->network->network_settings_page_slug : $this->settings_page_slug;
1313 - $base_url = $is_network ? network_admin_url( $this->admin_parent_page ) : admin_url( $this->admin_parent_page );
1314 -
1315 - wp_safe_redirect(
1316 - add_query_arg(
1317 - array(
1318 - 'page' => $page_slug,
1319 - 'wp_stream_message' => 'orphan_meta_cleanup_scheduled',
1320 - ),
1321 - $base_url
1322 - )
1323 - );
1324 - exit;
1325 - }
1326 -
1327 - /**
1328 - * Render admin notices for post-action redirects.
1329 - *
1330 - * Reads `wp_stream_message` from the query string and renders a matching
1331 - * notice. Used to surface "Clean Orphaned Meta" confirmation after the
1332 - * Ajax handler redirects back to Settings → Advanced.
1333 - *
1334 - * @return void
1335 - */
1336 - public function maybe_display_message() {
1337 - $message = wp_stream_filter_input( INPUT_GET, 'wp_stream_message' );
1338 - if ( empty( $message ) ) {
1339 - return;
1340 - }
1341 -
1342 - $notices = array(
1343 - 'orphan_meta_cleanup_scheduled' => __(
1344 - 'Orphaned meta cleanup scheduled. Progress is visible under Tools → Scheduled Actions.',
1345 - 'stream'
1346 - ),
1347 - );
1348 -
1349 - if ( ! isset( $notices[ $message ] ) ) {
1350 - return;
1351 - }
1352 -
1353 - printf(
1354 - '<div class="notice notice-success is-dismissible"><p>%s</p></div>',
1355 - esc_html( $notices[ $message ] )
1356 - );
1357 - }
1358 -
1359 - /**
1360 783 * Returns the admin action links.
1361 784 *
1362 785 * @filter plugin_action_links
1363 786 *
@@ -1371,9 +794,9 @@
1371 794 return $links;
1372 795 }
1373 796
1374 797 // Also don't show links in Network Admin if Stream isn't network enabled.
1375 - if ( is_network_admin() && $this->plugin->is_multisite_not_network_activated() ) {
798 + if ( is_network_admin() && is_multisite() && ! $this->plugin->is_network_activated() ) {
1376 799 return $links;
1377 800 }
1378 801
1379 802 if ( is_network_admin() ) {
@@ -1391,10 +814,22 @@
1391 814 admin_url( $this->admin_parent_page )
1392 815 );
1393 816 }
1394 817
1395 - $links[] = sprintf( '<a href="%s">%s</a>', esc_url( $admin_page_url ), esc_html__( 'Settings', 'stream' ) );
818 + $links[] = sprintf( '<a href="%s">%s</a>', esc_url( $admin_page_url ), esc_html__( 'Settings', 'default' ) );
1396 819
820 + if ( ! defined( 'DISALLOW_FILE_MODS' ) || false === DISALLOW_FILE_MODS ) {
821 + $url = add_query_arg(
822 + array(
823 + 'action' => 'wp_stream_uninstall',
824 + 'wp_stream_nonce' => wp_create_nonce( 'stream_nonce' ),
825 + ),
826 + admin_url( 'admin-ajax.php' )
827 + );
828 +
829 + $links[] = sprintf( '<span id="wp_stream_uninstall" class="delete"><a href="%s">%s</a></span>', esc_url( $url ), esc_html__( 'Uninstall', 'stream' ) );
830 + }
831 +
1397 832 return $links;
1398 833 }
1399 834
1400 835 /**
@@ -1420,18 +855,10 @@
1420 855 $page_description = apply_filters( 'wp_stream_settings_form_description', '' );
1421 856
1422 857 $sections = $this->plugin->settings->get_fields();
1423 858 $active_tab = wp_stream_filter_input( INPUT_GET, 'tab' );
1424 -
1425 - $this->plugin->enqueue_asset(
1426 - 'settings',
1427 - array(),
1428 - array(
1429 - 'i18n' => array(
1430 - 'confirm_purge' => __( 'Are you sure you want to delete all Stream activity records from the database? This cannot be undone.', 'stream' ),
1431 - ),
1432 - )
1433 - );
859 + $min = wp_stream_min_suffix();
860 + wp_enqueue_script( 'wp-stream-settings', $this->plugin->locations['url'] . 'ui/js/settings.' . $min . 'js', array( 'jquery' ), $this->plugin->get_version(), true );
1434 861 ?>
1435 862 <div class="wrap">
1436 863 <h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
1437 864
@@ -1444,9 +871,9 @@
1444 871 <?php if ( count( $sections ) > 1 ) : ?>
1445 872 <h2 class="nav-tab-wrapper">
1446 873 <?php $i = 0; ?>
1447 874 <?php foreach ( $sections as $section => $data ) : ?>
1448 - <?php ++$i; ?>
875 + <?php $i++; ?>
1449 876 <?php $is_active = ( ( 1 === $i && ! $active_tab ) || $active_tab === $section ); ?>
1450 877 <a href="<?php echo esc_url( add_query_arg( 'tab', $section ) ); ?>" class="nav-tab <?php echo $is_active ? esc_attr( ' nav-tab-active' ) : ''; ?>">
1451 878 <?php echo esc_html( $data['title'] ); ?>
1452 879 </a>
@@ -1459,9 +886,9 @@
1459 886 <div class="settings-sections">
1460 887 <?php
1461 888 $i = 0;
1462 889 foreach ( $sections as $section => $data ) {
1463 - ++$i;
890 + $i++;
1464 891
1465 892 $is_active = ( ( 1 === $i && ! $active_tab ) || $active_tab === $section );
1466 893
1467 894 if ( $is_active ) {
@@ -1618,9 +1045,9 @@
1618 1045 break;
1619 1046 }
1620 1047
1621 1048 if ( isset( $results ) ) {
1622 - echo wp_json_encode( $results );
1049 + echo wp_stream_json_encode( $results ); // xss ok.
1623 1050 }
1624 1051
1625 1052 die();
1626 1053 }
@@ -1658,8 +1085,12 @@
1658 1085 *
1659 1086 * @return mixed
1660 1087 */
1661 1088 public function get_user_meta( $user_id, $meta_key, $single = true ) {
1089 + if ( wp_stream_is_vip() && function_exists( 'get_user_attribute' ) ) {
1090 + return get_user_attribute( $user_id, $meta_key );
1091 + }
1092 +
1662 1093 return get_user_meta( $user_id, $meta_key, $single );
1663 1094 }
1664 1095
1665 1096 /**
@@ -1672,8 +1103,12 @@
1672 1103 *
1673 1104 * @return int|bool
1674 1105 */
1675 1106 public function update_user_meta( $user_id, $meta_key, $meta_value, $prev_value = '' ) {
1107 + if ( wp_stream_is_vip() && function_exists( 'update_user_attribute' ) ) {
1108 + return update_user_attribute( $user_id, $meta_key, $meta_value );
1109 + }
1110 +
1676 1111 return update_user_meta( $user_id, $meta_key, $meta_value, $prev_value );
1677 1112 }
1678 1113
1679 1114 /**
@@ -1685,7 +1120,11 @@
1685 1120 *
1686 1121 * @return bool
1687 1122 */
1688 1123 public function delete_user_meta( $user_id, $meta_key, $meta_value = '' ) {
1124 + if ( wp_stream_is_vip() && function_exists( 'delete_user_attribute' ) ) {
1125 + return delete_user_attribute( $user_id, $meta_key, $meta_value );
1126 + }
1127 +
1689 1128 return delete_user_meta( $user_id, $meta_key, $meta_value );
1690 1129 }
1691 1130 }