PluginProbe
Stream – Activity Log & Audit Trail / trunk
Stream – Activity Log & Audit Trail vtrunk
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
stream / classes / class-admin.php

class-admin.php in Stream – Activity Log & Audit Trail trunk, at classes/class-admin.php

1,951 lines 63.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Centralized manager for WordPress backend functionality.
4 *
5 * @package WP_Stream
6 */
7
8 namespace WP_Stream;
9
10 use DateTime;
11 use DateTimeZone;
12 use DateInterval;
13 use WP_CLI;
14 use WP_Roles;
15
16 /**
17 * Class - Admin
18 */
19 class Admin {
20
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 * Holds Instance of plugin object
81 *
82 * @var Plugin
83 */
84 public $plugin;
85
86 /**
87 * Holds Network class
88 *
89 * @var Network
90 */
91 public $network;
92
93 /**
94 * Holds Live Update class
95 *
96 * @var Live_Update
97 */
98 public $live_update;
99
100 /**
101 * Holds Export class
102 *
103 * @var Export
104 */
105 public $export;
106
107 /**
108 * Menu page screen id
109 *
110 * @var string
111 */
112 public $screen_id = array();
113
114 /**
115 * List table object
116 *
117 * @var List_Table
118 */
119 public $list_table = null;
120
121 /**
122 * Option to disable access to Stream
123 *
124 * @var bool
125 */
126 public $disable_access = false;
127
128 /**
129 * Class applied to the body of the admin screen
130 *
131 * @var string
132 */
133 public $admin_body_class = 'wp_stream_screen';
134
135 /**
136 * Slug of the records page
137 *
138 * @var string
139 */
140 public $records_page_slug = 'wp_stream';
141
142 /**
143 * Slug of the settings page
144 *
145 * @var string
146 */
147 public $settings_page_slug = 'wp_stream_settings';
148
149 /**
150 * Parent page of the records and settings pages
151 *
152 * @var string
153 */
154 public $admin_parent_page = 'admin.php';
155
156 /**
157 * Capability name for viewing records
158 *
159 * @var string
160 */
161 public $view_cap = 'view_stream';
162
163 /**
164 * Capability name for managing settings
165 *
166 * @var string
167 */
168 public $settings_cap = WP_STREAM_SETTINGS_CAPABILITY;
169
170 /**
171 * Total amount of authors to pre-load
172 *
173 * @var int
174 */
175 public $preload_users_max = 50;
176
177 /**
178 * Admin notices, collected and displayed on proper action
179 *
180 * @var array
181 */
182 public $notices = array();
183
184 /**
185 * Class constructor.
186 *
187 * @param Plugin $plugin Instance of plugin object.
188 */
189 public function __construct( $plugin ) {
190 $this->plugin = $plugin;
191
192 add_action( 'init', array( $this, 'init' ) );
193
194 // Ensure function used in various methods is pre-loaded.
195 if ( ! function_exists( 'is_plugin_active_for_network' ) ) {
196 require_once ABSPATH . '/wp-admin/includes/plugin.php';
197 }
198
199 // User and role caps.
200 add_filter( 'user_has_cap', array( $this, 'filter_user_caps' ), 10, 4 );
201 add_filter( 'role_has_cap', array( $this, 'filter_role_caps' ), 10, 3 );
202
203 if ( $this->plugin->is_multisite_network_activated() && ! is_network_admin() ) {
204 $options = (array) get_site_option( 'wp_stream_network', array() );
205 $option = isset( $options['general_site_access'] ) ? absint( $options['general_site_access'] ) : 1;
206
207 $this->disable_access = ( $option ) ? false : true;
208 }
209
210 // Register settings page.
211 if ( ! $this->disable_access ) {
212 add_action( 'admin_menu', array( $this, 'register_menu' ) );
213 }
214
215 // Admin notices.
216 add_action( 'admin_notices', array( $this, 'prepare_admin_notices' ) );
217 add_action( 'shutdown', array( $this, 'admin_notices' ) );
218
219 // Feature request notice.
220 add_action( 'admin_notices', array( $this, 'display_feature_request_notice' ) );
221
222 // Add admin body class.
223 add_filter( 'admin_body_class', array( $this, 'admin_body_class' ) );
224
225 // Plugin action links.
226 add_filter(
227 'plugin_action_links',
228 array(
229 $this,
230 'plugin_action_links',
231 ),
232 10,
233 2
234 );
235
236 // Load admin scripts and styles.
237 add_action(
238 'admin_enqueue_scripts',
239 array(
240 $this,
241 'admin_enqueue_scripts',
242 )
243 );
244 add_action( 'admin_enqueue_scripts', array( $this, 'admin_menu_css' ) );
245
246 // Reset Streams database.
247 add_action(
248 'wp_ajax_wp_stream_reset',
249 array(
250 $this,
251 'wp_ajax_reset',
252 )
253 );
254
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).
273 add_action( 'wp_loaded', array( $this, 'purge_schedule_setup' ) );
274 add_action(
275 self::AUTO_PURGE_ACTION,
276 array( $this, 'purge_scheduled_action' )
277 );
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
289 // Ajax users list.
290 add_action(
291 'wp_ajax_wp_stream_filters',
292 array(
293 $this,
294 'ajax_filters',
295 )
296 );
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 }
309
310 /**
311 * Load admin classes
312 *
313 * @action init
314 */
315 public function init() {
316 $this->network = new Network( $this->plugin );
317 $this->live_update = new Live_Update( $this->plugin );
318 $this->export = new Export( $this->plugin );
319
320 // Check if the host has configured the `REMOTE_ADDR` correctly.
321 $client_ip = $this->plugin->get_client_ip_address();
322 if ( empty( $client_ip ) && $this->is_stream_screen() ) {
323 $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' ) );
324 }
325 }
326
327 /**
328 * Output specific updates passed as URL parameters.
329 *
330 * @action admin_notices
331 *
332 * @return void
333 */
334 public function prepare_admin_notices() {
335 $message = wp_stream_filter_input( INPUT_GET, 'message' );
336
337 switch ( $message ) {
338 case 'settings_reset':
339 $this->notice( esc_html__( 'All site settings have been successfully reset.', 'stream' ) );
340 break;
341 }
342 }
343
344 /**
345 * Handle notice messages according to the appropriate context (WP-CLI or the WP Admin)
346 *
347 * @param string $message Message to output.
348 * @param bool $is_error If the message is error_level (true) or warning (false).
349 */
350 public function notice( $message, $is_error = true ) {
351 if ( defined( 'WP_CLI' ) && WP_CLI ) {
352 $message = wp_strip_all_tags( $message );
353
354 if ( $is_error ) {
355 WP_CLI::warning( $message );
356 } else {
357 WP_CLI::success( $message );
358 }
359 } else {
360 // Trigger admin notices late, so that any notices which occur during page load are displayed.
361 add_action( 'shutdown', array( $this, 'admin_notices' ) );
362
363 $notice = compact( 'message', 'is_error' );
364
365 if ( ! in_array( $notice, $this->notices, true ) ) {
366 $this->notices[] = $notice;
367 }
368 }
369 }
370
371 /**
372 * Show an error or other message in the WP Admin
373 *
374 * @action shutdown
375 */
376 public function admin_notices() {
377 global $allowedposttags;
378
379 $custom = array(
380 'progress' => array(
381 'class' => true,
382 'id' => true,
383 'max' => true,
384 'style' => true,
385 'value' => true,
386 ),
387 );
388
389 $allowed_html = array_merge( $allowedposttags, $custom );
390
391 ksort( $allowed_html );
392
393 foreach ( $this->notices as $notice ) {
394 $class_name = empty( $notice['is_error'] ) ? 'updated' : 'error';
395 $html_message = sprintf( '<div class="%s">%s</div>', esc_attr( $class_name ), wpautop( $notice['message'] ) );
396
397 echo wp_kses( $html_message, $allowed_html );
398 }
399 }
400
401 /**
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 * Register menu page
423 *
424 * @action admin_menu
425 *
426 * @return void
427 */
428 public function register_menu() {
429 /**
430 * Filter the main admin menu title
431 *
432 * @return string
433 */
434 $main_menu_title = apply_filters( 'wp_stream_admin_menu_title', esc_html__( 'Stream', 'stream' ) );
435
436 /**
437 * Filter the main admin menu position
438 *
439 * Note: Using longtail decimal string to reduce the chance of position conflicts, see Codex
440 *
441 * @return string
442 */
443 $main_menu_position = apply_filters( 'wp_stream_menu_position', '2.999999' );
444
445 /**
446 * Filter the main admin page title
447 *
448 * @return string
449 */
450 $main_page_title = apply_filters( 'wp_stream_admin_page_title', esc_html__( 'Stream Records', 'stream' ) );
451
452 $this->screen_id['main'] = add_menu_page(
453 $main_page_title,
454 $main_menu_title,
455 $this->view_cap,
456 $this->records_page_slug,
457 array( $this, 'render_list_table' ),
458 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDI0IDEwMjQiIGZpbGw9IjAwMCI+Cgk8cGF0aCBkPSJNOTAzLjExNSA1MTUuNDEzYy00OS4zOTIgMC05MS40NzQgMzEuMzM3LTEwNy40NiA3NS4yMDNsLTEyNC40MTEtMS41MzJjLTExLjM3Ny0uMzQ2LTIyLjc1MS0uNjg5LTM0LjEyOS0uOTk4bC0uMjQxLjU3NC0yMi40MzYtLjI3OC0uMTUzLS45Mi0xNS4wNTYtODIuOTMzLTIwLjE0Ni0xMDguNDA1LTIwLjU0NC0xMDguMzM3TDUwMy45ODIgMGwtNTMuMTQxIDQyOS4wMTMtMTYuMjE0IDEzNy45MzUtMTIuMDE2IDEwNi45MjQtMTE3LjI4Ni0yODUuMjItMTguMzUzIDIwMi44MWMtNDIuNTYyIDEuNDU0LTg1LjEyNyAyLjkzNC0xMjcuNjg4IDQuNzM4LTUzLjA5NyAyLjI5Mi0xMDYuMTg3IDQuNDczLTE1OS4yODQgNy41MzZ2NDIuMDQyYzUzLjA5NyAzLjA2IDEwNi4xODcgNS4yNDcgMTU5LjI4NCA3LjUzMyA1My4wOTMgMi4yNDUgMTA2LjE4IDQuMTk0IDE1OS4yNzMgNS45MDNsMTQuMjQuNDY1IDE3LjM1MSA0OC4zOWMxOC44NDIgNTEuODc0IDM3LjU0MiAxMDMuODA2IDU2Ljc2NSAxNTUuNTQxTDQ2Ni41MiAxMDI0bDQxLjUxMi0zMDguMjkzIDE3LjYzMy0xMzYuNjg1IDEwLjc3NiA1MC4zMjkgNTQuODE1IDI0OC41NDQgNzIuNTE2LTIxNy4yMTdoMTI5LjI2MWMxMy40OTMgNDguMTIxIDU3LjY1NSA4My40MjkgMTEwLjA3NSA4My40MjkgNjMuMTYgMCAxMTQuMzUyLTUxLjIwNSAxMTQuMzUyLTExNC4zNDggMC02My4xMzktNTEuMTg5LTExNC4zNDUtMTE0LjM0OS0xMTQuMzQ1bC4wMDQtLjAwMVoiIC8+Cjwvc3ZnPgo=',
459 $main_menu_position
460 );
461
462 /**
463 * Fires before submenu items are added to the Stream menu
464 * allowing plugins to add menu items before Settings
465 *
466 * @return void
467 */
468 do_action( 'wp_stream_admin_menu' );
469
470 /**
471 * Filter the Settings admin page title
472 *
473 * @return string
474 */
475 $settings_page_title = apply_filters( 'wp_stream_settings_form_title', esc_html__( 'Stream Settings', 'stream' ) );
476
477 $this->screen_id['settings'] = add_submenu_page(
478 $this->records_page_slug,
479 $settings_page_title,
480 esc_html__( 'Settings', 'stream' ),
481 $this->settings_cap,
482 $this->settings_page_slug,
483 array( $this, 'render_settings_page' )
484 );
485
486 if ( isset( $this->screen_id['main'] ) ) {
487 /**
488 * Fires just before the Stream list table is registered.
489 *
490 * @return void
491 */
492 do_action( 'wp_stream_admin_menu_screens' );
493
494 // Register the list table early, so it associates the column headers with 'Screen settings'.
495 add_action(
496 'load-' . $this->screen_id['main'],
497 array(
498 $this,
499 'register_list_table',
500 )
501 );
502 }
503 }
504
505 /**
506 * Enqueue scripts/styles for admin screen
507 *
508 * @action admin_enqueue_scripts
509 *
510 * @param string $hook Current hook.
511 *
512 * @return void
513 */
514 public function admin_enqueue_scripts( $hook ) {
515 if ( in_array( $hook, $this->screen_id, true ) ) {
516 $this->plugin->enqueue_asset(
517 'admin',
518 array(
519 $this->plugin->with_select2(),
520 $this->plugin->with_jquery_timeago(),
521 ),
522 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 )
530 );
531
532 $this->plugin->enqueue_asset(
533 'admin-exclude',
534 array(
535 $this->plugin->with_select2(),
536 ),
537 array(
538 'getActionsNonce' => wp_create_nonce( 'stream_get_actions' ),
539 )
540 );
541
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
547
548 $this->plugin->enqueue_asset(
549 'live-updates',
550 array( 'heartbeat' ),
551 array(
552 'current_screen' => $hook,
553 '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 ),
557 )
558 );
559 }
560
561 /**
562 * The maximum number of items that can be updated in bulk without receiving a warning.
563 *
564 * Stream watches for bulk actions performed in the WordPress Admin (such as updating
565 * many posts at once) and warns the user before proceeding if the number of items they
566 * are attempting to update exceeds this threshold value. Since Stream will try to save
567 * a log for each item, it will take longer than usual to complete the operation.
568 *
569 * The default threshold is 100 items.
570 *
571 * @return int
572 */
573 $bulk_actions_threshold = apply_filters( 'wp_stream_bulk_actions_threshold', 100 );
574
575 $this->plugin->enqueue_asset(
576 'global',
577 array(),
578 array(
579 'bulk_actions' => array(
580 'i18n' => array(
581 /* 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 ) ) ),
583 ),
584 'threshold' => absint( $bulk_actions_threshold ),
585 ),
586 'plugins_screen_url' => self_admin_url( 'plugins.php#stream' ),
587 )
588 );
589 }
590
591 /**
592 * Check whether or not the current admin screen belongs to Stream
593 *
594 * @return bool
595 */
596 public function is_stream_screen() {
597 if ( ! is_admin() ) {
598 return false;
599 }
600
601 $page = wp_stream_filter_input( INPUT_GET, 'page' );
602 if ( is_string( $page ) && false !== strpos( $page, $this->records_page_slug ) ) {
603 return true;
604 }
605
606 if ( is_admin() && function_exists( 'get_current_screen' ) ) {
607 $screen = get_current_screen();
608
609 return ( Alerts::POST_TYPE === $screen->post_type );
610 }
611
612 return false;
613 }
614
615 /**
616 * Add a specific body class to all Stream admin screens
617 *
618 * @param string $classes CSS classes to output to body.
619 *
620 * @filter admin_body_class
621 *
622 * @return string
623 */
624 public function admin_body_class( $classes ) {
625 $stream_classes = array();
626
627 if ( $this->is_stream_screen() ) {
628 $stream_classes[] = $this->admin_body_class;
629
630 if ( isset( $_GET['page'] ) ) { // // phpcs:ignore WordPress.Security.NonceVerification.Recommended
631 $stream_classes[] = sanitize_key( $_GET['page'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
632 }
633 }
634
635 /**
636 * Filter the Stream admin body classes
637 *
638 * @return array
639 */
640 $stream_classes = apply_filters( 'wp_stream_admin_body_classes', $stream_classes );
641 $stream_classes = implode( ' ', array_map( 'trim', $stream_classes ) );
642
643 return sprintf( '%s %s ', $classes, $stream_classes );
644 }
645
646 /**
647 * Add menu styles for various WP Admin skins.
648 *
649 * @action admin_enqueue_scripts
650 */
651 public function admin_menu_css() {
652 // Make sure we're working off a clean version.
653 if ( ! file_exists( ABSPATH . WPINC . '/version.php' ) ) {
654 return;
655 }
656 include ABSPATH . WPINC . '/version.php';
657
658 if ( ! isset( $wp_version ) ) {
659 return;
660 }
661
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 ";
681
682 wp_add_inline_style( 'wp-admin', $css );
683 }
684
685 /**
686 * Handle the reset AJAX request to reset logs.
687 *
688 * @return bool
689 */
690 public function wp_ajax_reset() {
691 check_ajax_referer( 'stream_nonce_reset', 'wp_stream_nonce_reset' );
692
693 if ( ! current_user_can( $this->settings_cap ) ) {
694 wp_die(
695 esc_html__( "You don't have sufficient privileges to do this action.", 'stream' )
696 );
697 }
698
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 $this->erase_stream_records();
706
707 if ( defined( 'WP_STREAM_TESTS' ) && WP_STREAM_TESTS ) {
708 return true;
709 }
710
711 wp_safe_redirect(
712 add_query_arg(
713 array(
714 'page' => is_network_admin() ? $this->network->network_settings_page_slug : $this->settings_page_slug,
715 'message' => 'data_erased',
716 ),
717 self_admin_url( $this->admin_parent_page )
718 )
719 );
720
721 exit;
722 }
723
724 /**
725 * Clears stream records from the database.
726 *
727 * @return void
728 */
729 private function erase_stream_records() {
730 global $wpdb;
731
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() ) {
735
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();
762 }
763 }
764
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 $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 )
1016 );
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 }
1034
1035 /**
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 * Schedules a purge of records.
1058 *
1059 * @return void
1060 */
1061 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' );
1066 }
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 }
1161
1162 /**
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 * Executes a scheduled purge
1180 *
1181 * @return void
1182 */
1183 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 }
1192
1193 // Don't purge when in Network Admin unless Stream is network activated.
1194 if (
1195 $this->plugin->is_multisite_not_network_activated()
1196 &&
1197 is_network_admin()
1198 ) {
1199 return;
1200 }
1201
1202 $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 );
1205 } else {
1206 $options = wp_parse_args( (array) get_option( 'wp_stream', array() ), $defaults );
1207 }
1208
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'] ) ) {
1224 return;
1225 }
1226
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 }
1236
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 }
1243
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' );
1256
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}" );
1282 }
1283
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
1327 );
1328
1329 $this->maybe_warn_large_table_without_action_scheduler(
1330 $record_count,
1331 __( 'delete records older than the retention period', 'stream' )
1332 );
1333 }
1334
1335 /**
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 * Returns the admin action links.
1616 *
1617 * @filter plugin_action_links
1618 *
1619 * @param array $links Action links.
1620 * @param string $file Plugin file.
1621 *
1622 * @return array
1623 */
1624 public function plugin_action_links( $links, $file ) {
1625 if ( plugin_basename( $this->plugin->locations['dir'] . 'stream.php' ) !== $file ) {
1626 return $links;
1627 }
1628
1629 // 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() ) {
1631 return $links;
1632 }
1633
1634 if ( is_network_admin() ) {
1635 $admin_page_url = add_query_arg(
1636 array(
1637 'page' => $this->network->network_settings_page_slug,
1638 ),
1639 network_admin_url( $this->admin_parent_page )
1640 );
1641 } else {
1642 $admin_page_url = add_query_arg(
1643 array(
1644 'page' => $this->settings_page_slug,
1645 ),
1646 admin_url( $this->admin_parent_page )
1647 );
1648 }
1649
1650 $links[] = sprintf( '<a href="%s">%s</a>', esc_url( $admin_page_url ), esc_html__( 'Settings', 'stream' ) );
1651
1652 return $links;
1653 }
1654
1655 /**
1656 * Render main page
1657 */
1658 public function render_list_table() {
1659 $this->list_table->prepare_items();
1660 ?>
1661 <div class="wrap">
1662 <h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
1663 <?php $this->list_table->display(); ?>
1664 </div>
1665 <?php
1666 }
1667
1668 /**
1669 * Render settings page
1670 */
1671 public function render_settings_page() {
1672 $option_key = $this->plugin->settings->option_key;
1673 $form_action = apply_filters( 'wp_stream_settings_form_action', admin_url( 'options.php' ) );
1674
1675 $page_description = apply_filters( 'wp_stream_settings_form_description', '' );
1676
1677 $sections = $this->plugin->settings->get_fields();
1678 $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 );
1689 ?>
1690 <div class="wrap">
1691 <h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
1692
1693 <?php if ( ! empty( $page_description ) ) : ?>
1694 <p><?php echo esc_html( $page_description ); ?></p>
1695 <?php endif; ?>
1696
1697 <?php settings_errors(); ?>
1698
1699 <?php if ( count( $sections ) > 1 ) : ?>
1700 <h2 class="nav-tab-wrapper">
1701 <?php $i = 0; ?>
1702 <?php foreach ( $sections as $section => $data ) : ?>
1703 <?php ++$i; ?>
1704 <?php $is_active = ( ( 1 === $i && ! $active_tab ) || $active_tab === $section ); ?>
1705 <a href="<?php echo esc_url( add_query_arg( 'tab', $section ) ); ?>" class="nav-tab <?php echo $is_active ? esc_attr( ' nav-tab-active' ) : ''; ?>">
1706 <?php echo esc_html( $data['title'] ); ?>
1707 </a>
1708 <?php endforeach; ?>
1709 </h2>
1710 <?php endif; ?>
1711
1712 <div class="nav-tab-content" id="tab-content-settings">
1713 <form method="post" action="<?php echo esc_attr( $form_action ); ?>" enctype="multipart/form-data">
1714 <div class="settings-sections">
1715 <?php
1716 $i = 0;
1717 foreach ( $sections as $section => $data ) {
1718 ++$i;
1719
1720 $is_active = ( ( 1 === $i && ! $active_tab ) || $active_tab === $section );
1721
1722 if ( $is_active ) {
1723 settings_fields( $option_key );
1724 do_settings_sections( $option_key );
1725 }
1726 }
1727 ?>
1728 </div>
1729 <?php submit_button(); ?>
1730 </form>
1731 </div>
1732 </div>
1733 <?php
1734 }
1735
1736 /**
1737 * Instantiate the list table
1738 */
1739 public function register_list_table() {
1740 $this->list_table = new List_Table(
1741 $this->plugin,
1742 array(
1743 'screen' => $this->screen_id['main'],
1744 )
1745 );
1746 }
1747
1748 /**
1749 * Check if a particular role has access
1750 *
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 * @param string $role User role.
1758 *
1759 * @return bool
1760 */
1761 private function role_can_view( $role ) {
1762 $allowed_roles = $this->plugin->settings->options['general_role_access'] ?? array();
1763
1764 return in_array( $role, (array) $allowed_roles, true );
1765 }
1766
1767 /**
1768 * Filter user caps to dynamically grant our view cap based on allowed roles
1769 *
1770 * @param array $allcaps All capabilities.
1771 * @param array $caps Required caps.
1772 * @param array $args Unused.
1773 * @param WP_User $user User.
1774 *
1775 * @filter user_has_cap
1776 *
1777 * @return array
1778 */
1779 public function filter_user_caps( $allcaps, $caps, $args, $user = null ) {
1780 global $wp_roles;
1781
1782 $_wp_roles = isset( $wp_roles ) ? $wp_roles : new WP_Roles();
1783
1784 $user = is_a( $user, 'WP_User' ) ? $user : wp_get_current_user();
1785
1786 // @see
1787 // https://github.com/WordPress/WordPress/blob/c67c9565f1495255807069fdb39dac914046b1a0/wp-includes/capabilities.php#L758
1788 $roles = array_unique(
1789 array_merge(
1790 $user->roles,
1791 array_filter(
1792 array_keys( $user->caps ),
1793 array( $_wp_roles, 'is_role' )
1794 )
1795 )
1796 );
1797
1798 $stream_view_caps = array( $this->view_cap );
1799
1800 foreach ( $caps as $cap ) {
1801 if ( in_array( $cap, $stream_view_caps, true ) ) {
1802 foreach ( $roles as $role ) {
1803 if ( $this->role_can_view( $role ) ) {
1804 $allcaps[ $cap ] = true;
1805
1806 break 2;
1807 }
1808 }
1809 }
1810 }
1811
1812 return $allcaps;
1813 }
1814
1815 /**
1816 * Filter role caps to dynamically grant our view cap based on allowed roles
1817 *
1818 * @filter role_has_cap
1819 *
1820 * @param array $allcaps All capabilities.
1821 * @param string $cap Require cap.
1822 * @param string $role User role.
1823 *
1824 * @return array
1825 */
1826 public function filter_role_caps( $allcaps, $cap, $role ) {
1827 $stream_view_caps = array( $this->view_cap );
1828
1829 if ( in_array( $cap, $stream_view_caps, true ) && $this->role_can_view( $role ) ) {
1830 $allcaps[ $cap ] = true;
1831 }
1832
1833 return $allcaps;
1834 }
1835
1836 /**
1837 * Ajax callback for return a user list.
1838 *
1839 * @action wp_ajax_wp_stream_filters
1840 */
1841 public function ajax_filters() {
1842 if ( ! defined( 'DOING_AJAX' ) || ! current_user_can( $this->plugin->admin->settings_cap ) ) {
1843 wp_die( '-1' );
1844 }
1845
1846 check_ajax_referer( 'stream_filters_user_search_nonce', 'nonce' );
1847
1848 switch ( wp_stream_filter_input( INPUT_GET, 'filter' ) ) {
1849 case 'user_id':
1850 $users = array_merge(
1851 array(
1852 0 => (object) array(
1853 'display_name' => 'WP-CLI',
1854 ),
1855 ),
1856 get_users()
1857 );
1858
1859 $search = wp_stream_filter_input( INPUT_GET, 'q' );
1860 if ( $search ) {
1861 // `search` arg for get_users() is not enough
1862 $users = array_filter(
1863 $users,
1864 function ( $user ) use ( $search ) {
1865 return false !== mb_strpos( mb_strtolower( $user->display_name ), mb_strtolower( $search ) );
1866 }
1867 );
1868 }
1869
1870 if ( count( $users ) > $this->preload_users_max ) {
1871 $users = array_slice( $users, 0, $this->preload_users_max );
1872 }
1873
1874 // Get gravatar / roles for final result set.
1875 $results = $this->get_users_record_meta( $users );
1876
1877 break;
1878 }
1879
1880 if ( isset( $results ) ) {
1881 echo wp_json_encode( $results );
1882 }
1883
1884 die();
1885 }
1886
1887 /**
1888 * Return relevant user meta data.
1889 *
1890 * @param array $authors Author data.
1891 * @return array
1892 */
1893 public function get_users_record_meta( $authors ) {
1894 $authors_records = array();
1895
1896 foreach ( $authors as $user_id => $args ) {
1897 $author = new Author( $args->ID );
1898
1899 $authors_records[ $user_id ] = array(
1900 'text' => $author->get_display_name(),
1901 'id' => $author->id,
1902 'label' => $author->get_display_name(),
1903 'icon' => $author->get_avatar_src( 32 ),
1904 'title' => '',
1905 );
1906 }
1907
1908 return $authors_records;
1909 }
1910
1911 /**
1912 * Get user meta in a way that is also safe for VIP
1913 *
1914 * @param int $user_id User ID.
1915 * @param string $meta_key Meta key.
1916 * @param bool $single Return first found meta value connected to the meta key (optional).
1917 *
1918 * @return mixed
1919 */
1920 public function get_user_meta( $user_id, $meta_key, $single = true ) {
1921 return get_user_meta( $user_id, $meta_key, $single );
1922 }
1923
1924 /**
1925 * Update user meta in a way that is also safe for VIP
1926 *
1927 * @param int $user_id User ID.
1928 * @param string $meta_key Meta key.
1929 * @param mixed $meta_value Meta value.
1930 * @param mixed $prev_value Previous meta value being overwritten (optional).
1931 *
1932 * @return int|bool
1933 */
1934 public function update_user_meta( $user_id, $meta_key, $meta_value, $prev_value = '' ) {
1935 return update_user_meta( $user_id, $meta_key, $meta_value, $prev_value );
1936 }
1937
1938 /**
1939 * Delete user meta in a way that is also safe for VIP
1940 *
1941 * @param int $user_id User ID.
1942 * @param string $meta_key Meta key.
1943 * @param mixed $meta_value Meta value (optional).
1944 *
1945 * @return bool
1946 */
1947 public function delete_user_meta( $user_id, $meta_key, $meta_value = '' ) {
1948 return delete_user_meta( $user_id, $meta_key, $meta_value );
1949 }
1950 }
1951