PluginProbe
Stream – Activity Log & Audit Trail / 4.2.2
Stream – Activity Log & Audit Trail v4.2.2
4.4.0 4.3.0 4.2.2 4.2.1 trunk 2.0.1 2.0.2 2.0.3 2.0.4 2.0.5 3.0.0 3.0.1 3.0.2 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.1 3.1.1 3.10.0 3.2.0 3.2.1 3.2.2 3.2.3 All 50 releases
stream / classes / class-admin.php

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

1,692 lines 49.9 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 * Holds Instance of plugin object
58 *
59 * @var Plugin
60 */
61 public $plugin;
62
63 /**
64 * Holds Network class
65 *
66 * @var Network
67 */
68 public $network;
69
70 /**
71 * Holds Live Update class
72 *
73 * @var Live_Update
74 */
75 public $live_update;
76
77 /**
78 * Holds Export class
79 *
80 * @var Export
81 */
82 public $export;
83
84 /**
85 * Menu page screen id
86 *
87 * @var string
88 */
89 public $screen_id = array();
90
91 /**
92 * List table object
93 *
94 * @var List_Table
95 */
96 public $list_table = null;
97
98 /**
99 * Option to disable access to Stream
100 *
101 * @var bool
102 */
103 public $disable_access = false;
104
105 /**
106 * Class applied to the body of the admin screen
107 *
108 * @var string
109 */
110 public $admin_body_class = 'wp_stream_screen';
111
112 /**
113 * Slug of the records page
114 *
115 * @var string
116 */
117 public $records_page_slug = 'wp_stream';
118
119 /**
120 * Slug of the settings page
121 *
122 * @var string
123 */
124 public $settings_page_slug = 'wp_stream_settings';
125
126 /**
127 * Parent page of the records and settings pages
128 *
129 * @var string
130 */
131 public $admin_parent_page = 'admin.php';
132
133 /**
134 * Capability name for viewing records
135 *
136 * @var string
137 */
138 public $view_cap = 'view_stream';
139
140 /**
141 * Capability name for managing settings
142 *
143 * @var string
144 */
145 public $settings_cap = WP_STREAM_SETTINGS_CAPABILITY;
146
147 /**
148 * Total amount of authors to pre-load
149 *
150 * @var int
151 */
152 public $preload_users_max = 50;
153
154 /**
155 * Admin notices, collected and displayed on proper action
156 *
157 * @var array
158 */
159 public $notices = array();
160
161 /**
162 * Class constructor.
163 *
164 * @param Plugin $plugin Instance of plugin object.
165 */
166 public function __construct( $plugin ) {
167 $this->plugin = $plugin;
168
169 add_action( 'init', array( $this, 'init' ) );
170
171 // Ensure function used in various methods is pre-loaded.
172 if ( ! function_exists( 'is_plugin_active_for_network' ) ) {
173 require_once ABSPATH . '/wp-admin/includes/plugin.php';
174 }
175
176 // User and role caps.
177 add_filter( 'user_has_cap', array( $this, 'filter_user_caps' ), 10, 4 );
178 add_filter( 'role_has_cap', array( $this, 'filter_role_caps' ), 10, 3 );
179
180 if ( $this->plugin->is_multisite_network_activated() && ! is_network_admin() ) {
181 $options = (array) get_site_option( 'wp_stream_network', array() );
182 $option = isset( $options['general_site_access'] ) ? absint( $options['general_site_access'] ) : 1;
183
184 $this->disable_access = ( $option ) ? false : true;
185 }
186
187 // Register settings page.
188 if ( ! $this->disable_access ) {
189 add_action( 'admin_menu', array( $this, 'register_menu' ) );
190 }
191
192 // Admin notices.
193 add_action( 'admin_notices', array( $this, 'prepare_admin_notices' ) );
194 add_action( 'shutdown', array( $this, 'admin_notices' ) );
195
196 // Feature request notice.
197 add_action( 'admin_notices', array( $this, 'display_feature_request_notice' ) );
198
199 // Add admin body class.
200 add_filter( 'admin_body_class', array( $this, 'admin_body_class' ) );
201
202 // Plugin action links.
203 add_filter(
204 'plugin_action_links',
205 array(
206 $this,
207 'plugin_action_links',
208 ),
209 10,
210 2
211 );
212
213 // Load admin scripts and styles.
214 add_action(
215 'admin_enqueue_scripts',
216 array(
217 $this,
218 'admin_enqueue_scripts',
219 )
220 );
221 add_action( 'admin_enqueue_scripts', array( $this, 'admin_menu_css' ) );
222
223 // Reset Streams database.
224 add_action(
225 'wp_ajax_wp_stream_reset',
226 array(
227 $this,
228 'wp_ajax_reset',
229 )
230 );
231
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 );
237
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).
244 add_action( 'wp_loaded', array( $this, 'purge_schedule_setup' ) );
245 add_action(
246 self::AUTO_PURGE_ACTION,
247 array( $this, 'purge_scheduled_action' )
248 );
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
260 // Ajax users list.
261 add_action(
262 'wp_ajax_wp_stream_filters',
263 array(
264 $this,
265 'ajax_filters',
266 )
267 );
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 }
280
281 /**
282 * Load admin classes
283 *
284 * @action init
285 */
286 public function init() {
287 $this->network = new Network( $this->plugin );
288 $this->live_update = new Live_Update( $this->plugin );
289 $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 }
297
298 /**
299 * Output specific updates passed as URL parameters.
300 *
301 * @action admin_notices
302 *
303 * @return void
304 */
305 public function prepare_admin_notices() {
306 $message = wp_stream_filter_input( INPUT_GET, 'message' );
307
308 switch ( $message ) {
309 case 'settings_reset':
310 $this->notice( esc_html__( 'All site settings have been successfully reset.', 'stream' ) );
311 break;
312 }
313 }
314
315 /**
316 * Handle notice messages according to the appropriate context (WP-CLI or the WP Admin)
317 *
318 * @param string $message Message to output.
319 * @param bool $is_error If the message is error_level (true) or warning (false).
320 */
321 public function notice( $message, $is_error = true ) {
322 if ( defined( 'WP_CLI' ) && WP_CLI ) {
323 $message = wp_strip_all_tags( $message );
324
325 if ( $is_error ) {
326 WP_CLI::warning( $message );
327 } else {
328 WP_CLI::success( $message );
329 }
330 } else {
331 // Trigger admin notices late, so that any notices which occur during page load are displayed.
332 add_action( 'shutdown', array( $this, 'admin_notices' ) );
333
334 $notice = compact( 'message', 'is_error' );
335
336 if ( ! in_array( $notice, $this->notices, true ) ) {
337 $this->notices[] = $notice;
338 }
339 }
340 }
341
342 /**
343 * Show an error or other message in the WP Admin
344 *
345 * @action shutdown
346 */
347 public function admin_notices() {
348 global $allowedposttags;
349
350 $custom = array(
351 'progress' => array(
352 'class' => true,
353 'id' => true,
354 'max' => true,
355 'style' => true,
356 'value' => true,
357 ),
358 );
359
360 $allowed_html = array_merge( $allowedposttags, $custom );
361
362 ksort( $allowed_html );
363
364 foreach ( $this->notices as $notice ) {
365 $class_name = empty( $notice['is_error'] ) ? 'updated' : 'error';
366 $html_message = sprintf( '<div class="%s">%s</div>', esc_attr( $class_name ), wpautop( $notice['message'] ) );
367
368 echo wp_kses( $html_message, $allowed_html );
369 }
370 }
371
372 /**
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 * Register menu page
394 *
395 * @action admin_menu
396 *
397 * @return void
398 */
399 public function register_menu() {
400 /**
401 * Filter the main admin menu title
402 *
403 * @return string
404 */
405 $main_menu_title = apply_filters( 'wp_stream_admin_menu_title', esc_html__( 'Stream', 'stream' ) );
406
407 /**
408 * Filter the main admin menu position
409 *
410 * Note: Using longtail decimal string to reduce the chance of position conflicts, see Codex
411 *
412 * @return string
413 */
414 $main_menu_position = apply_filters( 'wp_stream_menu_position', '2.999999' );
415
416 /**
417 * Filter the main admin page title
418 *
419 * @return string
420 */
421 $main_page_title = apply_filters( 'wp_stream_admin_page_title', esc_html__( 'Stream Records', 'stream' ) );
422
423 $this->screen_id['main'] = add_menu_page(
424 $main_page_title,
425 $main_menu_title,
426 $this->view_cap,
427 $this->records_page_slug,
428 array( $this, 'render_list_table' ),
429 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDI0IDEwMjQiIGZpbGw9IjAwMCI+Cgk8cGF0aCBkPSJNOTAzLjExNSA1MTUuNDEzYy00OS4zOTIgMC05MS40NzQgMzEuMzM3LTEwNy40NiA3NS4yMDNsLTEyNC40MTEtMS41MzJjLTExLjM3Ny0uMzQ2LTIyLjc1MS0uNjg5LTM0LjEyOS0uOTk4bC0uMjQxLjU3NC0yMi40MzYtLjI3OC0uMTUzLS45Mi0xNS4wNTYtODIuOTMzLTIwLjE0Ni0xMDguNDA1LTIwLjU0NC0xMDguMzM3TDUwMy45ODIgMGwtNTMuMTQxIDQyOS4wMTMtMTYuMjE0IDEzNy45MzUtMTIuMDE2IDEwNi45MjQtMTE3LjI4Ni0yODUuMjItMTguMzUzIDIwMi44MWMtNDIuNTYyIDEuNDU0LTg1LjEyNyAyLjkzNC0xMjcuNjg4IDQuNzM4LTUzLjA5NyAyLjI5Mi0xMDYuMTg3IDQuNDczLTE1OS4yODQgNy41MzZ2NDIuMDQyYzUzLjA5NyAzLjA2IDEwNi4xODcgNS4yNDcgMTU5LjI4NCA3LjUzMyA1My4wOTMgMi4yNDUgMTA2LjE4IDQuMTk0IDE1OS4yNzMgNS45MDNsMTQuMjQuNDY1IDE3LjM1MSA0OC4zOWMxOC44NDIgNTEuODc0IDM3LjU0MiAxMDMuODA2IDU2Ljc2NSAxNTUuNTQxTDQ2Ni41MiAxMDI0bDQxLjUxMi0zMDguMjkzIDE3LjYzMy0xMzYuNjg1IDEwLjc3NiA1MC4zMjkgNTQuODE1IDI0OC41NDQgNzIuNTE2LTIxNy4yMTdoMTI5LjI2MWMxMy40OTMgNDguMTIxIDU3LjY1NSA4My40MjkgMTEwLjA3NSA4My40MjkgNjMuMTYgMCAxMTQuMzUyLTUxLjIwNSAxMTQuMzUyLTExNC4zNDggMC02My4xMzktNTEuMTg5LTExNC4zNDUtMTE0LjM0OS0xMTQuMzQ1bC4wMDQtLjAwMVoiIC8+Cjwvc3ZnPgo=',
430 $main_menu_position
431 );
432
433 /**
434 * Fires before submenu items are added to the Stream menu
435 * allowing plugins to add menu items before Settings
436 *
437 * @return void
438 */
439 do_action( 'wp_stream_admin_menu' );
440
441 /**
442 * Filter the Settings admin page title
443 *
444 * @return string
445 */
446 $settings_page_title = apply_filters( 'wp_stream_settings_form_title', esc_html__( 'Stream Settings', 'stream' ) );
447
448 $this->screen_id['settings'] = add_submenu_page(
449 $this->records_page_slug,
450 $settings_page_title,
451 esc_html__( 'Settings', 'stream' ),
452 $this->settings_cap,
453 $this->settings_page_slug,
454 array( $this, 'render_settings_page' )
455 );
456
457 if ( isset( $this->screen_id['main'] ) ) {
458 /**
459 * Fires just before the Stream list table is registered.
460 *
461 * @return void
462 */
463 do_action( 'wp_stream_admin_menu_screens' );
464
465 // Register the list table early, so it associates the column headers with 'Screen settings'.
466 add_action(
467 'load-' . $this->screen_id['main'],
468 array(
469 $this,
470 'register_list_table',
471 )
472 );
473 }
474 }
475
476 /**
477 * Enqueue scripts/styles for admin screen
478 *
479 * @action admin_enqueue_scripts
480 *
481 * @param string $hook Current hook.
482 *
483 * @return void
484 */
485 public function admin_enqueue_scripts( $hook ) {
486 if ( in_array( $hook, $this->screen_id, true ) ) {
487 $this->plugin->enqueue_asset(
488 'admin',
489 array(
490 $this->plugin->with_select2(),
491 $this->plugin->with_jquery_timeago(),
492 ),
493 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 )
501 );
502
503 $this->plugin->enqueue_asset(
504 'admin-exclude',
505 array(
506 $this->plugin->with_select2(),
507 ),
508 array(
509 'getActionsNonce' => wp_create_nonce( 'stream_get_actions' ),
510 )
511 );
512
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
518
519 $this->plugin->enqueue_asset(
520 'live-updates',
521 array( 'heartbeat' ),
522 array(
523 'current_screen' => $hook,
524 '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 ),
528 )
529 );
530 }
531
532 /**
533 * The maximum number of items that can be updated in bulk without receiving a warning.
534 *
535 * Stream watches for bulk actions performed in the WordPress Admin (such as updating
536 * many posts at once) and warns the user before proceeding if the number of items they
537 * are attempting to update exceeds this threshold value. Since Stream will try to save
538 * a log for each item, it will take longer than usual to complete the operation.
539 *
540 * The default threshold is 100 items.
541 *
542 * @return int
543 */
544 $bulk_actions_threshold = apply_filters( 'wp_stream_bulk_actions_threshold', 100 );
545
546 $this->plugin->enqueue_asset(
547 'global',
548 array(),
549 array(
550 'bulk_actions' => array(
551 'i18n' => array(
552 /* 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 ) ) ),
554 ),
555 'threshold' => absint( $bulk_actions_threshold ),
556 ),
557 'plugins_screen_url' => self_admin_url( 'plugins.php#stream' ),
558 )
559 );
560 }
561
562 /**
563 * Check whether or not the current admin screen belongs to Stream
564 *
565 * @return bool
566 */
567 public function is_stream_screen() {
568 if ( ! is_admin() ) {
569 return false;
570 }
571
572 $page = wp_stream_filter_input( INPUT_GET, 'page' );
573 if ( is_string( $page ) && false !== strpos( $page, $this->records_page_slug ) ) {
574 return true;
575 }
576
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 return false;
584 }
585
586 /**
587 * Add a specific body class to all Stream admin screens
588 *
589 * @param string $classes CSS classes to output to body.
590 *
591 * @filter admin_body_class
592 *
593 * @return string
594 */
595 public function admin_body_class( $classes ) {
596 $stream_classes = array();
597
598 if ( $this->is_stream_screen() ) {
599 $stream_classes[] = $this->admin_body_class;
600
601 if ( isset( $_GET['page'] ) ) { // // phpcs:ignore WordPress.Security.NonceVerification.Recommended
602 $stream_classes[] = sanitize_key( $_GET['page'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
603 }
604 }
605
606 /**
607 * Filter the Stream admin body classes
608 *
609 * @return array
610 */
611 $stream_classes = apply_filters( 'wp_stream_admin_body_classes', $stream_classes );
612 $stream_classes = implode( ' ', array_map( 'trim', $stream_classes ) );
613
614 return sprintf( '%s %s ', $classes, $stream_classes );
615 }
616
617 /**
618 * Add menu styles for various WP Admin skins.
619 *
620 * @action admin_enqueue_scripts
621 */
622 public function admin_menu_css() {
623 // Make sure we're working off a clean version.
624 if ( ! file_exists( ABSPATH . WPINC . '/version.php' ) ) {
625 return;
626 }
627 include ABSPATH . WPINC . '/version.php';
628
629 if ( ! isset( $wp_version ) ) {
630 return;
631 }
632
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 ";
652
653 wp_add_inline_style( 'wp-admin', $css );
654 }
655
656 /**
657 * Handle the reset AJAX request to reset logs.
658 *
659 * @return bool
660 */
661 public function wp_ajax_reset() {
662 check_ajax_referer( 'stream_nonce_reset', 'wp_stream_nonce_reset' );
663
664 if ( ! current_user_can( $this->settings_cap ) ) {
665 wp_die(
666 esc_html__( "You don't have sufficient privileges to do this action.", 'stream' )
667 );
668 }
669
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 $this->erase_stream_records();
677
678 if ( defined( 'WP_STREAM_TESTS' ) && WP_STREAM_TESTS ) {
679 return true;
680 }
681
682 wp_safe_redirect(
683 add_query_arg(
684 array(
685 'page' => is_network_admin() ? $this->network->network_settings_page_slug : $this->settings_page_slug,
686 'message' => 'data_erased',
687 ),
688 self_admin_url( $this->admin_parent_page )
689 )
690 );
691
692 exit;
693 }
694
695 /**
696 * Clears stream records from the database.
697 *
698 * @return void
699 */
700 private function erase_stream_records() {
701 global $wpdb;
702
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() ) {
706
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();
733 }
734 }
735
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 $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 )
873 );
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 }
891
892 /**
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 * Schedules a purge of records.
915 *
916 * @return void
917 */
918 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' );
923 }
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 }
942
943 /**
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 * Executes a scheduled purge
961 *
962 * @return void
963 */
964 public function purge_scheduled_action() {
965 // Don't purge when in Network Admin unless Stream is network activated.
966 if (
967 $this->plugin->is_multisite_not_network_activated()
968 &&
969 is_network_admin()
970 ) {
971 return;
972 }
973
974 $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 );
977 } else {
978 $options = wp_parse_args( (array) get_option( 'wp_stream', array() ), $defaults );
979 }
980
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'] ) ) {
996 return;
997 }
998
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 }
1008
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 }
1015
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' );
1028
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}" );
1054 }
1055
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
1099 );
1100 }
1101
1102 /**
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 * Returns the admin action links.
1361 *
1362 * @filter plugin_action_links
1363 *
1364 * @param array $links Action links.
1365 * @param string $file Plugin file.
1366 *
1367 * @return array
1368 */
1369 public function plugin_action_links( $links, $file ) {
1370 if ( plugin_basename( $this->plugin->locations['dir'] . 'stream.php' ) !== $file ) {
1371 return $links;
1372 }
1373
1374 // 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() ) {
1376 return $links;
1377 }
1378
1379 if ( is_network_admin() ) {
1380 $admin_page_url = add_query_arg(
1381 array(
1382 'page' => $this->network->network_settings_page_slug,
1383 ),
1384 network_admin_url( $this->admin_parent_page )
1385 );
1386 } else {
1387 $admin_page_url = add_query_arg(
1388 array(
1389 'page' => $this->settings_page_slug,
1390 ),
1391 admin_url( $this->admin_parent_page )
1392 );
1393 }
1394
1395 $links[] = sprintf( '<a href="%s">%s</a>', esc_url( $admin_page_url ), esc_html__( 'Settings', 'stream' ) );
1396
1397 return $links;
1398 }
1399
1400 /**
1401 * Render main page
1402 */
1403 public function render_list_table() {
1404 $this->list_table->prepare_items();
1405 ?>
1406 <div class="wrap">
1407 <h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
1408 <?php $this->list_table->display(); ?>
1409 </div>
1410 <?php
1411 }
1412
1413 /**
1414 * Render settings page
1415 */
1416 public function render_settings_page() {
1417 $option_key = $this->plugin->settings->option_key;
1418 $form_action = apply_filters( 'wp_stream_settings_form_action', admin_url( 'options.php' ) );
1419
1420 $page_description = apply_filters( 'wp_stream_settings_form_description', '' );
1421
1422 $sections = $this->plugin->settings->get_fields();
1423 $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 );
1434 ?>
1435 <div class="wrap">
1436 <h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
1437
1438 <?php if ( ! empty( $page_description ) ) : ?>
1439 <p><?php echo esc_html( $page_description ); ?></p>
1440 <?php endif; ?>
1441
1442 <?php settings_errors(); ?>
1443
1444 <?php if ( count( $sections ) > 1 ) : ?>
1445 <h2 class="nav-tab-wrapper">
1446 <?php $i = 0; ?>
1447 <?php foreach ( $sections as $section => $data ) : ?>
1448 <?php ++$i; ?>
1449 <?php $is_active = ( ( 1 === $i && ! $active_tab ) || $active_tab === $section ); ?>
1450 <a href="<?php echo esc_url( add_query_arg( 'tab', $section ) ); ?>" class="nav-tab <?php echo $is_active ? esc_attr( ' nav-tab-active' ) : ''; ?>">
1451 <?php echo esc_html( $data['title'] ); ?>
1452 </a>
1453 <?php endforeach; ?>
1454 </h2>
1455 <?php endif; ?>
1456
1457 <div class="nav-tab-content" id="tab-content-settings">
1458 <form method="post" action="<?php echo esc_attr( $form_action ); ?>" enctype="multipart/form-data">
1459 <div class="settings-sections">
1460 <?php
1461 $i = 0;
1462 foreach ( $sections as $section => $data ) {
1463 ++$i;
1464
1465 $is_active = ( ( 1 === $i && ! $active_tab ) || $active_tab === $section );
1466
1467 if ( $is_active ) {
1468 settings_fields( $option_key );
1469 do_settings_sections( $option_key );
1470 }
1471 }
1472 ?>
1473 </div>
1474 <?php submit_button(); ?>
1475 </form>
1476 </div>
1477 </div>
1478 <?php
1479 }
1480
1481 /**
1482 * Instantiate the list table
1483 */
1484 public function register_list_table() {
1485 $this->list_table = new List_Table(
1486 $this->plugin,
1487 array(
1488 'screen' => $this->screen_id['main'],
1489 )
1490 );
1491 }
1492
1493 /**
1494 * Check if a particular role has access
1495 *
1496 * @param string $role User role.
1497 *
1498 * @return bool
1499 */
1500 private function role_can_view( $role ) {
1501 if ( in_array( $role, $this->plugin->settings->options['general_role_access'], true ) ) {
1502 return true;
1503 }
1504
1505 return false;
1506 }
1507
1508 /**
1509 * Filter user caps to dynamically grant our view cap based on allowed roles
1510 *
1511 * @param array $allcaps All capabilities.
1512 * @param array $caps Required caps.
1513 * @param array $args Unused.
1514 * @param WP_User $user User.
1515 *
1516 * @filter user_has_cap
1517 *
1518 * @return array
1519 */
1520 public function filter_user_caps( $allcaps, $caps, $args, $user = null ) {
1521 global $wp_roles;
1522
1523 $_wp_roles = isset( $wp_roles ) ? $wp_roles : new WP_Roles();
1524
1525 $user = is_a( $user, 'WP_User' ) ? $user : wp_get_current_user();
1526
1527 // @see
1528 // https://github.com/WordPress/WordPress/blob/c67c9565f1495255807069fdb39dac914046b1a0/wp-includes/capabilities.php#L758
1529 $roles = array_unique(
1530 array_merge(
1531 $user->roles,
1532 array_filter(
1533 array_keys( $user->caps ),
1534 array( $_wp_roles, 'is_role' )
1535 )
1536 )
1537 );
1538
1539 $stream_view_caps = array( $this->view_cap );
1540
1541 foreach ( $caps as $cap ) {
1542 if ( in_array( $cap, $stream_view_caps, true ) ) {
1543 foreach ( $roles as $role ) {
1544 if ( $this->role_can_view( $role ) ) {
1545 $allcaps[ $cap ] = true;
1546
1547 break 2;
1548 }
1549 }
1550 }
1551 }
1552
1553 return $allcaps;
1554 }
1555
1556 /**
1557 * Filter role caps to dynamically grant our view cap based on allowed roles
1558 *
1559 * @filter role_has_cap
1560 *
1561 * @param array $allcaps All capabilities.
1562 * @param string $cap Require cap.
1563 * @param string $role User role.
1564 *
1565 * @return array
1566 */
1567 public function filter_role_caps( $allcaps, $cap, $role ) {
1568 $stream_view_caps = array( $this->view_cap );
1569
1570 if ( in_array( $cap, $stream_view_caps, true ) && $this->role_can_view( $role ) ) {
1571 $allcaps[ $cap ] = true;
1572 }
1573
1574 return $allcaps;
1575 }
1576
1577 /**
1578 * Ajax callback for return a user list.
1579 *
1580 * @action wp_ajax_wp_stream_filters
1581 */
1582 public function ajax_filters() {
1583 if ( ! defined( 'DOING_AJAX' ) || ! current_user_can( $this->plugin->admin->settings_cap ) ) {
1584 wp_die( '-1' );
1585 }
1586
1587 check_ajax_referer( 'stream_filters_user_search_nonce', 'nonce' );
1588
1589 switch ( wp_stream_filter_input( INPUT_GET, 'filter' ) ) {
1590 case 'user_id':
1591 $users = array_merge(
1592 array(
1593 0 => (object) array(
1594 'display_name' => 'WP-CLI',
1595 ),
1596 ),
1597 get_users()
1598 );
1599
1600 $search = wp_stream_filter_input( INPUT_GET, 'q' );
1601 if ( $search ) {
1602 // `search` arg for get_users() is not enough
1603 $users = array_filter(
1604 $users,
1605 function ( $user ) use ( $search ) {
1606 return false !== mb_strpos( mb_strtolower( $user->display_name ), mb_strtolower( $search ) );
1607 }
1608 );
1609 }
1610
1611 if ( count( $users ) > $this->preload_users_max ) {
1612 $users = array_slice( $users, 0, $this->preload_users_max );
1613 }
1614
1615 // Get gravatar / roles for final result set.
1616 $results = $this->get_users_record_meta( $users );
1617
1618 break;
1619 }
1620
1621 if ( isset( $results ) ) {
1622 echo wp_json_encode( $results );
1623 }
1624
1625 die();
1626 }
1627
1628 /**
1629 * Return relevant user meta data.
1630 *
1631 * @param array $authors Author data.
1632 * @return array
1633 */
1634 public function get_users_record_meta( $authors ) {
1635 $authors_records = array();
1636
1637 foreach ( $authors as $user_id => $args ) {
1638 $author = new Author( $args->ID );
1639
1640 $authors_records[ $user_id ] = array(
1641 'text' => $author->get_display_name(),
1642 'id' => $author->id,
1643 'label' => $author->get_display_name(),
1644 'icon' => $author->get_avatar_src( 32 ),
1645 'title' => '',
1646 );
1647 }
1648
1649 return $authors_records;
1650 }
1651
1652 /**
1653 * Get user meta in a way that is also safe for VIP
1654 *
1655 * @param int $user_id User ID.
1656 * @param string $meta_key Meta key.
1657 * @param bool $single Return first found meta value connected to the meta key (optional).
1658 *
1659 * @return mixed
1660 */
1661 public function get_user_meta( $user_id, $meta_key, $single = true ) {
1662 return get_user_meta( $user_id, $meta_key, $single );
1663 }
1664
1665 /**
1666 * Update user meta in a way that is also safe for VIP
1667 *
1668 * @param int $user_id User ID.
1669 * @param string $meta_key Meta key.
1670 * @param mixed $meta_value Meta value.
1671 * @param mixed $prev_value Previous meta value being overwritten (optional).
1672 *
1673 * @return int|bool
1674 */
1675 public function update_user_meta( $user_id, $meta_key, $meta_value, $prev_value = '' ) {
1676 return update_user_meta( $user_id, $meta_key, $meta_value, $prev_value );
1677 }
1678
1679 /**
1680 * Delete user meta in a way that is also safe for VIP
1681 *
1682 * @param int $user_id User ID.
1683 * @param string $meta_key Meta key.
1684 * @param mixed $meta_value Meta value (optional).
1685 *
1686 * @return bool
1687 */
1688 public function delete_user_meta( $user_id, $meta_key, $meta_value = '' ) {
1689 return delete_user_meta( $user_id, $meta_key, $meta_value );
1690 }
1691 }
1692