*/ private $pre_trash = array(); /** * Posts published or updated in this request, decided at the end of the * request once their SEO fields are saved too (a new post marked noindex * must not be sent). * * @var array */ private $pending = array(); /** * Whether each post being updated in this request was live and indexable * just before the update (read before its fields change). * * @var array */ private $before = array(); /** * Depth of brand-new posts being inserted right now. WordPress saves * meta_input (including noindex) before the post goes live, so such meta * belongs to a new post, never to a live one. * * @var int */ private $inserting = 0; /** * Posts created in this request. They were never live before it, so they * are only sent when their final saved fields allow indexing, whatever * order code, WP-CLI or an importer saves the post and its meta in (meta_input * before the post goes live, or add_post_meta() after it is published). * * @var array */ private $created = array(); /** * Whether another SEO plugin is active and Orbitstem's output is off. * * @var bool */ private $safe_mode = false; /** * Constructor. * * @param Settings $settings Settings. * @param TypeRegistry $types Types. * @param MetaRepository $meta Meta. */ public function __construct( Settings $settings, TypeRegistry $types, MetaRepository $meta ) { $this->settings = $settings; $this->types = $types; $this->meta = $meta; } /** * Registers hooks. */ public function register() { add_action( 'init', array( $this, 'serve_key' ), 1 ); add_filter( 'wp_insert_post_data', array( $this, 'before_insert' ), 99, 2 ); add_action( 'wp_insert_post', array( $this, 'after_insert' ), 1, 3 ); add_action( 'pre_post_update', array( $this, 'before_update' ), 1 ); add_action( 'post_updated', array( $this, 'on_post_updated' ), 20, 3 ); add_action( 'transition_post_status', array( $this, 'on_transition' ), 20, 3 ); add_action( 'wp_trash_post', array( $this, 'before_trash' ) ); add_action( 'trashed_post', array( $this, 'on_trashed' ) ); foreach ( array( 'added_post_meta', 'updated_post_meta', 'deleted_post_meta' ) as $hook ) { add_action( $hook, array( $this, 'on_meta' ), 20, 3 ); } add_action( 'shutdown', array( $this, 'flush' ), 5 ); } /** * Registers the scheduled sending (also in safe mode, so a queued send * finds the status and stops without losing the queue). */ public function register_cron() { add_action( self::HOOK, array( $this, 'send' ) ); } /** * Pauses submissions while another SEO plugin is in charge. * * @param bool $on Whether safe mode is on. */ public function set_safe_mode( $on ) { $this->safe_mode = (bool) $on; } /** * SEO title, description, canonical or robots changed outside the editor * (Bulk Editor, CSV import): the page tells search engines something new. * * @param int|int[] $meta_id Meta ID(s). * @param int $post_id Post ID. * @param string $meta_key Meta key. */ public function on_meta( $meta_id, $post_id, $meta_key ) { if ( 0 !== strpos( (string) $meta_key, MetaRepository::PREFIX ) || MetaRepository::PREFIX . 'focus_keyword' === $meta_key ) { return; } $id = (int) $post_id; if ( isset( $this->pending[ $id ] ) ) { return; // Decided by flush() once all fields are saved. } $post = get_post( $id ); if ( $post && 'publish' === $post->post_status && $this->watches( $post ) ) { // Decided at the end of the request too. A live page that just became noindex is // sent, so engines drop it sooner; a post created by code with noindex never is. if ( isset( $this->created[ $id ] ) ) { $was_visible = false; // Meta added after the post was created in this request (importers). } else { // No "before" record while a new post is inserted: its meta_input, saved before it goes live. $was_visible = isset( $this->before[ $id ] ) ? $this->before[ $id ] : ! $this->inserting; } $this->defer( $post, $was_visible ); } } /** * A post is about to be inserted: notes whether it is brand new. * * @param array $data Post data. * @param array $postarr Raw post array. * @return array */ public function before_insert( $data, $postarr ) { if ( empty( $postarr['ID'] ) ) { ++$this->inserting; } return $data; } /** * A post insert or update finished. A brand-new post is remembered as * created in this request, so it is never treated as a page that was live. * * @param int $post_id Post ID. * @param WP_Post|null $post Post. * @param bool|null $update Whether an existing post was updated (null: unknown, older callers). */ public function after_insert( $post_id, $post = null, $update = null ) { $id = (int) $post_id; $is_new = null === $update ? ! isset( $this->before[ $id ] ) : ! $update; if ( ! $is_new ) { return; } $this->created[ $id ] = true; if ( $this->inserting > 0 ) { --$this->inserting; } } /** * Remembers whether a post was live and indexable before an update changes it. * * @param int $post_id Post ID. */ public function before_update( $post_id ) { $post = get_post( (int) $post_id ); if ( $post instanceof WP_Post && ! isset( $this->before[ $post->ID ] ) ) { $this->before[ $post->ID ] = 'publish' === $post->post_status && ! $this->hidden( $post ); } } /** * Queues the posts published or updated in this request that may be * indexed, now that their SEO fields are saved. */ public function flush() { $pending = $this->pending; $this->pending = array(); foreach ( $pending as $id => $was_visible ) { // A post created in this request was never live: only its final, saved noindex counts. $was_visible = $was_visible && ! isset( $this->created[ $id ] ); $post = get_post( $id ); // A live page that just became noindex is sent too, so engines drop it sooner; a new noindex page never is. if ( $post && 'publish' === $post->post_status && $this->watches( $post ) && ( $was_visible || ! $this->hidden( $post ) ) ) { $this->queue( (string) get_permalink( $post ) ); unset( $this->created[ $id ] ); // Announced now, so later changes in this request count as changes to a live page. } } } /** * Marks a post to be decided at the end of the request. * * @param WP_Post $post Post. * @param bool $was_visible Whether it was already live and indexable before this request. */ private function defer( WP_Post $post, $was_visible ) { $id = (int) $post->ID; $this->pending[ $id ] = ! empty( $this->pending[ $id ] ) || $was_visible; } /** * Whether submissions are on and allowed here, and why not. * * @return array{active:bool,reason:string} */ public function status() { if ( ! $this->settings->get( 'indexnow_enabled' ) ) { return array( 'active' => false, 'reason' => __( 'Off.', 'orbitstem-seo' ), ); } if ( ! get_option( 'blog_public' ) ) { return array( 'active' => false, 'reason' => __( 'Paused: Settings > Reading asks search engines not to index this site.', 'orbitstem-seo' ), ); } if ( $this->safe_mode ) { return array( 'active' => false, 'reason' => __( 'Paused: another SEO plugin is active (safe mode).', 'orbitstem-seo' ), ); } if ( \Orbitstem\SEO\Frontend\Robots::guard_applies( $this->settings ) ) { return array( 'active' => false, 'reason' => __( 'Paused: staging protection is on, so this copy does not announce its addresses.', 'orbitstem-seo' ), ); } $env = wp_get_environment_type(); /** * Whether to send IndexNow submissions from this site. By default only * production sites do, so staging and local copies stay quiet. * * @param bool $send Whether to send. * @param string $env Environment type. */ if ( ! apply_filters( 'orbitstem_seo_indexnow_send', 'production' === $env, $env ) ) { // Not self::HOOK: that is the cron event. return array( 'active' => false, /* translators: %s: environment type such as "staging" or "local" */ 'reason' => sprintf( __( 'Paused: this is a "%s" site, not production, so search engines are not notified.', 'orbitstem-seo' ), $env ), ); } return array( 'active' => true, 'reason' => '', ); } /** * The verification key, created the first time it is needed. * * @return string 32 hexadecimal characters. */ public function key() { $key = (string) get_option( self::KEY_OPTION, '' ); if ( ! preg_match( '/^[a-f0-9]{32}$/', $key ) ) { $key = md5( wp_generate_password( 64, true, true ) ); update_option( self::KEY_OPTION, $key, false ); } return $key; } /** * Address of the key file search engines fetch to verify the site. * * @return string */ public function key_url() { return home_url( '/' . $this->key() . '.txt' ); } /** * Answers requests for the key file while IndexNow is on. */ public function serve_key() { if ( ! $this->settings->get( 'indexnow_enabled' ) || empty( $_SERVER['REQUEST_URI'] ) ) { return; } $path = (string) wp_parse_url( esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ), PHP_URL_PATH ); $want = (string) wp_parse_url( $this->key_url(), PHP_URL_PATH ); if ( $want !== $path ) { return; } status_header( 200 ); header( 'Content-Type: text/plain; charset=utf-8' ); header( 'X-Robots-Tag: noindex' ); echo esc_html( $this->key() ); exit; } /** * Published item updated, unpublished, or given a new address. * * @param int $post_id Post ID. * @param WP_Post $after After. * @param WP_Post $before Before. */ public function on_post_updated( $post_id, $after, $before ) { if ( ! $this->watches( $after ) ) { return; } $was = 'publish' === $before->post_status; $is = 'publish' === $after->post_status; if ( $was && ! isset( $this->created[ (int) $post_id ] ) ) { // A post created in this request and not announced yet has no old address engines know. $old = (string) get_permalink( $before ); $now = (string) get_permalink( $after ); // Unpublished, or moved: engines should recrawl the old address and find it gone or redirected. if ( ! $is || $now !== $old ) { $this->queue( $old ); } } if ( $is ) { // SEO fields are saved after this hook, so the post's noindex is checked at the end of the request. $was_visible = isset( $this->before[ (int) $post_id ] ) ? $this->before[ (int) $post_id ] : $was && ! $this->hidden( $before ); $this->defer( $after, $was_visible ); } } /** * Newly published, including scheduled posts going live (no post_updated then). * * @param string $new_status New status. * @param string $old_status Old status. * @param WP_Post $post Post. */ public function on_transition( $new_status, $old_status, $post ) { if ( 'publish' === $new_status && 'publish' !== $old_status && $this->watches( $post ) ) { $this->defer( $post, false ); } } /** * Remembers the address before the trash renames the post. * * @param int $post_id Post ID. */ public function before_trash( $post_id ) { $post = get_post( $post_id ); if ( $post && 'publish' === $post->post_status && $this->watches( $post ) ) { $this->pre_trash[ (int) $post_id ] = (string) get_permalink( $post ); } } /** * A trashed published item: its address now answers 404. * * @param int $post_id Post ID. */ public function on_trashed( $post_id ) { if ( isset( $this->pre_trash[ $post_id ] ) ) { $this->queue( $this->pre_trash[ $post_id ] ); unset( $this->pre_trash[ $post_id ] ); } } /** * Whether changes to this post are reported. * * @param WP_Post $post Post. * @return bool */ private function watches( $post ) { return $post instanceof WP_Post && ! wp_is_post_revision( $post ) && '' === $post->post_password && $this->types->supports_post_type( $post->post_type ) && $this->settings->get( 'indexnow_enabled' ); } /** * Whether the post asks not to be indexed (no point asking engines to crawl it). * * @param WP_Post $post Post. * @return bool */ private function hidden( WP_Post $post ) { return (bool) $this->meta->get_post( $post->ID, 'noindex' ) || (bool) $this->settings->post_type( $post->post_type, 'noindex' ); } /** * Adds an address to the queue and schedules sending. * * @param string $url Address. */ public function queue( $url ) { $url = Url::normalize( $url, home_url( '/' ) ); if ( '' === $url || ! Url::is_internal( $url ) || ! $this->status()['active'] ) { return; } $queue = (array) get_option( self::QUEUE_OPTION, array() ); $urls = isset( $queue['urls'] ) ? (array) $queue['urls'] : array(); if ( in_array( $url, $urls, true ) ) { return; } $urls[] = $url; update_option( self::QUEUE_OPTION, array( 'urls' => array_slice( $urls, -self::MAX_URLS ), 'attempts' => isset( $queue['attempts'] ) ? (int) $queue['attempts'] : 0, ), false ); if ( ! wp_next_scheduled( self::HOOK ) ) { wp_schedule_single_event( time() + self::DELAY, self::HOOK ); } } /** * Addresses waiting to be sent. * * @return string[] */ public function queued() { $queue = (array) get_option( self::QUEUE_OPTION, array() ); return isset( $queue['urls'] ) ? array_values( (array) $queue['urls'] ) : array(); } /** * Sends the queue in one request (cron). Addresses sent in the last ten * minutes are skipped; a busy or unreachable endpoint is retried later. * * @return array|null Log entry, or null when nothing was sent. */ public function send() { if ( ! $this->status()['active'] ) { // Paused or off: keep the queue for when sending is allowed again (it is capped in size). return null; } $queue = (array) get_option( self::QUEUE_OPTION, array() ); $urls = isset( $queue['urls'] ) ? (array) $queue['urls'] : array(); delete_option( self::QUEUE_OPTION ); if ( ! $urls ) { return null; } $now = time(); $recent = array_filter( (array) get_option( self::RECENT_OPTION, array() ), function ( $time ) use ( $now ) { return (int) $time > $now - self::RESEND_AFTER; } ); $urls = array_values( array_filter( $urls, function ( $url ) use ( $recent ) { return ! isset( $recent[ md5( $url ) ] ); } ) ); if ( ! $urls ) { update_option( self::RECENT_OPTION, $recent, false ); return null; } $response = wp_remote_post( self::ENDPOINT, array( 'timeout' => 15, 'headers' => array( 'Content-Type' => 'application/json; charset=utf-8' ), 'body' => wp_json_encode( array( 'host' => (string) wp_parse_url( home_url(), PHP_URL_HOST ), 'key' => $this->key(), 'keyLocation' => $this->key_url(), 'urlList' => $urls, ) ), ) ); $code = is_wp_error( $response ) ? 0 : (int) wp_remote_retrieve_response_code( $response ); $entry = array( 'time' => $now, 'urls' => array_slice( $urls, 0, 20 ), 'count' => count( $urls ), 'code' => $code, 'ok' => in_array( $code, array( 200, 202 ), true ), 'message' => self::explain( $code, is_wp_error( $response ) ? $response->get_error_message() : '' ), ); $attempts = isset( $queue['attempts'] ) ? (int) $queue['attempts'] + 1 : 1; if ( ( 0 === $code || 429 === $code || $code >= 500 ) && $attempts < self::MAX_ATTEMPTS ) { // Temporary problem: keep the addresses (plus any queued meanwhile) and try again later. $entry['message'] .= ' ' . __( 'Will try again in 15 minutes.', 'orbitstem-seo' ); $pending = array_values( array_unique( array_merge( $urls, $this->queued() ) ) ); update_option( self::QUEUE_OPTION, array( 'urls' => array_slice( $pending, -self::MAX_URLS ), 'attempts' => $attempts, ), false ); wp_clear_scheduled_hook( self::HOOK ); wp_schedule_single_event( $now + 15 * MINUTE_IN_SECONDS, self::HOOK ); } elseif ( $entry['ok'] ) { foreach ( $urls as $url ) { $recent[ md5( $url ) ] = $now; } } update_option( self::RECENT_OPTION, $recent, false ); $log = (array) get_option( self::LOG_OPTION, array() ); array_unshift( $log, $entry ); update_option( self::LOG_OPTION, array_slice( $log, 0, self::LOG_SIZE ), false ); return $entry; } /** * Recent submissions, newest first. * * @return array */ public function log() { return array_values( (array) get_option( self::LOG_OPTION, array() ) ); } /** * Plain-language meaning of an IndexNow response. * * @param int $code HTTP status (0 when the request failed). * @param string $error Transport error. * @return string */ public static function explain( $code, $error = '' ) { switch ( $code ) { case 200: case 202: return __( 'Accepted: search engines were notified.', 'orbitstem-seo' ); case 400: return __( 'Rejected: the request was not valid.', 'orbitstem-seo' ); case 403: return __( 'Rejected: the key file could not be verified. Check that the key file address below opens and shows the key.', 'orbitstem-seo' ); case 422: return __( 'Rejected: the addresses do not belong to this site\'s host name, or the key does not match.', 'orbitstem-seo' ); case 429: return __( 'Too many requests: IndexNow asked to slow down.', 'orbitstem-seo' ); case 0: /* translators: %s: error message */ return sprintf( __( 'Could not reach IndexNow: %s', 'orbitstem-seo' ), $error ); default: /* translators: %d: HTTP status */ return sprintf( __( 'IndexNow answered with HTTP %d.', 'orbitstem-seo' ), $code ); } } }