plugin = $plugin;
add_action( 'init', array( $this, 'init' ) );
// Ensure function used in various methods is pre-loaded.
if ( ! function_exists( 'is_plugin_active_for_network' ) ) {
require_once ABSPATH . '/wp-admin/includes/plugin.php';
}
// User and role caps.
add_filter( 'user_has_cap', array( $this, 'filter_user_caps' ), 10, 4 );
add_filter( 'role_has_cap', array( $this, 'filter_role_caps' ), 10, 3 );
if ( $this->plugin->is_multisite_network_activated() && ! is_network_admin() ) {
$options = (array) get_site_option( 'wp_stream_network', array() );
$option = isset( $options['general_site_access'] ) ? absint( $options['general_site_access'] ) : 1;
$this->disable_access = ( $option ) ? false : true;
}
// Register settings page.
if ( ! $this->disable_access ) {
add_action( 'admin_menu', array( $this, 'register_menu' ) );
}
// Admin notices.
add_action( 'admin_notices', array( $this, 'prepare_admin_notices' ) );
add_action( 'shutdown', array( $this, 'admin_notices' ) );
// Feature request notice.
add_action( 'admin_notices', array( $this, 'display_feature_request_notice' ) );
// Add admin body class.
add_filter( 'admin_body_class', array( $this, 'admin_body_class' ) );
// Plugin action links.
add_filter(
'plugin_action_links',
array(
$this,
'plugin_action_links',
),
10,
2
);
// Load admin scripts and styles.
add_action(
'admin_enqueue_scripts',
array(
$this,
'admin_enqueue_scripts',
)
);
add_action( 'admin_enqueue_scripts', array( $this, 'admin_menu_css' ) );
// Reset Streams database.
add_action(
'wp_ajax_wp_stream_reset',
array(
$this,
'wp_ajax_reset',
)
);
// Manual "Clean orphaned meta now" action (Settings → Advanced).
add_action(
'wp_ajax_wp_stream_clean_orphan_meta',
array( $this, 'wp_ajax_clean_orphan_meta' )
);
// Render confirmation notices keyed by the wp_stream_message query
// arg set on post-action redirects (e.g. orphan_meta_cleanup_scheduled).
add_action( 'admin_notices', array( $this, 'maybe_display_message' ) );
add_action( 'network_admin_notices', array( $this, 'maybe_display_message' ) );
// Render the persisted "large batched operation queued to WP-Cron"
// warning on the next admin page load (see
// maybe_warn_large_table_without_action_scheduler()).
add_action( 'admin_notices', array( $this, 'display_large_table_cron_notice' ) );
add_action( 'network_admin_notices', array( $this, 'display_large_table_cron_notice' ) );
// Auto purge setup (Action Scheduler).
add_action( 'wp_loaded', array( $this, 'purge_schedule_setup' ) );
add_action(
self::AUTO_PURGE_ACTION,
array( $this, 'purge_scheduled_action' )
);
add_action(
self::AUTO_PURGE_BATCH_ACTION,
array( $this, 'auto_purge_batch' ),
10,
3
);
add_action(
self::AUTO_PURGE_REAPER_ACTION,
array( $this, 'auto_purge_reaper' )
);
// Ajax users list.
add_action(
'wp_ajax_wp_stream_filters',
array(
$this,
'ajax_filters',
)
);
// Async action for erasing large log tables.
add_action(
self::ASYNC_DELETION_ACTION,
array(
$this,
'erase_large_records',
),
10,
4
);
}
/**
* Load admin classes
*
* @action init
*/
public function init() {
$this->network = new Network( $this->plugin );
$this->live_update = new Live_Update( $this->plugin );
$this->export = new Export( $this->plugin );
// Check if the host has configured the `REMOTE_ADDR` correctly.
$client_ip = $this->plugin->get_client_ip_address();
if ( empty( $client_ip ) && $this->is_stream_screen() ) {
$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' ) );
}
}
/**
* Output specific updates passed as URL parameters.
*
* @action admin_notices
*
* @return void
*/
public function prepare_admin_notices() {
$message = wp_stream_filter_input( INPUT_GET, 'message' );
switch ( $message ) {
case 'settings_reset':
$this->notice( esc_html__( 'All site settings have been successfully reset.', 'stream' ) );
break;
}
}
/**
* Handle notice messages according to the appropriate context (WP-CLI or the WP Admin)
*
* @param string $message Message to output.
* @param bool $is_error If the message is error_level (true) or warning (false).
*/
public function notice( $message, $is_error = true ) {
if ( defined( 'WP_CLI' ) && WP_CLI ) {
$message = wp_strip_all_tags( $message );
if ( $is_error ) {
WP_CLI::warning( $message );
} else {
WP_CLI::success( $message );
}
} else {
// Trigger admin notices late, so that any notices which occur during page load are displayed.
add_action( 'shutdown', array( $this, 'admin_notices' ) );
$notice = compact( 'message', 'is_error' );
if ( ! in_array( $notice, $this->notices, true ) ) {
$this->notices[] = $notice;
}
}
}
/**
* Show an error or other message in the WP Admin
*
* @action shutdown
*/
public function admin_notices() {
global $allowedposttags;
$custom = array(
'progress' => array(
'class' => true,
'id' => true,
'max' => true,
'style' => true,
'value' => true,
),
);
$allowed_html = array_merge( $allowedposttags, $custom );
ksort( $allowed_html );
foreach ( $this->notices as $notice ) {
$class_name = empty( $notice['is_error'] ) ? 'updated' : 'error';
$html_message = sprintf( '
%s
', esc_attr( $class_name ), wpautop( $notice['message'] ) );
echo wp_kses( $html_message, $allowed_html );
}
}
/**
* Display a feature request notice.
*
* @return void
*/
public function display_feature_request_notice() {
$screen = get_current_screen();
// Display the notice only on the Stream settings page.
if ( empty( $this->screen_id['settings'] ) || $this->screen_id['settings'] !== $screen->id ) {
return;
}
printf(
'',
esc_html__( 'Have suggestions or found a bug?', 'stream' ),
esc_html__( 'Click here to let us know!', 'stream' )
);
}
/**
* Register menu page
*
* @action admin_menu
*
* @return void
*/
public function register_menu() {
/**
* Filter the main admin menu title
*
* @return string
*/
$main_menu_title = apply_filters( 'wp_stream_admin_menu_title', esc_html__( 'Stream', 'stream' ) );
/**
* Filter the main admin menu position
*
* Note: Using longtail decimal string to reduce the chance of position conflicts, see Codex
*
* @return string
*/
$main_menu_position = apply_filters( 'wp_stream_menu_position', '2.999999' );
/**
* Filter the main admin page title
*
* @return string
*/
$main_page_title = apply_filters( 'wp_stream_admin_page_title', esc_html__( 'Stream Records', 'stream' ) );
$this->screen_id['main'] = add_menu_page(
$main_page_title,
$main_menu_title,
$this->view_cap,
$this->records_page_slug,
array( $this, 'render_list_table' ),
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDI0IDEwMjQiIGZpbGw9IjAwMCI+Cgk8cGF0aCBkPSJNOTAzLjExNSA1MTUuNDEzYy00OS4zOTIgMC05MS40NzQgMzEuMzM3LTEwNy40NiA3NS4yMDNsLTEyNC40MTEtMS41MzJjLTExLjM3Ny0uMzQ2LTIyLjc1MS0uNjg5LTM0LjEyOS0uOTk4bC0uMjQxLjU3NC0yMi40MzYtLjI3OC0uMTUzLS45Mi0xNS4wNTYtODIuOTMzLTIwLjE0Ni0xMDguNDA1LTIwLjU0NC0xMDguMzM3TDUwMy45ODIgMGwtNTMuMTQxIDQyOS4wMTMtMTYuMjE0IDEzNy45MzUtMTIuMDE2IDEwNi45MjQtMTE3LjI4Ni0yODUuMjItMTguMzUzIDIwMi44MWMtNDIuNTYyIDEuNDU0LTg1LjEyNyAyLjkzNC0xMjcuNjg4IDQuNzM4LTUzLjA5NyAyLjI5Mi0xMDYuMTg3IDQuNDczLTE1OS4yODQgNy41MzZ2NDIuMDQyYzUzLjA5NyAzLjA2IDEwNi4xODcgNS4yNDcgMTU5LjI4NCA3LjUzMyA1My4wOTMgMi4yNDUgMTA2LjE4IDQuMTk0IDE1OS4yNzMgNS45MDNsMTQuMjQuNDY1IDE3LjM1MSA0OC4zOWMxOC44NDIgNTEuODc0IDM3LjU0MiAxMDMuODA2IDU2Ljc2NSAxNTUuNTQxTDQ2Ni41MiAxMDI0bDQxLjUxMi0zMDguMjkzIDE3LjYzMy0xMzYuNjg1IDEwLjc3NiA1MC4zMjkgNTQuODE1IDI0OC41NDQgNzIuNTE2LTIxNy4yMTdoMTI5LjI2MWMxMy40OTMgNDguMTIxIDU3LjY1NSA4My40MjkgMTEwLjA3NSA4My40MjkgNjMuMTYgMCAxMTQuMzUyLTUxLjIwNSAxMTQuMzUyLTExNC4zNDggMC02My4xMzktNTEuMTg5LTExNC4zNDUtMTE0LjM0OS0xMTQuMzQ1bC4wMDQtLjAwMVoiIC8+Cjwvc3ZnPgo=',
$main_menu_position
);
/**
* Fires before submenu items are added to the Stream menu
* allowing plugins to add menu items before Settings
*
* @return void
*/
do_action( 'wp_stream_admin_menu' );
/**
* Filter the Settings admin page title
*
* @return string
*/
$settings_page_title = apply_filters( 'wp_stream_settings_form_title', esc_html__( 'Stream Settings', 'stream' ) );
$this->screen_id['settings'] = add_submenu_page(
$this->records_page_slug,
$settings_page_title,
esc_html__( 'Settings', 'stream' ),
$this->settings_cap,
$this->settings_page_slug,
array( $this, 'render_settings_page' )
);
if ( isset( $this->screen_id['main'] ) ) {
/**
* Fires just before the Stream list table is registered.
*
* @return void
*/
do_action( 'wp_stream_admin_menu_screens' );
// Register the list table early, so it associates the column headers with 'Screen settings'.
add_action(
'load-' . $this->screen_id['main'],
array(
$this,
'register_list_table',
)
);
}
}
/**
* Enqueue scripts/styles for admin screen
*
* @action admin_enqueue_scripts
*
* @param string $hook Current hook.
*
* @return void
*/
public function admin_enqueue_scripts( $hook ) {
if ( in_array( $hook, $this->screen_id, true ) ) {
$this->plugin->enqueue_asset(
'admin',
array(
$this->plugin->with_select2(),
$this->plugin->with_jquery_timeago(),
),
array(
'i18n' => array(
'confirm_purge' => __( 'Are you sure you want to delete all Stream activity records from the database? This cannot be undone.', 'stream' ),
'confirm_defaults' => __( 'Are you sure you want to reset all site settings to default? This cannot be undone.', 'stream' ),
),
'locale' => strtolower( substr( get_locale(), 0, 2 ) ),
'gmt_offset' => get_option( 'gmt_offset' ),
)
);
$this->plugin->enqueue_asset(
'admin-exclude',
array(
$this->plugin->with_select2(),
),
array(
'getActionsNonce' => wp_create_nonce( 'stream_get_actions' ),
)
);
$current_order = isset( $_GET['order'] ) ? sanitize_key( wp_unslash( $_GET['order'] ) ) : 'desc'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( ! in_array( $current_order, array( 'asc', 'desc' ), true ) ) {
$current_order = 'desc';
}
$current_query = map_deep( wp_unslash( $_GET ), 'sanitize_text_field' ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$this->plugin->enqueue_asset(
'live-updates',
array( 'heartbeat' ),
array(
'current_screen' => $hook,
'current_page' => isset( $_GET['paged'] ) ? absint( wp_unslash( $_GET['paged'] ) ) : '1', // phpcs:ignore WordPress.Security.NonceVerification.Recommended
'current_order' => $current_order,
'current_query' => wp_json_encode( $current_query ),
'current_query_count' => count( $current_query ),
)
);
}
/**
* The maximum number of items that can be updated in bulk without receiving a warning.
*
* Stream watches for bulk actions performed in the WordPress Admin (such as updating
* many posts at once) and warns the user before proceeding if the number of items they
* are attempting to update exceeds this threshold value. Since Stream will try to save
* a log for each item, it will take longer than usual to complete the operation.
*
* The default threshold is 100 items.
*
* @return int
*/
$bulk_actions_threshold = apply_filters( 'wp_stream_bulk_actions_threshold', 100 );
$this->plugin->enqueue_asset(
'global',
array(),
array(
'bulk_actions' => array(
'i18n' => array(
/* translators: %s: a number of items (e.g. "1,742") */
'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 ) ) ),
),
'threshold' => absint( $bulk_actions_threshold ),
),
'plugins_screen_url' => self_admin_url( 'plugins.php#stream' ),
)
);
}
/**
* Check whether or not the current admin screen belongs to Stream
*
* @return bool
*/
public function is_stream_screen() {
if ( ! is_admin() ) {
return false;
}
$page = wp_stream_filter_input( INPUT_GET, 'page' );
if ( is_string( $page ) && false !== strpos( $page, $this->records_page_slug ) ) {
return true;
}
if ( is_admin() && function_exists( 'get_current_screen' ) ) {
$screen = get_current_screen();
return ( Alerts::POST_TYPE === $screen->post_type );
}
return false;
}
/**
* Add a specific body class to all Stream admin screens
*
* @param string $classes CSS classes to output to body.
*
* @filter admin_body_class
*
* @return string
*/
public function admin_body_class( $classes ) {
$stream_classes = array();
if ( $this->is_stream_screen() ) {
$stream_classes[] = $this->admin_body_class;
if ( isset( $_GET['page'] ) ) { // // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$stream_classes[] = sanitize_key( $_GET['page'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
}
}
/**
* Filter the Stream admin body classes
*
* @return array
*/
$stream_classes = apply_filters( 'wp_stream_admin_body_classes', $stream_classes );
$stream_classes = implode( ' ', array_map( 'trim', $stream_classes ) );
return sprintf( '%s %s ', $classes, $stream_classes );
}
/**
* Add menu styles for various WP Admin skins.
*
* @action admin_enqueue_scripts
*/
public function admin_menu_css() {
// Make sure we're working off a clean version.
if ( ! file_exists( ABSPATH . WPINC . '/version.php' ) ) {
return;
}
include ABSPATH . WPINC . '/version.php';
if ( ! isset( $wp_version ) ) {
return;
}
$css = "
body.{$this->admin_body_class} #wpbody-content .wrap h1:nth-child(1):before {
content: '';
display: inline-block;
width: 24px;
height: 24px;
margin-right: 8px;
vertical-align: text-bottom;
background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDI0IDEwMjQiIGZpbGw9ImN1cnJlbnRjb2xvciI+Cgk8cGF0aCBkPSJNOTAzLjExNSA1MTUuNDEzYy00OS4zOTIgMC05MS40NzQgMzEuMzM3LTEwNy40NiA3NS4yMDNsLTEyNC40MTEtMS41MzJjLTExLjM3Ny0uMzQ2LTIyLjc1MS0uNjg5LTM0LjEyOS0uOTk4bC0uMjQxLjU3NC0yMi40MzYtLjI3OC0uMTUzLS45Mi0xNS4wNTYtODIuOTMzLTIwLjE0Ni0xMDguNDA1LTIwLjU0NC0xMDguMzM3TDUwMy45ODIgMGwtNTMuMTQxIDQyOS4wMTMtMTYuMjE0IDEzNy45MzUtMTIuMDE2IDEwNi45MjQtMTE3LjI4Ni0yODUuMjItMTguMzUzIDIwMi44MWMtNDIuNTYyIDEuNDU0LTg1LjEyNyAyLjkzNC0xMjcuNjg4IDQuNzM4LTUzLjA5NyAyLjI5Mi0xMDYuMTg3IDQuNDczLTE1OS4yODQgNy41MzZ2NDIuMDQyYzUzLjA5NyAzLjA2IDEwNi4xODcgNS4yNDcgMTU5LjI4NCA3LjUzMyA1My4wOTMgMi4yNDUgMTA2LjE4IDQuMTk0IDE1OS4yNzMgNS45MDNsMTQuMjQuNDY1IDE3LjM1MSA0OC4zOWMxOC44NDIgNTEuODc0IDM3LjU0MiAxMDMuODA2IDU2Ljc2NSAxNTUuNTQxTDQ2Ni41MiAxMDI0bDQxLjUxMi0zMDguMjkzIDE3LjYzMy0xMzYuNjg1IDEwLjc3NiA1MC4zMjkgNTQuODE1IDI0OC41NDQgNzIuNTE2LTIxNy4yMTdoMTI5LjI2MWMxMy40OTMgNDguMTIxIDU3LjY1NSA4My40MjkgMTEwLjA3NSA4My40MjkgNjMuMTYgMCAxMTQuMzUyLTUxLjIwNSAxMTQuMzUyLTExNC4zNDggMC02My4xMzktNTEuMTg5LTExNC4zNDUtMTE0LjM0OS0xMTQuMzQ1bC4wMDQtLjAwMVoiIC8+Cjwvc3ZnPgo=');
}
#menu-posts-feedback .wp-menu-image:before {
font-family: dashicons !important;
content: '\\f175';
}
#adminmenu #menu-posts-feedback div.wp-menu-image {
background: none !important;
background-repeat: no-repeat;
}
";
wp_add_inline_style( 'wp-admin', $css );
}
/**
* Handle the reset AJAX request to reset logs.
*
* @return bool
*/
public function wp_ajax_reset() {
check_ajax_referer( 'stream_nonce_reset', 'wp_stream_nonce_reset' );
if ( ! current_user_can( $this->settings_cap ) ) {
wp_die(
esc_html__( "You don't have sufficient privileges to do this action.", 'stream' )
);
}
// Ensure the database tables exist before attempting to clear records.
// Install::check() short-circuits on DOING_AJAX, so call install()
// directly. dbDelta is idempotent and safe to run when tables already
// exist.
$this->plugin->install->install( $this->plugin->get_version() );
$this->erase_stream_records();
if ( defined( 'WP_STREAM_TESTS' ) && WP_STREAM_TESTS ) {
return true;
}
wp_safe_redirect(
add_query_arg(
array(
'page' => is_network_admin() ? $this->network->network_settings_page_slug : $this->settings_page_slug,
'message' => 'data_erased',
),
self_admin_url( $this->admin_parent_page )
)
);
exit;
}
/**
* Clears stream records from the database.
*
* @return void
*/
private function erase_stream_records() {
global $wpdb;
// If this is a multisite and it's not network activated,
// only delete the entries from the blog which made the request.
if ( $this->plugin->is_multisite_not_network_activated() ) {
// First check the log size.
$stream_log_size = self::get_blog_record_table_size();
// If this is a large log and we need to delete only the entries
// pertaining to an individual site, we will need to do those in batches.
if ( $this->plugin->is_large_records_table( $stream_log_size ) ) {
$this->schedule_erase_large_records( $stream_log_size );
return;
}
$wpdb->query(
$wpdb->prepare(
"DELETE `stream`, `meta`
FROM {$wpdb->stream} AS `stream`
LEFT JOIN {$wpdb->streammeta} AS `meta`
ON `meta`.`record_id` = `stream`.`ID`
WHERE `blog_id`=%d;",
get_current_blog_id()
)
);
} else {
// If we are deleting all the entries, we can truncate the tables.
$wpdb->query( "TRUNCATE {$wpdb->streammeta};" );
$wpdb->query( "TRUNCATE {$wpdb->stream};" );
// Tidy up any meta which may have been added in between the two truncations.
$this->delete_orphaned_meta();
}
}
/**
* Schedule the initial event to start erasing the logs from now.
*
* @param int $log_size The number of rows which will be affected.
* @return void
*/
private function schedule_erase_large_records( int $log_size ) {
global $wpdb;
$last_entry = $wpdb->get_var(
$wpdb->prepare(
"SELECT ID FROM {$wpdb->stream} WHERE `blog_id`=%d ORDER BY ID DESC LIMIT 1",
get_current_blog_id()
)
);
// If there are no entries to erase, don't try to erase them.
if ( empty( $last_entry ) ) {
return;
}
// We are going to delete this many and this many only.
// This is to avoid the situation where rows keep getting added
// between the Action Scheduler runs and they never stop.
$args = array(
'total' => (int) $log_size,
'done' => 0,
'last_entry' => (int) $last_entry,
'blog_id' => (int) get_current_blog_id(),
);
$this->plugin->scheduler->enqueue_async( self::ASYNC_DELETION_ACTION, $args );
$this->maybe_warn_large_table_without_action_scheduler(
(int) $log_size,
__( 'reset the Stream database (delete all records for this site)', 'stream' )
);
}
/**
* Warn when a large-table batched operation has to lean on WP-Cron.
*
* Action Scheduler is purpose-built to drain long self-chaining batch
* jobs reliably; default WP-Cron fires opportunistically on traffic and
* can stall a multi-hour chain on a low-traffic site. When Stream is
* running the WP-Cron fallback (the `wp_stream_use_action_scheduler`
* filter returned false, or the bundled AS library is absent) against a
* table over the large-table threshold, surface a notice pointing the
* operator at a deterministic WP-CLI drain instead of failing silently.
*
* Delivery depends on context. Under WP-CLI the warning is emitted
* immediately via {@see Admin::notice()} (WP_CLI::warning) — scheduling
* the batch chain onto WP-Cron does not drain it, so a headless /
* low-traffic site is exactly where the chain can stall. Outside WP-CLI
* neither call site renders its own output (the recurring purge runs
* under DOING_CRON; the manual reset redirects and exits before its
* shutdown hook output reaches the browser), so the message is persisted
* to {@see Admin::LARGE_TABLE_CRON_NOTICE_OPTION} and rendered on the
* next admin page load by {@see Admin::display_large_table_cron_notice()}.
*
* No-op when Action Scheduler is the active backend (built to drain long
* chains). The `wp_stream_enable_auto_purge` filter deliberately does NOT
* gate this helper: it governs TTL retention purging only, while this
* warning also covers the manual database reset — an operator who manages
* retention externally can still click "Reset Stream Database" and needs
* the stall warning. The auto-purge call site is already gated by the
* filter's early return in {@see Admin::purge_scheduled_action()}.
*
* @param int $record_count Number of rows the operation will touch.
* @param string $operation Human-readable, translated description of what the
* batched work does (e.g. "delete records older than
* the retention period"), interpolated into the notice.
* @return void
*/
private function maybe_warn_large_table_without_action_scheduler( int $record_count, string $operation ) {
if ( $this->plugin->scheduler instanceof AS_Scheduler ) {
return;
}
if ( ! $this->plugin->is_large_records_table( $record_count ) ) {
return;
}
$message = sprintf(
/* translators: 1: operation description (e.g. "delete records older than the retention period"), 2: number of records, 3: WP-CLI command. */
__( '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' ),
$operation,
number_format_i18n( $record_count ),
'wp cron event run --due-now'
);
if ( defined( 'WP_CLI' ) && WP_CLI ) {
// Immediate WP_CLI::warning — the operator is watching the terminal.
$this->notice( $message );
return;
}
// Persist for the next admin page load. Neither call site can render
// output itself: the recurring purge runs under DOING_CRON (response
// discarded) and the manual reset redirects + exits before shutdown
// output reaches the browser. No autoload — this is set rarely and
// read only in the admin.
update_option( self::LARGE_TABLE_CRON_NOTICE_OPTION, $message, false );
}
/**
* Render (and clear) the persisted large-table WP-Cron warning.
*
* Counterpart to {@see Admin::maybe_warn_large_table_without_action_scheduler()}:
* displays the stored warning on the first admin page an operator with
* the Stream settings capability loads after a large batched operation
* was queued onto WP-Cron.
*
* @action admin_notices
* @action network_admin_notices
*
* @return void
*/
public function display_large_table_cron_notice() {
if ( ! current_user_can( $this->settings_cap ) ) {
return;
}
$message = get_option( self::LARGE_TABLE_CRON_NOTICE_OPTION );
if ( empty( $message ) ) {
return;
}
delete_option( self::LARGE_TABLE_CRON_NOTICE_OPTION );
printf(
'%s
',
wp_kses_post( wpautop( $message ) )
);
}
/**
* Checks if the async deletion process is running.
*
* Checks pending AND in-flight state, mirroring
* {@see Admin::is_running_auto_purge()}. Under WP-Cron the event is
* removed from the cron array before its callback runs, so a
* pending-only probe would momentarily read idle mid-chain and briefly
* re-expose the reset link in Settings. The batch worker keeps the
* best-effort running marker set for that window (see
* {@see Admin::erase_large_records()}). The marker transient is shared
* with the auto-purge chain, which only makes both guards more
* conservative — never less safe.
*
* @return bool True if the async deletion process is running, false otherwise.
*/
public static function is_running_async_deletion() {
$plugin = wp_stream_get_instance();
if ( empty( $plugin->scheduler ) ) {
return false;
}
return $plugin->scheduler->any_pending_or_running( array( self::ASYNC_DELETION_ACTION ) );
}
/**
* Checks if any auto-purge action is currently scheduled or in-flight.
*
* Returns true when either the batched chain worker or the terminal
* orphan reaper is pending OR running. The recurring scheduler is
* intentionally excluded — it is always pending under normal operation,
* so including it here would make the probe useless. Used by the
* Settings → Advanced UI to render an "Auto-purge currently running"
* notice and by the recurring callback as an overlap guard.
*
* Checks both PENDING and IN-PROGRESS statuses so a chain that is
* mid-execution (e.g. the batch worker is currently running and has not
* yet enqueued the next batch) still reports as running. Without the
* RUNNING check the overlap guard can let a second parallel chain stack
* against the same rows.
*
* @return bool
*/
public static function is_running_auto_purge() {
$plugin = wp_stream_get_instance();
if ( empty( $plugin->scheduler ) ) {
return false;
}
return $plugin->scheduler->any_pending_or_running(
array( self::AUTO_PURGE_BATCH_ACTION, self::AUTO_PURGE_REAPER_ACTION )
);
}
/**
* Erases large records from the stream table.
*
* This function deletes records from the stream table in batches, starting from a given entry ID.
* It deletes records in reverse chronological order, starting from the largest ID and going back.
* The number of records deleted in each batch is determined by the batch size, which can be filtered
* using the 'wp_stream_batch_size' hook.
*
* @param int $total The total number of records to be deleted.
* @param int $done The number of records that have already been deleted.
* @param int $last_entry The ID of the last entry that was deleted.
* @param int $blog_id The ID of the blog for which the records should be deleted.
* @return void
*/
public function erase_large_records( int $total, int $done, int $last_entry, int $blog_id ) {
global $wpdb;
// Best-effort "running" marker, mirroring auto_purge_batch(). Under
// WP-Cron the event is dequeued before this callback runs, so without
// the marker is_running_async_deletion() would momentarily read idle
// between batches and briefly re-expose the reset link in Settings.
// No-op under Action Scheduler; self-expires on a fatal.
$this->plugin->scheduler->mark_running( 'async_deletion' );
$start_from = $wpdb->get_var(
$wpdb->prepare(
"SELECT ID FROM {$wpdb->stream} WHERE ID < %d AND `blog_id`=%d ORDER BY ID DESC LIMIT 1",
$last_entry + 1, // A tweak to get it correct the first time through.
get_current_blog_id()
)
);
if ( empty( $start_from ) ) {
// Terminal batch: nothing left to delete, no further event will
// be chained, and no work follows within this callback — safe to
// clear the marker immediately (unlike the auto-purge chain,
// whose terminal batch hands off to the reaper).
$this->plugin->scheduler->mark_done( 'async_deletion' );
return;
}
/**
* Filters the number of records in the {$wpdb->stream} table to do at a time.
*
* @since 4.1.0
*
* @param int $batch_size The batch size, default 250000.
*/
$batch_size = apply_filters( 'wp_stream_batch_size', 250000 );
// This will tend to erase them in reverse chronological order,
// ie it will start from the largest ID and go back from there.
$wpdb->query(
$wpdb->prepare(
"DELETE `stream`, `meta`
FROM {$wpdb->stream} AS `stream`
LEFT JOIN {$wpdb->streammeta} AS `meta`
ON `meta`.`record_id` = `stream`.`ID`
WHERE ID <= %d AND ID >= %d AND `blog_id`=%d;",
$start_from,
$start_from - $batch_size,
get_current_blog_id()
)
);
$remaining = $wpdb->get_var(
$wpdb->prepare( "SELECT COUNT(ID) FROM {$wpdb->stream} WHERE `blog_id`=%d", $blog_id )
);
$done = $total - $remaining;
$this->plugin->scheduler->enqueue_async(
self::ASYNC_DELETION_ACTION,
array(
'total' => (int) $total,
'done' => (int) $done,
'last_entry' => (int) $start_from - $batch_size, // The last ID checked.
'blog_id' => (int) $blog_id,
)
);
}
/**
* Retrieves the size of the blog record table for a specific blog.
*
* @param int|null $blog_id The ID of the blog. If not provided, the current blog ID will be used.
* @return int The size of the blog record table.
*/
public static function get_blog_record_table_size( $blog_id = null ): int {
global $wpdb;
$blog_id = empty( $blog_id ) ? get_current_blog_id() : $blog_id;
$blog_size = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(ID) FROM {$wpdb->stream} WHERE `blog_id`=%d",
$blog_id
)
);
return (int) $blog_size;
}
/**
* Schedules a purge of records.
*
* @return void
*/
public function purge_schedule_setup() {
// Clear the legacy WP-Cron event scheduled by Stream <= 4.1.x so it
// cannot double-fire alongside the new recurring action.
if ( wp_next_scheduled( 'wp_stream_auto_purge' ) ) {
wp_clear_scheduled_hook( 'wp_stream_auto_purge' );
}
$scheduler = $this->plugin->scheduler;
/**
* Filter whether Stream schedules its TTL record auto-purge at all.
*
* Custom storage drivers that manage retention externally (TTL
* indexes, partition rotation, a warehouse job, etc.) can return
* false to disable all TTL purge scheduling regardless of the
* scheduler backend. Any already-registered recurring purge is
* unscheduled from both backends so it cannot keep firing.
*
* @param bool $enabled Whether auto-purge scheduling is enabled.
*/
if ( ! apply_filters( 'wp_stream_enable_auto_purge', true ) ) {
// Tear down only once, then record the 'disabled' sentinel in the
// backend marker. This runs on every wp_loaded, so without the
// guard a permanently-disabled site would pay the unschedule
// probes on every request; with it, steady state is a single
// in-memory compare (the marker is autoloaded). The sentinel also
// covers a site upgrading with the filter already active (no
// marker yet, but a recurring action left by a previous version).
// The executing path is independently gated by the same filter in
// purge_scheduled_action(), so a stray entry that somehow survives
// cannot purge anything anyway.
if ( 'disabled' !== get_option( self::SCHEDULER_BACKEND_OPTION ) ) {
$scheduler->unschedule_all( self::AUTO_PURGE_ACTION );
wp_unschedule_hook( self::AUTO_PURGE_ACTION );
// Also clear the Action Scheduler store when its API is
// available but AS is not the active backend (e.g. the cron
// backend is selected while WooCommerce provides AS). The
// active-backend unschedule above cannot see AS's store, and
// this filter promises teardown from BOTH backends. When AS
// is entirely absent this is skipped — a stray AS entry
// cannot execute (no AS runner), and if AS appears later the
// action fires as a no-op thanks to the execute-path gate.
if ( ! $scheduler instanceof AS_Scheduler && function_exists( 'as_unschedule_all_actions' ) ) {
( new AS_Scheduler() )->unschedule_all( self::AUTO_PURGE_ACTION );
}
update_option( self::SCHEDULER_BACKEND_OPTION, 'disabled' );
}
return;
}
$backend = $scheduler instanceof AS_Scheduler ? 'action_scheduler' : 'wp_cron';
// Detect a backend switch and clear the inactive backend's copy of the
// recurring action exactly once. A site that switched schedulers (via
// the wp_stream_use_action_scheduler filter) would otherwise keep
// firing the purge from BOTH backends — the two stores are independent
// and neither overlap guard can see the other. The marker is an
// autoloaded option, so the steady-state cost on every wp_loaded is a
// single in-memory compare; the cleanup query runs only on the first
// page load after a switch. Idempotent and self-healing. No data is
// affected — only the redundant schedule entry.
if ( get_option( self::SCHEDULER_BACKEND_OPTION ) !== $backend ) {
$cleanup_done = true;
if ( 'action_scheduler' === $backend ) {
// Drop any leftover WP-Cron recurring event.
wp_unschedule_hook( self::AUTO_PURGE_ACTION );
} elseif ( function_exists( 'as_unschedule_all_actions' ) ) {
// Drop any leftover Action Scheduler recurring action. Routed
// through AS_Scheduler so the as_*() call stays contained there.
( new AS_Scheduler() )->unschedule_all( self::AUTO_PURGE_ACTION );
} else {
// Action Scheduler is not loaded (cron backend selected and no
// other plugin provides AS), so its store cannot be cleaned
// right now. Do NOT write the marker: if an AS-providing
// plugin (e.g. WooCommerce) is installed later, the stray
// Stream recurring action in the AS store would resume firing
// alongside the cron one — and the cron overlap guard cannot
// see it. Leaving the marker stale retries this cleanup on a
// later request once as_unschedule_all_actions() exists.
$cleanup_done = false;
}
if ( $cleanup_done ) {
update_option( self::SCHEDULER_BACKEND_OPTION, $backend );
}
}
// 12 hours == old `twicedaily` interval. The scheduler only schedules
// a fresh recurring action when one is not already registered.
$scheduler->schedule_recurring(
time(),
12 * HOUR_IN_SECONDS,
self::AUTO_PURGE_ACTION,
array(),
self::AUTO_PURGE_GROUP
);
}
/**
* Deletes orphaned meta records from the database.
*
* Deletes meta records from the stream meta table where the corresponding
* stream record no longer exists.
*
* @global wpdb $wpdb The WordPress database object.
*/
protected function delete_orphaned_meta() {
global $wpdb;
$wpdb->query(
"DELETE `meta` FROM {$wpdb->streammeta} as `meta` LEFT JOIN {$wpdb->stream} as `stream` ON `stream`.`ID`=`meta`.`record_id` WHERE `stream`.`ID` IS NULL"
);
}
/**
* Executes a scheduled purge
*
* @return void
*/
public function purge_scheduled_action() {
// Respect the auto-purge master switch on the executing path too, not
// just at scheduling time. A recurring action already in flight when
// the filter flips to false (or an args-specific entry the unschedule
// missed) would otherwise still run a purge cycle the operator opted
// out of. This filter is documented in Admin::purge_schedule_setup().
if ( ! apply_filters( 'wp_stream_enable_auto_purge', true ) ) {
return;
}
// Don't purge when in Network Admin unless Stream is network activated.
if (
$this->plugin->is_multisite_not_network_activated()
&&
is_network_admin()
) {
return;
}
$defaults = $this->plugin->settings->get_defaults();
if ( $this->plugin->is_multisite_network_activated() ) {
$options = wp_parse_args( (array) get_site_option( 'wp_stream_network', array() ), $defaults );
} else {
$options = wp_parse_args( (array) get_option( 'wp_stream', array() ), $defaults );
}
// TTL fallback. Settings::get_defaults() runs every settings field
// through the `wp_stream_settings_option_fields` filter, which
// Network::get_network_admin_fields() uses to strip the `records_ttl`
// field from the per-site option's defaults set. When this callback runs
// outside any admin context (Action Scheduler, WP-CLI, system cron), the
// per-site option_key is in effect, so the filtered defaults array does
// not contain general_records_ttl at all. Apply the documented 30-day
// default (classes/class-settings.php, `records_ttl` field) only when
// the key is genuinely missing, so an operator who set the value via
// CLI/SQL keeps their explicit choice.
if ( ! isset( $options['general_records_ttl'] ) ) {
$options['general_records_ttl'] = 30;
}
if ( ! empty( $options['general_keep_records_indefinitely'] ) ) {
return;
}
// Refuse to purge with a non-positive TTL. The UI enforces min=1, but
// CLI/SQL can set 0 or a negative integer. Honoring those would mean
// "delete every record on every cycle", which has no legitimate use
// case (keep_records_indefinitely covers the opposite extreme).
// Bailing out makes operator error visible (records stop being purged)
// instead of catastrophic (records get wiped repeatedly).
if ( (int) $options['general_records_ttl'] < 1 ) {
return;
}
// Overlap guard: if any auto-purge action (batch worker or reaper) is
// pending or in-progress, don't stack a new chain. Reuses the same
// probe used by the Settings UI so the two views of "running" agree.
if ( self::is_running_auto_purge() ) {
return;
}
/**
* Fires once per auto-purge cycle, after all bail-out checks pass and
* immediately before deletion work is enqueued.
*
* Preserved for backward compatibility with consumers that hooked the
* legacy WP-Cron event of the same name in Stream <= 4.1.x. Note that
* since 4.2.0 this fires only when a purge is actually about to run —
* it no longer fires on every cron tick regardless of whether work
* happens. Hook into the recurring AS action (Admin::AUTO_PURGE_ACTION)
* directly if you need the older "every tick" semantics.
*/
do_action( 'wp_stream_auto_purge' );
// Snapshot the UTC cutoff once per recurring tick. Each batch in this
// chain operates against this fixed cutoff so the chain is finite.
$days = (int) $options['general_records_ttl'];
$cutoff = ( new DateTime( 'now', new DateTimeZone( 'UTC' ) ) )
->sub( DateInterval::createFromDateString( $days . ' days' ) )
->format( 'Y-m-d H:i:s' );
// blog_id = 0 means "all blogs" (network-activated path).
$blog_id = $this->plugin->is_multisite_not_network_activated() ? (int) get_current_blog_id() : 0;
global $wpdb;
// "Is this a large table?" decision matches the manual reset path
// (Admin::erase_stream_records()). When the table is small the cost
// of scheduling a chain (and waiting for AS to drain it on the next
// runner tick) exceeds the cost of a single inline DELETE. Only fall
// through to the batched chain when the filter says "yes, large".
if ( $blog_id > 0 ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$record_count = (int) $wpdb->get_var(
$wpdb->prepare( "SELECT COUNT(ID) FROM {$wpdb->stream} WHERE `blog_id` = %d", $blog_id )
);
} else {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$record_count = (int) $wpdb->get_var( "SELECT COUNT(ID) FROM {$wpdb->stream}" );
}
if ( ! $this->plugin->is_large_records_table( $record_count ) ) {
// Small-table fast path: one inline multi-table DELETE, then enqueue
// the orphan reaper as a one-shot async action so the heal step is
// still observable in Tools → Scheduled Actions.
if ( $blog_id > 0 ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->query(
$wpdb->prepare(
"DELETE `stream`, `meta`
FROM {$wpdb->stream} AS `stream`
LEFT JOIN {$wpdb->streammeta} AS `meta`
ON `meta`.`record_id` = `stream`.`ID`
WHERE `stream`.`created` < %s AND `stream`.`blog_id` = %d;",
$cutoff,
$blog_id
)
);
} else {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->query(
$wpdb->prepare(
"DELETE `stream`, `meta`
FROM {$wpdb->stream} AS `stream`
LEFT JOIN {$wpdb->streammeta} AS `meta`
ON `meta`.`record_id` = `stream`.`ID`
WHERE `stream`.`created` < %s;",
$cutoff
)
);
}
$this->plugin->scheduler->enqueue_async( self::AUTO_PURGE_REAPER_ACTION, array(), self::AUTO_PURGE_GROUP );
return;
}
// Large-table path: batched chain.
$this->plugin->scheduler->enqueue_async(
self::AUTO_PURGE_BATCH_ACTION,
array(
'cutoff' => $cutoff,
'blog_id' => $blog_id,
),
self::AUTO_PURGE_GROUP
);
$this->maybe_warn_large_table_without_action_scheduler(
$record_count,
__( 'delete records older than the retention period', 'stream' )
);
}
/**
* Async Action Scheduler callback: delete one batch of records eligible
* under the snapshotted UTC cutoff, then chain the next batch (or the
* orphan reaper when nothing remains).
*
* Window-based deletion mirrors {@see Admin::erase_large_records()} so the
* InnoDB lock footprint is bounded and predictable on bloated tables.
*
* @param string $cutoff MySQL DATETIME string in UTC.
* @param int $blog_id Blog to scope to, or 0 for all blogs (network-activated).
* @param int $last_entry The lower-bound ID of the previous batch's window; 0 on the
* first batch in a chain. The next SELECT uses `ID < last_entry`
* when non-zero, guaranteeing forward progress even on tables
* that grow rapidly during the chain. Trade-off: any eligible
* row that lands inside the already-touched ID range
* [window_low, start_from] after that batch ran is skipped
* by the current chain and picked up on the next recurring
* tick (or small-table fast path). Possible sources: dev/test
* seeders, importer/migration plugins replaying historical
* rows, or PHP/MySQL clock skew on `created`. Steady-state
* logging via Log::log() uses monotonic IDs and current UTC,
* so this is a no-op for normal production traffic.
* @throws \InvalidArgumentException When $cutoff is empty (signals AS to mark the action as failed).
* @return void
*/
public function auto_purge_batch( $cutoff, $blog_id = 0, $last_entry = 0 ) {
global $wpdb;
$cutoff = (string) $cutoff;
$blog_id = (int) $blog_id;
$last_entry = (int) $last_entry;
// Defensive: a malformed cutoff would otherwise translate to a no-op
// DELETE that still busies the DB. Throw so Action Scheduler marks
// the action as failed (and visible in Tools → Scheduled Actions)
// rather than silently completing. In practice this is unreachable
// because purge_scheduled_action() always populates the cutoff arg
// and AS args are immutable; the guard exists for third-party code
// that may enqueue the action with bad input.
if ( '' === $cutoff ) {
throw new \InvalidArgumentException( 'auto_purge_batch requires a non-empty cutoff.' );
}
// Best-effort "running" marker for schedulers without a native RUNNING
// store (cron). Bridges the gap between this batch starting and the
// next chained event being enqueued; self-expires on a fatal. No-op
// under Action Scheduler. Cleared when the chain reaches its terminal
// reaper (see the empty-$start_from branch below).
$this->plugin->scheduler->mark_running( 'auto_purge' );
/**
* Filters the number of records to delete per batch.
*
* Shared with the manual reset path (see {@see Admin::erase_large_records()})
* so site owners only need to tune one knob.
*
* @since 4.1.0
*
* @param int $batch_size Default 250000.
*/
$batch_size = (int) apply_filters( 'wp_stream_batch_size', 250000 );
if ( $batch_size < 1 ) {
$batch_size = 250000;
}
// Find the highest-ID record still eligible under the snapshotted cutoff
// that lies strictly below the previous window's lower bound (when set).
// $last_entry=0 means "first batch in chain" — search from the top.
if ( $blog_id > 0 && $last_entry > 0 ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$start_from = $wpdb->get_var(
$wpdb->prepare(
"SELECT ID FROM {$wpdb->stream} WHERE `created` < %s AND `blog_id` = %d AND `ID` < %d ORDER BY ID DESC LIMIT 1",
$cutoff,
$blog_id,
$last_entry
)
);
} elseif ( $blog_id > 0 ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$start_from = $wpdb->get_var(
$wpdb->prepare(
"SELECT ID FROM {$wpdb->stream} WHERE `created` < %s AND `blog_id` = %d ORDER BY ID DESC LIMIT 1",
$cutoff,
$blog_id
)
);
} elseif ( $last_entry > 0 ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$start_from = $wpdb->get_var(
$wpdb->prepare(
"SELECT ID FROM {$wpdb->stream} WHERE `created` < %s AND `ID` < %d ORDER BY ID DESC LIMIT 1",
$cutoff,
$last_entry
)
);
} else {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$start_from = $wpdb->get_var(
$wpdb->prepare(
"SELECT ID FROM {$wpdb->stream} WHERE `created` < %s ORDER BY ID DESC LIMIT 1",
$cutoff
)
);
}
if ( empty( $start_from ) ) {
// Chain is done. Schedule the orphan reaper as the terminal step.
// The running marker is NOT cleared here: under WP-Cron the reaper
// event is removed from the cron array before its callback runs,
// so clearing now would let the overlap guard read "idle" while
// the reaper's orphan-meta DELETE is still executing. The reaper
// clears the marker itself when it finishes.
$this->plugin->scheduler->enqueue_async( self::AUTO_PURGE_REAPER_ACTION, array(), self::AUTO_PURGE_GROUP );
return;
}
$start_from = (int) $start_from;
$window_low = max( 0, $start_from - $batch_size );
// Multi-table DELETE: parent + meta in one statement. Mirrors
// Admin::erase_large_records().
if ( $blog_id > 0 ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->query(
$wpdb->prepare(
"DELETE `stream`, `meta`
FROM {$wpdb->stream} AS `stream`
LEFT JOIN {$wpdb->streammeta} AS `meta`
ON `meta`.`record_id` = `stream`.`ID`
WHERE `stream`.`ID` <= %d
AND `stream`.`ID` >= %d
AND `stream`.`created` < %s
AND `stream`.`blog_id` = %d;",
$start_from,
$window_low,
$cutoff,
$blog_id
)
);
} else {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->query(
$wpdb->prepare(
"DELETE `stream`, `meta`
FROM {$wpdb->stream} AS `stream`
LEFT JOIN {$wpdb->streammeta} AS `meta`
ON `meta`.`record_id` = `stream`.`ID`
WHERE `stream`.`ID` <= %d
AND `stream`.`ID` >= %d
AND `stream`.`created` < %s;",
$start_from,
$window_low,
$cutoff
)
);
}
// Chain the next batch. Pass $window_low as the new upper bound so the
// next SELECT cannot pick up rows in or above the window we just touched.
$this->plugin->scheduler->enqueue_async(
self::AUTO_PURGE_BATCH_ACTION,
array(
'cutoff' => $cutoff,
'blog_id' => $blog_id,
'last_entry' => $window_low,
),
self::AUTO_PURGE_GROUP
);
}
/**
* Terminal Action Scheduler callback for the auto-purge chain.
*
* Runs once per chain (after the last batch) and once when the manual
* "Clean orphaned meta now" button is used. Cleans up meta rows whose
* parent stream row is already gone — i.e. residue from historical
* unbatched purges and from any logger races during a chain.
*
* @return void
*/
public function auto_purge_reaper() {
// Keep the overlap guard reading "busy" while the orphan-meta DELETE
// runs. Under WP-Cron the event is removed from the cron array before
// this callback executes, so without the marker a recurring purge
// tick or a manual "clean orphaned meta" click could stack parallel
// work against the same rows. No-op under Action Scheduler, which
// tracks RUNNING state natively. Self-expires on a fatal.
$this->plugin->scheduler->mark_running( 'auto_purge' );
$this->delete_orphaned_meta();
$this->plugin->scheduler->mark_done( 'auto_purge' );
}
/**
* Ajax handler for the "Clean orphaned meta now" button on
* Settings → Advanced.
*
* Schedules an immediate async run of the orphan reaper. Idempotent:
* if a reaper is already scheduled, returns without enqueuing a second.
*
* Returns true under WP_STREAM_TESTS so PHPUnit can call this directly
* without exiting the worker.
*
* @return bool|void True under tests; otherwise redirects and exits.
*/
public function wp_ajax_clean_orphan_meta() {
if ( ! current_user_can( $this->settings_cap ) ) {
wp_die( esc_html__( 'You do not have permission to do this.', 'stream' ), 403 );
}
check_ajax_referer( 'stream_nonce_clean_orphan_meta', 'wp_stream_nonce_clean_orphan_meta' );
if ( empty( $this->plugin->scheduler ) ) {
wp_die( esc_html__( 'No scheduler is available.', 'stream' ), 500 );
}
// Idempotency: skip enqueue when any auto-purge action is already
// pending or running. is_running_auto_purge() checks PENDING + RUNNING
// across the batch worker and the reaper, so a chain that will run
// its own terminal reaper is not duplicated by a manual click landing
// in the small CSRF/stale-URL window where the UI link is hidden.
if ( ! self::is_running_auto_purge() ) {
$this->plugin->scheduler->enqueue_async( self::AUTO_PURGE_REAPER_ACTION, array(), self::AUTO_PURGE_GROUP );
}
if ( defined( 'WP_STREAM_TESTS' ) && WP_STREAM_TESTS ) {
return true;
}
$is_network = $this->plugin->is_multisite_network_activated();
$page_slug = $is_network ? $this->network->network_settings_page_slug : $this->settings_page_slug;
$base_url = $is_network ? network_admin_url( $this->admin_parent_page ) : admin_url( $this->admin_parent_page );
wp_safe_redirect(
add_query_arg(
array(
'page' => $page_slug,
'wp_stream_message' => 'orphan_meta_cleanup_scheduled',
),
$base_url
)
);
exit;
}
/**
* Render admin notices for post-action redirects.
*
* Reads `wp_stream_message` from the query string and renders a matching
* notice. Used to surface "Clean Orphaned Meta" confirmation after the
* Ajax handler redirects back to Settings → Advanced.
*
* @return void
*/
public function maybe_display_message() {
$message = wp_stream_filter_input( INPUT_GET, 'wp_stream_message' );
if ( empty( $message ) ) {
return;
}
$notices = array(
'orphan_meta_cleanup_scheduled' => __(
'Orphaned meta cleanup scheduled. Progress is visible under Tools → Scheduled Actions.',
'stream'
),
);
if ( ! isset( $notices[ $message ] ) ) {
return;
}
printf(
'',
esc_html( $notices[ $message ] )
);
}
/**
* Returns the admin action links.
*
* @filter plugin_action_links
*
* @param array $links Action links.
* @param string $file Plugin file.
*
* @return array
*/
public function plugin_action_links( $links, $file ) {
if ( plugin_basename( $this->plugin->locations['dir'] . 'stream.php' ) !== $file ) {
return $links;
}
// Also don't show links in Network Admin if Stream isn't network enabled.
if ( is_network_admin() && $this->plugin->is_multisite_not_network_activated() ) {
return $links;
}
if ( is_network_admin() ) {
$admin_page_url = add_query_arg(
array(
'page' => $this->network->network_settings_page_slug,
),
network_admin_url( $this->admin_parent_page )
);
} else {
$admin_page_url = add_query_arg(
array(
'page' => $this->settings_page_slug,
),
admin_url( $this->admin_parent_page )
);
}
$links[] = sprintf( '%s', esc_url( $admin_page_url ), esc_html__( 'Settings', 'stream' ) );
return $links;
}
/**
* Render main page
*/
public function render_list_table() {
$this->list_table->prepare_items();
?>
list_table->display(); ?>
plugin->settings->option_key;
$form_action = apply_filters( 'wp_stream_settings_form_action', admin_url( 'options.php' ) );
$page_description = apply_filters( 'wp_stream_settings_form_description', '' );
$sections = $this->plugin->settings->get_fields();
$active_tab = wp_stream_filter_input( INPUT_GET, 'tab' );
$this->plugin->enqueue_asset(
'settings',
array(),
array(
'i18n' => array(
'confirm_purge' => __( 'Are you sure you want to delete all Stream activity records from the database? This cannot be undone.', 'stream' ),
),
)
);
?>
list_table = new List_Table(
$this->plugin,
array(
'screen' => $this->screen_id['main'],
)
);
}
/**
* Check if a particular role has access
*
* The user_has_cap/role_has_cap filters that call this are registered in the
* constructor, but the Settings object is not constructed until init priority 9.
* A capability check fired before then (e.g. by a security plugin evaluating
* firewall rules on plugins_loaded) must be denied rather than fatal on the
* null options chain.
*
* @param string $role User role.
*
* @return bool
*/
private function role_can_view( $role ) {
$allowed_roles = $this->plugin->settings->options['general_role_access'] ?? array();
return in_array( $role, (array) $allowed_roles, true );
}
/**
* Filter user caps to dynamically grant our view cap based on allowed roles
*
* @param array $allcaps All capabilities.
* @param array $caps Required caps.
* @param array $args Unused.
* @param WP_User $user User.
*
* @filter user_has_cap
*
* @return array
*/
public function filter_user_caps( $allcaps, $caps, $args, $user = null ) {
global $wp_roles;
$_wp_roles = isset( $wp_roles ) ? $wp_roles : new WP_Roles();
$user = is_a( $user, 'WP_User' ) ? $user : wp_get_current_user();
// @see
// https://github.com/WordPress/WordPress/blob/c67c9565f1495255807069fdb39dac914046b1a0/wp-includes/capabilities.php#L758
$roles = array_unique(
array_merge(
$user->roles,
array_filter(
array_keys( $user->caps ),
array( $_wp_roles, 'is_role' )
)
)
);
$stream_view_caps = array( $this->view_cap );
foreach ( $caps as $cap ) {
if ( in_array( $cap, $stream_view_caps, true ) ) {
foreach ( $roles as $role ) {
if ( $this->role_can_view( $role ) ) {
$allcaps[ $cap ] = true;
break 2;
}
}
}
}
return $allcaps;
}
/**
* Filter role caps to dynamically grant our view cap based on allowed roles
*
* @filter role_has_cap
*
* @param array $allcaps All capabilities.
* @param string $cap Require cap.
* @param string $role User role.
*
* @return array
*/
public function filter_role_caps( $allcaps, $cap, $role ) {
$stream_view_caps = array( $this->view_cap );
if ( in_array( $cap, $stream_view_caps, true ) && $this->role_can_view( $role ) ) {
$allcaps[ $cap ] = true;
}
return $allcaps;
}
/**
* Ajax callback for return a user list.
*
* @action wp_ajax_wp_stream_filters
*/
public function ajax_filters() {
if ( ! defined( 'DOING_AJAX' ) || ! current_user_can( $this->plugin->admin->settings_cap ) ) {
wp_die( '-1' );
}
check_ajax_referer( 'stream_filters_user_search_nonce', 'nonce' );
switch ( wp_stream_filter_input( INPUT_GET, 'filter' ) ) {
case 'user_id':
$users = array_merge(
array(
0 => (object) array(
'display_name' => 'WP-CLI',
),
),
get_users()
);
$search = wp_stream_filter_input( INPUT_GET, 'q' );
if ( $search ) {
// `search` arg for get_users() is not enough
$users = array_filter(
$users,
function ( $user ) use ( $search ) {
return false !== mb_strpos( mb_strtolower( $user->display_name ), mb_strtolower( $search ) );
}
);
}
if ( count( $users ) > $this->preload_users_max ) {
$users = array_slice( $users, 0, $this->preload_users_max );
}
// Get gravatar / roles for final result set.
$results = $this->get_users_record_meta( $users );
break;
}
if ( isset( $results ) ) {
echo wp_json_encode( $results );
}
die();
}
/**
* Return relevant user meta data.
*
* @param array $authors Author data.
* @return array
*/
public function get_users_record_meta( $authors ) {
$authors_records = array();
foreach ( $authors as $user_id => $args ) {
$author = new Author( $args->ID );
$authors_records[ $user_id ] = array(
'text' => $author->get_display_name(),
'id' => $author->id,
'label' => $author->get_display_name(),
'icon' => $author->get_avatar_src( 32 ),
'title' => '',
);
}
return $authors_records;
}
/**
* Get user meta in a way that is also safe for VIP
*
* @param int $user_id User ID.
* @param string $meta_key Meta key.
* @param bool $single Return first found meta value connected to the meta key (optional).
*
* @return mixed
*/
public function get_user_meta( $user_id, $meta_key, $single = true ) {
return get_user_meta( $user_id, $meta_key, $single );
}
/**
* Update user meta in a way that is also safe for VIP
*
* @param int $user_id User ID.
* @param string $meta_key Meta key.
* @param mixed $meta_value Meta value.
* @param mixed $prev_value Previous meta value being overwritten (optional).
*
* @return int|bool
*/
public function update_user_meta( $user_id, $meta_key, $meta_value, $prev_value = '' ) {
return update_user_meta( $user_id, $meta_key, $meta_value, $prev_value );
}
/**
* Delete user meta in a way that is also safe for VIP
*
* @param int $user_id User ID.
* @param string $meta_key Meta key.
* @param mixed $meta_value Meta value (optional).
*
* @return bool
*/
public function delete_user_meta( $user_id, $meta_key, $meta_value = '' ) {
return delete_user_meta( $user_id, $meta_key, $meta_value );
}
}