post_type ) ) { return; } $post_type = $screen->post_type; if ( ! $this->is_sortable_post_type( $post_type ) ) { return; } if ( ! $this->user_can_sort( $post_type ) ) { return; } add_action( 'wp', [ $this, 'enqueue_scripts' ] ); add_action( 'admin_head', [ $this, 'render_help_tab' ] ); add_action( 'page_row_actions', [ $this, 'add_row_actions' ], 10, 2 ); } /** * Determine whether given post type is sortable or not. * * @param string $post_type Post type to check. * * @return boolean */ private function is_sortable_post_type( $post_type = 'post' ) { return ( post_type_supports( $post_type, 'page-attributes' ) || is_post_type_hierarchical( $post_type ) ); } /** * Checks to see if the current user has the capability to "edit others" for a post type. * * @param string $post_type Post type name. * * @return bool True or false */ private function user_can_sort( $post_type ) { $post_type_object = get_post_type_object( $post_type ); $edit_others_cap = empty( $post_type_object ) ? 'edit_others_' . $post_type . 's' : $post_type_object->cap->edit_others_posts; return current_user_can( $edit_others_cap ); } /** * Load scripts/styles when in menu_order view. */ public function enqueue_scripts() { $orderby = get_query_var( 'orderby' ); $screen = get_current_screen(); $post_type = $screen->post_type ?? 'post'; if ( ! $this->is_menu_order_query( $orderby ) ) { return; } wp_enqueue_script( 'adminoptim-page-ordering', self::MODULE_URL . 'assets/js/page-ordering.min.js', [ 'jquery-ui-sortable', 'wp-html-entities' ], ADMINOPTIMIZER_VERSION, true ); wp_localize_script( 'adminoptim-page-ordering', 'adminoptim_page_ordering_data', [ '_wpnonce' => wp_create_nonce( self::NONCE_ACTION ), /* translators: %1$s is replaced with the post type name */ 'confirmation_msg' => sprintf( esc_html__( 'Are you sure you want to reset the ordering of the "%1$s" post type?', 'admin-optimizer' ), $post_type ), ] ); wp_enqueue_style( 'adminoptim-page-ordering', self::MODULE_URL . 'assets/css/page-ordering.min.css', [], ADMINOPTIMIZER_VERSION ); } /** * Add page ordering help to the help tab. */ public function render_help_tab() { $screen = get_current_screen(); $post_type = $screen->post_type ?? 'post'; $screen->add_help_tab( [ 'id' => 'adminoptim_page_ordering_help_tab', 'title' => esc_html__( 'Page Ordering', 'admin-optimizer' ), 'content' => sprintf( '
%s
%s', esc_html__( 'To reposition an item, simply drag and drop the row by "clicking and holding" it anywhere (outside of the links and form controls) and moving it to its new position.', 'admin-optimizer' ), esc_attr( get_query_var( 'post_type' ) ), /* translators: %1$s is replaced with the post type name */ sprintf( esc_html__( 'Reset %1$s order', 'admin-optimizer' ), $post_type ) ), ] ); } /** * Modify the row actions for hierarchical post types. * * @param array $actions An array of row action links. * @param \WP_Post $post The post object. * * @return array */ public function add_row_actions( $actions, $post ) { $post = get_post( $post ); if ( ! $post ) { return $actions; } if ( ! current_user_can( 'edit_post', $post->ID ) ) { return $actions; } [ 'top_level_pages' => $top_level_pages, 'children_pages' => $children_pages ] = $this->get_walked_pages( $post->post_type ); $edit_link = get_edit_post_link( $post->ID, 'raw' ); $nonce_value = wp_create_nonce( "adminoptim-page-ordering-nonce-move-{$post->ID}" ); $move_out_link = add_query_arg( [ 'action' => 'adminoptim-move-under-grandparent', 'spo_nonce' => $nonce_value, 'post_type' => $post->post_type, ], $edit_link ); $move_in_link = add_query_arg( [ 'action' => 'adminoptim-move-under-sibling', 'spo_nonce' => $nonce_value, 'post_type' => $post->post_type, ], $edit_link ); $parent_id = $post->post_parent; if ( $parent_id ) { $actions['adminoptim-move-under-grandparent'] = sprintf( '%s', esc_url( $move_out_link ), sprintf( /* translators: %s: parent page/post title */ __( 'Remove from under %s', 'admin-optimizer' ), esc_html( get_the_title( $parent_id ) ) // [FIX #4] Prevent stored XSS via crafted post titles. ) ); } $siblings = ( 0 === $post->post_parent ) ? $top_level_pages : ( $children_pages[ $post->post_parent ] ?? [] ); $sibling_id = $this->get_previous_sibling_id( $siblings, $post->ID ); if ( $sibling_id ) { $actions['adminoptim-move-under-sibling'] = sprintf( '%s', esc_url( $move_in_link ), sprintf( /* translators: %s: sibling page/post title */ __( 'Move under %s', 'admin-optimizer' ), esc_html( get_the_title( $sibling_id ) ) // [FIX #4] Prevent stored XSS via crafted post titles. ) ); } return $actions; } /** * Add custom columns to the pages list table. */ public function add_custom_columns() { $post_types = get_post_types_by_support( 'page-attributes' ); if ( is_array( $post_types ) && ! empty( $post_types ) ) { foreach ( $post_types as $post_type_name ) { add_filter( 'manage_' . $post_type_name . '_posts_columns', [ $this, 'add_page_order_column' ] ); add_action( 'manage_' . $post_type_name . '_posts_custom_column', [ $this, 'render_page_order_column' ], 10, 2 ); add_filter( 'manage_edit-' . $post_type_name . '_sortable_columns', [ $this, 'add_page_order_sortable_column' ] ); } } } /** * Add Page Order column to Pages list table. * * @param array $columns Existing columns. * @return array */ public function add_page_order_column( $columns ) { $label = __( 'Page Order', 'admin-optimizer' ); if ( isset( $columns['title'] ) ) { $updated = []; foreach ( $columns as $key => $value ) { $updated[ $key ] = $value; if ( 'title' === $key ) { $updated['adminoptim_page_order'] = $label; } } return $updated; } $columns['adminoptim_page_order'] = $label; return $columns; } /** * Render the Page Order column value. * * @param string $column Column name. * @param int $post_id Post ID. * @return void */ public function render_page_order_column( $column, $post_id ) { if ( 'adminoptim_page_order' !== $column ) { return; } $post = get_post( $post_id ); if ( ! $post ) { return; } echo esc_html( (string) (int) $post->menu_order ); } /** * Filters the Post list table sortable columns. * * @param array $columns An array of sortable columns. * * @return array */ public function add_page_order_sortable_column( $columns ) { if ( ! isset( $columns['adminoptim_page_order'] ) ) { $columns['adminoptim_page_order'] = 'menu_order'; } return $columns; } /** * Move a post in/up the post parent tree. * * @param int $post_id The post ID. */ public function move_under_grandparent( $post_id ) { $post = get_post( $post_id ); if ( ! $post ) { $this->redirect_to_referer(); return; // [FIX #2] Ensure execution halts if redirect is intercepted (e.g. in tests). } check_admin_referer( "adminoptim-page-ordering-nonce-move-{$post->ID}", 'spo_nonce' ); if ( ! current_user_can( 'edit_post', $post->ID ) ) { wp_die( esc_html__( 'You are not allowed to edit this item.', 'admin-optimizer' ) ); } if ( 0 === $post->post_parent ) { $this->redirect_to_referer(); return; // [FIX #2] } $ancestors = get_post_ancestors( $post ); $parent_id = ( 1 === count( $ancestors ) ) ? 0 : $ancestors[1]; wp_update_post( [ 'ID' => $post->ID, 'post_parent' => $parent_id, ] ); $this->redirect_to_referer(); } /** * Move a post out/down the post parent tree. * * @param int $post_id The post ID. */ public function move_under_sibling( $post_id ) { $post = get_post( $post_id ); if ( ! $post ) { $this->redirect_to_referer(); return; // [FIX #2] Ensure execution halts if redirect is intercepted (e.g. in tests). } check_admin_referer( "adminoptim-page-ordering-nonce-move-{$post->ID}", 'spo_nonce' ); if ( ! current_user_can( 'edit_post', $post->ID ) ) { wp_die( esc_html__( 'You are not allowed to edit this item.', 'admin-optimizer' ) ); } [ 'top_level_pages' => $top_level_pages, 'children_pages' => $children_pages ] = $this->get_walked_pages( $post->post_type ); $siblings = ( 0 === $post->post_parent ) ? $top_level_pages : $children_pages[ $post->post_parent ]; $sibling_id = $this->get_previous_sibling_id( $siblings, $post->ID ); if ( ! $sibling_id ) { $this->redirect_to_referer(); return; // [FIX #2] } wp_update_post( [ 'ID' => $post->ID, 'post_parent' => $sibling_id, ] ); $this->redirect_to_referer(); } /** * Redirect the user after modifying the post parent. */ private function redirect_to_referer() { global $post_type; $send_back = wp_get_referer(); if ( ! $send_back || str_contains( $send_back, 'post.php' ) || str_contains( $send_back, 'post-new.php' ) ) { if ( 'attachment' === $post_type ) { $send_back = admin_url( 'upload.php' ); } else { $send_back = admin_url( 'edit.php' ); if ( ! empty( $post_type ) ) { $send_back = add_query_arg( 'post_type', $post_type, $send_back ); } } } else { $send_back = remove_query_arg( [ 'trashed', 'untrashed', 'deleted', 'ids' ], $send_back ); } wp_safe_redirect( $send_back ); exit; } /** * Walk the pages and return top level and children pages. * * @param string $post_type Post type to walk. * * @return array { * @type \WP_Post[] $top_level_pages Top level pages. * @type \WP_Post[] $children_pages Children pages. * } */ private function get_walked_pages( $post_type = 'page' ) { global $wpdb; $pages = get_pages( [ 'sort_column' => 'menu_order title', 'post_type' => $post_type, ] ); $top_level_pages = []; $children_pages = []; $bad_parents = []; foreach ( $pages as $page ) { if ( $page->post_parent === $page->ID ) { $page->post_parent = 0; $wpdb->update( $wpdb->posts, [ 'post_parent' => 0 ], [ 'ID' => $page->ID ] ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery clean_post_cache( $page ); $bad_parents[] = $page->ID; } if ( $page->post_parent > 0 ) { $children_pages[ $page->post_parent ][] = $page; } else { $top_level_pages[] = $page; } } _prime_post_caches( $bad_parents, false, false ); return [ 'top_level_pages' => $top_level_pages, 'children_pages' => $children_pages, ]; } /** * Page ordering ajax callback. * * @return void */ public function handle_sort_ajax() { $post_id = isset( $_POST['id'] ) ? (int) $_POST['id'] : 0; $has_neighbor = isset( $_POST['previd'] ) || isset( $_POST['nextid'] ); if ( ! $post_id || ! $has_neighbor ) { wp_die( -1 ); } $nonce = isset( $_POST['_wpnonce'] ) ? sanitize_key( wp_unslash( $_POST['_wpnonce'] ) ) : ''; if ( ! wp_verify_nonce( $nonce, self::NONCE_ACTION ) ) { wp_die( -1 ); } $post = get_post( $post_id ); if ( ! $post || ! $this->user_can_sort( $post->post_type ) ) { wp_die( -1 ); } $previd = isset( $_POST['previd'] ) ? (int) $_POST['previd'] : 0; $nextid = isset( $_POST['nextid'] ) ? (int) $_POST['nextid'] : 0; $start = isset( $_POST['start'] ) ? (int) $_POST['start'] : 1; // [FIX #3 & #5] Validate JSON result; use already-cast $post_id as fallback to avoid raw superglobal. $excluded_raw = isset( $_POST['excluded'] ) ? json_decode( wp_unslash( $_POST['excluded'] ), true ) : null; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $excluded = is_array( $excluded_raw ) ? array_map( 'intval', $excluded_raw ) : [ $post_id ]; $result = $this->apply_ordering( $post_id, $previd ? $previd : false, $nextid ? $nextid : false, $start, $excluded ); if ( is_wp_error( $result ) ) { wp_die( -1 ); } wp_die( wp_json_encode( $result ) ); } /** * Page ordering reset ajax callback. * * @return void */ public function handle_reset_ajax() { global $wpdb; $nonce = isset( $_POST['_wpnonce'] ) ? sanitize_key( wp_unslash( $_POST['_wpnonce'] ) ) : ''; if ( wp_verify_nonce( $nonce, self::NONCE_ACTION ) ) { $post_type = isset( $_POST['post_type'] ) ? sanitize_text_field( wp_unslash( $_POST['post_type'] ) ) : ''; if ( ! empty( $post_type ) && $this->user_can_sort( $post_type ) ) { $wpdb->update( $wpdb->posts, [ 'menu_order' => 0 ], [ 'post_type' => $post_type ], [ '%d' ], [ '%s' ] ); // phpcs:ignore. } } wp_die(); } /** * Apply the page ordering for a given move request. * * @param int $post_id The post ID. * @param int $previd The previous post ID. * @param int $nextid The next post ID. * @param int $start The start index. * @param array $excluded Array of post IDs. * * @return object|\WP_Error|string */ public function apply_ordering( $post_id, $previd, $nextid, $start, $excluded ) { $post = empty( $post_id ) ? false : get_post( (int) $post_id ); if ( ! $post ) { return new \WP_Error( 'invalid', __( 'Missing mandatory parameters.', 'admin-optimizer' ) ); } // [FIX #1] Removed error_reporting(0) — suppressing all PHP errors globally is unsafe; // it persists for the entire request, hiding genuine errors from all subsequent code. // [FIX #7] Removed redundant double-cast; callers already cast these to int. // [FIX #6] Removed dead WordPress < 4.0 version branch (WP 4.0 released 2014). $previd = empty( $previd ) ? false : (int) $previd; $nextid = empty( $nextid ) ? false : (int) $nextid; $start = empty( $start ) ? 1 : (int) $start; $excluded = empty( $excluded ) ? [ $post_id ] : array_map( 'intval', (array) $excluded ); $return_data = new \stdClass(); $new_pos = []; do_action( 'adminoptim_page_ordering_pre_order_posts', $post, $start ); $parent_id = $this->resolve_target_parent( $post, $previd, $nextid ); if ( $nextid && $parent_id !== $this->get_parent_id( $nextid ) ) { $nextid = false; } $max_sortable_posts = (int) apply_filters( 'adminoptim_page_ordering_limit', 50 ); if ( $max_sortable_posts < 5 ) { $max_sortable_posts = 50; } $post_stati = get_post_stati( [ 'show_in_admin_all_list' => true, ] ); $siblings_query = [ 'depth' => 1, 'posts_per_page' => $max_sortable_posts, 'post_type' => $post->post_type, 'post_status' => $post_stati, 'post_parent' => $parent_id, 'post__not_in' => $excluded, // phpcs:ignore 'orderby' => [ 'menu_order' => 'ASC', 'title' => 'ASC', ], 'update_post_term_cache' => false, 'update_post_meta_cache' => false, 'ignore_sticky_posts' => true, ]; // [FIX #6] Dead WP < 4.0 branch removed. $siblings = new \WP_Query( $siblings_query ); remove_action( 'post_updated', 'wp_save_post_revision' ); foreach ( $siblings->posts as $sibling ) : if ( $sibling->ID === $post->ID ) { continue; } if ( $nextid === $sibling->ID ) { $this->update_position( $post->ID, $start, $parent_id ); $new_pos[ $post->ID ] = $this->format_position( $post->ID, $start, $parent_id ); ++$start; } if ( isset( $new_pos[ $post->ID ] ) && $sibling->menu_order >= $start ) { $return_data->next = false; break; } if ( $sibling->menu_order !== $start ) { wp_update_post( [ 'ID' => $sibling->ID, 'menu_order' => $start, ] ); } $new_pos[ $sibling->ID ] = $start; ++$start; if ( ! $nextid && $previd === $sibling->ID ) { $this->update_position( $post->ID, $start, $parent_id ); $new_pos[ $post->ID ] = $this->format_position( $post->ID, $start, $parent_id ); ++$start; } endforeach; if ( ! isset( $return_data->next ) && $siblings->max_num_pages > 1 ) { $return_data->next = [ 'id' => $post->ID, 'previd' => $previd, 'nextid' => $nextid, 'start' => $start, 'excluded' => array_merge( array_keys( $new_pos ), $excluded ), ]; } else { $return_data->next = false; } do_action( 'adminoptim_page_ordering_ordered_posts', $post, $new_pos ); if ( ! $return_data->next && $this->post_has_children( $post->ID, $post->post_type, $post_stati ) ) { return 'children'; } $return_data->new_pos = $new_pos; return $return_data; } /** * Registers the API endpoint for sorting from the REST endpoint. */ public function register_rest_routes() { register_rest_route( self::ROUTE_NS, self::ROUTE_NAME, [ 'methods' => 'POST', 'callback' => [ $this, 'rest_page_ordering' ], 'permission_callback' => [ $this, 'rest_page_ordering_permissions_check' ], 'args' => [ 'id' => [ 'description' => __( 'ID of item we want to sort', 'admin-optimizer' ), 'required' => true, 'type' => 'integer', 'minimum' => 1, ], 'previd' => [ 'description' => __( 'ID of item we want to be previous to after sorting', 'admin-optimizer' ), 'required' => true, 'type' => [ 'boolean', 'integer' ], ], 'nextid' => [ 'description' => __( 'ID of item we want to be next to after sorting', 'admin-optimizer' ), 'required' => true, 'type' => [ 'boolean', 'integer' ], ], 'start' => [ 'default' => 1, 'description' => __( 'Index we start with when sorting', 'admin-optimizer' ), 'required' => false, 'type' => 'integer', ], 'excluded' => [ 'default' => [], 'description' => __( 'Array of IDs we want to exclude', 'admin-optimizer' ), 'required' => false, 'type' => 'array', 'items' => [ 'type' => 'integer', ], ], ], ] ); } /** * Check if a given request has access to reorder content. * * @param \WP_REST_Request $request Full data about the request. * @return bool|\WP_Error */ public function rest_page_ordering_permissions_check( \WP_REST_Request $request ) { $post_id = $request->get_param( 'id' ); if ( ! current_user_can( 'edit_post', $post_id ) ) { return false; } $post_type = get_post_type( $post_id ); $post_type_obj = get_post_type_object( $post_type ); if ( ! $post_type || empty( $post_type_obj ) || empty( $post_type_obj->show_in_rest ) ) { return false; } if ( ! $this->is_sortable_post_type( $post_type ) ) { return new \WP_Error( 'not_enabled', esc_html__( 'This post type is not sortable.', 'admin-optimizer' ) ); } return true; } /** * Handle REST page sorting. * * @param \WP_REST_Request $request The REST request object. * * @return \WP_REST_Response|\WP_Error */ public function rest_page_ordering( \WP_REST_Request $request ) { $post_id = empty( $request->get_param( 'id' ) ) ? false : (int) $request->get_param( 'id' ); $previd = empty( $request->get_param( 'previd' ) ) ? false : (int) $request->get_param( 'previd' ); $nextid = empty( $request->get_param( 'nextid' ) ) ? false : (int) $request->get_param( 'nextid' ); $start = empty( $request->get_param( 'start' ) ) ? 1 : (int) $request->get_param( 'start' ); // [FIX #8] REST schema already validates & parses 'excluded' as an integer array. // Calling json_decode() here again is a double-decode bug. Use the parsed value directly. $excluded = $request->get_param( 'excluded' ); if ( empty( $excluded ) ) { // Also accept legacy 'exclude' param for backwards compat. $excluded = $request->get_param( 'exclude' ); } $excluded = empty( $excluded ) ? [ $post_id ] : array_map( 'intval', (array) $excluded ); if ( false === $post_id || ( false === $previd && false === $nextid ) ) { return new \WP_Error( 'invalid', __( 'Missing mandatory parameters.', 'admin-optimizer' ) ); } $page_ordering = $this->apply_ordering( $post_id, $previd, $nextid, $start, $excluded ); if ( is_wp_error( $page_ordering ) ) { return $page_ordering; } return new \WP_REST_Response( [ 'status' => 200, 'response' => 'success', 'body_response' => $page_ordering, ] ); } /** * Check if the query is ordered by menu order. * * @param array|string $orderby The orderby parameter. * * @return bool True if the query is ordered by menu order, false otherwise. */ private function is_menu_order_query( $orderby ) { if ( is_string( $orderby ) && 0 === strpos( $orderby, 'menu_order' ) ) { return true; } if ( is_array( $orderby ) && isset( $orderby['menu_order'] ) && 'ASC' === $orderby['menu_order'] ) { return true; } return false; } /** * Get the previous sibling ID of a post. * * @param array $siblings The array of sibling posts. * @param int $post_id The post ID. * * @return int The previous sibling ID. */ private function get_previous_sibling_id( array $siblings, $post_id ) { $filtered = wp_list_filter( $siblings, [ 'ID' => $post_id ] ); if ( empty( $filtered ) ) { return 0; } $key = array_key_first( $filtered ); if ( 0 === $key ) { return 0; } $previous = $siblings[ $key - 1 ]; return $previous ? $previous->ID : 0; } /** * Resolve the target parent of a post. * * @param \WP_Post $post The post object. * @param int $previd The previous post ID. * @param int $nextid The next post ID. * * @return int The target parent ID. */ private function resolve_target_parent( \WP_Post $post, $previd, $nextid ) { $parent_id = $post->post_parent; $next_post_parent = $nextid ? $this->get_parent_id( $nextid ) : false; if ( $previd === $next_post_parent ) { return $next_post_parent; } if ( $next_post_parent !== $parent_id ) { $prev_post_parent = $previd ? $this->get_parent_id( $previd ) : false; if ( $prev_post_parent !== $parent_id ) { return ( false !== $prev_post_parent ) ? $prev_post_parent : $next_post_parent; } } return $parent_id; } /** * Get the parent ID of a post. * * @param int $post_id The post ID. * * @return int The parent ID. */ private function get_parent_id( $post_id ) { return wp_get_post_parent_id( $post_id ); } /** * Update the position of a post. * * @param int $post_id The post ID. * @param int $menu_order The menu order. * @param int $parent_id The parent ID. */ private function update_position( $post_id, $menu_order, $parent_id ) { wp_update_post( [ 'ID' => $post_id, 'menu_order' => $menu_order, 'post_parent' => $parent_id, ] ); } /** * Format the position of a post. * * @param int $post_id The post ID. * @param int $menu_order The menu order. * @param int $parent_id The parent ID. * * @return array The formatted position. */ private function format_position( $post_id, $menu_order, $parent_id ) { $ancestors = get_post_ancestors( $post_id ); return [ 'menu_order' => $menu_order, 'post_parent' => $parent_id, 'depth' => count( $ancestors ), ]; } /** * Check if a post has children. * * @param int $post_id The post ID. * @param string $post_type The post type. * @param array $post_stati The post statuses. * * @return bool True if the post has children, false otherwise. */ private function post_has_children( $post_id, $post_type, $post_stati ) { $children = new \WP_Query( [ 'posts_per_page' => 1, 'post_type' => $post_type, 'post_status' => $post_stati, 'post_parent' => $post_id, 'fields' => 'ids', 'update_post_term_cache' => false, 'update_post_meta_cache' => false, 'ignore_sticky' => true, 'no_found_rows' => true, ] ); return $children->have_posts(); } }