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( '
', 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' ),
);
?>
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' );
}
?>
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;
}
?>
' . esc_html__( '(pattern)', 'orbitstem-seo' ) . '' : ''; ?>