`; on that request {@see self::handle()} runs once the front end has enqueued its * scripts, checks the token, renders the page into a buffer it throws away and, after the footer, answers with the * registered handles, sources and inline code as JSON. Rendering the whole page catches scripts that templates, blocks, * shortcodes and `wp_footer` callbacks enqueue late. The token is stored as a hash, works once and lasts a few minutes; * a request without a valid one renders the page as usual. * * A site that cannot reach itself (common in containers and behind some hosts) makes {@see self::request()} return * null, and the scan goes on without this part. */ final class EnqueueDump { public const QUERY = 'consentfold_dump'; public const OPTION = 'consentfold_scan_dump'; /** * Seconds a token stays valid. */ public const TTL = 300; private const MAX_SCRIPTS = 2000; private const MAX_INLINE = 51200; /** * Ends the request after the answer; replaced in tests. * * @var callable */ private $terminate; /** * Output buffer level when the handler was built; anything above it is output to discard. * * @var int */ private int $buffer_level; /** * Whether the answer has been sent. * * @var bool */ private bool $answered = false; /** * Constructor. * * @param callable|null $terminate Ends the request after the answer; `exit` by default. */ public function __construct( ?callable $terminate = null ) { $this->terminate = $terminate ?? static function (): void { exit; }; $this->buffer_level = ob_get_level(); } /** * Hook the handler. It is a no-op unless the request carries the query parameter. */ public function register(): void { // Before the template: block themes print the page's opening markup ahead of `wp_enqueue_scripts`. add_action( 'template_redirect', array( $this, 'handle' ), 0 ); } /** * Ask the site for its registered scripts. * * @return list|null Null when the site cannot be reached or does not answer with a dump. */ public function request(): ?array { $response = Loopback::get( add_query_arg( self::QUERY, $this->issue(), home_url( '/' ) ), array( 'headers' => array( 'Accept' => 'application/json' ) ) ); if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { return null; } $data = json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! is_array( $data ) || true !== ( $data[ self::QUERY ] ?? null ) || ! is_array( $data['scripts'] ?? null ) ) { return null; } $scripts = array(); foreach ( $data['scripts'] as $script ) { if ( is_array( $script ) && is_string( $script['handle'] ?? null ) && is_string( $script['src'] ?? null ) && is_string( $script['inline'] ?? null ) ) { $scripts[] = array( 'handle' => $script['handle'], 'src' => $script['src'], 'inline' => $script['inline'], ); } } return $scripts; } /** * Take over a request that carries a valid token: let the page render into a discarded buffer and answer once the * footer has run. */ public function handle(): void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- The one-time token is the credential; there is no user session on a self-request. $token = isset( $_GET[ self::QUERY ] ) && is_string( $_GET[ self::QUERY ] ) ? sanitize_text_field( wp_unslash( $_GET[ self::QUERY ] ) ) : ''; if ( '' === $token || ! $this->consume( $token ) ) { return; } if ( ! defined( 'DONOTCACHEPAGE' ) ) { define( 'DONOTCACHEPAGE', true ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- Constant read by page cache plugins. } // The page is rendered only to run its enqueue code; nothing of it is sent. ob_start( static fn (): string => '' ); // The footer comes last for scripts; the shutdown hook covers a theme that never reaches it. add_action( 'wp_footer', array( $this, 'respond' ), PHP_INT_MAX ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core hook. add_action( 'shutdown', array( $this, 'respond' ), 0 ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core hook. } /** * Answer with the registered scripts, and end the request. Runs once. */ public function respond(): void { if ( $this->answered ) { return; } $this->answered = true; // Output that is still buffered would corrupt the JSON. while ( ob_get_level() > $this->buffer_level ) { if ( ! ob_end_clean() ) { // A buffer that cannot be removed; looping on it would never end. break; } } if ( ! headers_sent() ) { nocache_headers(); header( 'Content-Type: application/json; charset=utf-8' ); header( 'X-Robots-Tag: noindex' ); } echo wp_json_encode( array( self::QUERY => true, 'scripts' => $this->scripts(), ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON. call_user_func( $this->terminate ); } /** * Create a token and keep its hash; an earlier unused token stops working. * * @return string The token. */ private function issue(): string { $token = wp_generate_password( 40, false ); update_option( self::OPTION, array( 'hash' => hash( 'sha256', $token ), 'expires' => time() + self::TTL, ), false ); return $token; } /** * Use up a token. * * @param string $token Token from the request. * @return bool Whether it was the valid, unexpired and unused one. */ private function consume( string $token ): bool { $stored = get_option( self::OPTION, null ); if ( ! is_array( $stored ) || ! is_string( $stored['hash'] ?? null ) || ! is_int( $stored['expires'] ?? null ) ) { return false; } if ( $stored['expires'] < time() ) { delete_option( self::OPTION ); return false; } // A failed delete means another request used the token first. return hash_equals( $stored['hash'], hash( 'sha256', $token ) ) && delete_option( self::OPTION ); } /** * The registered scripts with their inline code. * * @return list */ private function scripts(): array { $scripts = wp_scripts(); $list = array(); foreach ( array_slice( $scripts->registered, 0, self::MAX_SCRIPTS, true ) as $handle => $dependency ) { $inline = array( (string) $scripts->get_data( (string) $handle, 'data' ) ); foreach ( array( 'before', 'after' ) as $position ) { $parts = $scripts->get_data( (string) $handle, $position ); $inline = array_merge( $inline, is_array( $parts ) ? array_filter( $parts, 'is_string' ) : array() ); } $list[] = array( 'handle' => (string) $handle, 'src' => is_string( $dependency->src ) ? $dependency->src : '', 'inline' => substr( implode( "\n", $inline ), 0, self::MAX_INLINE ), ); } return $list; } }