is_user_enabled() || ( isset( $_GET['post_type'] ) && self::POST_TYPE_SLUG === $_GET['post_type'] ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended || ( isset( $_GET['post'], $_GET['action'] ) && 'edit' === $_GET['action'] && self::POST_TYPE_SLUG === get_post_type( (int) $_GET['post'] ) ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended || ( isset( $_GET['taxonomy'] ) && AMP_Validation_Error_Taxonomy::TAXONOMY_SLUG === $_GET['taxonomy'] ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended ); if ( $show_in_menu && current_user_can( 'manage_options' ) ) { $show_in_menu = AMP_Options_Manager::OPTION_NAME; } register_post_type( self::POST_TYPE_SLUG, [ 'labels' => [ 'all_items' => __( 'All Validated URLs', 'amp' ), 'name' => _x( 'AMP Validated URLs', 'post type general name', 'amp' ), 'menu_name' => __( 'Validated URLs', 'amp' ), 'singular_name' => __( 'Validated URL', 'amp' ), 'not_found' => __( 'No validated URLs found', 'amp' ), 'not_found_in_trash' => __( 'No forgotten validated URLs', 'amp' ), 'search_items' => __( 'Search validated URLs', 'amp' ), 'edit_item' => '', // Overwritten in JS, so this prevents the page header from appearing and changing. ], 'supports' => false, 'public' => false, 'show_ui' => true, 'show_in_menu' => $show_in_menu, 'map_meta_cap' => false, 'capabilities' => array_merge( array_fill_keys( [ 'edit_post', 'read_post', 'delete_post', 'edit_posts', 'edit_others_posts', 'delete_posts', 'publish_posts', 'read_private_posts', ], AMP_Validation_Manager::VALIDATE_CAPABILITY ), [ // Hide the add new post link, as new posts are created programmatically. 'create_posts' => 'do_not_allow', ] ), // @todo Show in rest. ] ); if ( $show_in_menu ) { add_action( 'admin_menu', [ __CLASS__, 'update_validated_url_menu_item' ] ); } // Rename the top-level menu from "Validated URLs" to "AMP DevTools" when the user does not have access to the AMP settings screen. if ( $show_in_menu && ! current_user_can( 'manage_options' ) ) { add_action( 'admin_menu', static function () { global $menu; foreach ( $menu as &$menu_item ) { if ( 'edit.php?post_type=' . self::POST_TYPE_SLUG === $menu_item[2] ) { $menu_item[0] = esc_html__( 'AMP DevTools', 'amp' ); $menu_item[6] = OptionsMenu::ICON_BASE64_SVG; break; } } } ); } // Ensure cached count of URLs with new validation errors is flushed whenever a URL is updated, trashed, or deleted. $handle_delete = static function ( $post_id ) { if ( static::POST_TYPE_SLUG === get_post_type( $post_id ) ) { delete_transient( static::NEW_VALIDATION_ERROR_URLS_COUNT_TRANSIENT ); delete_transient( AMP_Validation_Error_Taxonomy::TRANSIENT_KEY_ERROR_INDEX_COUNTS ); } }; add_action( 'save_post_' . self::POST_TYPE_SLUG, $handle_delete ); add_action( 'trash_post', $handle_delete ); add_action( 'delete_post', $handle_delete ); if ( is_admin() ) { self::add_admin_hooks(); } } /** * Handle update to plugin. * * @param string $old_version Old version. */ public static function handle_plugin_update( $old_version ) { // Update the old post type slug from amp_invalid_url to amp_validated_url. if ( '1.0-' === substr( $old_version, 0, 4 ) || version_compare( $old_version, '1.0', '<' ) ) { global $wpdb; $post_ids = get_posts( [ 'post_type' => 'amp_invalid_url', 'fields' => 'ids', 'posts_per_page' => -1, ] ); foreach ( $post_ids as $post_id ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $wpdb->update( $wpdb->posts, [ 'post_type' => self::POST_TYPE_SLUG ], [ 'ID' => $post_id ] ); clean_post_cache( $post_id ); } } } /** * Add admin hooks. */ public static function add_admin_hooks() { add_action( 'admin_enqueue_scripts', [ __CLASS__, 'enqueue_post_list_screen_scripts' ] ); // Edit post screen hooks. add_action( 'admin_enqueue_scripts', [ __CLASS__, 'enqueue_edit_post_screen_scripts' ] ); add_action( 'add_meta_boxes', [ __CLASS__, 'add_meta_boxes' ], PHP_INT_MAX ); add_action( 'edit_form_after_title', [ __CLASS__, 'render_single_url_list_table' ] ); add_filter( 'edit_' . AMP_Validation_Error_Taxonomy::TAXONOMY_SLUG . '_per_page', [ __CLASS__, 'get_terms_per_page' ] ); add_action( 'admin_init', [ __CLASS__, 'add_taxonomy' ] ); add_action( 'edit_form_top', [ __CLASS__, 'print_url_as_title' ] ); // Post list screen hooks. add_filter( 'view_mode_post_types', static function( $post_types ) { return array_diff( $post_types, [ AMP_Validated_URL_Post_Type::POST_TYPE_SLUG ] ); } ); add_action( 'load-edit.php', static function() { if ( 'edit-' . AMP_Validated_URL_Post_Type::POST_TYPE_SLUG !== get_current_screen()->id ) { return; } add_action( 'admin_head-edit.php', static function() { global $mode; $mode = 'list'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited } ); } ); add_action( 'admin_notices', [ __CLASS__, 'render_link_to_error_index_screen' ] ); add_filter( 'the_title', [ __CLASS__, 'filter_the_title_in_post_list_table' ], 10, 2 ); add_action( 'restrict_manage_posts', [ __CLASS__, 'render_post_filters' ], 10, 2 ); add_filter( 'manage_' . self::POST_TYPE_SLUG . '_posts_columns', [ __CLASS__, 'add_post_columns' ] ); add_filter( 'manage_' . self::POST_TYPE_SLUG . '_columns', [ __CLASS__, 'add_single_post_columns' ] ); add_action( 'manage_posts_custom_column', [ __CLASS__, 'output_custom_column' ], 10, 2 ); add_filter( 'bulk_actions-edit-' . self::POST_TYPE_SLUG, [ __CLASS__, 'filter_bulk_actions' ], 10, 2 ); add_filter( 'bulk_actions-' . self::POST_TYPE_SLUG, '__return_false' ); add_filter( 'handle_bulk_actions-edit-' . self::POST_TYPE_SLUG, [ __CLASS__, 'handle_bulk_action' ], 10, 3 ); add_action( 'admin_notices', [ __CLASS__, 'print_admin_notice' ] ); add_action( 'admin_action_' . self::VALIDATE_ACTION, [ __CLASS__, 'handle_validate_request' ] ); add_action( 'post_action_' . self::UPDATE_POST_TERM_STATUS_ACTION, [ __CLASS__, 'handle_validation_error_status_update' ] ); add_filter( 'post_row_actions', [ __CLASS__, 'filter_post_row_actions' ], PHP_INT_MAX - 1, 2 ); add_filter( sprintf( 'views_edit-%s', self::POST_TYPE_SLUG ), [ __CLASS__, 'filter_table_views' ] ); add_filter( 'bulk_post_updated_messages', [ __CLASS__, 'filter_bulk_post_updated_messages' ], 10, 2 ); add_filter( 'admin_title', [ __CLASS__, 'filter_admin_title' ] ); // Hide irrelevant "published" label in the AMP Validated URLs post list. add_filter( 'post_date_column_status', static function ( $status, $post ) { if ( AMP_Validated_URL_Post_Type::POST_TYPE_SLUG === get_post_type( $post ) ) { $status = ''; } return $status; }, 10, 2 ); // Prevent query vars from persisting after redirect. add_filter( 'removable_query_args', static function ( $query_vars ) { $query_vars[] = 'amp_actioned'; $query_vars[] = 'amp_taxonomy_terms_updated'; $query_vars[] = AMP_Validated_URL_Post_Type::REMAINING_ERRORS; $query_vars[] = 'amp_urls_tested'; $query_vars[] = 'amp_validate_error'; return $query_vars; } ); } /** * Enqueue style. */ public static function enqueue_post_list_screen_scripts() { $screen = get_current_screen(); if ( ! $screen instanceof \WP_Screen ) { return; } // Enqueue this on both the 'AMP Validated URLs' page and the single URL page. if ( 'edit-' . self::POST_TYPE_SLUG === $screen->id || self::POST_TYPE_SLUG === $screen->id ) { wp_enqueue_style( 'amp-admin-tables', amp_get_asset_url( 'css/admin-tables.css' ), [ 'amp-icons' ], AMP__VERSION ); wp_styles()->add_data( 'amp-admin-tables', 'rtl', 'replace' ); } if ( 'edit-' . self::POST_TYPE_SLUG !== $screen->id ) { return; } wp_register_style( 'amp-validation-tooltips', amp_get_asset_url( 'css/amp-validation-tooltips.css' ), [ 'wp-pointer' ], AMP__VERSION ); wp_styles()->add_data( 'amp-validation-tooltips', 'rtl', 'replace' ); $asset_file = AMP__DIR__ . '/assets/js/amp-validation-tooltips.asset.php'; $asset = require $asset_file; $dependencies = $asset['dependencies']; $version = $asset['version']; wp_register_script( 'amp-validation-tooltips', amp_get_asset_url( 'js/amp-validation-tooltips.js' ), $dependencies, $version, true ); wp_enqueue_style( 'amp-validation-error-taxonomy', amp_get_asset_url( 'css/amp-validation-error-taxonomy.css' ), [ 'common', 'amp-validation-tooltips', 'amp-icons' ], AMP__VERSION ); wp_styles()->add_data( 'amp-validation-error-taxonomy', 'rtl', 'replace' ); wp_enqueue_script( 'amp-validation-detail-toggle', amp_get_asset_url( 'js/amp-validation-detail-toggle.js' ), [ 'wp-dom-ready', 'wp-i18n', 'amp-validation-tooltips' ], AMP__VERSION, true ); } /** * On the 'AMP Validated URLs' screen, renders a link to the 'Error Index' page. * * @see AMP_Validation_Error_Taxonomy::render_link_to_invalid_urls_screen() */ public static function render_link_to_error_index_screen() { if ( ! ( get_current_screen() && 'edit' === get_current_screen()->base && self::POST_TYPE_SLUG === get_current_screen()->post_type ) ) { return; } $taxonomy_object = get_taxonomy( AMP_Validation_Error_Taxonomy::TAXONOMY_SLUG ); if ( ! current_user_can( $taxonomy_object->cap->manage_terms ) ) { return; } $id = 'link-errors-index'; printf( '%s', esc_url( get_admin_url( null, 'edit-tags.php?taxonomy=' . AMP_Validation_Error_Taxonomy::TAXONOMY_SLUG . '&post_type=' . self::POST_TYPE_SLUG ) ), esc_attr( $id ), esc_html__( 'View Error Index', 'amp' ) ); ?> labels->menu_name; $submenu_item[0] = $menu_name_label; if ( ValidationCounts::is_needed() ) { // Append markup to display a loading spinner while the unreviewed count is being fetched. $submenu_item[0] .= ' '; } break; } } } /** * Get the count of URLs that have new validation errors. * * @since 1.3 * * @return int Count of new validation error URLs. */ public static function get_validation_error_urls_count() { $count = get_transient( static::NEW_VALIDATION_ERROR_URLS_COUNT_TRANSIENT ); if ( false !== $count ) { // Handle case where integer stored in transient gets returned as string when persistent object cache is not // used. This is due to wp_options.option_value being a string. return (int) $count; } // Make sure filter is added in REST API context which is otherwise only added via AMP_Validation_Error_Taxonomy::add_admin_hooks(). $callback = [ AMP_Validation_Error_Taxonomy::class, 'filter_posts_where_for_validation_error_status' ]; $priority = 10; $has_filter = has_filter( 'posts_where', $callback ); if ( false === $has_filter ) { add_filter( 'posts_where', $callback, $priority, 2 ); } $query = new WP_Query( [ 'post_type' => self::POST_TYPE_SLUG, AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_STATUS_QUERY_VAR => [ AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_NEW_REJECTED_STATUS, AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_NEW_ACCEPTED_STATUS, ], 'update_post_meta_cache' => false, 'update_post_term_cache' => false, ] ); // Remove filter if we added it in this method. if ( false === $has_filter ) { remove_filter( 'posts_where', $callback, $priority ); } $count = $query->found_posts; set_transient( static::NEW_VALIDATION_ERROR_URLS_COUNT_TRANSIENT, $count, DAY_IN_SECONDS ); return $count; } /** * Gets validation errors for a given validated URL post. * * @param string|int|WP_Post $url Either the URL string or a post (ID or WP_Post) of amp_validated_url type. * @param array $args { * Args. * * @type bool $ignore_accepted Exclude validation errors that are accepted (invalid markup removed). Default false. * } * @return array List of errors, with keys for term, data, status, and (sanitization) forced. */ public static function get_invalid_url_validation_errors( $url, $args = [] ) { $args = array_merge( [ 'ignore_accepted' => false, ], $args ); // Look up post by URL or ensure the amp_validated_url object. if ( is_string( $url ) ) { $post = self::get_invalid_url_post( $url ); } else { $post = get_post( $url ); } if ( ! $post || self::POST_TYPE_SLUG !== $post->post_type ) { return []; } // Skip when parse error. $stored_validation_errors = json_decode( $post->post_content, true ); if ( ! is_array( $stored_validation_errors ) ) { return []; } $errors = []; foreach ( $stored_validation_errors as $stored_validation_error ) { if ( ! isset( $stored_validation_error['term_slug'] ) ) { continue; } $term = AMP_Validation_Error_Taxonomy::get_term( $stored_validation_error['term_slug'] ); if ( ! $term ) { continue; } $sanitization = AMP_Validation_Error_Taxonomy::get_validation_error_sanitization( $stored_validation_error['data'] ); if ( $args['ignore_accepted'] && ( AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_NEW_ACCEPTED_STATUS === $sanitization['status'] || AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_ACK_ACCEPTED_STATUS === $sanitization['status'] ) ) { continue; } $errors[] = array_merge( [ 'term' => $term, 'data' => $stored_validation_error['data'], ], $sanitization ); } return $errors; } /** * Display summary of the validation error counts for a given post. * * @param int|WP_Post $post Post of amp_validated_url type. */ public static function display_invalid_url_validation_error_counts_summary( $post ) { $validation_errors = self::get_invalid_url_validation_errors( $post ); $counts = self::count_invalid_url_validation_errors( $validation_errors ); $removed_count = ( $counts['new_accepted'] + $counts['ack_accepted'] ); $kept_count = ( $counts['new_rejected'] + $counts['ack_rejected'] ); $result = []; if ( $kept_count ) { $title = ''; if ( $counts['new_rejected'] > 0 && $counts['ack_rejected'] > 0 ) { $title = sprintf( /* translators: %s is the count of new validation errors */ _n( '%s validation error with kept markup is new', '%s validation errors with kept markup are new', $counts['new_rejected'], 'amp' ), $counts['new_rejected'] ); } $result[] = sprintf( '%s %s: %s', esc_attr( $counts['new_rejected'] > 0 ? 'has-new' : '' ), esc_attr( $title ), Icon::invalid()->to_html(), esc_html__( 'Invalid markup kept', 'amp' ), number_format_i18n( $kept_count ) ); } if ( $removed_count ) { $title = ''; if ( $counts['new_accepted'] > 0 && $counts['ack_accepted'] > 0 ) { $title = sprintf( /* translators: %s is the count of new validation errors */ _n( '%s validation error with removed markup is new', '%s validation errors with removed markup are new', $counts['new_rejected'], 'amp' ), $counts['new_accepted'] ); } $icon = ( $counts['new_accepted'] + $counts['new_rejected'] ) > 0 ? Icon::removed() : Icon::valid(); $result[] = sprintf( '%s %s: %s', esc_attr( $counts['new_accepted'] > 0 ? 'has-new' : '' ), esc_attr( $title ), $icon->to_html(), esc_html__( 'Invalid markup removed', 'amp' ), number_format_i18n( $removed_count ) ); } if ( 0 === $removed_count && 0 === $kept_count ) { $result[] = sprintf( '%s %s', Icon::valid()->to_html(), esc_html__( 'All markup valid', 'amp' ) ); } echo implode( '', $result ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped printf( '', (int) ( $counts['new_accepted'] + $counts['new_rejected'] > 0 ) ); } /** * Gets the existing custom post that stores errors for the $url, if it exists. * * @param string $url The (in)valid URL. * @param array $options { * Options. * * @type bool $normalize Whether to normalize the URL. * @type bool $include_trashed Include trashed. * } * @return WP_Post|null The post of the existing custom post, or null. */ public static function get_invalid_url_post( $url, $options = [] ) { $default = [ 'normalize' => true, 'include_trashed' => false, ]; $options = wp_parse_args( $options, $default ); if ( $options['normalize'] ) { $url = self::normalize_url_for_storage( $url ); } $slug = md5( $url ); $post = get_page_by_path( $slug, OBJECT, self::POST_TYPE_SLUG ); if ( $post instanceof WP_Post ) { return $post; } if ( $options['include_trashed'] ) { $post = get_page_by_path( $slug . '__trashed', OBJECT, self::POST_TYPE_SLUG ); if ( $post instanceof WP_Post ) { return $post; } } return null; } /** * Get the URL from a given amp_validated_url post. * * The URL will be returned with the amp query var added to it if the site is not canonical. The post_title * is always stored using the canonical AMP-less URL. * * @param int|WP_Post $post Post. * @return string|null The URL stored for the post or null if post does not exist or it is not the right type. */ public static function get_url_from_post( $post ) { $post = get_post( $post ); if ( ! $post || self::POST_TYPE_SLUG !== $post->post_type ) { return null; } $url = $post->post_title; // Add AMP query var if in transitional mode. if ( ! amp_is_canonical() ) { $url = amp_add_paired_endpoint( $url ); } // Set URL scheme based on whether HTTPS is current. $url = set_url_scheme( $url, ( 'http' === wp_parse_url( home_url(), PHP_URL_SCHEME ) ) ? 'http' : 'https' ); return $url; } /** * Get the markup status preview URL. * * Adds a _wpnonce query param for the markup status preview action. * * @since 1.5.0 * * @param string $url Frontend URL to preview markup status changes. * @return string Preview URL. */ protected static function get_markup_status_preview_url( $url ) { return add_query_arg( '_wpnonce', wp_create_nonce( AMP_Validation_Manager::MARKUP_STATUS_PREVIEW_ACTION ), $url ); } /** * Normalize a URL for storage. * * The AMP query param is removed to facilitate switching between standard and transitional. * The URL scheme is also normalized to HTTPS to help with transition from HTTP to HTTPS. * * @param string $url URL. * @return string Normalized URL. */ public static function normalize_url_for_storage( $url ) { // Only ever store the canonical version. if ( ! amp_is_canonical() ) { $url = amp_remove_paired_endpoint( $url ); } // Remove fragment identifier in the rare case it could be provided. It is irrelevant for validation. $url = strtok( $url, '#' ); // Query args to be removed from validated URLs. $removable_query_vars = array_merge( wp_removable_query_args(), [ 'preview_id', 'preview_nonce', 'preview', QueryVar::NOAMP, AMP_Validation_Manager::VALIDATE_QUERY_VAR ] ); // Normalize query args, removing all that are not recognized or which are removable. $url_parts = explode( '?', $url, 2 ); if ( 2 === count( $url_parts ) ) { $args = wp_parse_args( $url_parts[1] ); foreach ( $removable_query_vars as $removable_query_arg ) { unset( $args[ $removable_query_arg ] ); } $url = $url_parts[0]; if ( ! empty( $args ) ) { $url = $url_parts[0] . '?' . build_query( $args ); } } // Normalize the scheme as HTTPS. $url = set_url_scheme( $url, 'https' ); return $url; } /** * Stores the validation errors. * * If there are no validation errors provided, then any existing amp_validated_url post is deleted. * * @param array $validation_errors Validation errors. * @param string $url URL on which the validation errors occurred. Will be normalized to non-AMP version. * @param array $args { * Args. * * @type int|WP_Post $invalid_url_post Post to update. Optional. If empty, then post is looked up by URL. * @type array $queried_object Queried object, including keys for type and id. May be empty. * @type array $stylesheets Stylesheet data. May be empty. * @type array $php_fatal_error PHP Fatal Error. May be empty. * } * @return int|WP_Error $post_id The post ID of the custom post type used, or WP_Error on failure. * @global WP $wp */ public static function store_validation_errors( $validation_errors, $url, $args = [] ) { $url = self::normalize_url_for_storage( $url ); $slug = md5( $url ); $post = null; if ( ! empty( $args['invalid_url_post'] ) ) { $post = get_post( $args['invalid_url_post'] ); } if ( ! $post ) { $post = self::get_invalid_url_post( $url, [ 'include_trashed' => true, 'normalize' => false, // Since already normalized. ] ); } /* * The details for individual validation errors is stored in the amp_validation_error taxonomy terms. * The post content just contains the slugs for these terms and the sources for the given instance of * the validation error. */ $stored_validation_errors = []; // Prevent Kses from corrupting JSON in description. $pre_term_description_filters = [ 'wp_filter_kses' => has_filter( 'pre_term_description', 'wp_filter_kses' ), 'wp_targeted_link_rel' => has_filter( 'pre_term_description', 'wp_targeted_link_rel' ), ]; foreach ( $pre_term_description_filters as $callback => $priority ) { if ( false !== $priority ) { remove_filter( 'pre_term_description', $callback, $priority ); } } $terms = []; foreach ( $validation_errors as $data ) { $term_data = AMP_Validation_Error_Taxonomy::prepare_validation_error_taxonomy_term( $data ); $term_slug = $term_data['slug']; if ( ! isset( $terms[ $term_slug ] ) ) { // Not using WP_Term_Query since more likely individual terms are cached and wp_insert_term() will itself look at this cache anyway. $term = AMP_Validation_Error_Taxonomy::get_term( $term_slug ); if ( ! ( $term instanceof WP_Term ) ) { /* * The default term_group is 0 so that is AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_NEW_REJECTED_STATUS. * If sanitization auto-acceptance is enabled, then the term_group will be updated below. */ $r = wp_insert_term( $term_slug, AMP_Validation_Error_Taxonomy::TAXONOMY_SLUG, wp_slash( $term_data ) ); if ( is_wp_error( $r ) ) { continue; } $term_id = $r['term_id']; update_term_meta( $term_id, 'created_date_gmt', current_time( 'mysql', true ) ); /* * When sanitization is forced by filter, make sure the term is created with the filtered status. * For some reason, the wp_insert_term() function doesn't work with the term_group being passed in. */ $sanitization = AMP_Validation_Error_Taxonomy::get_validation_error_sanitization( $data ); if ( 'with_filter' === $sanitization['forced'] ) { $term_data['term_group'] = $sanitization['status']; wp_update_term( $term_id, AMP_Validation_Error_Taxonomy::TAXONOMY_SLUG, [ 'term_group' => $sanitization['status'], ] ); } elseif ( AMP_Validation_Manager::is_sanitization_auto_accepted( $data ) ) { $term_data['term_group'] = AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_NEW_ACCEPTED_STATUS; wp_update_term( $term_id, AMP_Validation_Error_Taxonomy::TAXONOMY_SLUG, [ 'term_group' => $term_data['term_group'], ] ); } $term = get_term( $term_id ); } $terms[ $term_slug ] = $term; } $stored_validation_errors[] = compact( 'term_slug', 'data' ); } // Finish preventing Kses from corrupting JSON in description. foreach ( $pre_term_description_filters as $callback => $priority ) { if ( false !== $priority ) { add_filter( 'pre_term_description', $callback, $priority ); } } $post_content = wp_json_encode( $stored_validation_errors ); $placeholder = 'amp_validated_url_content_placeholder' . wp_rand(); // Guard against Kses from corrupting content by adding post_content after content_save_pre filter applies. $insert_post_content = static function( $post_data ) use ( $placeholder, $post_content ) { $should_supply_post_content = ( isset( $post_data['post_content'], $post_data['post_type'] ) && $placeholder === $post_data['post_content'] && AMP_Validated_URL_Post_Type::POST_TYPE_SLUG === $post_data['post_type'] ); if ( $should_supply_post_content ) { $post_data['post_content'] = wp_slash( $post_content ); } return $post_data; }; add_filter( 'wp_insert_post_data', $insert_post_content ); // Create a new invalid AMP URL post, or update the existing one. $r = wp_insert_post( wp_slash( [ 'ID' => $post ? $post->ID : null, 'post_type' => self::POST_TYPE_SLUG, 'post_title' => $url, 'post_name' => $slug, 'post_content' => $placeholder, // Content is provided via wp_insert_post_data filter above to guard against Kses-corruption. 'post_status' => 'publish', ] ), true ); remove_filter( 'wp_insert_post_data', $insert_post_content ); if ( is_wp_error( $r ) ) { return $r; } $post_id = $r; wp_set_object_terms( $post_id, wp_list_pluck( $terms, 'term_id' ), AMP_Validation_Error_Taxonomy::TAXONOMY_SLUG ); update_post_meta( $post_id, self::VALIDATED_ENVIRONMENT_POST_META_KEY, self::get_validated_environment() ); if ( isset( $args['queried_object'] ) ) { update_post_meta( $post_id, self::QUERIED_OBJECT_POST_META_KEY, $args['queried_object'] ); } if ( isset( $args['stylesheets'] ) ) { // Note that json_encode() is being used here because wp_slash() will coerce scalar values to strings. update_post_meta( $post_id, self::STYLESHEETS_POST_META_KEY, wp_slash( wp_json_encode( $args['stylesheets'] ) ) ); } if ( isset( $args['php_fatal_error'] ) ) { if ( empty( $args['php_fatal_error'] ) ) { delete_post_meta( $post_id, self::PHP_FATAL_ERROR_POST_META_KEY ); } else { // Note that json_encode() is being used here because wp_slash() will coerce scalar values to strings. update_post_meta( $post_id, self::PHP_FATAL_ERROR_POST_META_KEY, wp_slash( wp_json_encode( $args['php_fatal_error'] ) ) ); } } delete_transient( static::NEW_VALIDATION_ERROR_URLS_COUNT_TRANSIENT ); return $post_id; } /** * Delete batch of stylesheets postmeta. * * Given that parsed CSS can be quite large (250KB+) and is not de-duplicated across each validated URL, it is important * to not store the stylesheet data indefinitely in order to not excessively bloat the database. The reality is that * keeping around the parsed stylesheet data is of little value given that it will quickly go stale as themes and * plugins are updated. * * @since 2.0 * * @param int $count Count of batch size to delete. * @param string|array $before Date before which to find amp_validated_url posts to delete. * Accepts strtotime()-compatible string, or array of 'year', 'month', 'day' values. * @return int Count of postmeta that were deleted. */ public static function delete_stylesheets_postmeta_batch( $count, $before ) { $deleted = 0; $query = new WP_Query( [ 'post_type' => self::POST_TYPE_SLUG, 'meta_key' => self::STYLESHEETS_POST_META_KEY, 'date_query' => [ [ 'before' => $before, ], ], 'fields' => 'ids', 'orderby' => 'date', 'order' => 'ASC', 'posts_per_page' => $count, ] ); foreach ( $query->get_posts() as $post_id ) { if ( delete_post_meta( $post_id, self::STYLESHEETS_POST_META_KEY ) ) { $deleted++; } } return $deleted; } /** * Garbage-collect validated URL posts. * * Now with Site Scanning in v2.2, the most recently published post will be validated on a weekly basis. If the user * never sees the list of Validated URLs--such as when the user doesn't have DevTools turned on--the end result is * a perpetual increase in the number of validated URLs. Over time this will result in validation data taking up * more and more of the database. When all of the validation errors associated with a validated URL are unreviewed, * or if all of the validation errors are related to other validated URLs as well, then there is no need to keep * the old validated URLs in perpetuity. They should be garbage-collected. * * @since 2.2 * * @param int $count Count of batch size to delete. Default is 100. * @param string|array $before Date before which to find amp_validated_url posts to delete. * Accepts strtotime()-compatible string, or array of 'year', 'month', 'day' values. * @return int Count of deleted posts. */ public static function garbage_collect_validated_urls( $count = 100, $before = '1 week ago' ) { $deleted = 0; // The random order in this query is needed in case the oldest 100 URLs end up not being eligible for garbage- // collection. In that case, garbage collection would get stuck. So by getting a random set of validated URLs // we can prevent the garbage collection from ceasing to function. $query = new WP_Query( [ 'post_type' => self::POST_TYPE_SLUG, 'orderby' => 'rand', // phpcs:ignore WordPressVIPMinimum.Performance.OrderByRand.orderby_orderby -- Due to garbage collection, there should not be more than a dozen posts. 'posts_per_page' => $count, 'date_query' => [ [ 'before' => $before, ], ], ] ); foreach ( $query->get_posts() as $post ) { if ( ! self::is_post_safe_to_garbage_collect( $post ) ) { continue; } if ( wp_delete_post( $post->ID ) ) { $deleted++; } } return $deleted; } /** * Check whether an amp_validated_url post is safe to garbage-collect. * * @since 2.2 * * @param WP_Post $validated_url_post Validated URL post. * @return bool Whether safe to garbage-collect. */ public static function is_post_safe_to_garbage_collect( WP_Post $validated_url_post ) { // Check sanity. if ( self::POST_TYPE_SLUG !== $validated_url_post->post_type ) { return false; } // Skip non-stale validated URLs. if ( count( self::get_post_staleness( $validated_url_post ) ) === 0 ) { return false; } $validation_error_terms = wp_get_post_terms( $validated_url_post->ID, AMP_Validation_Error_Taxonomy::TAXONOMY_SLUG ); if ( ! is_array( $validation_error_terms ) ) { return false; } /** @var WP_Term[] $validation_error_terms */ foreach ( $validation_error_terms as $validation_error_term ) { // If this error is associated with other URL(s), the reference count will remain non-zero if this validated // URL is garbage-collected, and thus the term will not be removed as part of the Clear Empty operation. if ( $validation_error_term->count > 1 ) { continue; } // If the validation error has been reviewed (aka acknowledged), then check to make sure that the // validation error is associated with at least one other URL. This is so that when a user clicks // Clear Empty they won't inadvertently clear out the reviewed validation error terms. This is only // relevant when the user has DevTools turned on, as this is the way that a term could have the // reviewed state in the first place. if ( in_array( $validation_error_term->term_group, [ AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_ACK_ACCEPTED_STATUS, AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_ACK_REJECTED_STATUS, ], true ) ) { // This URL is the only one that is associated with the term, so it's not safe to garbage collect. return false; } // If the term's removal status is not the same as the default removed status for the validation // error, and this is the only instance of that validation error for a URL, then skip removing the URL. $is_sanitized = in_array( $validation_error_term->term_group, [ AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_NEW_ACCEPTED_STATUS, AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_ACK_ACCEPTED_STATUS, ], true ); $error_data = json_decode( $validation_error_term->description, true ); if ( is_array( $error_data ) && AMP_Validation_Manager::is_sanitization_auto_accepted( $error_data ) !== $is_sanitized ) { return false; } } return true; } /** * Get recent validation errors by source. * * @since 2.0 * @todo This can be stored in object cache, invalidated whenever a validated URL post is inserted/updated/deleted. * * @param int $count Maximum count of validated URLs to gather validation errors from. * @return array Multidimensional array where root keys are source types, sub-keys are source names, and leaf arrays are the validation error terms, data, and the post IDs the error occurs on. */ public static function get_recent_validation_errors_by_source( $count = 100 ) { $posts = get_posts( [ 'post_type' => self::POST_TYPE_SLUG, 'posts_per_page' => $count, 'orderby' => 'date', 'order' => 'desc', ] ); $errors_by_source = []; foreach ( $posts as $post ) { // Skip validated URLs which are stale since results will be misleading. if ( self::get_post_staleness( $post ) ) { continue; } $validation_errors = self::get_invalid_url_validation_errors( $post ); if ( empty( $validation_errors ) ) { continue; } foreach ( $validation_errors as $validation_error ) { if ( empty( $validation_error['data']['sources'] ) || ! $validation_error['term'] instanceof WP_Term ) { continue; } foreach ( $validation_error['data']['sources'] as $source ) { if ( ! isset( $source['type'], $source['name'] ) ) { continue; } $data = json_decode( $validation_error['term']->description, true ); if ( ! is_array( $data ) ) { continue; } if ( ! isset( $errors_by_source[ $source['type'] ][ $source['name'] ][ $validation_error['term']->slug ] ) ) { $errors_by_source[ $source['type'] ][ $source['name'] ][ $validation_error['term']->slug ] = [ 'term' => $validation_error['term'], 'data' => $data, 'post_ids' => [], ]; } $errors_by_source[ $source['type'] ][ $source['name'] ][ $validation_error['term']->slug ]['post_ids'][] = $post->ID; } } } return $errors_by_source; } /** * Get the environment properties which will likely effect whether validation results are stale. * * @return array Environment. */ public static function get_validated_environment() { $plugin_registry = Services::get( 'plugin_registry' ); $theme = []; $theme_obj = null; if ( Services::get( 'reader_theme_loader' )->is_enabled() ) { $theme_obj = Services::get( 'reader_theme_loader' )->get_reader_theme(); } if ( ! $theme_obj instanceof WP_Theme ) { $theme_obj = wp_get_theme(); } if ( ! $theme_obj->errors() ) { $theme[ $theme_obj->get_stylesheet() ] = $theme_obj->get( 'Version' ); $parent_theme_obj = $theme_obj->parent(); if ( $parent_theme_obj ) { $theme[ $parent_theme_obj->get_stylesheet() ] = $parent_theme_obj->get( 'Version' ); } } return [ 'theme' => $theme, 'plugins' => wp_list_pluck( $plugin_registry->get_plugins( true, false ), 'Version' ), // @todo What about multiple plugins being in the same directory? 'options' => wp_array_slice_assoc( AMP_Options_Manager::get_options(), [ Option::ALL_TEMPLATES_SUPPORTED, Option::READER_THEME, Option::SUPPORTED_POST_TYPES, Option::SUPPORTED_TEMPLATES, Option::THEME_SUPPORT, ] ), ]; } /** * Get the differences between the current themes, plugins, and relevant options when amp_validated_url post was last updated and now. * * @param int|WP_Post $post Post of amp_validated_url type. * @return array { * Staleness of the validation results. An empty array if the results are fresh. * * @type string $theme The theme that was active but is no longer. Absent if theme is the same. * @type array $plugins Plugins that used to be active but are no longer, or which are active now but weren't. Also includes plugins that have version updates. Absent if the plugins were the same. * @type array $options Options that used to be set. Absent if the options were the same. * } */ public static function get_post_staleness( $post ) { $post = get_post( $post ); if ( empty( $post ) || self::POST_TYPE_SLUG !== $post->post_type ) { return []; } $old_validated_environment = get_post_meta( $post->ID, self::VALIDATED_ENVIRONMENT_POST_META_KEY, true ); $new_validated_environment = self::get_validated_environment(); $staleness = []; // Theme difference. if ( isset( $old_validated_environment['theme'] ) && $new_validated_environment['theme'] !== $old_validated_environment['theme'] ) { if ( is_string( $old_validated_environment['theme'] ) ) { $old_validated_environment['theme'] = [ $old_validated_environment['theme'] => null, ]; } $new_active_theme = array_diff_assoc( $new_validated_environment['theme'], $old_validated_environment['theme'] ); if ( ! empty( $new_active_theme ) ) { $staleness['theme']['new'] = array_keys( $new_active_theme ); } $old_active_theme = array_diff_assoc( $old_validated_environment['theme'], $new_validated_environment['theme'] ); if ( ! empty( $old_active_theme ) ) { $staleness['theme']['old'] = array_keys( $old_active_theme ); } } // Plugin difference. if ( isset( $old_validated_environment['plugins'] ) ) { $new_active_plugins = array_diff_assoc( $new_validated_environment['plugins'], $old_validated_environment['plugins'] ); if ( ! empty( $new_active_plugins ) ) { $staleness['plugins']['new'] = array_keys( $new_active_plugins ); } $old_active_plugins = array_diff_assoc( $old_validated_environment['plugins'], $new_validated_environment['plugins'] ); if ( ! empty( $old_active_plugins ) ) { $staleness['plugins']['old'] = array_keys( $old_active_plugins ); } } // Options difference. if ( isset( $old_validated_environment['options'] ) ) { $old_options = $old_validated_environment['options']; } else { $old_options = []; } $option_differences = []; foreach ( $new_validated_environment['options'] as $option => $value ) { if ( ! isset( $old_options[ $option ] ) ) { $option_differences[ $option ] = null; } elseif ( $old_options[ $option ] !== $value ) { $option_differences[ $option ] = $old_options[ $option ]; } } if ( ! empty( $option_differences ) ) { $staleness['options'] = $option_differences; } return $staleness; } /** * Adds post columns to the UI for the validation errors. * * @param array $columns The post columns. * @return array $columns The new post columns. */ public static function add_post_columns( $columns ) { $columns = array_merge( $columns, [ AMP_Validation_Error_Taxonomy::ERROR_STATUS => sprintf( '%s
', esc_html__( 'Markup Status', 'amp' ), esc_attr( sprintf( '%s
', __( 'Markup Status', 'amp' ), __( 'When invalid markup is removed it will not block a URL from being served as AMP; the validation error will be sanitized, where the offending markup is stripped from the response to ensure AMP validity. If invalid AMP markup is kept, then URLs is occurs on will not be served as AMP pages.', 'amp' ) ) ) ), AMP_Validation_Error_Taxonomy::FOUND_ELEMENTS_AND_ATTRIBUTES => esc_html__( 'Invalid Markup', 'amp' ), AMP_Validation_Error_Taxonomy::SOURCES_INVALID_OUTPUT => esc_html__( 'Sources', 'amp' ), 'css_usage' => esc_html__( 'CSS Usage', 'amp' ), ] ); if ( isset( $columns['title'] ) ) { $columns['title'] = esc_html__( 'URL', 'amp' ); } // Move date to end. if ( isset( $columns['date'] ) ) { unset( $columns['date'] ); $columns['date'] = esc_html__( 'Last Checked', 'amp' ); } if ( ! empty( $_GET[ AMP_Validation_Error_Taxonomy::TAXONOMY_SLUG ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended unset( $columns['error_status'], $columns[ AMP_Validation_Error_Taxonomy::REMOVED_ELEMENTS ], $columns[ AMP_Validation_Error_Taxonomy::REMOVED_ATTRIBUTES ] ); $columns[ AMP_Validation_Error_Taxonomy::SOURCES_INVALID_OUTPUT ] = esc_html__( 'Sources', 'amp' ); $columns['date'] = esc_html__( 'Last Checked', 'amp' ); $columns['title'] = esc_html__( 'URL', 'amp' ); } return $columns; } /** * Adds post columns to the /wp-admin/post.php page for amp_validated_url. * * @return array The filtered post columns. */ public static function add_single_post_columns() { return [ 'cb' => '', 'error_code' => __( 'Error', 'amp' ), 'error_type' => __( 'Type', 'amp' ), 'details' => sprintf( '%s', esc_html__( 'Context', 'amp' ), esc_attr( sprintf( '%s
', esc_html__( 'Context', 'amp' ), esc_html__( 'The parent element of where the error occurred.', 'amp' ) ) ) ), 'sources_with_invalid_output' => __( 'Sources', 'amp' ), 'status' => sprintf( '%s', esc_html__( 'Markup Status', 'amp' ), esc_attr( sprintf( '%s
', esc_html__( 'Markup Status', 'amp' ), esc_html__( 'When invalid markup is removed it will not block a URL from being served as AMP; the validation error will be sanitized, where the offending markup is stripped from the response to ensure AMP validity. If invalid AMP markup is kept, then URLs is occurs on will not be served as AMP pages.', 'amp' ) ) ) ), 'reviewed' => sprintf( '%s', esc_html__( 'Reviewed', 'amp' ), esc_attr( sprintf( '%s
', esc_html__( 'Reviewed', 'amp' ), esc_html__( 'Confirm that the action being taken on the invalid markup (causing a validation error) has been seen and approved.', 'amp' ) ) ) ), ]; } /** * Outputs custom columns in the /wp-admin UI for the AMP validation errors. * * @param string $column_name The name of the column. * @param int $post_id The ID of the post for the column. * @return void */ public static function output_custom_column( $column_name, $post_id ) { $post = get_post( $post_id ); if ( self::POST_TYPE_SLUG !== $post->post_type ) { return; } $validation_errors = self::get_invalid_url_validation_errors( $post_id ); $error_summary = AMP_Validation_Error_Taxonomy::summarize_validation_errors( wp_list_pluck( $validation_errors, 'data' ) ); switch ( $column_name ) { case 'error_status': $staleness = self::get_post_staleness( $post_id ); if ( ! empty( $staleness ) ) { echo '' . esc_html__( 'Stale results', 'amp' ) . '
'; } self::display_invalid_url_validation_error_counts_summary( $post_id ); break; case AMP_Validation_Error_Taxonomy::FOUND_ELEMENTS_AND_ATTRIBUTES: $items = []; if ( ! empty( $error_summary[ AMP_Validation_Error_Taxonomy::REMOVED_ELEMENTS ] ) ) { foreach ( $error_summary[ AMP_Validation_Error_Taxonomy::REMOVED_ELEMENTS ] as $name => $count ) { if ( 1 === (int) $count ) { $items[] = sprintf( '<%s>', esc_html( $name ) );
} else {
$items[] = sprintf( '<%s> (%d)', esc_html( $name ), $count );
}
}
}
if ( ! empty( $error_summary[ AMP_Validation_Error_Taxonomy::REMOVED_ATTRIBUTES ] ) ) {
foreach ( $error_summary[ AMP_Validation_Error_Taxonomy::REMOVED_ATTRIBUTES ] as $name => $count ) {
if ( 1 === (int) $count ) {
$items[] = sprintf( '%s', esc_html( $name ) );
} else {
$items[] = sprintf( '%s (%d)', esc_html( $name ), $count );
}
}
}
if ( ! empty( $error_summary['removed_pis'] ) ) {
foreach ( $error_summary['removed_pis'] as $name => $count ) {
if ( 1 === (int) $count ) {
$items[] = sprintf( '<?%s…?>', esc_html( $name ) );
} else {
$items[] = sprintf( '<?%s…?> (%d)', esc_html( $name ), $count );
}
}
}
if ( ! empty( $items ) ) {
$imploded_items = implode( ',%s',
esc_html( $block )
);
}
}
}
if ( empty( $output ) && ! empty( $sources['hook'] ) ) {
switch ( $sources['hook'] ) {
case 'the_content':
$dashicon = 'edit';
$source_name = __( 'Content', 'amp' );
break;
case 'the_excerpt':
$dashicon = 'edit';
$source_name = __( 'Excerpt', 'amp' );
break;
default:
$dashicon = 'wordpress-alt';
$source_name = sprintf(
/* translators: %s is the hook name */
__( 'Hook: %s', 'amp' ),
$sources['hook']
);
}
$output[] = sprintf( '%s', esc_attr( $dashicon ), esc_html( $source_name ) );
}
if ( empty( $sources ) && $active_theme ) {
$theme_obj = wp_get_theme( $active_theme );
if ( ! $theme_obj->errors() ) {
$theme_name = $theme_obj->get( 'Name' );
} else {
$theme_name = $active_theme;
}
$output[] = '%s
%s
%s
'; esc_html_e( 'There are no validated URLs with this validation error. Would you like to delete it?', 'amp' ); $delete_url = wp_nonce_url( add_query_arg( [ 'action' => 'delete', 'term_id' => $error->term_id, ] ), 'delete' ); printf( ' %s ', esc_url( $delete_url ), esc_html__( 'Delete', 'amp' ) ); echo '
';
echo '';
esc_html_e( 'Stale results', 'amp' );
echo '';
echo '
';
if ( ! empty( $staleness['theme'] ) && ! empty( $staleness['plugins'] ) ) {
esc_html_e( 'The theme and plugins have changed since these results were obtained.', 'amp' );
echo ' ';
} elseif ( ! empty( $staleness['theme'] ) ) {
esc_html_e( 'The theme has changed since these results were obtained.', 'amp' );
echo ' ';
} elseif ( ! empty( $staleness['plugins'] ) ) {
esc_html_e( 'Plugins have been updated since these results were obtained.', 'amp' );
echo ' ';
}
esc_html_e( 'Please recheck.', 'amp' );
echo '
%s
', wp_kses_post( sprintf( /* translators: placeholder is URL to recheck the post */ __( 'Stylesheet information for this URL is no longer available. Such data is automatically deleted after a week to reduce database storage. It is of little value to store long-term given that it becomes stale as themes and plugins are updated. To obtain the latest stylesheet information, recheck this URL.', 'amp' ), esc_url( self::get_recheck_url( $post ) . '#amp_stylesheets' ) ) ) ); return; } $stylesheets = json_decode( $stylesheets, true ); if ( ! is_array( $stylesheets ) ) { printf( '%s
', esc_html__( 'Unable to retrieve data for stylesheets.', 'amp' ) ); return; } $style_custom_cdata_spec = null; foreach ( AMP_Allowed_Tags_Generated::get_allowed_tag( 'style' ) as $spec_rule ) { if ( isset( $spec_rule[ AMP_Rule_Spec::TAG_SPEC ]['spec_name'] ) && AMP_Style_Sanitizer::STYLE_AMP_CUSTOM_SPEC_NAME === $spec_rule[ AMP_Rule_Spec::TAG_SPEC ]['spec_name'] ) { $style_custom_cdata_spec = $spec_rule[ AMP_Rule_Spec::CDATA ]; } } $included_final_size = 0; $included_original_size = 0; $excluded_final_size = 0; $excluded_original_size = 0; $excluded_stylesheets = 0; $max_final_size = 0; $included_status = 1; $excessive_status = 2; $excluded_status = 3; // Determine which stylesheets are included based on their priorities. $pending_stylesheet_indices = array_keys( $stylesheets ); usort( $pending_stylesheet_indices, static function ( $a, $b ) use ( $stylesheets ) { return $stylesheets[ $a ]['priority'] - $stylesheets[ $b ]['priority']; } ); foreach ( $pending_stylesheet_indices as $i ) { // @todo Add information about amp-keyframes as well. if ( ! isset( $stylesheets[ $i ]['group'] ) || 'amp-custom' !== $stylesheets[ $i ]['group'] || ! empty( $stylesheets[ $i ]['duplicate'] ) ) { continue; } $max_final_size = max( $max_final_size, $stylesheets[ $i ]['final_size'] ); if ( $stylesheets[ $i ]['included'] ) { $included_final_size += $stylesheets[ $i ]['final_size']; $included_original_size += $stylesheets[ $i ]['original_size']; if ( $included_final_size >= $style_custom_cdata_spec['max_bytes'] && $stylesheets[ $i ]['final_size'] > 0 ) { $stylesheets[ $i ]['status'] = $excessive_status; } else { $stylesheets[ $i ]['status'] = $included_status; } } else { $excluded_final_size += $stylesheets[ $i ]['final_size']; $excluded_original_size += $stylesheets[ $i ]['original_size']; $excluded_stylesheets++; $stylesheets[ $i ]['status'] = $excluded_status; } } ?>
| 100 ) { $icon = Icon::invalid(); } elseif ( $percentage_budget_used >= AMP_Style_Sanitizer::CSS_BUDGET_WARNING_PERCENTAGE ) { $icon = Icon::warning(); } else { $icon = Icon::valid(); } echo $icon->to_html(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> | |
| () | () | ||||||||
|---|---|---|---|---|---|---|---|---|---|
|
|
', esc_attr__( 'Stylesheet included', 'amp' ) ); break; case $excessive_status: printf( '', esc_attr__( 'Stylesheet overruns CSS budget yet it is still included on page', 'amp' ) ); break; case $excluded_status: printf( '', esc_attr__( 'Stylesheet excluded due to exceeding CSS budget', 'amp' ) ); break; } ?> | '; // @todo Consider adding the basename of the CSS file. } elseif ( 'style_element' === $stylesheet['origin'] ) { $origin_abbr_text = ' |