d additional required capabilities. $position = array_search( $cap, $caps, true ); if ( false !== $position ) { $caps[ $position ] = 'manage_options'; } } return $caps; } /** * Whether the user has the required capability to validate. * * Checks for permissions before validating. * * @param int|WP_User|null $user User to check for the capability. If null, the current user is used. * @return boolean $has_cap Whether the current user has the capability. */ public static function has_cap( $user = null ) { if ( null === $user ) { $user = wp_get_current_user(); } return user_can( $user, self::VALIDATE_CAPABILITY ); } /** * Add validation error. * * @param array $error Error info, especially code. * @param array $data Additional data, including the node. * * @return bool Whether the validation error should result in sanitization. */ public static function add_validation_error( array $error, array $data = [] ) { $node = null; $sources = null; if ( isset( $data['node'] ) && $data['node'] instanceof DOMNode ) { $node = $data['node']; } if ( self::is_validate_request() ) { if ( ! empty( $error['sources'] ) ) { $sources = $error['sources']; } elseif ( $node ) { $sources = self::locate_sources( $node ); } } unset( $error['sources'] ); if ( ! isset( $error['code'] ) ) { $error['code'] = 'unknown'; } /** * Filters the validation error array. * * This allows plugins to add amend additional properties which can help with * more accurately identifying a validation error beyond the name of the parent * node and the element's attributes. The $sources are also omitted because * these are only available during an explicit validation request and so they * are not suitable for plugins to vary sanitization by. If looking to force a * validation error to be ignored, use the 'amp_validation_error_sanitized' * filter instead of attempting to return an empty value with this filter (as * that is not supported). * * @since 1.0 * * @param array $error Validation error to be printed. * @param array $context { * Context data for validation error sanitization. * * @type DOMNode $node Node for which the validation error is being reported. May be null. * } */ $error = apply_filters( 'amp_validation_error', $error, compact( 'node' ) ); $sanitization = AMP_Validation_Error_Taxonomy::get_validation_error_sanitization( $error ); $sanitized = ( AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_NEW_ACCEPTED_STATUS === $sanitization['status'] || AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_ACK_ACCEPTED_STATUS === $sanitization['status'] ); /* * Ignore validation errors which are forcibly sanitized by filter. This includes errors accepted via * AMP_Validation_Error_Taxonomy::accept_validation_errors(), such as the acceptable_errors in core themes. * This was introduced in to prevent forcibly-sanitized * validation errors from being reported, to avoid noise and wasted storage. It was inadvertently * reverted in de7b04b but then restored as part of . */ if ( $sanitized && 'with_filter' === $sanitization['forced'] ) { return true; } // Add sources back into the $error for referencing later. @todo It may be cleaner to store sources separately to avoid having to re-remove later during storage. $error = array_merge( $error, compact( 'sources' ) ); self::$validation_results[] = compact( 'error', 'sanitized' ); return $sanitized; } /** * Reset the stored removed nodes and attributes. * * After testing if the markup is valid, * these static values will remain. * So reset them in case another test is needed. * * @return void */ public static function reset_validation_results() { self::$validation_results = []; self::$enqueued_style_sources = []; self::$enqueued_script_sources = []; self::$extra_script_sources = []; self::$extra_style_sources = []; self::$original_block_render_callbacks = []; self::$wp_editor_sources = []; } /** * Checks the AMP validity of the post content. * * If it's not valid AMP, it displays an error message above the 'Classic' editor. * * This is essentially a PHP implementation of ampBlockValidation.handleValidationErrorsStateChange() in JS. * * @deprecated In 2.1 the classic editor block validation was removed. * @codeCoverageIgnore * @return void */ public static function print_edit_form_validation_status() { _deprecated_function( __METHOD__, '2.1' ); } /** * Get source start comment. * * @param array $source Source data. * @param bool $is_start Whether the comment is the start or end. * @return string HTML Comment. */ public static function get_source_comment( array $source, $is_start = true ) { unset( $source['reflection'] ); return sprintf( '', $is_start ? '' : '/', str_replace( '--', '', wp_json_encode( $source ) ) ); } /** * Parse source comment. * * @param DOMComment $comment Comment. * @return array|null Parsed source or null if not a source comment. */ public static function parse_source_comment( DOMComment $comment ) { if ( ! preg_match( '#^\s*(?P/)?amp-source-stack\s+(?P{.+})\s*$#s', $comment->nodeValue, $matches ) ) { return null; } $source = json_decode( $matches['args'], true ); $closing = ! empty( $matches['closing'] ); return compact( 'source', 'closing' ); } /** * Recursively determine if a given dependency depends on another. * * @since 1.3 * * @param WP_Dependencies $dependencies Dependencies. * @param string $current_handle Current handle. * @param string $dependency_handle Dependency handle. * @return bool Whether the current handle is a dependency of the dependency handle. */ protected static function has_dependency( WP_Dependencies $dependencies, $current_handle, $dependency_handle ) { if ( $current_handle === $dependency_handle ) { return true; } if ( ! isset( $dependencies->registered[ $current_handle ] ) ) { return false; } foreach ( $dependencies->registered[ $current_handle ]->deps as $handle ) { if ( self::has_dependency( $dependencies, $handle, $dependency_handle ) ) { return true; } } return false; } /** * Determine if a script element matches a given script handle. * * @param DOMElement $element Element. * @param string $script_handle Script handle. * @return bool */ protected static function is_matching_script( DOMElement $element, $script_handle ) { // Use the ID attribute which was added to printed scripts after WP ?.?. if ( $element->getAttribute( Attribute::ID ) === "{$script_handle}-js" ) { return true; } if ( ! isset( wp_scripts()->registered[ $script_handle ] ) ) { return false; } $script_dependency = wp_scripts()->registered[ $script_handle ]; if ( empty( $script_dependency->src ) ) { return false; } // Script src attribute is haystack because includes protocol and may include query args (like ver). return false !== strpos( $element->getAttribute( 'src' ), preg_replace( '#^https?:(?=//)#', '', $script_dependency->src ) ); } /** * Walk back tree to find the open sources. * * @todo This method and others for sourcing could be moved to a separate class. * * @param DOMNode $node Node to look for. * @return array[][] { * The data of the removed sources (theme, plugin, or mu-plugin). * * @type string $name The name of the source. * @type string $type The type of the source. * } */ public static function locate_sources( DOMNode $node ) { $dom = Document::fromNode( $node ); $comments = $dom->xpath->query( 'preceding::comment()[ starts-with( ., "amp-source-stack" ) or starts-with( ., "/amp-source-stack" ) ]', $node ); $sources = []; $matches = []; foreach ( $comments as $comment ) { $parsed_comment = self::parse_source_comment( $comment ); if ( ! $parsed_comment ) { continue; } if ( $parsed_comment['closing'] ) { array_pop( $sources ); } else { $sources[] = $parsed_comment['source']; } } $is_enqueued_link = ( $node instanceof DOMElement && 'link' === $node->nodeName && preg_match( '/(?P.+)-css$/', (string) $node->getAttribute( Attribute::ID ), $matches ) && wp_styles()->query( $matches['handle'] ) ); if ( $is_enqueued_link ) { // Directly enqueued stylesheet. if ( isset( self::$enqueued_style_sources[ $matches['handle'] ] ) ) { $sources = array_merge( self::$enqueued_style_sources[ $matches['handle'] ], $sources ); } // Stylesheet added as a dependency. foreach ( wp_styles()->done as $style_handle ) { if ( $matches['handle'] !== $style_handle ) { continue; } foreach ( self::find_done_dependent_handles( wp_styles(), $style_handle, array_keys( self::$enqueued_style_sources ) ) as $enqueued_style_sources_handle ) { $sources = array_merge( array_map( static function ( $enqueued_style_source ) use ( $style_handle ) { $enqueued_style_source['dependency_handle'] = $style_handle; return $enqueued_style_source; }, self::$enqueued_style_sources[ $enqueued_style_sources_handle ] ), $sources ); } } } $is_inline_style = ( $node instanceof DOMElement && 'style' === $node->nodeName && $node->firstChild instanceof DOMText && $node->hasAttribute( Attribute::ID ) && preg_match( '/^(?P.+)-inline-css$/', $node->getAttribute( Attribute::ID ), $matches ) && wp_styles()->query( $matches['handle'] ) && isset( self::$extra_style_sources[ $matches['handle'] ] ) ); if ( $is_inline_style ) { $text = $node->textContent; foreach ( self::$extra_style_sources[ $matches['handle'] ] as $css => $extra_sources ) { if ( false !== strpos( $text, $css ) ) { $sources = array_merge( $sources, $extra_sources ); } } } if ( $node instanceof DOMElement && 'script' === $node->nodeName ) { $enqueued_script_handles = array_intersect( wp_scripts()->done, array_keys( self::$enqueued_script_sources ) ); if ( $node->hasAttribute( 'src' ) ) { // External scripts, directly enqueued. foreach ( $enqueued_script_handles as $enqueued_script_handle ) { if ( ! self::is_matching_script( $node, $enqueued_script_handle ) ) { continue; } $sources = array_merge( self::$enqueued_script_sources[ $enqueued_script_handle ], $sources ); break; } // External scripts, added as a dependency. foreach ( wp_scripts()->done as $script_handle ) { if ( ! self::is_matching_script( $node, $script_handle ) ) { continue; } foreach ( self::find_done_dependent_handles( wp_scripts(), $script_handle, array_keys( self::$enqueued_script_sources ) ) as $enqueued_script_sources_handle ) { $sources = array_merge( array_map( static function ( $enqueued_script_source ) use ( $script_handle ) { $enqueued_script_source['dependency_handle'] = $script_handle; return $enqueued_script_source; }, self::$enqueued_script_sources[ $enqueued_script_sources_handle ] ), $sources ); } } } elseif ( $node->firstChild instanceof DOMText ) { $text = $node->textContent; $script_handle = null; $script_type = null; if ( $node->hasAttribute( Attribute::ID ) && preg_match( '/^(.+)-js-(extra|after|before|translations)/', $node->getAttribute( Attribute::ID ), $matches ) ) { $script_handle = $matches[1]; $script_type = $matches[2]; } if ( 'translations' === $script_type ) { // Obtain sources for script translations. if ( isset( self::$enqueued_script_sources[ $script_handle ] ) ) { $sources = array_merge( $sources, self::$enqueued_script_sources[ $script_handle ] ); } foreach ( self::find_done_dependent_handles( wp_scripts(), $script_handle, array_keys( self::$enqueued_script_sources ) ) as $enqueued_script_sources_handle ) { $sources = array_merge( array_map( static function ( $enqueued_script_source ) use ( $script_handle ) { $enqueued_script_source['dependency_handle'] = $script_handle; return $enqueued_script_source; }, self::$enqueued_script_sources[ $enqueued_script_sources_handle ] ), $sources ); } } else { // Identify the inline script sources. foreach ( self::$extra_script_sources as $extra_data => $extra_sources ) { if ( false === strpos( $text, $extra_data ) ) { continue; } $has_non_core = false; foreach ( $extra_sources as $extra_source ) { if ( 'core' !== $extra_source['type'] ) { $has_non_core = true; break; } } if ( $has_non_core ) { $sources = array_merge( $sources, $extra_sources ); } else { if ( isset( self::$enqueued_script_sources[ $script_handle ] ) ) { $sources = array_merge( $sources, self::$enqueued_script_sources[ $script_handle ] ); } foreach ( self::find_done_dependent_handles( wp_scripts(), $script_handle, array_keys( self::$enqueued_script_sources ) ) as $enqueued_script_sources_handle ) { $sources = array_merge( array_map( static function ( $enqueued_script_source ) use ( $script_handle ) { $enqueued_script_source['dependency_handle'] = $script_handle; return $enqueued_script_source; }, self::$enqueued_script_sources[ $enqueued_script_sources_handle ] ), $sources ); } } } } // Add indirect sources for inline scripts added by wp_editor(). foreach ( $sources as $source ) { if ( isset( $source['function'] ) && '_WP_Editors::editor_js' === $source['function'] ) { $sources = array_merge( $sources, self::$wp_editor_sources ); } } } } $sources = array_values( array_unique( $sources, SORT_REGULAR ) ); return $sources; } /** * Find dependent handles that have been printed. * * @param WP_Dependencies $dependencies Dependencies. * @param string $handle Handle. * @param string[] $enqueued_handles Enqueued handles. * @return string[] Found handles. */ private static function find_done_dependent_handles( WP_Dependencies $dependencies, $handle, $enqueued_handles ) { $dependent_handles = []; foreach ( $enqueued_handles as $enqueued_handle ) { if ( $enqueued_handle !== $handle && $dependencies->query( $enqueued_handle, 'done' ) && self::has_dependency( $dependencies, $enqueued_handle, $handle ) ) { $dependent_handles[] = $enqueued_handle; } } return $dependent_handles; } /** * Add block source comments. * * @param string $content Content prior to blocks being processed. * @return string Content with source comments added. */ public static function add_block_source_comments( $content ) { self::$block_content_index = 0; $start_block_pattern = implode( '', [ '##s', ] ); return preg_replace_callback( $start_block_pattern, [ __CLASS__, 'handle_block_source_comment_replacement' ], $content ); } /** * Handle block source comment replacement. * * @see \AMP_Validation_Manager::add_block_source_comments() * * @param array $matches Matches. * * @return string Replaced. */ protected static function handle_block_source_comment_replacement( $matches ) { $replaced = $matches[0]; // Obtain source information for block. $source = [ 'block_name' => $matches['name'], 'post_id' => get_the_ID(), ]; if ( empty( $matches['closing'] ) ) { $source['block_content_index'] = self::$block_content_index; ++self::$block_content_index; } // Make implicit core namespace explicit. $is_implicit_core_namespace = ( false === strpos( $source['block_name'], '/' ) ); $source['block_name'] = $is_implicit_core_namespace ? 'core/' . $source['block_name'] : $source['block_name']; if ( ! empty( $matches['attributes'] ) ) { $source['block_attrs'] = json_decode( $matches['attributes'] ); } $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $source['block_name'] ); if ( $block_type && $block_type->is_dynamic() ) { $render_callback = $block_type->render_callback; // Access the underlying callback which was wrapped by ::wrap_block_callbacks() below. while ( $render_callback instanceof AMP_Validation_Callback_Wrapper ) { $render_callback = $render_callback->get_callback_function(); } $callback_reflection = Services::get( 'dev_tools.callback_reflection' ); $callback_source = $callback_reflection->get_source( $render_callback ); // Handle special case to undo the wrapping that Gutenberg does in gutenberg_inject_default_block_context(). if ( $callback_source && 'plugin' === $callback_source['type'] && 'gutenberg' === $callback_source['name'] && array_key_exists( $source['block_name'], self::$original_block_render_callbacks ) ) { $callback_source = $callback_reflection->get_source( self::$original_block_render_callbacks[ $source['block_name'] ] ); } if ( $callback_source ) { $source = array_merge( $source, $callback_source ); } } if ( ! empty( $matches['closing'] ) ) { $replaced .= self::get_source_comment( $source, false ); } else { $replaced = self::get_source_comment( $source, true ) . $replaced; if ( ! empty( $matches['self_closing'] ) ) { unset( $source['block_content_index'] ); $replaced .= self::get_source_comment( $source, false ); } } return $replaced; } /** * Wrap callbacks for registered blocks to keep track of queued assets and the source for anything printed for validation. * * @param array $args Array of arguments for registering a block type. * * @return array Array of arguments for registering a block type. */ public static function wrap_block_callbacks( $args ) { if ( ! isset( $args['render_callback'] ) || ! is_callable( $args['render_callback'] ) ) { return $args; } $callback_reflection = Services::get( 'dev_tools.callback_reflection' ); $source = $callback_reflection->get_source( $args['render_callback'] ); if ( ! $source ) { return $args; } unset( $source['reflection'] ); // Omit from stored source. $original_function = $args['render_callback']; if ( isset( $args['name'] ) ) { self::$original_block_render_callbacks[ $args['name'] ] = $original_function; } $args['render_callback'] = new AMP_Validation_Callback_Wrapper( [ 'function' => $original_function, 'source' => $source, 'accepted_args' => 3, // The three args passed to render_callback are $attributes, $content, and $block. ] ); return $args; } /** * Wrap callbacks for registered widgets to keep track of queued assets and the source for anything printed for validation. * * @return void * @global array $wp_registered_widgets */ public static function wrap_widget_callbacks() { global $wp_registered_widgets; $callback_reflection = Services::get( 'dev_tools.callback_reflection' ); foreach ( $wp_registered_widgets as $widget_id => &$registered_widget ) { $source = $callback_reflection->get_source( $registered_widget['callback'] ); if ( ! $source ) { continue; } $source['widget_id'] = $widget_id; unset( $source['reflection'] ); // Omit from stored source. $function = $registered_widget['callback']; $accepted_args = 2; // For the $instance and $args arguments. $callback = compact( 'function', 'accepted_args', 'source' ); $registered_widget['callback'] = new AMP_Validation_Callback_Wrapper( $callback ); } } /** * Wrap filter/action callback functions for a given hook. * * Wrapped callback functions are reset to their original functions after invocation. * This runs at the 'all' action. The shutdown hook is excluded. * * @global WP_Hook[] $wp_filter * @param string $hook Hook name for action or filter. * @return void */ public static function wrap_hook_callbacks( $hook ) { global $wp_filter; if ( ! isset( $wp_filter[ $hook ] ) || 'shutdown' === $hook ) { return; } $callback_reflection = Services::get( 'dev_tools.callback_reflection' ); self::$current_hook_source_stack[ $hook ] = []; foreach ( $wp_filter[ $hook ]->callbacks as $priority => &$callbacks ) { foreach ( $callbacks as &$callback ) { $source = $callback_reflection->get_source( $callback['function'] ); if ( ! $source ) { continue; } // Skip considering ourselves. if ( 'AMP_Validation_Manager::add_block_source_comments' === $source['function'] ) { continue; } $indirect_sources = []; if ( '_WP_Editors::enqueue_scripts' === $source['function'] ) { $indirect_sources = self::$wp_editor_sources; } /** * Reflection. * * @var ReflectionFunction|ReflectionMethod $reflection */ $reflection = $source['reflection']; unset( $source['reflection'] ); // Omit from stored source. // Add hook to stack for decorate_filter_source to read from. self::$current_hook_source_stack[ $hook ][] = $source; /* * Wrapped callbacks cause PHP warnings when the wrapped function has arguments passed by reference. * We have a special case to support functions that have the first argument passed by reference, namely * wp_default_scripts() and wp_default_styles(). But other configurations are bypassed. */ $passed_by_ref = self::has_parameters_passed_by_reference( $reflection ); if ( $passed_by_ref > 1 ) { continue; } $source['hook'] = $hook; $source['priority'] = $priority; $original_function = $callback['function']; $wrapped_callback = new AMP_Validation_Callback_Wrapper( array_merge( $callback, compact( 'priority', 'source', 'indirect_sources' ) ), static function () use ( &$callback, $original_function ) { // Restore the original callback function in case other plugins are introspecting filters. // This logic runs immediately before the original function is actually invoked. $callback['function'] = $original_function; } ); if ( 1 === $passed_by_ref ) { $callback['function'] = [ $wrapped_callback, 'invoke_with_first_ref_arg' ]; } else { $callback['function'] = $wrapped_callback; } } } } /** * Determine whether the given reflection method/function has params passed by reference. * * @since 0.7 * @param ReflectionFunction|ReflectionMethod $reflection Reflection. * @return int Whether there are parameters passed by reference, where 0 means none were passed, 1 means the first was passed, and 2 means some other configuration. */ protected static function has_parameters_passed_by_reference( $reflection ) { $status = 0; foreach ( $reflection->getParameters() as $i => $parameter ) { if ( $parameter->isPassedByReference() ) { if ( 0 === $i ) { $status = 1; } else { $status = 2; break; } } } return $status; } /** * Filters the output created by a shortcode callback. * * @since 0.7 * * @param string $output Shortcode output. * @param string $tag Shortcode name. * @return string Output. * @global array $shortcode_tags */ public static function decorate_shortcode_source( $output, $tag ) { global $shortcode_tags; if ( ! isset( $shortcode_tags[ $tag ] ) ) { return $output; } $callback_reflection = Services::get( 'dev_tools.callback_reflection' ); $source = $callback_reflection->get_source( $shortcode_tags[ $tag ] ); if ( empty( $source ) ) { return $output; } $source['shortcode'] = $tag; $output = implode( '', [ self::get_source_comment( $source, true ), $output, self::get_source_comment( $source, false ), ] ); return $output; } /** * Filters the output created by embeds. * * @since 1.0 * * @param string $output Embed output. * @param string $url URL. * @param array $attr Attributes. * @return string Output. */ public static function decorate_embed_source( $output, $url, $attr ) { $source = [ 'embed' => $url, 'attr' => $attr, ]; return implode( '', [ self::get_source_comment( $source, true ), trim( $output ), self::get_source_comment( $source, false ), ] ); } /** * Wraps output of a filter to add source stack comments. * * @todo Duplicate with AMP_Validation_Manager::wrap_buffer_with_source_comments()? * @param string $value Value. * @return string Value wrapped in source comments. */ public static function decorate_filter_source( $value ) { // Abort if the output is not a string and it doesn't contain any HTML tags. if ( ! is_string( $value ) || ! preg_match( '/<.+?>/s', $value ) ) { return $value; } $post = get_post(); $source = [ 'hook' => current_filter(), 'filter' => true, ]; if ( $post ) { $source['post_id'] = $post->ID; $source['post_type'] = $post->post_type; } if ( isset( self::$current_hook_source_stack[ current_filter() ] ) ) { $sources = self::$current_hook_source_stack[ current_filter() ]; array_pop( $sources ); // Remove self. $source['sources'] = $sources; } return implode( '', [ self::get_source_comment( $source, true ), $value, self::get_source_comment( $source, false ), ] ); } /** * Gets the plugin or theme of the callback, if one exists. * * @deprecated 2.0.2 Use \AmpProject\AmpWP\DevTools\CallbackReflection::get_source(). * @codeCoverageIgnore * * @param string|array|callable $callback The callback for which to get the plugin. * @return array|null { * The source data. * * @type string $type Source type (core, plugin, mu-plugin, or theme). * @type string $name Source name. * @type string $file Relative file path based on the type. * @type string $function Normalized function name. * @type ReflectionMethod|ReflectionFunction $reflection Reflection. * } */ public static function get_source( $callback ) { _deprecated_function( __METHOD__, '2.0.2', '\AmpProject\AmpWP\DevTools\CallbackReflection::get_source' ); return Services::get( 'dev_tools.callback_reflection' ) ->get_source( $callback ); } /** * Check whether or not output buffering is currently possible. * * This is to guard against a fatal error: "ob_start(): Cannot use output buffering in output buffering display handlers". * * @return bool Whether output buffering is allowed. */ public static function can_output_buffer() { // Output buffering for validation can only be done while overall output buffering is being done for the response. if ( ! AMP_Theme_Support::is_output_buffering() ) { return false; } // Abort when in shutdown since output has finished, when we're likely in the overall output buffering display handler. if ( did_action( 'shutdown' ) ) { return false; } // Check if any functions in call stack are output buffering display handlers. $called_functions = []; $backtrace = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace -- Only way to find out if we are in a buffering display handler. foreach ( $backtrace as $call_stack ) { if ( '{closure}' === $call_stack['function'] ) { $called_functions[] = 'Closure::__invoke'; } elseif ( isset( $call_stack['class'] ) ) { $called_functions[] = sprintf( '%s::%s', $call_stack['class'], $call_stack['function'] ); } else { $called_functions[] = $call_stack['function']; } } return 0 === count( array_intersect( ob_list_handlers(), $called_functions ) ); } /** * Wraps a callback in comments if it outputs markup. * * If the sanitizer removes markup, * this indicates which plugin it was from. * The call_user_func_array() logic is mainly copied from WP_Hook:apply_filters(). * * @deprecated No longer used as of 2.2.1. * @codeCoverageIgnore * * @param array $callback { * The callback data. * * @type callable $function * @type int $accepted_args * @type array $source * } * @return AMP_Validation_Callback_Wrapper $wrapped_callback The callback, wrapped in comments. */ public static function wrapped_callback( $callback ) { return new AMP_Validation_Callback_Wrapper( $callback ); } /** * Wrap output buffer with source comments. * * A key reason for why this is a method and not a closure is so that * the can_output_buffer method will be able to identify it by name. * * @since 0.7 * @todo Is duplicate of \AMP_Validation_Manager::decorate_filter_source()? * * @param string $output Output buffer. * @return string Output buffer conditionally wrapped with source comments. */ public static function wrap_buffer_with_source_comments( $output ) { if ( empty( self::$hook_source_stack ) ) { return $output; } $source = self::$hook_source_stack[ count( self::$hook_source_stack ) - 1 ]; // Wrap output that contains HTML tags (as opposed to actions that trigger in HTML attributes). if ( ! empty( $output ) && preg_match( '/<.+?>/s', $output ) ) { $output = implode( '', [ self::get_source_comment( $source, true ), $output, self::get_source_comment( $source, false ), ] ); } return $output; } /** * Get nonce for performing amp_validate request. * * The returned nonce is irrespective of the authenticated user. * * @return string Nonce. */ public static function get_amp_validate_nonce() { return wp_hash( self::VALIDATE_QUERY_VAR . wp_nonce_tick(), 'nonce' ); } /** * Whether the request is to validate URL for validation errors. * * All AMP responses get validated, but when the amp_validate query parameter is present, then the source information * for each validation error is captured and the validation results are returned as JSON instead of the AMP HTML page. * * @return bool|WP_Error Whether to validate. False is returned if it is not a validate request. WP_Error returned * if unauthenticated, unauthorized, and/or invalid nonce supplied. True returned if * validate response should be served. */ public static function should_validate_response() { $args = self::get_validate_request_args(); if ( null === $args[ self::VALIDATE_QUERY_VAR_NONCE ] ) { return false; } if ( ! hash_equals( self::get_amp_validate_nonce(), $args[ self::VALIDATE_QUERY_VAR_NONCE ] ) ) { return new WP_Error( 'http_request_failed', __( 'Nonce authentication failed.', 'amp' ) ); } return true; } /** * Get response data for a validate request. * * @see AMP_Content_Sanitizer::sanitize_document() * * @param array $sanitization_results { * Results of sanitizing a document, as returned by AMP_Content_Sanitizer::sanitize_document(). * * @type array $scripts Scripts. * @type array $stylesheets Stylesheets. * @type AMP_Base_Sanitizer[] $sanitizers Sanitizers. * } * @return array Validate response data. */ public static function get_validate_response_data( $sanitization_results ) { $data = [ 'results' => self::$validation_results, 'queried_object' => null, 'url' => amp_get_current_url(), ]; $queried_object = get_queried_object(); if ( $queried_object ) { $data['queried_object'] = []; $queried_object_id = get_queried_object_id(); if ( $queried_object_id ) { $data['queried_object']['id'] = $queried_object_id; } if ( $queried_object instanceof WP_Post ) { $data['queried_object']['type'] = 'post'; } elseif ( $queried_object instanceof WP_Term ) { $data['queried_object']['type'] = 'term'; } elseif ( $queried_object instanceof WP_User ) { $data['queried_object']['type'] = 'user'; } elseif ( $queried_object instanceof WP_Post_Type ) { $data['queried_object']['type'] = 'post_type'; } } /** * Sanitizers * * @var AMP_Base_Sanitizer[] $sanitizers */ $sanitizers = $sanitization_results['sanitizers']; foreach ( $sanitizers as $class_name => $sanitizer ) { $sanitizer_data = $sanitizer->get_validate_response_data(); $conflicting_keys = array_intersect( array_keys( $sanitizer_data ), array_keys( $data ) ); if ( ! empty( $conflicting_keys ) ) { _doing_it_wrong( esc_html( "$class_name::get_validate_response_data" ), esc_html( 'Method is returning array with conflicting keys: ' . implode( ', ', $conflicting_keys ) ), '1.5' ); } else { $data = array_merge( $data, $sanitizer_data ); } } return $data; } /** * Remove source stack comments which appear inside of script and style tags. * * HTML comments that appear inside of script and style elements get parsed as text content. AMP does not allow * such HTML comments to appear inside of CDATA, resulting in validation errors to be emitted when validating a * page that happens to have source stack comments output when generating JSON data (e.g. All in One SEO). * Additionally, when source stack comments are output inside of style elements the result can either be CSS * parse errors or incorrect stylesheet sizes being reported due to the presence of the source stack comments. * So to prevent these issues from occurring, the source stack comments need to be removed from the document prior * to sanitizing. * * @since 1.5 * * @param Document $dom Document. */ public static function remove_illegal_source_stack_comments( Document $dom ) { /** * Script element. * * @var DOMText $text */ foreach ( $dom->xpath->query( '//text()[ contains( ., "#s', '', $text->nodeValue ); } } /** * Send validate response. * * @since 2.2 * @see AMP_Theme_Support::prepare_response() * * @param array $sanitization_results Sanitization results. * @param int $status_code Status code. * @param array|null $last_error Last error. * @return string JSON. */ public static function send_validate_response( $sanitization_results, $status_code, $last_error ) { status_header( 200 ); if ( ! headers_sent() ) { header( 'Content-Type: application/json; charset=utf-8' ); } $data = [ 'http_status_code' => $status_code, 'php_fatal_error' => false, ]; if ( $last_error && in_array( $last_error['type'], [ E_ERROR, E_RECOVERABLE_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_PARSE ], true ) ) { $data['php_fatal_error'] = $last_error; } $data = array_merge( $data, self::get_validate_response_data( $sanitization_results ) ); $args = self::get_validate_request_args(); $data['revalidated'] = true; if ( $args[ self::VALIDATE_QUERY_VAR_CACHE ] ) { $validation_errors = wp_list_pluck( $data['results'], 'error' ); $validated_url_post_id = AMP_Validated_URL_Post_Type::store_validation_errors( $validation_errors, amp_get_current_url(), $data ); if ( is_wp_error( $validated_url_post_id ) ) { status_header( 500 ); return wp_json_encode( [ 'code' => $validated_url_post_id->get_error_code(), 'message' => $validated_url_post_id->get_error_message(), ] ); } else { status_header( 201 ); $data['validated_url_post'] = [ 'id' => $validated_url_post_id, 'edit_link' => get_edit_post_link( $validated_url_post_id, 'raw' ), ]; } } if ( $args[ self::VALIDATE_QUERY_VAR_OMIT_STYLESHEETS ] ) { unset( $data['stylesheets'] ); } $data['url'] = remove_query_arg( self::VALIDATE_QUERY_VAR, $data['url'] ); return wp_json_encode( $data, JSON_UNESCAPED_SLASHES ); } /** * Send cached validate response if it is requested and available. * * When a validate request is made with the `amp_validate[cached_if_fresh]=true` query parameter, before a page * begins rendering a check is made for whether there is already an `amp_validated_url` post for the current URL. * If there is, and the post is not stale, then the previous results are returned without re-rendering page and * obtaining the validation data. */ public static function maybe_send_cached_validate_response() { if ( ! self::is_validate_request() ) { return; } $args = self::get_validate_request_args(); if ( ! $args[ self::VALIDATE_QUERY_VAR_CACHED_IF_FRESH ] ) { return; } $post = AMP_Validated_URL_Post_Type::get_invalid_url_post( amp_get_current_url() ); if ( ! ( $post instanceof WP_Post ) ) { return; } $staleness = AMP_Validated_URL_Post_Type::get_post_staleness( $post ); if ( count( $staleness ) > 0 ) { return; } $response = [ 'http_status_code' => 200, // Note: This is not currently cached in postmeta. 'php_fatal_error' => false, 'results' => [], 'queried_object' => null, 'url' => null, 'revalidated' => false, // Since cached was used. 'validated_url_post' => [ 'id' => $post->ID, 'edit_link' => get_edit_post_link( $post->ID, 'raw' ), ], ]; if ( ! $args[ self::VALIDATE_QUERY_VAR_OMIT_STYLESHEETS ] ) { $stylesheets = get_post_meta( $post->ID, AMP_Validated_URL_Post_Type::STYLESHEETS_POST_META_KEY, true ); if ( $stylesheets ) { $response['stylesheets'] = json_decode( $stylesheets, true ); } } $stored_validation_errors = json_decode( $post->post_content, true ); if ( is_array( $stored_validation_errors ) ) { $response['results'] = array_map( static function ( $stored_validation_error ) { $error = $stored_validation_error['data']; $sanitized = AMP_Validation_Error_Taxonomy::is_validation_error_sanitized( $error ); return compact( 'error', 'sanitized' ); }, $stored_validation_errors ); } $queried_object = get_post_meta( $post->ID, AMP_Validated_URL_Post_Type::QUERIED_OBJECT_POST_META_KEY, true ); if ( $queried_object ) { $response['queried_object'] = $queried_object; } $php_fatal_error = get_post_meta( $post->ID, AMP_Validated_URL_Post_Type::PHP_FATAL_ERROR_POST_META_KEY, true ); if ( $php_fatal_error ) { $response['php_fatal_error'] = $php_fatal_error; } $response['url'] = AMP_Validated_URL_Post_Type::get_url_from_post( $post ); wp_send_json( $response, 200, JSON_UNESCAPED_SLASHES ); } /** * Finalize validation. * * @see AMP_Validation_Manager::add_admin_bar_menu_items() * * @param Document $dom Document. * @return bool Whether the document should be displayed to the user. */ public static function finalize_validation( Document $dom ) { $total_count = 0; $kept_count = 0; $unreviewed_count = 0; foreach ( self::$validation_results as $validation_result ) { $sanitization = AMP_Validation_Error_Taxonomy::get_validation_error_sanitization( $validation_result['error'] ); if ( ! ( (int) $sanitization['status'] & AMP_Validation_Error_Taxonomy::ACCEPTED_VALIDATION_ERROR_BIT_MASK ) ) { ++$kept_count; } if ( ! ( (int) $sanitization['status'] & AMP_Validation_Error_Taxonomy::ACKNOWLEDGED_VALIDATION_ERROR_BIT_MASK ) ) { ++$unreviewed_count; } ++$total_count; } /* * Override AMP status in admin bar set in \AMP_Validation_Manager::add_admin_bar_menu_items() * when there are validation errors which have not been explicitly accepted. */ if ( is_admin_bar_showing() && self::$amp_admin_bar_item_added && $total_count > 0 ) { self::update_admin_bar_item( $dom, $total_count, $kept_count, $unreviewed_count ); } // If no invalid markup is kept, then the page should definitely be displayed to the user. if ( 0 === $kept_count ) { return true; } // When overrides are present, go ahead and display to the user. if ( ! empty( self::$validation_error_status_overrides ) ) { return true; } $sandboxing_level = amp_get_sandboxing_level(); /* * In AMP-first, documents with invalid AMP markup can still be served. The amp attribute will be omitted in * order to prevent GSC from complaining about a validation error already surfaced inside of WordPress. * This is intended to not serve dirty AMP, but rather a non-AMP document (intentionally not valid AMP) that * contains the AMP runtime and AMP components. * * Otherwise, if in Paired AMP then redirect to the non-AMP version if the current user isn't an user who * can manage validation error statuses (access developer tools) and change the AMP options for the template * mode. Such users should be able to see kept invalid markup on the AMP page even though it is invalid. * * Also, if sandboxing is not set to strict mode, then the page should be displayed to the user. */ if ( amp_is_canonical() || ( 1 === $sandboxing_level || 2 === $sandboxing_level ) ) { return true; } // Otherwise, since it is in a paired mode, only allow showing the dirty AMP page if the user is authorized. // If not, normally the result is redirection to the non-AMP version. return self::has_cap() || is_customize_preview(); } /** * Override AMP status in admin bar set in \AMP_Validation_Manager::add_admin_bar_menu_items() * when there are validation errors which have not been explicitly accepted. * * @param Document $dom Document. * @param int $total_count Total count of validation errors (more than 0). * @param int $kept_count Count of validation errors with invalid markup kept. * @param int $unreviewed_count Count of unreviewed validation errors. */ private static function update_admin_bar_item( Document $dom, $total_count, $kept_count, $unreviewed_count ) { $parent_menu_item = $dom->getElementById( 'wp-admin-bar-amp' ); if ( ! $parent_menu_item instanceof DOMElement ) { return; } $parent_menu_link = $dom->xpath->query( './a[ @href ]', $parent_menu_item )->item( 0 ); $admin_bar_icon = $dom->xpath->query( './span[ @id = "amp-admin-bar-item-status-icon" ]', $parent_menu_link )->item( 0 ); $validate_link = $dom->xpath->query( './/li[ @id = "wp-admin-bar-amp-validity" ]/a[ @href ]', $parent_menu_item )->item( 0 ); if ( ! $parent_menu_link instanceof DOMElement || ! $admin_bar_icon instanceof DOMElement || ! $validate_link instanceof DOMElement ) { return; } /* * When in Paired AMP, non-administrators accessing the AMP version will get redirected to the non-AMP version * if there are is kept invalid markup. In Paired AMP, the AMP plugin never intends to advertise the availability * of dirty AMP pages. However, in AMP-first (Standard mode), there is no non-AMP version to redirect to, so * kept invalid markup does not cause redirection but rather the `amp` attribute is removed from the AMP page * to serve an intentionally invalid AMP page with the AMP runtime loaded which is exempted from AMP validation * (and excluded from being indexed as an AMP page). So this is why the first conditional will only show the * error icon for kept markup when _not_ AMP-first. This will only be displayed to administrators who are directly * accessing the AMP version. Otherwise, if there is no kept invalid markup _or_ it is AMP-first, then the AMP * admin bar item will be updated to show if there are any unreviewed validation errors (regardless of whether * they are kept or removed). */ if ( $kept_count > 0 && ! amp_is_canonical() ) { $admin_bar_icon->setAttribute( 'class', 'ab-icon amp-icon ' . Icon::INVALID ); } elseif ( $unreviewed_count > 0 || $kept_count > 0 ) { $admin_bar_icon->setAttribute( 'class', 'ab-icon amp-icon ' . Icon::WARNING ); } // Update the text of the link to reflect the status of the validation error(s). $items = []; if ( $unreviewed_count > 0 ) { if ( $unreviewed_count === $total_count ) { /* translators: text is describing validation issue(s) */ $items[] = _n( 'unreviewed', 'all unreviewed', $unreviewed_count, 'amp' ); } else { $items[] = sprintf( /* translators: %s the total count of unreviewed validation errors */ _n( '%s unreviewed', '%s unreviewed', $unreviewed_count, 'amp' ), number_format_i18n( $unreviewed_count ) ); } } if ( $kept_count > 0 ) { if ( $kept_count === $total_count ) { /* translators: text is describing validation issue(s) */ $items[] = _n( 'kept', 'all kept', $kept_count, 'amp' ); } else { $items[] = sprintf( /* translators: %s the total count of unreviewed validation errors */ _n( '%s kept', '%s kept', $kept_count, 'amp' ), number_format_i18n( $kept_count ) ); } } if ( empty( $items ) ) { /* translators: text is describing validation issue(s) */ $items[] = _n( 'reviewed', 'all reviewed', $total_count, 'amp' ); } $text = sprintf( /* translators: %s is total count of validation errors */ _n( '%s issue:', '%s issues:', $total_count, 'amp' ), number_format_i18n( $total_count ) ); $text .= ' ' . implode( ', ', $items ); $validate_link->appendChild( $dom->createTextNode( ' ' ) ); $small = $dom->createElement( Tag::SMALL ); try { $small->setAttribute( Attribute::STYLE, 'font-size: smaller' ); } catch ( MaxCssByteCountExceeded $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch // Making the font size smaller is just a nice-to-have. } $small->appendChild( $dom->createTextNode( sprintf( '(%s)', $text ) ) ); $validate_link->appendChild( $small ); } /** * Adds the validation callback if front-end validation is needed. * * @param array $sanitizers The AMP sanitizers. * @return array $sanitizers The filtered AMP sanitizers. */ public static function filter_sanitizer_args( $sanitizers ) { foreach ( $sanitizers as &$args ) { $args['validation_error_callback'] = __CLASS__ . '::add_validation_error'; } if ( isset( $sanitizers[ AMP_Style_Sanitizer::class ] ) ) { $sanitizers[ AMP_Style_Sanitizer::class ]['should_locate_sources'] = self::is_validate_request(); $css_validation_errors = []; foreach ( self::$validation_error_status_overrides as $slug => $status ) { $term = AMP_Validation_Error_Taxonomy::get_term( $slug ); if ( ! $term ) { continue; } $validation_error = json_decode( $term->description, true ); $is_css_validation_error = ( is_array( $validation_error ) && isset( $validation_error['code'] ) && in_array( $validation_error['code'], AMP_Style_Sanitizer::get_css_parser_validation_error_codes(), true ) ); if ( $is_css_validation_error ) { $css_validation_errors[ $slug ] = $status; } } if ( ! empty( $css_validation_errors ) ) { $sanitizers[ AMP_Style_Sanitizer::class ]['parsed_cache_variant'] = md5( wp_json_encode( $css_validation_errors ) ); } } return $sanitizers; } /** * Validate a URL to be validated. * * @param string $url URL. * @return string|WP_Error Validated URL or else error. */ private static function validate_validation_url( $url ) { $validated_url = wp_validate_redirect( $url ); if ( ! $validated_url ) { return new WP_Error( 'http_request_failed', /* translators: %s is the URL being redirected to. */ sprintf( __( 'Unable to validate a URL on another site. Attempted to validate: %s', 'amp' ), $url ) ); } return $validated_url; } /** * Validates a given URL. * * The validation errors will be stored in the validation status custom post type, * as well as in a transient. * * @param string $url The URL to validate. This need not include the amp query var. * @return WP_Error|array { * Response. * * @type array $results Validation results, where each nested array contains an error key and sanitized key. * @type string $url Final URL that was checked or redirected to. * @type array $queried_object Queried object, including keys for 'type' and 'id'. * @type array $stylesheets Stylesheet data. * @type string $php_fatal_error PHP fatal error which occurred during validation. * } */ public static function validate_url( $url ) { if ( ! amp_is_canonical() && ! amp_has_paired_endpoint( $url ) ) { $url = amp_add_paired_endpoint( $url ); } $added_query_vars = [ self::VALIDATE_QUERY_VAR => [ self::VALIDATE_QUERY_VAR_NONCE => self::get_amp_validate_nonce(), self::VALIDATE_QUERY_VAR_CACHE_BUST => wp_rand(), ], ]; // Ensure the URL to be validated is on the site. $validation_url = self::validate_validation_url( $url ); if ( is_wp_error( $validation_url ) ) { return $validation_url; } $validation_url = add_query_arg( $added_query_vars, $validation_url ); $r = null; /** This filter is documented in wp-includes/class-http.php */ $allowed_redirects = apply_filters( 'http_request_redirection_count', 5 ); for ( $redirect_count = 0; $redirect_count < $allowed_redirects; $redirect_count++ ) { $r = wp_remote_get( $validation_url, [ 'cookies' => wp_unslash( $_COOKIE ), // phpcs:ignore WordPressVIPMinimum.Variables.RestrictedVariables.cache_constraints___COOKIE -- Pass along cookies so private pages and drafts can be accessed. 'timeout' => 15, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- Increase from default of 5 to give extra time for the plugin to identify the sources for any given validation errors. /** This filter is documented in wp-includes/class-wp-http-streams.php */ 'sslverify' => apply_filters( 'https_local_ssl_verify', false ), 'redirection' => 0, // Because we're in a loop for redirection. 'headers' => [ 'Cache-Control' => 'no-cache', ], ] ); // If the response is not a redirect, then break since $r is all we need. $response_code = wp_remote_retrieve_response_code( $r ); $location_header = wp_remote_retrieve_header( $r, 'Location' ); $is_redirect = ( $response_code && $response_code > 300 && $response_code < 400 && $location_header ); if ( ! $is_redirect ) { break; } // Ensure absolute URL. if ( '/' === substr( $location_header, 0, 1 ) ) { $location_header = preg_replace( '#(^https?://[^/]+)/.*#', '$1', home_url( '/' ) ) . $location_header; } // Prevent following a redirect to another site, which won't work for validation anyway. $validation_url = self::validate_validation_url( $location_header ); if ( is_wp_error( $validation_url ) ) { return $validation_url; } $validation_url = add_query_arg( $added_query_vars, $validation_url ); } if ( is_wp_error( $r ) ) { return $r; } $response = trim( wp_remote_retrieve_body( $r ) ); if ( wp_remote_retrieve_response_code( $r ) >= 400 ) { $data = json_decode( $response, true ); return new WP_Error( is_array( $data ) && isset( $data['code'] ) ? $data['code'] : wp_remote_retrieve_response_code( $r ), is_array( $data ) && isset( $data['message'] ) ? $data['message'] : wp_remote_retrieve_response_message( $r ) ); } if ( wp_remote_retrieve_response_code( $r ) >= 300 ) { return new WP_Error( 'http_request_failed', __( 'Too many redirects.', 'amp' ) ); } $url = remove_query_arg( array_keys( $added_query_vars ), $validation_url ); // Strip byte order mark (BOM). while ( "\xEF\xBB\xBF" === substr( $response, 0, 3 ) ) { $response = substr( $response, 3 ); } // Strip any leading whitespace. $response = ltrim( $response ); // Strip HTML comments that may have been injected at the end of the response (e.g. by a caching plugin). while ( ! empty( $response ) ) { $response = rtrim( $response ); $length = strlen( $response ); if ( $length < 3 || '-' !== $response[ $length - 3 ] || '-' !== $response[ $length - 2 ] || '>' !== $response[ $length - 1 ] ) { break; } $start = strrpos( $response, '