add_action( 'rest_api_init', $this, 'register_routes' ); } /** * Registers every route. */ public function register_routes(): void { register_rest_route( self::NAMESPACE_V1, '/redirects', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_redirects' ), 'permission_callback' => array( $this, 'can_manage_plugin' ), 'args' => array( 'search' => array( 'type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_text_field', ), 'page' => array( 'type' => 'integer', 'default' => 1, 'sanitize_callback' => 'absint', ), 'per_page' => array( 'type' => 'integer', 'default' => 20, 'sanitize_callback' => 'absint', ), ), ), array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'create_redirect' ), 'permission_callback' => array( $this, 'can_manage_options' ), ), ) ); register_rest_route( self::NAMESPACE_V1, '/redirects/(?P\d+)', array( array( 'methods' => WP_REST_Server::EDITABLE, 'callback' => array( $this, 'update_redirect' ), 'permission_callback' => array( $this, 'can_manage_options' ), ), array( 'methods' => WP_REST_Server::DELETABLE, 'callback' => array( $this, 'delete_redirect' ), 'permission_callback' => array( $this, 'can_manage_options' ), ), ) ); register_rest_route( self::NAMESPACE_V1, '/redirects/settings', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'save_settings' ), 'permission_callback' => array( $this, 'can_manage_options' ), ) ); register_rest_route( self::NAMESPACE_V1, '/redirects/suggestions/(?P[a-f0-9]{32})', array( 'methods' => WP_REST_Server::DELETABLE, 'callback' => array( $this, 'dismiss_suggestion' ), 'permission_callback' => array( $this, 'can_manage_options' ), ) ); register_rest_route( self::NAMESPACE_V1, '/not-found', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_not_found' ), 'permission_callback' => array( $this, 'can_manage_plugin' ), 'args' => array( 'orderby' => array( 'type' => 'string', 'default' => 'hits', 'sanitize_callback' => 'sanitize_key', ), 'order' => array( 'type' => 'string', 'default' => 'desc', 'sanitize_callback' => 'sanitize_key', ), 'page' => array( 'type' => 'integer', 'default' => 1, 'sanitize_callback' => 'absint', ), 'per_page' => array( 'type' => 'integer', 'default' => 20, 'sanitize_callback' => 'absint', ), ), ), array( 'methods' => WP_REST_Server::DELETABLE, 'callback' => array( $this, 'clear_not_found' ), 'permission_callback' => array( $this, 'can_manage_options' ), ), ) ); register_rest_route( self::NAMESPACE_V1, '/not-found/(?P\d+)', array( 'methods' => WP_REST_Server::DELETABLE, 'callback' => array( $this, 'delete_not_found' ), 'permission_callback' => array( $this, 'can_manage_options' ), ) ); } /** * Permission for writes: managing options (a rule redirects real traffic). * * @return bool */ public function can_manage_options() { return current_user_can( 'manage_options' ); } /** * GET /redirects: a page of rules plus everything the screen needs next * to them (log settings, suggestions, whether another SEO plugin has the * redirect runtime switched off). * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public function get_redirects( WP_REST_Request $request ): WP_REST_Response { $per_page = max( 1, min( 100, (int) $request->get_param( 'per_page' ) ) ); $page = max( 1, (int) $request->get_param( 'page' ) ); $result = ( new ABDS_SEO_Redirect_Repository() )->paged( (string) $request->get_param( 'search' ), $page, $per_page ); return new WP_REST_Response( array( 'items' => $result['items'], 'total' => $result['total'], 'total_pages' => (int) ceil( $result['total'] / $per_page ), 'settings' => ABDS_SEO_Not_Found_Log::settings(), 'suggestions' => ABDS_SEO_Redirect_Suggestions::all(), 'suppressed' => ( new ABDS_SEO_Compat_Detector() )->has_active_competitor(), ) ); } /** * POST /redirects. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function create_redirect( WP_REST_Request $request ) { $checked = $this->check( $this->input( $request ), 0 ); if ( is_wp_error( $checked ) ) { return $checked; } $id = ( new ABDS_SEO_Redirect_Repository() )->create( $checked ); if ( false === $id ) { return new WP_Error( 'abds_seo_redirect_save_failed', __( 'The rule could not be saved.', 'abds-seo' ), array( 'status' => 500 ) ); } // A suggestion for this address is answered by the rule. ABDS_SEO_Redirect_Suggestions::dismiss( md5( $checked['source'] ) ); return new WP_REST_Response( array( 'id' => $id ), 201 ); } /** * PUT/PATCH /redirects/{id}: replaces the fields given (the rest keep their values). * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function update_redirect( WP_REST_Request $request ) { $id = (int) $request->get_param( 'id' ); $repository = new ABDS_SEO_Redirect_Repository(); $current = $repository->get( $id ); if ( null === $current ) { return new WP_Error( 'abds_seo_redirect_not_found', __( 'That rule no longer exists.', 'abds-seo' ), array( 'status' => 404 ) ); } $input = array_merge( $current, $this->input( $request, true ) ); $checked = $this->check( $input, $id ); if ( is_wp_error( $checked ) ) { return $checked; } $repository->update( $id, $checked ); return new WP_REST_Response( array( 'id' => $id ) ); } /** * DELETE /redirects/{id}. * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public function delete_redirect( WP_REST_Request $request ): WP_REST_Response { ( new ABDS_SEO_Redirect_Repository() )->delete( (int) $request->get_param( 'id' ) ); return new WP_REST_Response( array( 'deleted' => true ) ); } /** * POST /redirects/settings: the log's on/off switch and row limit. * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public function save_settings( WP_REST_Request $request ): WP_REST_Response { $input = array(); if ( $request->has_param( 'log_enabled' ) ) { $input['log_enabled'] = rest_sanitize_boolean( $request->get_param( 'log_enabled' ) ); } if ( $request->has_param( 'log_limit' ) ) { $input['log_limit'] = (int) $request->get_param( 'log_limit' ); } return new WP_REST_Response( ABDS_SEO_Not_Found_Log::save_settings( $input ) ); } /** * DELETE /redirects/suggestions/{id}. * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public function dismiss_suggestion( WP_REST_Request $request ): WP_REST_Response { ABDS_SEO_Redirect_Suggestions::dismiss( (string) $request->get_param( 'id' ) ); return new WP_REST_Response( array( 'dismissed' => true ) ); } /** * GET /not-found. * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public function get_not_found( WP_REST_Request $request ): WP_REST_Response { $per_page = max( 1, min( 100, (int) $request->get_param( 'per_page' ) ) ); $result = ( new ABDS_SEO_Not_Found_Log() )->paged( (string) $request->get_param( 'orderby' ), (string) $request->get_param( 'order' ), max( 1, (int) $request->get_param( 'page' ) ), $per_page ); return new WP_REST_Response( array( 'items' => $result['items'], 'total' => $result['total'], 'total_pages' => (int) ceil( $result['total'] / $per_page ), 'limit' => ABDS_SEO_Not_Found_Log::settings()['log_limit'], 'frequent' => ABDS_SEO_Not_Found_Log::FREQUENT_HITS, ) ); } /** * DELETE /not-found: empties the log. * * @return WP_REST_Response */ public function clear_not_found(): WP_REST_Response { ( new ABDS_SEO_Not_Found_Log() )->clear(); return new WP_REST_Response( array( 'cleared' => true ) ); } /** * DELETE /not-found/{id}. * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public function delete_not_found( WP_REST_Request $request ): WP_REST_Response { ( new ABDS_SEO_Not_Found_Log() )->delete( (int) $request->get_param( 'id' ) ); return new WP_REST_Response( array( 'deleted' => true ) ); } /** * The rule fields from a request, sanitized as plain text (the matcher * does the structural validation). * * @param WP_REST_Request $request Request. * @param bool $partial Only the fields actually sent. * @return array */ protected function input( WP_REST_Request $request, bool $partial = false ): array { $out = array(); foreach ( array( 'source', 'target' ) as $field ) { if ( ! $partial || $request->has_param( $field ) ) { $out[ $field ] = sanitize_text_field( (string) $request->get_param( $field ) ); } } if ( ! $partial || $request->has_param( 'type' ) ) { $out['type'] = (int) $request->get_param( 'type' ); } if ( ! $partial || $request->has_param( 'active' ) ) { $out['active'] = null === $request->get_param( 'active' ) ? true : rest_sanitize_boolean( $request->get_param( 'active' ) ); } return $out; } /** * Runs the matcher's save-time checks against the stored rules. * * @param array $input Candidate rule. * @param int $id ID of the rule being edited (0 = new). * @return array|WP_Error The cleaned rule, or an error with a plain explanation. */ protected function check( array $input, int $id ) { $input['id'] = $id; $check = ABDS_SEO_Redirect_Matcher::check_rule( $input, ( new ABDS_SEO_Redirect_Repository() )->all_rules(), (string) wp_parse_url( home_url(), PHP_URL_HOST ) ); if ( null !== $check['code'] ) { return new WP_Error( 'abds_seo_redirect_' . $check['code'], self::message( $check['code'], $check['detail'] ), array( 'status' => 400 ) ); } return $check['rule']; } /** * Plain-language reason a rule was refused. * * @param string $code Code from ABDS_SEO_Redirect_Matcher::check_rule(). * @param string $detail Extra detail (e.g. which rule starts the loop). * @return string */ public static function message( string $code, string $detail = '' ): string { switch ( $code ) { case 'source_required': return __( 'Enter the old address to redirect from.', 'abds-seo' ); case 'source_invalid': return __( 'The old address must be on this site: enter a path such as /old-page.', 'abds-seo' ); case 'wildcard_misplaced': return __( 'A * is allowed only at the very end of the old address, after a slash (for example /blog/*).', 'abds-seo' ); case 'too_broad': return __( 'A rule for /* would redirect the whole site. Name a section, for example /blog/*.', 'abds-seo' ); case 'target_required': return __( 'Enter where visitors should be sent.', 'abds-seo' ); case 'target_invalid': return __( 'The new address must start with / (a page of this site) or be a full http(s) address.', 'abds-seo' ); case 'target_wildcard_without_source': return __( 'A * in the new address is only allowed when the old address ends in /* too, and only at its very end.', 'abds-seo' ); case 'type_invalid': return __( 'Choose 301, 302, 307 or 410.', 'abds-seo' ); case 'self_redirect': return __( 'This rule would send the address to itself, so visitors would never arrive anywhere.', 'abds-seo' ); case 'loop': return sprintf( /* translators: %s: the old address of a rule where the circle begins. */ __( 'These rules would send a visitor round in a circle (starting from %s). Change the new address so the chain ends on a real page.', 'abds-seo' ), $detail ); case 'chain_too_long': return sprintf( /* translators: 1: the old address where the chain begins, 2: the longest chain allowed. */ __( 'The chain of redirects starting from %1$s would be longer than %2$d steps. Point the earlier rules straight at the final address instead.', 'abds-seo' ), $detail, ABDS_SEO_Redirect_Matcher::MAX_HOPS ); case 'duplicate_source': return __( 'There is already a rule for this old address. Edit that one instead.', 'abds-seo' ); default: return __( 'The rule is not valid.', 'abds-seo' ); } } }