redirects = $redirects; } /** * Machine id (matches Guardian::LOG_ID). * * @return string */ public function id() { return Guardian::LOG_ID; } /** * Label. * * @return string */ public function label() { return __( 'Guardian', 'orbitstem-seo' ); } /** * Description. * * @return string */ public function description() { return __( 'Address changes, trashed pages and search-visibility changes recorded by the Guardian.', 'orbitstem-seo' ); } /** * No issues. * * @return string[] */ public function issue_ids() { return array(); } /** * Not used. * * @return bool */ public function is_destructive() { return false; } /** * No candidates. * * @param int $limit Unused. * @return array */ public function candidates( $limit ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- interface signature. return array(); } /** * No candidates. * * @param string $key Unused. * @return null */ public function find( $key ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- interface signature. return null; } /** * Not used. * * @param array $item Unused. * @return bool */ public function can_apply( array $item ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- interface signature. return false; } /** * Not used. * * @param array $item Unused. * @return WP_Error */ public function apply( array $item ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- interface signature. return new WP_Error( 'unsupported', __( 'Guardian changes are recorded automatically.', 'orbitstem-seo' ) ); } /** * Not verified. * * @param array $change Unused. * @return array{ok:bool,note:string} */ public function verify( array $change ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- interface signature. return array( 'ok' => true, 'note' => '', ); } /** * Undoes a recorded change. * * @param array $change Change-log row. * @return true|WP_Error */ public function rollback( array $change ) { $kind = isset( $change['state_after']['kind'] ) ? $change['state_after']['kind'] : ''; Guardian::suspend(); try { switch ( $kind ) { case 'slug': return $this->undo_slug( $change ); case 'trash': return $this->undo_trash( $change ); case 'visibility': return $this->undo_visibility(); case 'settings': return $this->undo_settings( $change ); case 'robots': return $this->undo_robots( $change ); case 'post_noindex': case 'post_canonical': return $this->undo_post_meta( $change, $kind ); case 'freed': return $this->undo_freed( $change ); } return new WP_Error( 'unknown', __( 'This change cannot be undone here.', 'orbitstem-seo' ) ); } finally { Guardian::resume(); } } /** * Restores the old slug and parent, and removes the redirect the change added. * * @param array $change Change. * @return true|WP_Error */ private function undo_slug( array $change ) { $post = get_post( $change['object_id'] ); if ( ! $post || ! current_user_can( 'edit_post', $post->ID ) ) { return new WP_Error( 'permission', __( 'The post no longer exists or you cannot edit it.', 'orbitstem-seo' ) ); } if ( Url::normalize( (string) get_permalink( $post ), home_url( '/' ) ) !== $change['state_after']['url'] ) { return new WP_Error( 'conflict', __( 'The address changed again after this change, so it was not undone.', 'orbitstem-seo' ) ); } $redirect_id = (int) $change['state_after']['redirect_id']; $rule = $redirect_id ? $this->redirects->get( $redirect_id ) : null; if ( $rule && Url::normalize( $rule['target'], home_url( '/' ) ) !== $change['state_after']['url'] ) { return new WP_Error( 'conflict', __( 'The redirect added with this change was edited later, so it was not undone.', 'orbitstem-seo' ) ); } $old_name = (string) $change['state_before']['post_name']; $same = get_posts( array( 'name' => $old_name, 'post_type' => $post->post_type, 'post_parent' => (int) $change['state_before']['post_parent'], 'post_status' => array( 'publish', 'future', 'draft', 'pending', 'private' ), 'posts_per_page' => 2, 'fields' => 'ids', ) ); if ( array_diff( array_map( 'intval', $same ), array( (int) $post->ID ) ) ) { /* translators: %s: slug */ return new WP_Error( 'conflict', sprintf( __( 'Another item now uses the old address "%s", so the change was not undone.', 'orbitstem-seo' ), $old_name ) ); } $updated = wp_update_post( array( 'ID' => $post->ID, 'post_name' => $change['state_before']['post_name'], 'post_parent' => (int) $change['state_before']['post_parent'], ), true ); if ( is_wp_error( $updated ) ) { return $updated; } if ( $rule ) { $this->redirects->delete( $rule['id'], false ); if ( ! empty( $change['state_before']['replaced'] ) ) { $this->redirects->restore( $change['state_before']['replaced'] ); } } if ( ! empty( $change['state_before']['removed'] ) ) { $this->redirects->restore( $change['state_before']['removed'] ); } // Redirects added for child pages, and older rules pointed at the new address. foreach ( isset( $change['state_after']['children'] ) ? (array) $change['state_after']['children'] : array() as $child_rule ) { $rule = $this->redirects->get( (int) $child_rule ); // Only rules that still point below the renamed page. A child moved since then // has its rule re-pointed to its new address; keep that one, or its old address 404s. if ( $rule && 0 === strpos( Url::normalize( $rule['target'], home_url( '/' ) ), $change['state_after']['url'] ) ) { $this->redirects->delete( (int) $child_rule, false ); } } foreach ( isset( $change['state_before']['repointed'] ) ? (array) $change['state_before']['repointed'] : array() as $old ) { $current = $this->redirects->get( (int) $old['id'] ); // "to": the address it was pointed at (a child page's for rules below it). Older entries lack it. $pointed = isset( $old['to'] ) ? Url::normalize( $old['to'], home_url( '/' ) ) : $change['state_after']['url']; if ( $current && Url::normalize( $current['target'], home_url( '/' ) ) === $pointed ) { $this->redirects->update( $current['id'], array( 'source' => $current['source'], 'target' => $old['target'], 'code' => $current['code'], 'note' => $current['note'], ), false ); } } return true; } /** * Puts back a redirect removed when a page was published at its address. * * @param array $change Change. * @return true|WP_Error */ private function undo_freed( array $change ) { if ( ! current_user_can( Admin::capability() ) ) { return new WP_Error( 'permission', __( 'You cannot manage redirects.', 'orbitstem-seo' ) ); } $rule = (array) $change['state_before']['rule']; return true === $this->redirects->restore( $rule ) ? true : new WP_Error( 'conflict', __( 'A redirect for that address exists again, so nothing was restored.', 'orbitstem-seo' ) ); } /** * Restores a trashed post to its previous status and removes the redirect added for it. * * @param array $change Change. * @return true|WP_Error */ private function undo_trash( array $change ) { $post = get_post( $change['object_id'] ); if ( ! $post || 'trash' !== $post->post_status ) { return new WP_Error( 'conflict', __( 'The post is no longer in the trash (restored or deleted permanently), so nothing was undone.', 'orbitstem-seo' ) ); } if ( ! current_user_can( 'delete_post', $post->ID ) ) { return new WP_Error( 'permission', __( 'You cannot restore this post.', 'orbitstem-seo' ) ); } $redirect_id = (int) $change['state_after']['redirect_id']; add_filter( 'wp_untrash_post_status', 'wp_untrash_post_set_previous_status', 10, 3 ); $restored = wp_untrash_post( $post->ID ); remove_filter( 'wp_untrash_post_status', 'wp_untrash_post_set_previous_status', 10 ); if ( ! $restored ) { return new WP_Error( 'failed', __( 'WordPress could not restore the post.', 'orbitstem-seo' ) ); } if ( $redirect_id && $this->redirects->get( $redirect_id ) ) { $this->redirects->delete( $redirect_id, false ); } return true; } /** * Allows search engines again. * * @return true|WP_Error */ private function undo_visibility() { if ( ! current_user_can( 'manage_options' ) ) { return new WP_Error( 'permission', __( 'You cannot change reading settings.', 'orbitstem-seo' ) ); } if ( '0' !== (string) get_option( 'blog_public' ) ) { return new WP_Error( 'conflict', __( 'Search engines are already allowed.', 'orbitstem-seo' ) ); } update_option( 'blog_public', '1' ); return true; } /** * Restores indexability settings that are still as the change left them. * * @param array $change Change. * @return true|WP_Error */ private function undo_settings( array $change ) { if ( ! current_user_can( Admin::capability() ) ) { return new WP_Error( 'permission', __( 'You cannot change Orbitstem SEO settings.', 'orbitstem-seo' ) ); } $stored = (array) get_option( Settings::OPTION, array() ); $current = Guardian::indexability( $stored ); foreach ( $change['state_after']['values'] as $key => $value ) { if ( ! isset( $current[ $key ] ) || $current[ $key ] !== $value ) { return new WP_Error( 'conflict', __( 'Those settings were changed again later, so this change was not undone.', 'orbitstem-seo' ) ); } } foreach ( $change['state_before']['values'] as $key => $value ) { $path = explode( '.', $key ); if ( 1 === count( $path ) ) { $stored[ $path[0] ] = $value; } elseif ( 2 === count( $path ) ) { $stored[ $path[0] ][ $path[1] ] = $value; } else { $stored[ $path[0] ][ $path[1] ][ $path[2] ] = $value; } } update_option( Settings::OPTION, $stored ); return true; } /** * Restores the robots.txt rules and AI crawler choices, if unchanged since. * * @param array $change Change. * @return true|WP_Error */ private function undo_robots( array $change ) { if ( ! current_user_can( Admin::capability() ) ) { return new WP_Error( 'permission', __( 'You cannot change Orbitstem SEO settings.', 'orbitstem-seo' ) ); } $stored = (array) get_option( Settings::OPTION, array() ); $merged = array_merge( Settings::defaults(), $stored ); foreach ( $change['state_after']['values'] as $key => $value ) { if ( $merged[ $key ] !== $value ) { return new WP_Error( 'conflict', __( 'The robots.txt settings were changed again later, so this change was not undone.', 'orbitstem-seo' ) ); } } foreach ( $change['state_before']['values'] as $key => $value ) { $stored[ $key ] = $value; } update_option( Settings::OPTION, $stored ); return true; } /** * Restores a post's noindex or canonical. * * @param array $change Change. * @param string $kind post_noindex|post_canonical. * @return true|WP_Error */ private function undo_post_meta( array $change, $kind ) { $post = get_post( $change['object_id'] ); if ( ! $post || ! current_user_can( 'edit_post', $post->ID ) ) { return new WP_Error( 'permission', __( 'The post no longer exists or you cannot edit it.', 'orbitstem-seo' ) ); } $field = 'post_noindex' === $kind ? 'noindex' : 'canonical'; $current = get_post_meta( $post->ID, MetaRepository::PREFIX . $field, true ); $same = 'noindex' === $field ? (bool) $current === (bool) $change['state_after']['value'] : (string) $current === (string) $change['state_after']['value']; if ( ! $same ) { return new WP_Error( 'conflict', __( 'This setting was changed again later, so it was not undone.', 'orbitstem-seo' ) ); } $before = $change['state_before']['value']; if ( '' === $before || false === $before ) { delete_post_meta( $post->ID, MetaRepository::PREFIX . $field ); } else { update_post_meta( $post->ID, MetaRepository::PREFIX . $field, $before ); } return true; } }