db = $db; $this->module = $module; $this->options = get_option( Redirect_Manager::OPTION_NAME, [] ); add_action( 'admin_init', [ $this, 'init' ] ); add_action( 'admin_post_adminoptim_redirect_save', [ $this, 'handle_save' ] ); add_action( 'admin_post_adminoptim_redirect_delete', [ $this, 'handle_delete' ] ); add_action( 'admin_post_adminoptim_redirect_toggle', [ $this, 'handle_toggle' ] ); add_action( 'admin_post_adminoptim_redirect_bulk_delete', [ $this, 'handle_bulk_delete' ] ); } /** * Option keys that only the Pro settings UI writes. * * @return string[] */ protected function get_pro_only_option_keys(): array { return [ 'auto_create_slug_redirect', 'create_redirected_status', 'monitor_404' ]; } /** * Register settings fields. * * @return void */ public function init() { $this->settings_options = [ [ 'option_group' => Redirect_Manager::OPTION_NAME, 'option_name' => Redirect_Manager::OPTION_NAME, 'args' => [ 'sanitize_callback' => [ $this, 'sanitize_settings' ] ], ], ]; $this->settings_sections = [ 'free' => [ 'id' => 'adminoptim-redirect-manager-post-workflow', 'title' => __( 'Post Redirect Workflow', 'admin-optimizer' ), 'description' => __( 'Control how redirects can be created from post list tables and editor screens.', 'admin-optimizer' ), 'menu_slug' => Redirect_Manager::OPTION_NAME, 'option_name' => Redirect_Manager::OPTION_NAME, 'fields' => [ [ 'type' => 'checkbox', 'title' => __( 'Enable post redirect tools', 'admin-optimizer' ), 'id' => 'enable-post-redirect-tools', 'name' => 'enable_post_redirect_tools', 'label' => __( 'Allow redirects to be created from posts, pages, and selected post types.', 'admin-optimizer' ), ], [ 'type' => 'checkboxes', 'title' => __( 'Enable for post types', 'admin-optimizer' ), 'id' => 'post-redirect-post-types', 'name' => 'post_redirect_post_types', 'default' => [ 'post', 'page' ], 'choices' => $this->get_post_type_choices(), 'desc' => __( 'Only published content in these post types should show redirect workflow actions.', 'admin-optimizer' ), ], [ 'type' => 'checkbox', 'title' => __( 'Show action in post table', 'admin-optimizer' ), 'id' => 'show-post-table-action', 'name' => 'show_post_table_action', 'label' => __( 'Add a "Redirect this post" row action for published posts.', 'admin-optimizer' ), ], [ 'type' => 'checkbox', 'title' => __( 'Show panel in post editor', 'admin-optimizer' ), 'id' => 'show-post-editor-panel', 'name' => 'show_post_editor_panel', 'label' => __( 'Add redirect controls to the post editor screen.', 'admin-optimizer' ), ], [ 'type' => 'checkbox', 'title' => __( 'Unpublish redirected source posts', 'admin-optimizer' ), 'id' => 'auto-unpublish-on-redirect', 'name' => 'auto_unpublish_on_redirect', 'default' => 1, 'label' => __( 'When redirecting the current post elsewhere, move the source post out of Published status after the redirect is created.', 'admin-optimizer' ), ], [ 'type' => 'select', 'title' => __( 'Status after redirect', 'admin-optimizer' ), 'id' => 'redirect-unpublish-status', 'name' => 'unpublish_status', 'default' => 'redirected', 'choices' => $this->get_unpublish_status_choices(), 'desc' => __( 'Used when a published source post is redirected elsewhere. "Redirected" is preferred when available.', 'admin-optimizer' ), ], [ 'type' => 'checkbox', 'title' => __( 'Allow editors to create redirects', 'admin-optimizer' ), 'id' => 'allow-editors-create-redirects', 'name' => 'allow_editors_create_redirects', 'label' => __( 'Allow users who can edit the source post to create post redirects. Administrators can always manage redirects.', 'admin-optimizer' ), ], [ 'type' => 'select', 'title' => __( 'Restore behavior', 'admin-optimizer' ), 'id' => 'redirect-restore-behavior', 'name' => 'restore_behavior', 'default' => 'disable', 'choices' => [ 'none' => __( 'Do nothing', 'admin-optimizer' ), 'disable' => __( 'Disable the redirect', 'admin-optimizer' ), 'delete' => __( 'Delete the redirect', 'admin-optimizer' ), ], 'desc' => __( 'When a redirected post is restored, choose what should happen to its related redirect rule.', 'admin-optimizer' ), ], ], ], ]; if ( $this->is_pro_active() ) { $this->settings_sections = $this->inject_pro_fields( $this->settings_sections, 'adminoptim-redirect-manager-post-workflow', [ [ 'type' => 'checkbox', 'title' => __( 'Auto-create redirects for slug changes', 'admin-optimizer' ), 'id' => 'auto-create-slug-redirect', 'name' => 'auto_create_slug_redirect', 'label' => __( 'When a published post permalink changes, redirect the old URL to the new URL without unpublishing the post.', 'admin-optimizer' ), ], [ 'type' => 'checkbox', 'title' => __( 'Create Redirected status automatically', 'admin-optimizer' ), 'id' => 'create-redirected-status', 'name' => 'create_redirected_status', 'label' => __( 'Use the Custom Post Status module to create or ensure a protected "Redirected" status.', 'admin-optimizer' ), ], ] ); $this->settings_sections['404-monitor'] = [ 'id' => 'adminoptim-redirect-manager-404-monitor', 'title' => __( '404 Monitor', 'admin-optimizer' ), 'description' => __( 'Record missing frontend URLs so useful 404s can be reviewed and turned into redirects.', 'admin-optimizer' ), 'menu_slug' => Redirect_Manager::OPTION_NAME, 'option_name' => Redirect_Manager::OPTION_NAME, 'fields' => [ [ 'type' => 'checkbox', 'title' => __( 'Monitor 404 URLs', 'admin-optimizer' ), 'id' => 'monitor-404', 'name' => 'monitor_404', 'label' => __( 'Track frontend 404 requests and show them in the 404 Monitor tab.', 'admin-optimizer' ), ], ], ]; } else { $this->settings_sections['pro'] = [ 'id' => 'adminoptim-redirect-manager-pro', 'title' => __( 'Pro Options', 'admin-optimizer' ), /* translators: %1$s is the anchor link to the Pro version. %2$s is the closing anchor tag */ 'description' => sprintf( __( 'Upgrade to the %1$sPro version%2$s to access these features', 'admin-optimizer' ), '', '' ), 'menu_slug' => Redirect_Manager::MENU_SLUG . '_pro', 'option_name' => Redirect_Manager::OPTION_NAME, 'args' => [ 'before_section' => '
', 'after_section' => '
', ], 'fields' => [ [ 'type' => 'checkbox', 'title' => __( 'Auto-create redirects for slug changes', 'admin-optimizer' ), 'id' => 'auto-create-slug-redirect-pro', 'name' => 'auto_create_slug_redirect_pro', 'disabled' => 'disabled', 'label' => __( 'When a published post permalink changes, redirect the old URL to the new URL without unpublishing the post.', 'admin-optimizer' ), ], [ 'type' => 'checkbox', 'title' => __( 'Create Redirected status automatically', 'admin-optimizer' ), 'id' => 'create-redirected-status-pro', 'name' => 'create_redirected_status_pro', 'disabled' => 'disabled', 'label' => __( 'Use the Custom Post Status module to create or ensure a protected "Redirected" status.', 'admin-optimizer' ), ], [ 'type' => 'checkbox', 'title' => __( 'Monitor 404 URLs', 'admin-optimizer' ), 'id' => 'monitor-404-pro', 'name' => 'monitor_404_pro', 'disabled' => 'disabled', 'label' => __( 'Track frontend 404 requests and show them in the 404 Monitor tab.', 'admin-optimizer' ), ], ], ]; } $this->setup(); } /** * Handle add/edit form submission * * @return void */ public function handle_save() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Unauthorized', 'admin-optimizer' ) ); } check_admin_referer( 'adminoptim_redirect_save' ); $id = ! empty( $_POST['redirect_id'] ) ? absint( $_POST['redirect_id'] ) : 0; $redirect_404_id = ! empty( $_POST['redirect_404_id'] ) ? absint( $_POST['redirect_404_id'] ) : 0; $source_raw = isset( $_POST['source_url'] ) ? sanitize_text_field( wp_unslash( $_POST['source_url'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce verified via check_admin_referer() above; validated via Redirect_Manager_DB::validate_target_url() below before use. $target_raw = isset( $_POST['target_url'] ) ? wp_unslash( $_POST['target_url'] ) : ''; $type_raw = isset( $_POST['redirect_type'] ) ? (int) $_POST['redirect_type'] : 301; $redirect_type = in_array( $type_raw, Redirect_Manager::REDIRECT_TYPES, true ) ? $type_raw : 301; $source_post_id = ! empty( $_POST['source_post_id'] ) ? absint( $_POST['source_post_id'] ) : 0; $preserve_query = ! empty( $_POST['preserve_query'] ) ? 1 : 0; $allow_external_submitted = ! empty( $_POST['allow_external'] ); $source_url = $this->normalize_source( $source_raw ); $redirect_page = Redirect_Manager::get_module_admin_url(); if ( '' === trim( $source_raw ) || '' === trim( (string) $target_raw ) ) { $redirect_page = Redirect_Manager::get_module_admin_url( [ 'message' => 'invalid' ] ); wp_safe_redirect( $redirect_page ); exit; } $target_validated = Redirect_Manager_DB::validate_target_url( (string) $target_raw ); if ( is_wp_error( $target_validated ) ) { wp_safe_redirect( add_query_arg( 'message', 'invalid_target', $redirect_page ) ); exit; } $target_raw = $target_validated; $existing = $id > 0 ? $this->db->get_by_id( $id ) : null; if ( $this->is_protected_path( $source_url ) ) { wp_safe_redirect( add_query_arg( 'message', 'protected_path', $redirect_page ) ); exit; } $is_external = Redirect_Manager_DB::is_external_target( $target_raw ); $allow_external = Redirect_Manager_DB::resolve_allow_external_for_save( $target_raw, $allow_external_submitted, $existing ); if ( is_wp_error( $allow_external ) ) { wp_safe_redirect( add_query_arg( 'message', 'external_not_allowed', $redirect_page ) ); exit; } $target_path = (string) wp_parse_url( $target_raw, PHP_URL_PATH ); if ( ! $is_external && $this->normalize_source( $target_path ) === $source_url ) { wp_safe_redirect( add_query_arg( 'message', 'self_redirect', $redirect_page ) ); exit; } if ( $this->db->source_exists( $source_url, $id ) ) { wp_safe_redirect( add_query_arg( 'message', 'duplicate_source', $redirect_page ) ); exit; } $saved_id = $id; if ( $id > 0 ) { $this->db->update( $id, [ 'source_url' => $source_url, 'target_url' => $target_raw, 'redirect_type' => $redirect_type, 'preserve_query' => $preserve_query, 'allow_external' => $allow_external, ] ); } else { $saved_id = $this->db->insert( [ 'source_url' => $source_url, 'target_url' => $target_raw, 'redirect_type' => $redirect_type, 'preserve_query' => $preserve_query, 'allow_external' => $allow_external, ] ); } if ( $saved_id > 0 && $source_post_id > 0 ) { $this->maybe_mark_source_post_redirected( $source_post_id, $saved_id ); } if ( $saved_id > 0 && $redirect_404_id > 0 ) { // Pro hooks in here to remove the source 404 log this rule was created from. do_action( 'adminoptim_redirect_manager_redirect_created_from_404', $redirect_404_id ); } wp_safe_redirect( add_query_arg( 'message', 'saved', $redirect_page ) ); exit; } /** * Handle single redirect delete * * @return void */ public function handle_delete() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Unauthorized', 'admin-optimizer' ) ); } $id = isset( $_POST['redirect_id'] ) ? absint( $_POST['redirect_id'] ) : 0; check_admin_referer( 'adminoptim_redirect_delete_' . $id ); $this->db->delete( $id ); wp_safe_redirect( Redirect_Manager::get_module_admin_url( [ 'message' => 'deleted' ] ) ); exit; } /** * Handle enable/disable toggle * * @return void */ public function handle_toggle() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Unauthorized', 'admin-optimizer' ) ); } $id = isset( $_POST['redirect_id'] ) ? absint( $_POST['redirect_id'] ) : 0; $enabled = isset( $_POST['enabled'] ) ? absint( $_POST['enabled'] ) : 1; check_admin_referer( 'adminoptim_redirect_toggle_' . $id ); $this->db->toggle_enabled( $id, $enabled ); wp_safe_redirect( Redirect_Manager::get_module_admin_url() ); exit; } /** * Handle bulk delete form submission * * @return void */ public function handle_bulk_delete() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Unauthorized', 'admin-optimizer' ) ); } check_admin_referer( 'adminoptim_redirect_bulk_delete' ); $ids = ! empty( $_POST['redirect_ids'] ) ? array_map( 'absint', (array) $_POST['redirect_ids'] ) : []; if ( ! empty( $ids ) ) { $this->db->delete_multiple( $ids ); } wp_safe_redirect( Redirect_Manager::get_module_admin_url() ); exit; } /** * Known settings-page notice messages, keyed by the `message` query arg. * Pro extends this with its own 404 Monitor messages. * * @return array Map of message key to [ notice type, text ]. */ private function get_settings_messages(): array { $messages = [ 'saved' => [ 'success', __( 'Redirect saved successfully.', 'admin-optimizer' ) ], 'deleted' => [ 'success', __( 'Redirect deleted.', 'admin-optimizer' ) ], 'invalid' => [ 'error', __( 'Source URL and Target URL are required.', 'admin-optimizer' ) ], 'invalid_target' => [ 'error', __( 'Target URL must be a relative path or an http/https URL without credentials.', 'admin-optimizer' ) ], 'external_not_allowed' => [ 'error', __( 'This target URL points to an external website. Enable "Allow external redirect" to continue.', 'admin-optimizer' ) ], 'protected_path' => [ 'error', __( 'That source URL is a protected WordPress path and cannot be redirected.', 'admin-optimizer' ) ], 'self_redirect' => [ 'error', __( 'Source and target resolve to the same URL. A redirect would cause a loop.', 'admin-optimizer' ) ], 'duplicate_source' => [ 'error', __( 'A redirect for that source URL already exists. Edit the existing rule instead.', 'admin-optimizer' ) ], ]; return apply_filters( 'adminoptim_redirect_manager_settings_messages', $messages ); } /** * Render the settings page * * @return void */ public function render_settings_page() { $edit_id = ! empty( $_GET['edit_id'] ) ? absint( $_GET['edit_id'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $editing = $edit_id > 0 ? $this->db->get_by_id( $edit_id ) : null; $message = ! empty( $_GET['message'] ) ? sanitize_key( $_GET['message'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $tab = ! empty( $_GET['tab'] ) ? sanitize_key( $_GET['tab'] ) : 'rules'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $source_post_id = ! empty( $_GET['source_post'] ) ? absint( $_GET['source_post'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $prefill_source = ! empty( $_GET['source_url'] ) ? sanitize_text_field( wp_unslash( $_GET['source_url'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $prefill_404_id = ! empty( $_GET['redirect_404'] ) ? absint( $_GET['redirect_404'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $source_value = $editing ? $editing->source_url : ''; if ( ! $editing && '' !== $prefill_source ) { $source_value = $prefill_source; } $allowed_tabs = [ 'rules', 'settings', '404' ]; if ( ! in_array( $tab, $allowed_tabs, true ) ) { $tab = 'rules'; } ?> get_settings_messages(); if ( '' !== $message && isset( $known_messages[ $message ] ) ) { [ $notice_type, $notice_text ] = $known_messages[ $message ]; printf( '

%s

', esc_attr( $notice_type ), esc_html( $notice_text ) ); } ?>
render_settings_on_page( Redirect_Manager::OPTION_NAME ); ?> is_pro_active() ) : ?> render_settings_on_page( Redirect_Manager::MENU_SLUG . '_pro', [ 'remove_submit_button' => true ] ); ?>

is_pro_active() ) : ?>

', '' ); ?>

tag, %2$s is closing tag, %3$s is opening tag, %4$s is closing tag */ printf( esc_html__( '%1$sTip:%2$s Use redirects as simple click-tracking links. Create a short URL such as %3$s/go/newsletter%4$s, point it to any destination, and track its clicks below.', 'admin-optimizer' ), '', '', '', '' ); ?>

0 ) : ?> 0 ) : ?>

db ); $list_table->prepare_items(); $list_table->display(); ?>
home_url(), ] ); } /** * Sanitize settings options. * * @param array $options Raw options. * * @return array */ public function sanitize_settings( $options ) { $options = is_array( $options ) ? $options : []; $sanitized = [ 'enable_post_redirect_tools' => ! empty( $options['enable_post_redirect_tools'] ) ? 1 : 0, 'show_post_table_action' => ! empty( $options['show_post_table_action'] ) ? 1 : 0, 'show_post_editor_panel' => ! empty( $options['show_post_editor_panel'] ) ? 1 : 0, 'auto_unpublish_on_redirect' => ! empty( $options['auto_unpublish_on_redirect'] ) ? 1 : 0, 'allow_editors_create_redirects' => ! empty( $options['allow_editors_create_redirects'] ) ? 1 : 0, ]; if ( $this->is_pro_active() ) { $sanitized['auto_create_slug_redirect'] = ! empty( $options['auto_create_slug_redirect'] ) ? 1 : 0; $sanitized['create_redirected_status'] = ! empty( $options['create_redirected_status'] ) ? 1 : 0; $sanitized['monitor_404'] = ! empty( $options['monitor_404'] ) ? 1 : 0; } $post_type_choices = array_keys( $this->get_post_type_choices() ); $post_types = isset( $options['post_redirect_post_types'] ) ? (array) $options['post_redirect_post_types'] : [ 'post', 'page' ]; $post_types = array_values( array_intersect( array_map( 'sanitize_key', $post_types ), $post_type_choices ) ); $sanitized['post_redirect_post_types'] = ! empty( $post_types ) ? $post_types : [ 'post' ]; $status_choices = array_keys( $this->get_unpublish_status_choices() ); $unpublish_status = isset( $options['unpublish_status'] ) ? sanitize_key( $options['unpublish_status'] ) : 'redirected'; $sanitized['unpublish_status'] = in_array( $unpublish_status, $status_choices, true ) ? $unpublish_status : 'draft'; $restore_behavior = isset( $options['restore_behavior'] ) ? sanitize_key( $options['restore_behavior'] ) : 'disable'; $sanitized['restore_behavior'] = in_array( $restore_behavior, [ 'none', 'disable', 'delete' ], true ) ? $restore_behavior : 'disable'; return $this->carry_forward_inactive_pro_keys( $sanitized ); } /** * Get post type choices for post redirect workflows. * * @return array */ private function get_post_type_choices(): array { $post_types = get_post_types( [ 'show_ui' => true, 'public' => true, ], 'objects' ); unset( $post_types['attachment'], $post_types['wp_block'], $post_types['wp_navigation'] ); $choices = []; foreach ( $post_types as $post_type ) { if ( empty( $post_type->name ) || empty( $post_type->label ) ) { continue; } $choices[ $post_type->name ] = $post_type->label; } return $choices; } /** * Get post status choices for unpublishing redirected source posts. * * @return array */ private function get_unpublish_status_choices(): array { $choices = [ 'redirected' => __( 'Redirected', 'admin-optimizer' ), 'draft' => __( 'Draft', 'admin-optimizer' ), 'private' => __( 'Private', 'admin-optimizer' ), ]; $statuses = get_post_stati( [ 'internal' => false ], 'objects' ); foreach ( $statuses as $status => $status_object ) { if ( in_array( $status, [ 'publish', 'future', 'pending', 'auto-draft', 'trash' ], true ) ) { continue; } if ( isset( $choices[ $status ] ) ) { continue; } $choices[ $status ] = $status_object->label; } return $choices; } /** * Normalize a source URL to a consistent format for storage and lookup. * * Trims whitespace from user input then delegates to the shared DB normalizer. * * @param string $raw Raw source input. * * @return string */ private function normalize_source( string $raw ): string { return Redirect_Manager_DB::normalize_source( trim( $raw ) ); } /** * Store redirect metadata on a source post and unpublish it if configured. * * @param int $post_id Source post ID. * @param int $redirect_id Redirect ID. * * @return void */ private function maybe_mark_source_post_redirected( int $post_id, int $redirect_id ): void { $post = get_post( $post_id ); if ( ! $post instanceof \WP_Post || 'publish' !== $post->post_status ) { return; } update_post_meta( $post_id, Redirect_Manager::POST_REDIRECT_META_KEY, $redirect_id ); $this->module->maybe_unpublish_redirected_post( $post ); } /** * Check whether a path is a protected WordPress core path that must not be redirected * * @param string $path Normalized source path. * * @return bool */ private function is_protected_path( string $path ): bool { $site_base = rtrim( (string) wp_parse_url( site_url(), PHP_URL_PATH ), '/' ); $content_base = rtrim( (string) wp_parse_url( content_url(), PHP_URL_PATH ), '/' ); $admin_base = rtrim( (string) wp_parse_url( admin_url(), PHP_URL_PATH ), '/' ); $login_path = rtrim( (string) wp_parse_url( wp_login_url(), PHP_URL_PATH ), '/' ); $rest_base = rtrim( (string) wp_parse_url( rest_url(), PHP_URL_PATH ), '/' ); $protected = array_filter( [ $admin_base, $login_path, $rest_base, $content_base, $site_base . '/wp-cron.php', $site_base . '/xmlrpc.php', ] ); foreach ( $protected as $protected_path ) { if ( $path === $protected_path || 0 === strpos( $path, $protected_path . '/' ) ) { return true; } } return false; } }