is_active() ) { return; } // WP-CLI recording: a valid single-use token is the only requirement, // and the page is recorded as a logged-out visitor would see it. // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- The single-use token is the credential. if ( isset( $_GET[ self::CLI_PARAM ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- See above. $token = sanitize_text_field( wp_unslash( $_GET[ self::CLI_PARAM ] ) ); if ( self::consume_cli_token( $token ) && ! is_user_logged_in() ) { $this->cli = true; $this->authorized = true; ob_start(); $this->cli_ob_level = ob_get_level(); } else { $recorder->discard(); } return; } $settings = Hooktrail_Settings::get(); $user_id = get_current_user_id(); $allowed = $user_id && current_user_can( self::CAPABILITY ) && self::is_enabled_for( $user_id ) && ! wp_doing_cron() && ! ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ); if ( $allowed && wp_doing_ajax() ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.NonceVerification.Missing -- Only reads the action name to skip Hooktrail's own requests. $action = isset( $_REQUEST['action'] ) ? sanitize_key( wp_unslash( $_REQUEST['action'] ) ) : ''; $allowed = ! empty( $settings['record_background'] ) && ! in_array( $action, self::OWN_AJAX, true ); } elseif ( $allowed && is_admin() ) { $allowed = ! empty( $settings['record_admin'] ); } if ( ! $allowed ) { $recorder->discard(); return; } $recorder->set_trace_hooks( self::get_trace_hooks( $user_id ) ); $this->authorized = true; } /** * @param int $user_id User ID. * @return bool */ public static function is_enabled_for( $user_id ) { return '1' === (string) get_user_meta( (int) $user_id, self::USER_META, true ); } /** * @param int $user_id User ID. * @return array Hook names the user chose to trace. */ public static function get_trace_hooks( $user_id ) { $hooks = get_user_meta( (int) $user_id, self::TRACE_META, true ); return is_array( $hooks ) ? array_values( array_filter( $hooks, 'is_string' ) ) : array(); } // ------------------------------------------------------------------ // WP-CLI tokens // ------------------------------------------------------------------ /** * Create a single-use token for one WP-CLI recording. Only called from * the command line (Hooktrail_CLI). * * @return string */ public static function create_cli_token() { $token = wp_generate_password( 40, false, false ); set_transient( self::CLI_TRANSIENT . hash( 'sha256', $token ), 1, 2 * MINUTE_IN_SECONDS ); return $token; } /** * @param string $token Token from the URL. * @return bool */ private static function consume_cli_token( $token ) { if ( ! is_string( $token ) || ! preg_match( '/^[A-Za-z0-9]{40}$/', $token ) ) { return false; } $key = self::CLI_TRANSIENT . hash( 'sha256', $token ); if ( ! get_transient( $key ) ) { return false; } delete_transient( $key ); return true; } /** * Replace the page with its map as JSON, for a WP-CLI recording. */ public function cli_output() { if ( ! $this->cli || ! $this->authorized ) { return; } $status = http_response_code(); while ( ob_get_level() >= $this->cli_ob_level && ob_get_level() > 0 ) { ob_end_clean(); } $recorder = Hooktrail_Recorder::instance(); $recorder->stop(); $collector = new Hooktrail_Collector( $recorder ); $data = $collector->build(); $data['status'] = (int) $status; wp_send_json( $data, 200 ); } // ------------------------------------------------------------------ // Page display // ------------------------------------------------------------------ public function enqueue() { if ( ! $this->authorized || $this->cli || wp_doing_ajax() ) { return; } wp_enqueue_style( 'hooktrail', HOOKTRAIL_URL . 'assets/hooktrail.css', array(), HOOKTRAIL_VERSION ); wp_enqueue_script( 'hooktrail', HOOKTRAIL_URL . 'assets/hooktrail.js', array(), HOOKTRAIL_VERSION, true ); } /** * Print the recorded map at the end of the page. */ public function render() { if ( ! $this->authorized || $this->cli || $this->rendered || is_customize_preview() ) { return; } $this->rendered = true; $recorder = Hooktrail_Recorder::instance(); $recorder->stop(); $collector = new Hooktrail_Collector( $recorder ); $data = $collector->build(); $data['hideNoisy'] = ! empty( Hooktrail_Settings::get()['hide_noisy'] ); $data['tracing'] = self::get_trace_hooks( get_current_user_id() ); $data['ajax'] = array( 'url' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( self::AJAX_NONCE ), ); $data['i18n'] = self::i18n(); echo '
'; wp_print_inline_script_tag( 'window.hooktrailData = ' . wp_json_encode( $data, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_SLASHES | JSON_PARTIAL_OUTPUT_ON_ERROR ) . ';', array( 'id' => 'hooktrail-data' ) ); } // ------------------------------------------------------------------ // Background requests (AJAX and REST) // ------------------------------------------------------------------ public function store_background() { if ( ! $this->authorized || $this->cli || $this->rendered ) { return; } $context = Hooktrail_Collector::context(); if ( 'ajax' !== $context && 'rest' !== $context ) { return; } // REST requests without a valid nonce are switched to logged out // by WordPress after init; keep nothing for those. if ( ! current_user_can( self::CAPABILITY ) || empty( Hooktrail_Settings::get()['record_background'] ) ) { return; } $recorder = Hooktrail_Recorder::instance(); $recorder->stop(); $collector = new Hooktrail_Collector( $recorder ); $data = $collector->build( array( 'assets' => false, 'not_fired' => false, ) ); $user_id = get_current_user_id(); $list = self::get_background( $user_id ); array_unshift( $list, array( 'id' => wp_generate_password( 12, false, false ), 'time' => time(), 'method' => $data['method'], 'request' => $data['request'], 'context' => $context, 'status' => (int) http_response_code(), 'summary' => $data['summary'], 'data' => $data, ) ); set_transient( self::BG_TRANSIENT . $user_id, array_slice( $list, 0, self::BG_KEEP ), 30 * MINUTE_IN_SECONDS ); } /** * @param int $user_id User ID. * @return array */ private static function get_background( $user_id ) { $list = get_transient( self::BG_TRANSIENT . (int) $user_id ); return is_array( $list ) ? $list : array(); } /** * Every Hooktrail AJAX request needs the capability; the nonce is * checked at the top of each handler. */ private function require_capability() { if ( ! current_user_can( self::CAPABILITY ) ) { wp_send_json_error( null, 403 ); } } public function ajax_bg_list() { check_ajax_referer( self::AJAX_NONCE, 'nonce' ); $this->require_capability(); $items = array(); foreach ( self::get_background( get_current_user_id() ) as $entry ) { unset( $entry['data'] ); $items[] = $entry; } wp_send_json_success( $items ); } public function ajax_bg_get() { check_ajax_referer( self::AJAX_NONCE, 'nonce' ); $this->require_capability(); $id = isset( $_POST['id'] ) ? sanitize_key( wp_unslash( $_POST['id'] ) ) : ''; foreach ( self::get_background( get_current_user_id() ) as $entry ) { if ( strtolower( $entry['id'] ) === $id ) { wp_send_json_success( $entry['data'] ); } } wp_send_json_error( null, 404 ); } /** * Add or remove a filter from the user's trace list. */ public function ajax_trace() { check_ajax_referer( self::AJAX_NONCE, 'nonce' ); $this->require_capability(); $hook = isset( $_POST['hook'] ) ? sanitize_text_field( wp_unslash( $_POST['hook'] ) ) : ''; $mode = isset( $_POST['mode'] ) && 'remove' === sanitize_key( wp_unslash( $_POST['mode'] ) ) ? 'remove' : 'add'; if ( ! preg_match( '/^[^\s<>"\'&]{1,190}$/', $hook ) ) { wp_send_json_error( null, 400 ); } $user_id = get_current_user_id(); $hooks = self::get_trace_hooks( $user_id ); if ( 'remove' === $mode ) { $hooks = array_values( array_diff( $hooks, array( $hook ) ) ); } elseif ( ! in_array( $hook, $hooks, true ) ) { $hooks[] = $hook; $hooks = array_slice( $hooks, -Hooktrail_Recorder::MAX_TRACE_HOOKS ); } update_user_meta( $user_id, self::TRACE_META, $hooks ); wp_send_json_success( $hooks ); } // ------------------------------------------------------------------ // Admin bar switch // ------------------------------------------------------------------ /** * @param WP_Admin_Bar $bar Admin bar. */ public function admin_bar( $bar ) { if ( ! current_user_can( self::CAPABILITY ) ) { return; } if ( $this->authorized && ! $this->cli ) { $bar->add_node( array( 'id' => 'hooktrail', 'title' => esc_html__( 'Hooktrail', 'hooktrail' ), 'href' => '#hooktrail', 'meta' => array( 'class' => 'hooktrail-ab-open' ), ) ); $bar->add_node( array( 'parent' => 'hooktrail', 'id' => 'hooktrail-open', 'title' => esc_html__( 'Show hook map', 'hooktrail' ), 'href' => '#hooktrail', 'meta' => array( 'class' => 'hooktrail-ab-open' ), ) ); $bar->add_node( array( 'parent' => 'hooktrail', 'id' => 'hooktrail-off', 'title' => esc_html__( 'Turn Hooktrail off', 'hooktrail' ), 'href' => self::toggle_url( 'off' ), ) ); return; } $bar->add_node( array( 'id' => 'hooktrail', 'title' => esc_html__( 'Hooktrail: off', 'hooktrail' ), 'href' => self::toggle_url( 'on' ), 'meta' => array( 'title' => esc_attr__( 'Record the hooks on the pages you view', 'hooktrail' ) ), ) ); } /** * @param string $state 'on' or 'off'. * @return string */ public static function toggle_url( $state ) { return wp_nonce_url( add_query_arg( array( 'action' => 'hooktrail_toggle', 'state' => 'on' === $state ? 'on' : 'off', ), admin_url( 'admin-post.php' ) ), self::NONCE_ACTION ); } public function handle_toggle() { if ( ! current_user_can( self::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'hooktrail' ), '', array( 'response' => 403 ) ); } check_admin_referer( self::NONCE_ACTION ); $on = isset( $_GET['state'] ) && 'on' === sanitize_key( wp_unslash( $_GET['state'] ) ); $user_id = get_current_user_id(); if ( $on ) { update_user_meta( $user_id, self::USER_META, '1' ); self::set_cookie( true ); } else { delete_user_meta( $user_id, self::USER_META ); delete_transient( self::BG_TRANSIENT . $user_id ); self::set_cookie( false ); } $back = wp_get_referer(); wp_safe_redirect( $back ? $back : admin_url( 'tools.php?page=hooktrail' ) ); exit; } /** * The cookie only tells Hooktrail to start listening early in the * request; authorize() still checks the real user. * * @param bool $on Set or clear. */ private static function set_cookie( $on ) { $paths = array_unique( array( COOKIEPATH, SITECOOKIEPATH ) ); foreach ( $paths as $path ) { setcookie( HOOKTRAIL_COOKIE, $on ? '1' : '', array( 'expires' => $on ? time() + 30 * DAY_IN_SECONDS : time() - YEAR_IN_SECONDS, 'path' => $path ? $path : '/', 'domain' => COOKIE_DOMAIN ? COOKIE_DOMAIN : '', 'secure' => is_ssl(), 'httponly' => true, 'samesite' => 'Lax', ) ); } } // ------------------------------------------------------------------ /** * Translatable labels for the drawer. * * @return array */ private static function i18n() { return array( 'title' => __( 'Hooktrail', 'hooktrail' ), 'tabTimeline' => __( 'Timeline', 'hooktrail' ), 'tabFlame' => __( 'Flame', 'hooktrail' ), 'tabComponent' => __( 'By plugin', 'hooktrail' ), 'tabShared' => __( 'Shared hooks', 'hooktrail' ), 'tabTrace' => __( 'Trace', 'hooktrail' ), 'tabQueries' => __( 'Queries', 'hooktrail' ), 'tabAssets' => __( 'Assets', 'hooktrail' ), 'tabIssues' => __( 'Issues', 'hooktrail' ), 'tabNotFired' => __( 'Not fired', 'hooktrail' ), 'tabBackground' => __( 'Background', 'hooktrail' ), 'tabCompare' => __( 'Compare', 'hooktrail' ), 'search' => __( 'Filter hooks, callbacks or files', 'hooktrail' ), 'showNoisy' => __( 'Show high-volume hooks', 'hooktrail' ), 'export' => __( 'Export JSON', 'hooktrail' ), 'copy' => __( 'Copy summary', 'hooktrail' ), 'copied' => __( 'Copied', 'hooktrail' ), 'close' => __( 'Close', 'hooktrail' ), 'hooks' => __( 'hooks fired', 'hooktrail' ), 'calls' => __( 'calls', 'hooktrail' ), 'callbacks' => __( 'callbacks', 'hooktrail' ), 'queries' => __( 'queries', 'hooktrail' ), 'warnings' => __( 'PHP warnings', 'hooktrail' ), 'action' => __( 'action', 'hooktrail' ), 'filter' => __( 'filter', 'hooktrail' ), 'priority' => __( 'Priority', 'hooktrail' ), 'callback' => __( 'Callback', 'hooktrail' ), 'source' => __( 'Added by', 'hooktrail' ), 'location' => __( 'File', 'hooktrail' ), 'hook' => __( 'Hook', 'hooktrail' ), 'noCallbacks' => __( 'Nothing is attached to this hook.', 'hooktrail' ), 'noResults' => __( 'Nothing matches.', 'hooktrail' ), 'firedInside' => __( 'Fired inside', 'hooktrail' ), 'firstRun' => __( 'First run took', 'hooktrail' ), 'runsLast' => __( 'Runs last, so it has the final say', 'hooktrail' ), 'runsAfterOthers' => __( 'Runs after the other plugins here', 'hooktrail' ), /* translators: %d: number of WordPress core callbacks not listed */ 'coreHidden' => __( '%d WordPress core callbacks on this hook are not listed here. The Timeline shows the full list.', 'hooktrail' ), 'sharedIntro' => __( 'Hooks where more than one plugin or theme attaches a callback, with their callbacks in the order they run (lowest priority first). On a filter, a later callback can change or undo what an earlier one did.', 'hooktrail' ), 'noShared' => __( 'No hook on this page is shared by more than one plugin or theme.', 'hooktrail' ), 'startNote' => __( 'Recording starts when Hooktrail loads, so hooks that fired before it (for example while earlier plugins load) are not included. Callbacks are listed as they were registered when each hook first fired.', 'hooktrail' ), 'flameIntro' => __( 'Each bar is a hook\'s first run, from when it started to when its last callback finished. Hooks fired inside another hook sit below it. Wider bars took longer.', 'hooktrail' ), 'slowest' => __( 'Slowest hooks (first run, including everything inside them)', 'hooktrail' ), 'traceIntro' => __( 'Follow a filter\'s value through every callback, to see exactly which plugin changed what. Pick up to three filters, then reload the page.', 'hooktrail' ), 'traceThis' => __( 'Trace this filter', 'hooktrail' ), 'tracing' => __( 'Tracing', 'hooktrail' ), 'stopTrace' => __( 'Stop tracing', 'hooktrail' ), 'reload' => __( 'Reload page', 'hooktrail' ), 'traceAdded' => __( 'Added. Reload the page to see the trace.', 'hooktrail' ), 'traceNone' => __( 'No filters are being traced. Open a filter in the Timeline and choose "Trace this filter", or enter a filter name here.', 'hooktrail' ), 'traceNotRun' => __( 'This filter did not run on this page, or it ran before tracing could start (hooks before init cannot be traced).', 'hooktrail' ), 'traceInput' => __( 'Value passed in', 'hooktrail' ), 'traceUnchanged' => __( 'No change', 'hooktrail' ), 'traceChanged' => __( 'Changed the value', 'hooktrail' ), /* translators: %d: run number */ 'traceRun' => __( 'Run %d', 'hooktrail' ), 'traceFinal' => __( 'Final value', 'hooktrail' ), 'traceAdd' => __( 'Trace', 'hooktrail' ), 'onlyChanged' => __( 'Only callbacks that changed the value', 'hooktrail' ), /* translators: %d: number of callbacks */ 'unchangedHidden' => __( '%d callbacks that left the value unchanged are hidden.', 'hooktrail' ), 'queriesIntro' => __( 'Every database query on this page, with the hook that was running and the code that ran it. Queries made before Hooktrail loaded are not included.', 'hooktrail' ), 'byComponentQ' => __( 'Queries by plugin', 'hooktrail' ), 'duplicates' => __( 'Repeated queries', 'hooktrail' ), /* translators: %d: number of times */ 'times' => __( '%d times', 'hooktrail' ), 'calledFrom' => __( 'Called from', 'hooktrail' ), 'assetsIntro' => __( 'The theme template used for this page, the theme files loaded, and every script and stylesheet printed, with its size and the plugin or theme that added it.', 'hooktrail' ), 'template' => __( 'Template', 'hooktrail' ), 'blockTemplate' => __( 'Block template', 'hooktrail' ), 'themeFiles' => __( 'Theme files loaded', 'hooktrail' ), 'scripts' => __( 'Scripts', 'hooktrail' ), 'styles' => __( 'Styles', 'hooktrail' ), 'handle' => __( 'Handle', 'hooktrail' ), 'size' => __( 'Size', 'hooktrail' ), 'inline' => __( 'Inline', 'hooktrail' ), 'footer' => __( 'footer', 'hooktrail' ), 'total' => __( 'Total', 'hooktrail' ), 'external' => __( 'external', 'hooktrail' ), 'noAssets' => __( 'Asset details are only recorded for full page views.', 'hooktrail' ), 'issuesIntro' => __( 'Things worth a closer look on this page. None of these are necessarily bugs.', 'hooktrail' ), 'noIssues' => __( 'Nothing to report on this page.', 'hooktrail' ), 'issueErrors' => __( 'PHP warnings and notices', 'hooktrail' ), 'issueSamePrio' => __( 'Plugins on the same filter at the same priority', 'hooktrail' ), 'issueSamePrioH' => __( 'They run in the order they were added, which can change when plugins are activated in a different order.', 'hooktrail' ), 'issueExtreme' => __( 'Callbacks at extreme priorities', 'hooktrail' ), 'issueExtremeH' => __( 'Very high or low priorities are often used to force running first or last, which can hide another plugin\'s changes.', 'hooktrail' ), 'issueRemoved' => __( 'Callbacks removed while the page loaded', 'hooktrail' ), /* translators: 1: earlier loading stage, 2: later loading stage */ 'removedBetween' => __( 'removed between %1$s and %2$s', 'hooktrail' ), 'issueDupes' => __( 'Repeated database queries', 'hooktrail' ), 'issueSlow' => __( 'Slow hooks with plugin or theme callbacks', 'hooktrail' ), 'during' => __( 'during', 'hooktrail' ), 'notFiredIntro' => __( 'Callbacks registered on hooks that did not run on this page. Many are normal (they run on other pages or later in the request); in custom code they can point to leftovers.', 'hooktrail' ), 'showCore' => __( 'Include WordPress core', 'hooktrail' ), 'bgIntro' => __( 'AJAX and REST API requests you made while recording (for example saves in the block editor). The last five are kept for 30 minutes.', 'hooktrail' ), 'refresh' => __( 'Refresh', 'hooktrail' ), 'open' => __( 'Open', 'hooktrail' ), 'noBackground' => __( 'No background requests recorded yet.', 'hooktrail' ), 'viewing' => __( 'Viewing', 'hooktrail' ), 'backToPage' => __( 'Back to this page', 'hooktrail' ), 'compareIntro' => __( 'Compare two recordings, for example before and after activating or updating a plugin. Use maps saved with Export JSON or with "wp hooktrail record".', 'hooktrail' ), 'compareA' => __( 'Before (A)', 'hooktrail' ), 'compareB' => __( 'After (B)', 'hooktrail' ), 'thisPage' => __( 'This page', 'hooktrail' ), 'chooseFile' => __( 'Choose a JSON file', 'hooktrail' ), 'badFile' => __( 'That file is not a Hooktrail map.', 'hooktrail' ), 'hooksOnlyA' => __( 'Hooks only in A', 'hooktrail' ), 'hooksOnlyB' => __( 'Hooks only in B', 'hooktrail' ), 'cbAdded' => __( 'Callbacks added in B', 'hooktrail' ), 'cbRemoved' => __( 'Callbacks missing in B', 'hooktrail' ), 'cbMoved' => __( 'Callbacks with a different priority', 'hooktrail' ), 'assetsAdded' => __( 'Assets added in B', 'hooktrail' ), 'assetsRemoved' => __( 'Assets missing in B', 'hooktrail' ), 'totals' => __( 'Totals', 'hooktrail' ), 'noDifferences' => __( 'No differences.', 'hooktrail' ), ); } }