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( '

%1$s %2$s

', 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( '

%s

', 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' ), ), ) ); ?>

1 ) : ?>
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 ); } }