[ WP_REST_Server::READABLE, 'search', $manage ], '/test' => [ WP_REST_Server::CREATABLE, 'test', $manage ], '/test/live' => [ WP_REST_Server::CREATABLE, 'live', $manage ], '/stats' => [ WP_REST_Server::READABLE, 'stats', $manage ], '/import/parse' => [ WP_REST_Server::CREATABLE, 'parse', $manage ], '/import/run' => [ WP_REST_Server::CREATABLE, 'run', $manage ], '/import/sources' => [ WP_REST_Server::READABLE, 'sources', $manage ], '/export' => [ WP_REST_Server::READABLE, 'export', $manage ], ]; foreach ( $routes as $route => [ $method, $callback, $permission ] ) { register_rest_route( $ns, $route, [ 'methods' => $method, 'callback' => [ $this, $callback ], 'permission_callback' => $permission, ] ); } register_rest_route( $ns, '/settings', [ [ 'methods' => WP_REST_Server::READABLE, 'callback' => [ $this, 'settings' ], 'permission_callback' => [ RestModule::class, 'can_configure' ], ], [ 'methods' => WP_REST_Server::EDITABLE, 'callback' => [ $this, 'save_settings' ], 'permission_callback' => [ RestModule::class, 'can_configure' ], ], ] ); } /* --------------------------------------------------------------------- * Finding a destination * ------------------------------------------------------------------ */ /** * Pages, posts, products and categories by title — what the "To" field suggests as * somebody types. A pasted address of a page on this site is recognised too, so the * result can be linked to the page rather than stored as text. */ public function search( WP_REST_Request $request ): WP_REST_Response { $term = trim( (string) $request->get_param( 'term' ) ); $out = []; if ( '' === $term ) { return new WP_REST_Response( [ 'items' => [] ] ); } if ( str_starts_with( $term, '/' ) || preg_match( '#^https?://#i', $term ) ) { $address = Address::parse( $term ); if ( $address->is_local() ) { $post_id = url_to_postid( Engine::absolute( $address->path ) ); if ( $post_id > 0 ) { $out[] = [ 'pasted' => true ] + $this->post_item( $post_id ); } } } // Titles and slugs only — never page content, or "sho" lists Cart and My account // because their text mentions the shop. Pages and posts are asked for separately // from products, so a thousand products whose names happen to contain "car" can // never push the Cart page out of the list. $types = ContentTypes::post_types(); $products = array_values( array_intersect( $types, [ 'product' ] ) ); $others = array_values( array_diff( $types, $products ) ); $pages = []; foreach ( $this->titled( $term, $others, 10 ) as $post_id ) { $pages[] = $this->post_item( $post_id ); } $terms = get_terms( [ 'taxonomy' => array_values( get_taxonomies( [ 'public' => true ] ) ), 'name__like' => $term, 'hide_empty' => false, 'number' => 3, ] ); foreach ( is_array( $terms ) ? $terms : [] as $t ) { if ( ! $t instanceof \WP_Term ) { continue; } $link = get_term_link( $t ); $tax = get_taxonomy( $t->taxonomy ); if ( is_string( $link ) ) { $pages[] = [ 'type' => 'term', 'id' => $t->term_id, 'title' => $t->name, 'kind' => $tax ? (string) $tax->labels->singular_name : $t->taxonomy, 'url' => $link, 'path' => Address::parse( $link )->relative(), ]; } } $items = []; foreach ( $this->titled( $term, $products, 10 ) as $post_id ) { $items[] = $this->post_item( $post_id ); } // Exact title, then starts with it, then a word starting with it, then containing it; // pages before posts before products when two match equally well. A pasted address // that matched a page stays on top. $needle = mb_strtolower( $term ); $rank = static function ( array $item ) use ( $needle ): array { $title = mb_strtolower( (string) $item['title'] ); $score = match ( true ) { ! empty( $item['pasted'] ) => -1, $title === $needle => 0, str_starts_with( $title, $needle ) => 1, 1 === preg_match( '/(^|[\s\-\/(])' . preg_quote( $needle, '/' ) . '/u', $title ) => 2, str_contains( $title, $needle ) => 3, default => 4, }; $kind = match ( true ) { 'term' === $item['type'] => 2, 'page' === get_post_type( $item['id'] ) => 0, 'post' === get_post_type( $item['id'] ) => 1, 'product' === get_post_type( $item['id'] ) => 3, default => 2, }; return [ $score, $kind ]; }; $sorted = static function ( array $found ) use ( $rank ): array { $order = array_flip( array_keys( $found ) ); uksort( $found, static fn( $a, $b ): int => [ $rank( $found[ $a ] ), $order[ $a ] ] <=> [ $rank( $found[ $b ] ), $order[ $b ] ] ); return array_values( $found ); }; // Pages first into the ten places, products into what is left — then all of it in // rank order, so an exact product match still sits above a loose page match. $pages = array_slice( $sorted( array_merge( $out, $pages ) ), 0, 10 ); $out = $sorted( array_merge( $pages, array_slice( $sorted( $items ), 0, 10 - count( $pages ) ) ) ); // One entry per destination, keeping the first (the pasted-address match, if any). $seen = []; $out = array_values( array_filter( $out, static function ( array $item ) use ( &$seen ): bool { $key = $item['type'] . $item['id']; if ( isset( $seen[ $key ] ) ) { return false; } $seen[ $key ] = true; return true; } ) ); $out = array_slice( $out, 0, 10 ); // Two products both called "Mug" need telling apart. The list always shows each // one's address; a duplicated title also gets its SKU where there is one. $titles = array_count_values( array_map( static fn( array $i ): string => mb_strtolower( (string) $i['title'] ), $out ) ); foreach ( $out as &$item ) { $item['duplicate'] = ( $titles[ mb_strtolower( (string) $item['title'] ) ] ?? 0 ) > 1; } unset( $item ); return new WP_REST_Response( [ 'items' => $out ] ); } /** * Published posts of these types whose title or slug contains the term, best matches * first. One query; the ordering does in SQL what rank() does afterwards, so the * limit keeps the right rows rather than the first ones found. * * @param list $types * @return list */ private function titled( string $term, array $types, int $limit ): array { global $wpdb; if ( [] === $types ) { return []; } $escaped = $wpdb->esc_like( $term ); $slug = $wpdb->esc_like( sanitize_title( $term ) ); $in = implode( ',', array_fill( 0, count( $types ), '%s' ) ); // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders, PluginCheck.Security.DirectDB.UnescapedDBParameter -- {$in} is a run of %s placeholders built above, filled by the spread. $ids = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type IN ({$in}) AND ( post_title LIKE %s OR ( %s <> '' AND post_name LIKE %s ) ) ORDER BY CASE WHEN post_title = %s THEN 0 WHEN post_title LIKE %s THEN 1 WHEN post_title LIKE %s OR post_title LIKE %s THEN 2 WHEN post_title LIKE %s THEN 3 ELSE 4 END, CASE post_type WHEN 'page' THEN 0 WHEN 'post' THEN 1 ELSE 2 END, CHAR_LENGTH(post_title), post_title LIMIT %d", ...array_merge( $types, [ '%' . $escaped . '%', $slug, '%' . $slug . '%', $term, $escaped . '%', '% ' . $escaped . '%', '%-' . $escaped . '%', '%' . $escaped . '%', $limit, ] ) ) ); // phpcs:enable return array_map( 'intval', (array) $ids ); } /** @return array */ private function post_item( int $post_id ): array { $url = (string) get_permalink( $post_id ); $type = get_post_type_object( (string) get_post_type( $post_id ) ); return [ 'type' => 'post', 'id' => $post_id, 'title' => html_entity_decode( get_the_title( $post_id ), ENT_QUOTES, 'UTF-8' ) ?: __( '(no title)', 'codebold-redirects' ), 'kind' => $type ? (string) $type->labels->singular_name : '', 'sku' => 'product' === get_post_type( $post_id ) ? (string) get_post_meta( $post_id, '_sku', true ) : '', 'url' => $url, 'path' => Address::parse( $url )->relative(), ]; } /* --------------------------------------------------------------------- * The tester * ------------------------------------------------------------------ */ /** * What would happen to a visitor at this address — and why, rule by rule. * * Answered by the same Engine the front end uses, as a typical visitor by default * (logged out, on a computer) rather than as the administrator asking, with each of * those traits switchable so a conditional rule can be tested from the screen. */ public function test( WP_REST_Request $request ): WP_REST_Response|WP_Error { $input = trim( (string) $request->get_param( 'url' ) ); if ( '' === $input ) { return new WP_Error( 'codebold_no_url', __( 'Enter an address to test.', 'codebold-redirects' ), [ 'status' => 400 ] ); } $address = Address::parse( $input ); if ( ! $address->is_local() ) { return new WP_Error( 'codebold_external', /* translators: %s: a domain */ sprintf( __( '%s is a different website. Only addresses on this site can be redirected from here.', 'codebold-redirects' ), $address->host ), [ 'status' => 400 ] ); } $as = (array) $request->get_param( 'as' ); $visitor = Validator::neutral()->with( [ 'logged_in' => ! empty( $as['logged_in'] ), 'roles' => ! empty( $as['logged_in'] ) && ! empty( $as['role'] ) ? [ sanitize_key( (string) $as['role'] ) ] : [], 'mobile' => ! empty( $as['mobile'] ), 'referrer' => esc_url_raw( (string) ( $as['referrer'] ?? '' ) ), 'language' => sanitize_text_field( (string) ( $as['language'] ?? '' ) ), 'country' => strtoupper( sanitize_key( (string) ( $as['country'] ?? '' ) ) ), 'cookies' => self::cookies( (string) ( $as['cookie'] ?? '' ) ), 'ip' => sanitize_text_field( (string) ( $as['ip'] ?? '' ) ), 'user_agent' => sanitize_text_field( (string) ( $as['agent'] ?? '' ) ), ] ); $protected = false; foreach ( Validator::PROTECTED as $path ) { if ( str_starts_with( mb_strtolower( $address->path ) . '/', $path . '/' ) ) { $protected = true; } } $post_id = url_to_postid( Engine::absolute( $address->path ) ); $trace = []; $late = false; $outcome = $protected ? null : $this->engine->resolve( $address, $visitor, 'early', $trace ); // No ordinary rule, and probably no page: the "only if not found" rules get their // turn, as they would on the front end once WordPress had drawn a blank. if ( null === $outcome && ! $protected && 0 === $post_id && '/' !== $address->path ) { $late_trace = []; $outcome = $this->engine->resolve( $address, $visitor->with( [ 'not_found' => true ] ), 'late', $late_trace ); if ( null !== $outcome ) { $late = true; $trace = $late_trace; } } $hops = []; foreach ( null !== $outcome ? $outcome->hits : [] as $hit ) { $hops[] = [ 'id' => $hit->rule->id, 'source' => $hit->rule->source, 'match_type' => $hit->rule->match_type, 'code' => $hit->rule->status_code, 'url' => $hit->url, ]; } $result = match ( true ) { $protected => 'protected', null === $outcome => 'none', $outcome->loop => 'loop', null === $outcome->url => 'answer', default => 'redirect', }; foreach ( $trace as &$step ) { foreach ( $step['checked'] as &$check ) { $check['why'] = self::why( (string) $check['result'] ); } unset( $check ); } unset( $step ); return new WP_REST_Response( [ 'address' => $address->relative(), 'result' => $result, 'url' => $outcome?->url, 'code' => $outcome?->code, 'hops' => $hops, 'trace' => $trace, 'late' => $late, 'page' => $post_id > 0 ? [ 'id' => $post_id, 'title' => html_entity_decode( get_the_title( $post_id ), ENT_QUOTES, 'UTF-8' ) ] : null, 'visitor' => [ 'logged_in' => $visitor->logged_in, 'mobile' => $visitor->mobile ], ] ); } /** * "name=value; other=1" as typed in the tester, into the cookies a visitor would send. * * @return array */ private static function cookies( string $typed ): array { $out = []; foreach ( explode( ';', $typed ) as $pair ) { [ $name, $value ] = array_pad( explode( '=', $pair, 2 ), 2, '' ); $name = sanitize_text_field( trim( $name ) ); if ( '' !== $name ) { $out[ $name ] = sanitize_text_field( trim( $value ) ); } } return $out; } private static function why( string $result ): string { return match ( $result ) { 'matched' => __( 'Matched — this rule applies.', 'codebold-redirects' ), 'schedule' => __( 'The address matches, but the rule is outside its schedule right now.', 'codebold-redirects' ), 'conditions' => __( 'The address matches, but a condition does not hold for this visitor.', 'codebold-redirects' ), 'not_found_only' => __( 'The address matches, but this rule only applies when the page does not exist.', 'codebold-redirects' ), 'no_target' => __( 'The address matches, but the destination no longer exists.', 'codebold-redirects' ), default => '', }; } /** * Ask the site itself, the way a browser would, and follow each hop. * * This is the check that catches what the rules cannot see: a redirect in .htaccess, * a CDN rule, another plugin answering first, a page cache serving something stale. */ public function live( WP_REST_Request $request ): WP_REST_Response|WP_Error { $address = Address::parse( trim( (string) $request->get_param( 'url' ) ) ); if ( ! $address->is_local() ) { return new WP_Error( 'codebold_external', __( 'Only addresses on this site can be checked from here.', 'codebold-redirects' ), [ 'status' => 400 ] ); } $url = Engine::absolute( $address->relative() ); $hops = []; for ( $i = 0; $i < 8; $i++ ) { // Three seconds a hop. A site that cannot reach itself — common on local and // password-protected sites — should say so quickly, not after a long wait. $response = wp_remote_get( $url, [ 'redirection' => 0, 'timeout' => 3, 'limit_response_size' => 2048, 'user-agent' => 'CodeBold Redirects tester; ' . home_url( '/' ), // Core's own filter for requests a site makes to itself, so a site that has // opted in to verifying its own certificate is respected here too. 'sslverify' => (bool) apply_filters( 'https_local_ssl_verify', false ), // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound ] ); if ( is_wp_error( $response ) ) { $hops[] = [ 'url' => $url, 'status' => 0, 'error' => self::plain_error( $response->get_error_message() ), 'detail' => $response->get_error_message() ]; break; } $status = (int) wp_remote_retrieve_response_code( $response ); $location = (string) wp_remote_retrieve_header( $response, 'location' ); $by = (string) wp_remote_retrieve_header( $response, 'x-redirect-by' ); $hops[] = [ 'url' => $url, 'status' => $status, 'location' => $location, 'by' => $by ]; if ( $status < 300 || $status >= 400 || '' === $location ) { break; } $next = \WP_Http::make_absolute_url( $location, $url ); // Stop at the edge of the site: what another website does next is its business, // and following it would make this a tool for probing other people's servers. if ( ! Address::parse( $next )->is_local() ) { $hops[] = [ 'url' => $next, 'status' => null, 'external' => true ]; break; } $url = $next; } return new WP_REST_Response( [ 'hops' => $hops ] ); } /** * A transport error in words a site owner can act on. The original is kept for the * "Technical details" disclosure, where whoever looks after the server will want it. */ private static function plain_error( string $raw ): string { $lower = strtolower( $raw ); if ( str_contains( $lower, 'timed out' ) || str_contains( $lower, 'curl error 28' ) ) { return __( "Your site didn't answer itself in time. This often happens on local or password-protected sites. The rule check above is still accurate.", 'codebold-redirects' ); } if ( str_contains( $lower, 'resolve' ) ) { return __( "Your site couldn't look up its own address from the server. This often happens on local sites. The rule check above is still accurate.", 'codebold-redirects' ); } if ( str_contains( $lower, 'ssl' ) || str_contains( $lower, 'certificate' ) ) { return __( "Your site's security certificate wasn't accepted when it asked itself, which is usual on local sites. The rule check above is still accurate.", 'codebold-redirects' ); } if ( str_contains( $lower, 'refused' ) || str_contains( $lower, 'connect' ) ) { return __( "Your site couldn't connect to itself. The server may block requests to its own address. The rule check above is still accurate.", 'codebold-redirects' ); } return __( "Your site couldn't be asked directly just now. The rule check above is still accurate.", 'codebold-redirects' ); } /* --------------------------------------------------------------------- * Import and export * ------------------------------------------------------------------ */ public function sources(): WP_REST_Response { return new WP_REST_Response( [ 'sources' => $this->sources->detect() ] ); } public function parse( WP_REST_Request $request ): WP_REST_Response|WP_Error { $source = sanitize_key( (string) $request->get_param( 'source' ) ); if ( '' !== $source ) { if ( ! array_key_exists( $source, $this->sources->labels() ) ) { return new WP_Error( 'codebold_bad_source', __( 'Unknown plugin to import from.', 'codebold-redirects' ), [ 'status' => 400 ] ); } $parsed = $this->importer->read_source( $source ); } else { $content = (string) $request->get_param( 'content' ); if ( '' === trim( $content ) ) { return new WP_Error( 'codebold_empty', __( 'Choose a file or paste some redirects first.', 'codebold-redirects' ), [ 'status' => 400 ] ); } $format = sanitize_key( (string) ( $request->get_param( 'format' ) ?? 'auto' ) ); $parsed = $this->importer->read( $content, in_array( $format, [ 'auto', 'csv', 'json', 'server' ], true ) ? $format : 'auto' ); } $assessed = $this->importer->assess( $parsed['rows'] ); // Only what will actually be added is kept for the import; everything else is // already explained in the preview. $problems = array_values( array_filter( $assessed['rows'], static fn( array $r ): bool => in_array( $r['status'], [ 'refused', 'duplicate' ], true ) ) ); return new WP_REST_Response( [ 'token' => [] === $assessed['ready'] ? '' : $this->importer->stash( $assessed['ready'] ), 'format' => $parsed['format'], 'read' => count( $parsed['rows'] ), 'total' => count( $assessed['ready'] ), 'counts' => $assessed['counts'] + [ 'unreadable' => count( $parsed['errors'] ) ], 'batch' => Importer::BATCH, 'preview' => array_slice( $assessed['rows'], 0, 25 ), 'problems' => array_slice( $problems, 0, 200 ), 'errors' => array_slice( $parsed['errors'], 0, 200 ), 'error_count' => count( $parsed['errors'] ), ] ); } public function run( WP_REST_Request $request ): WP_REST_Response|WP_Error { $result = $this->importer->run( (string) $request->get_param( 'token' ), max( 0, (int) $request->get_param( 'batch' ) ), sanitize_text_field( (string) $request->get_param( 'group' ) ) ); if ( is_wp_error( $result ) ) { return $result; } return new WP_REST_Response( $result + [ 'counts' => $this->repository->counts(), 'groups' => $this->repository->groups() ] ); } public function export( WP_REST_Request $request ): WP_REST_Response { $format = (string) $request->get_param( 'format' ); return new WP_REST_Response( $this->exporter->export( in_array( $format, Exporter::FORMATS, true ) ? $format : 'csv', sanitize_text_field( (string) $request->get_param( 'group' ) ) ) ); } /* --------------------------------------------------------------------- * Settings and numbers * ------------------------------------------------------------------ */ public function settings(): WP_REST_Response { return new WP_REST_Response( Settings::all() ); } public function save_settings( WP_REST_Request $request ): WP_REST_Response { $values = (array) $request->get_json_params(); $saved = Settings::update( $values ); // The setting that empties the 404 log faster should take effect now, not // tomorrow at whatever time the daily job runs. if ( array_key_exists( 'log_retention', $values ) ) { $this->log->prune( (int) $saved['log_retention'] ); } return new WP_REST_Response( $saved ); } public function stats(): WP_REST_Response { return new WP_REST_Response( [ 'redirects' => $this->repository->counts(), 'not_found' => $this->log->counts(), 'conditions' => array_combine( Conditions::TYPES, array_map( [ Conditions::class, 'type_label' ], Conditions::TYPES ) ), ] ); } }