History. */ class RedirectsPage { const SLUG = 'orbitstem-seo-redirects'; const ACTION = 'orbitstem_seo_redirects'; const PER_PAGE = 50; const LOCK = 'orbitstem_seo_redirect_import_lock'; /** * Seconds after which the import lock of a crashed request is taken over * (a part runs for about fifteen seconds). */ const LOCK_TTL = 120; /** * Plugin services. * * @var Plugin */ private $plugin; /** * Constructor. * * @param Plugin $plugin Plugin services. */ public function __construct( Plugin $plugin ) { $this->plugin = $plugin; } /** * Registers hooks. */ public function register() { add_action( 'admin_menu', array( $this, 'menu' ), 16 ); add_action( 'admin_post_' . self::ACTION, array( $this, 'handle' ) ); add_action( 'admin_post_' . self::EXPORT, array( $this, 'export' ) ); } const EXPORT = 'orbitstem_seo_redirects_export'; /** * Redirect import and export. * * @return Transfer */ private function transfer() { return new Transfer( $this->plugin->redirects ); } /** * Downloads the rules as CSV, Apache or nginx rules. */ public function export() { if ( ! current_user_can( Admin::capability() ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'orbitstem-seo' ), 403 ); } check_admin_referer( self::EXPORT ); $format = isset( $_GET['format'] ) ? sanitize_key( wp_unslash( $_GET['format'] ) ) : 'csv'; $transfer = $this->transfer(); $date = gmdate( 'Y-m-d' ); nocache_headers(); if ( 'apache' === $format || 'nginx' === $format ) { header( 'Content-Type: text/plain; charset=utf-8' ); header( 'Content-Disposition: attachment; filename="orbitstem-redirects-' . $format . '-' . $date . '.txt"' ); echo $transfer->server_rules( $format ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- plain-text download of the site's own rules. exit; } header( 'Content-Type: text/csv; charset=utf-8' ); header( 'Content-Disposition: attachment; filename="orbitstem-redirects-' . $date . '.csv"' ); $out = fopen( 'php://output', 'w' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- streams the download. $transfer->write_csv( $out ); fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- streams the download. exit; } /** * Transient keys for the current user's pending, running and last import, * and a running undo. * * @param string $what plan|run|last|undo. * @return string */ private static function import_key( $what ) { return 'orbitstem_seo_redirect_' . $what . '_' . get_current_user_id(); } /** * Adds the submenu page. */ public function menu() { add_submenu_page( Admin::SLUG, __( 'Redirects', 'orbitstem-seo' ), __( 'Redirects', 'orbitstem-seo' ), Admin::capability(), self::SLUG, array( $this, 'render' ) ); } /** * Stores a one-time notice for the current user. * * @param string $type success|error. * @param string $message Message. */ public static function notice( $type, $message ) { set_transient( 'orbitstem_seo_notice_' . get_current_user_id(), array( $type, $message ), MINUTE_IN_SECONDS ); } /** * Prints and clears the current user's notice. */ public static function print_notice() { $key = 'orbitstem_seo_notice_' . get_current_user_id(); $notice = get_transient( $key ); if ( is_array( $notice ) ) { delete_transient( $key ); printf( '

%2$s

', esc_attr( 'error' === $notice[0] ? 'error' : 'success' ), esc_html( $notice[1] ) ); } } /** * Handles save, delete and clear-log requests. */ public function handle() { if ( ! current_user_can( Admin::capability() ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'orbitstem-seo' ), 403 ); } check_admin_referer( self::ACTION ); $repo = $this->plugin->redirects; $task = isset( $_POST['task'] ) ? sanitize_key( wp_unslash( $_POST['task'] ) ) : ''; $tab = 'rules'; if ( 'save' === $task ) { $id = isset( $_POST['id'] ) ? absint( $_POST['id'] ) : 0; $data = array( // Sources, targets and patterns are validated and sanitized by Repository::validate(). 'source' => isset( $_POST['source'] ) ? wp_unslash( $_POST['source'] ) : '', // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized 'target' => isset( $_POST['target'] ) ? wp_unslash( $_POST['target'] ) : '', // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized 'code' => isset( $_POST['code'] ) ? absint( $_POST['code'] ) : 301, 'is_regex' => ! empty( $_POST['is_regex'] ), 'note' => isset( $_POST['note'] ) ? sanitize_text_field( wp_unslash( $_POST['note'] ) ) : '', ); $result = $id ? $repo->update( $id, $data ) : $repo->create( $data ); if ( is_wp_error( $result ) ) { self::notice( 'error', $result->get_error_message() ); $back = array( 'edit' => $id, ); wp_safe_redirect( add_query_arg( $back + array( 'page' => self::SLUG ), admin_url( 'admin.php' ) ) ); exit; } $this->plugin->not_found->forget( Repository::normalize_path( $data['source'] ) ); self::notice( 'success', $id ? __( 'Redirect updated.', 'orbitstem-seo' ) : __( 'Redirect added.', 'orbitstem-seo' ) ); } elseif ( 'delete' === $task ) { $result = $repo->delete( isset( $_POST['id'] ) ? absint( $_POST['id'] ) : 0 ); self::notice( is_wp_error( $result ) ? 'error' : 'success', is_wp_error( $result ) ? $result->get_error_message() : __( 'Redirect deleted. You can undo this in Repair > History.', 'orbitstem-seo' ) ); } elseif ( 'clear404' === $task ) { $this->plugin->not_found->clear(); self::notice( 'success', __( '404 log cleared.', 'orbitstem-seo' ) ); $tab = '404'; } elseif ( 'group' === $task ) { $prefix = isset( $_POST['prefix'] ) ? '/' . trim( sanitize_text_field( wp_unslash( $_POST['prefix'] ) ), '/' ) : ''; $result = '/' === $prefix ? new \WP_Error( 'prefix', __( 'Choose a folder.', 'orbitstem-seo' ) ) : $repo->create( array( 'source' => '^' . preg_quote( $prefix, '#' ) . '/(.*)$', // "#" is harmless inside the pattern; Repository::pattern() escapes its own "@" delimiter. 'target' => isset( $_POST['target'] ) ? wp_unslash( $_POST['target'] ) : '', // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- validated by Repository::validate(). 'code' => 301, 'is_regex' => true, /* translators: %s: folder such as /old-shop */ 'note' => sprintf( __( 'Missing addresses below %s (from the 404 log)', 'orbitstem-seo' ), $prefix ), ) ); if ( ! is_wp_error( $result ) ) { $this->plugin->not_found->forget_prefix( $prefix ); // Fixed: the group leaves the list. } self::notice( is_wp_error( $result ) ? 'error' : 'success', is_wp_error( $result ) ? $result->get_error_message() : __( 'Pattern redirect added. It only applies to addresses that do not exist, so real pages in that folder keep working.', 'orbitstem-seo' ) ); $tab = '404'; } elseif ( 'import_preview' === $task ) { $transfer = $this->transfer(); $source = isset( $_POST['source_plugin'] ) ? sanitize_key( wp_unslash( $_POST['source_plugin'] ) ) : ''; $rows = array(); if ( '' !== $source ) { $rows = $transfer->read_source( $source ); } elseif ( ! empty( $_FILES['file']['tmp_name'] ) && is_uploaded_file( $_FILES['file']['tmp_name'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- checked with is_uploaded_file(). $text = file_get_contents( $_FILES['file']['tmp_name'] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- local upload; every value is validated by Repository::validate(). $rows = strlen( (string) $text ) > 8388608 ? new \WP_Error( 'size', __( 'The file is larger than 8 MB.', 'orbitstem-seo' ) ) : $transfer->parse_csv( (string) $text ); } if ( is_wp_error( $rows ) ) { self::notice( 'error', $rows->get_error_message() ); } elseif ( ! $rows ) { self::notice( 'error', __( 'Nothing to import: choose a CSV file or another plugin with redirects.', 'orbitstem-seo' ) ); } else { set_transient( self::import_key( 'plan' ), $transfer->plan( $rows ), HOUR_IN_SECONDS ); } $tab = 'transfer'; } elseif ( in_array( $task, array( 'import_apply', 'import_continue', 'import_undo', 'undo_continue' ), true ) ) { $tab = 'transfer'; // One import or undo at a time: a double click or a second tab must not run a second batch alongside. if ( ! Lock::acquire( self::LOCK, self::LOCK_TTL ) ) { // A double click finds its own job already started: the progress screen is the answer, not an error. $own = is_array( get_transient( self::import_key( 'import_undo' === $task ? 'undo' : 'run' ) ) ) || false !== get_transient( self::import_key( 'started' ) ); // This user's own job (it may even have finished already). if ( ( 'import_apply' === $task || 'import_undo' === $task ) && ! $own ) { self::notice( 'error', __( 'An import or undo of redirects is already running. Wait until it has finished, then try again.', 'orbitstem-seo' ) ); } else { sleep( 2 ); // The progress page sends the next part by itself: do not let a second tab flood the server. } } else { try { if ( 'import_apply' === $task || 'import_undo' === $task ) { // Lets a second click of the same button know this user's job is under way, not someone else's. set_transient( self::import_key( 'started' ), 1, MINUTE_IN_SECONDS ); } if ( 'import_apply' === $task ) { $this->import_start(); } elseif ( 'import_continue' === $task ) { $this->import_batch(); } elseif ( 'import_undo' === $task ) { $this->undo_start(); } else { $this->undo_batch(); } } finally { Lock::release( self::LOCK ); } } } elseif ( 'import_cancel' === $task ) { delete_transient( self::import_key( 'plan' ) ); $tab = 'transfer'; } wp_safe_redirect( add_query_arg( array( 'page' => self::SLUG, 'tab' => $tab, ), admin_url( 'admin.php' ) ) ); exit; } /** * Starts the import of the previewed rules. Runs while holding the lock. */ private function import_start() { if ( is_array( get_transient( self::import_key( 'undo' ) ) ) ) { return; // An undo is still running; the preview stays for afterwards. } $plan = get_transient( self::import_key( 'plan' ) ); delete_transient( self::import_key( 'plan' ) ); if ( ! is_array( $plan ) || ! $plan['create'] || is_array( get_transient( self::import_key( 'run' ) ) ) ) { return; // Nothing to import, or already started (double click). } // The undo list is saved as the import goes, so a stopped import can still be undone as a whole. set_transient( self::import_key( 'last' ), array(), DAY_IN_SECONDS ); set_transient( self::import_key( 'run' ), array( 'rows' => $plan['create'], 'done' => 0, 'changes' => array(), 'failed' => array(), ), DAY_IN_SECONDS ); $this->import_batch(); } /** * Starts undoing the last import. Runs while holding the lock. */ private function undo_start() { $last = get_transient( self::import_key( 'last' ) ); if ( ! is_array( $last ) || ! $last || is_array( get_transient( self::import_key( 'undo' ) ) ) ) { return; // Nothing to undo, or already started (double click). } // An import that stopped part way is undone as a whole: it does not continue afterwards. delete_transient( self::import_key( 'run' ) ); delete_transient( self::import_key( 'last' ) ); set_transient( self::import_key( 'undo' ), array( 'ids' => array_values( array_reverse( $last ) ), 'done' => 0, 'undone' => 0, 'kept' => 0, ), DAY_IN_SECONDS ); $this->undo_batch(); } /** * Undoes the next part of the last import, in the same time-boxed parts as * import_batch(). Each rule is handled once, so a rule removed in an earlier * part is never counted again. */ private function undo_batch() { $undo = get_transient( self::import_key( 'undo' ) ); if ( ! is_array( $undo ) ) { return; } $start = microtime( true ); $total = count( $undo['ids'] ); while ( $undo['done'] < $total && microtime( true ) - $start < 15 ) { foreach ( array_slice( $undo['ids'], $undo['done'], 50 ) as $change_id ) { $result = $this->plugin->repair->rollback( (int) $change_id ); ++$undo['done']; if ( true === $result || ( is_wp_error( $result ) && 'state' === $result->get_error_code() ) ) { ++$undo['undone']; // Already undone (for example in Repair > History) counts as removed, not as kept. } elseif ( is_wp_error( $result ) && 'missing' !== $result->get_error_code() ) { ++$undo['kept']; } } set_transient( self::import_key( 'undo' ), $undo, DAY_IN_SECONDS ); } if ( $undo['done'] < $total ) { return; // The page continues with the next part. } delete_transient( self::import_key( 'undo' ) ); /* translators: 1: rules removed, 2: rules kept */ self::notice( $undo['kept'] ? 'error' : 'success', sprintf( __( 'Import undone. Removed: %1$d. Kept because they were changed since: %2$d.', 'orbitstem-seo' ), $undo['undone'], $undo['kept'] ) ); } /** * Imports the next part of a running import: as many rules as fit in about * fifteen seconds, so a large file never hits the server's time limit. */ private function import_batch() { $run = get_transient( self::import_key( 'run' ) ); if ( ! is_array( $run ) ) { return; } $start = microtime( true ); $transfer = $this->transfer(); $total = count( $run['rows'] ); while ( $run['done'] < $total && microtime( true ) - $start < 15 ) { $chunk = array_slice( $run['rows'], $run['done'], 100 ); $changes = $transfer->import( $chunk, $failed ); $run['done'] += count( $chunk ); $run['changes'] = array_merge( $run['changes'], $changes ); $run['failed'] = array_merge( $run['failed'], $failed ); set_transient( self::import_key( 'last' ), $run['changes'], DAY_IN_SECONDS ); set_transient( self::import_key( 'run' ), $run, DAY_IN_SECONDS ); } if ( $run['done'] < $total ) { return; // The page continues with the next part. } delete_transient( self::import_key( 'run' ) ); $message = sprintf( /* translators: %d: number of redirects */ _n( '%d redirect imported. You can undo the whole import below, or single rules in Repair > History.', '%d redirects imported. You can undo the whole import below, or single rules in Repair > History.', count( $run['changes'] ), 'orbitstem-seo' ), count( $run['changes'] ) ); if ( $run['failed'] ) { $first = $run['failed'][0]; $message .= ' ' . sprintf( /* translators: 1: number of rules, 2: first source, 3: reason */ _n( '%1$d rule was not imported because something changed since the preview (%2$s: %3$s).', '%1$d rules were not imported because something changed since the preview (first: %2$s: %3$s).', count( $run['failed'] ), 'orbitstem-seo' ), count( $run['failed'] ), $first['source'], $first['message'] ); } self::notice( $run['failed'] ? 'error' : 'success', $message ); } /** * Renders the page. */ public function render() { if ( ! current_user_can( Admin::capability() ) ) { wp_die( esc_html__( 'You do not have permission to view this page.', 'orbitstem-seo' ) ); } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- view state only. $tab = isset( $_GET['tab'] ) && in_array( $_GET['tab'], array( '404', 'transfer' ), true ) ? sanitize_key( $_GET['tab'] ) : 'rules'; Ui::open( self::SLUG, __( 'Redirects', 'orbitstem-seo' ), __( 'Send old addresses to new ones, and see what visitors could not find.', 'orbitstem-seo' ) ); ?> render_404(); } elseif ( 'transfer' === $tab ) { $this->render_transfer(); } else { $this->render_rules(); } ?> plugin->redirects; // phpcs:disable WordPress.Security.NonceVerification.Recommended -- view state only. $edit_id = isset( $_GET['edit'] ) ? absint( $_GET['edit'] ) : 0; // Settings::sanitize_text() keeps %xx and non-Latin text that sanitize_text_field() would strip. $prefill = isset( $_GET['source'] ) ? \Orbitstem\SEO\Core\Settings::sanitize_text( wp_unslash( $_GET['source'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $search = isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : ''; $paged = isset( $_GET['paged'] ) ? max( 1, absint( $_GET['paged'] ) ) : 1; // phpcs:enable $rule = $edit_id ? $repo->get( $edit_id ) : null; $codes = array( 301 => __( '301 Moved permanently', 'orbitstem-seo' ), 302 => __( '302 Found (temporary)', 'orbitstem-seo' ), 307 => __( '307 Temporary redirect', 'orbitstem-seo' ), 308 => __( '308 Permanent redirect', 'orbitstem-seo' ), 410 => __( '410 Gone (no redirect)', 'orbitstem-seo' ), ); ?>

' . esc_html__( 'Cancel editing', 'orbitstem-seo' ) . '' : ''; ?>

count( $search ); $rows = $repo->search( $search, self::PER_PAGE, ( $paged - 1 ) * self::PER_PAGE ); ?>
' . esc_html__( '(pattern)', 'orbitstem-seo' ) . '' : ''; ?>' . esc_html( $row['note'] ) . '' : ''; ?>
');"> |
add_query_arg( 'paged', '%#%' ), 'format' => '', 'current' => $paged, 'total' => max( 1, (int) ceil( $total / self::PER_PAGE ) ), ) ) ); echo '
'; } /** * 404 log tab. */ private function render_404() { $log = $this->plugin->not_found; ?>

plugin->settings->get( 'not_found_log' ) ) { /* translators: %d: number of paths kept */ echo esc_html( sprintf( __( 'Addresses visitors or crawlers requested that do not exist, most frequent first. No IP addresses are stored. The log keeps the %d most recently seen addresses.', 'orbitstem-seo' ), NotFoundLog::KEEP ) ); } else { esc_html_e( 'The 404 log is turned off in Settings.', 'orbitstem-seo' ); } ?>

top( 100 ); ?>
groups(); ?>

/…

transfer(); $plan = get_transient( self::import_key( 'plan' ) ); $last = get_transient( self::import_key( 'last' ) ); $sources = $transfer->sources(); $export = function ( $format ) { return wp_nonce_url( admin_url( 'admin-post.php?action=' . self::EXPORT . '&format=' . $format ), self::EXPORT ); }; $undo = get_transient( self::import_key( 'undo' ) ); if ( is_array( $undo ) ) { ?>

render_import_preview( $plan ); return; } ?>


$source ) : ?>

' . esc_html__( '(pattern)', 'orbitstem-seo' ) . '' : ''; ?>