>,total:int} */ public function paginate( string $search = '', int $page = 1, int $per_page = 50 ): array { global $wpdb; $table = Schema::table( 'redirects' ); $like = '%' . $wpdb->esc_like( $search ) . '%'; $offset = max( 0, ( $page - 1 ) * $per_page ); // An empty search makes the first condition true, so one literal query covers both cases. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin table. $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE ( %s = '' OR source_path LIKE %s OR target_url LIKE %s )", $table, $search, $like, $like ) ); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE ( %s = '' OR source_path LIKE %s OR target_url LIKE %s ) ORDER BY id DESC LIMIT %d OFFSET %d", $table, $search, $like, $like, $per_page, $offset ), ARRAY_A ); // phpcs:enable return array( 'items' => array_map( array( self::class, 'shape' ), (array) $rows ), 'total' => $total, ); } /** * All enabled rules (used to build the front-end map). * * @return array> */ public function enabled(): array { global $wpdb; $table = Schema::table( 'redirects' ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Cached by Redirector. return (array) $wpdb->get_results( $wpdb->prepare( 'SELECT id, source_path, target_url, code, match_mode FROM %i WHERE enabled = 1', $table ), ARRAY_A ); } /** * One rule. * * @param int $id ID. * @return array|null */ public function find( int $id ): ?array { global $wpdb; $table = Schema::table( 'redirects' ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin table. $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE id = %d', $table, $id ), ARRAY_A ); return is_array( $row ) ? self::shape( $row ) : null; } /** * Find by normalized source. * * @param string $source Normalized source path. * @return array|null */ public function find_by_source( string $source ): ?array { global $wpdb; $table = Schema::table( 'redirects' ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin table. $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE source_hash = %s', $table, sha1( $source ) ), ARRAY_A ); return is_array( $row ) ? self::shape( $row ) : null; } /** * Create or update a rule after validation. * * @param array $input source_path, target_url, code, match_mode, enabled. * @param int $id Existing ID (0 = create). * @return array|\WP_Error Saved rule or error. */ public function save( array $input, int $id = 0 ) { global $wpdb; $clean = $this->validate( $input, $id ); if ( is_wp_error( $clean ) ) { return $clean; } $table = Schema::table( 'redirects' ); $data = array( 'source_path' => $clean['source_path'], 'source_hash' => sha1( $clean['source_path'] ), 'target_url' => $clean['target_url'], 'code' => $clean['code'], 'match_mode' => $clean['match_mode'], 'enabled' => $clean['enabled'], ); if ( $id > 0 ) { $wpdb->update( $table, $data, array( 'id' => $id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin table. } else { $data['created_at'] = Clock::mysql(); $wpdb->insert( $table, $data ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Plugin table. $id = (int) $wpdb->insert_id; } Redirector::flush_cache(); $saved = $this->find( $id ); return null !== $saved ? $saved : new \WP_Error( 'unbreak_redirect_save', __( 'The redirect could not be saved.', 'unbreak' ) ); } /** * Delete a rule. * * @param int $id ID. */ public function delete( int $id ): bool { global $wpdb; $deleted = (bool) $wpdb->delete( Schema::table( 'redirects' ), array( 'id' => $id ), array( '%d' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin table. Redirector::flush_cache(); return $deleted; } /** * Count a hit. * * @param int $id ID. */ public function hit( int $id ): void { global $wpdb; $table = Schema::table( 'redirects' ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- One write per redirected request. $wpdb->query( $wpdb->prepare( 'UPDATE %i SET hits = hits + 1, last_hit_at = %s WHERE id = %d', $table, Clock::mysql(), $id ) ); } /** * Validate and normalize input. * * @param array $input Input. * @param int $id Rule being edited (0 = new). * @return array{source_path:string,target_url:string,code:int,match_mode:string,enabled:int}|\WP_Error */ public function validate( array $input, int $id = 0 ) { $source = Path::normalize( (string) ( $input['source_path'] ?? '' ), true ); if ( null === $source || '/' === $source ) { return new \WP_Error( 'unbreak_redirect_source', __( 'Enter a valid source path on this site, for example /old-page/.', 'unbreak' ), array( 'status' => 400 ) ); } $target_raw = trim( (string) ( $input['target_url'] ?? '' ) ); if ( '' === $target_raw ) { return new \WP_Error( 'unbreak_redirect_target', __( 'Enter a target URL or path.', 'unbreak' ), array( 'status' => 400 ) ); } if ( '/' === $target_raw[0] && ( ! isset( $target_raw[1] ) || '/' !== $target_raw[1] ) ) { $target = esc_url_raw( home_url( $target_raw ) ); } else { $target = esc_url_raw( $target_raw, array( 'http', 'https' ) ); } if ( '' === $target || null === Url::canonicalize( $target ) ) { return new \WP_Error( 'unbreak_redirect_target', __( 'The target must be an http(s) URL or a path starting with /.', 'unbreak' ), array( 'status' => 400 ) ); } $code = (int) ( $input['code'] ?? 301 ); if ( ! in_array( $code, self::CODES, true ) ) { $code = 301; } $mode = (string) ( $input['match_mode'] ?? '404' ); if ( ! in_array( $mode, self::MODES, true ) ) { $mode = '404'; } $enabled = ! isset( $input['enabled'] ) || ! empty( $input['enabled'] ) ? 1 : 0; $existing = $this->find_by_source( $source ); if ( null !== $existing && (int) $existing['id'] !== $id ) { return new \WP_Error( 'unbreak_redirect_duplicate', __( 'A redirect for this source path already exists.', 'unbreak' ), array( 'status' => 409 ) ); } $target_key = Url::is_internal( $target ) ? Path::normalize( $target, true ) : null; if ( null !== $target_key && ( $target_key === $source || Path::strip_query( $target_key ) === $source ) ) { return new \WP_Error( 'unbreak_redirect_loop', __( 'This redirect points to itself.', 'unbreak' ), array( 'status' => 400 ) ); } $chain = $this->chain_error( $source, $target_key, $id ); if ( null !== $chain ) { return $chain; } return array( 'source_path' => $source, 'target_url' => $target, 'code' => $code, 'match_mode' => $mode, 'enabled' => $enabled, ); } /** * Detect loops and chains longer than MAX_CHAIN hops that the new rule would create. * * @param string $source New rule source. * @param string|null $target_key New rule internal target (null = external target). * @param int $id Rule being edited. */ private function chain_error( string $source, ?string $target_key, int $id ): ?\WP_Error { $edges = array(); foreach ( $this->enabled() as $rule ) { if ( (int) $rule['id'] === $id ) { continue; } $to = Url::is_internal( (string) $rule['target_url'] ) ? Path::normalize( (string) $rule['target_url'], true ) : null; if ( null !== $to ) { $edges[ (string) $rule['source_path'] ] = $to; } } if ( null !== $target_key ) { $edges[ $source ] = $target_key; } // Forward: follow the chain starting at the new rule. $forward = 0; $seen = array( $source => true ); $current = $source; while ( isset( $edges[ $current ] ) ) { $current = $edges[ $current ]; ++$forward; if ( isset( $seen[ $current ] ) ) { return new \WP_Error( 'unbreak_redirect_loop', __( 'This redirect would create a redirect loop.', 'unbreak' ), array( 'status' => 400 ) ); } $seen[ $current ] = true; } // Backward: rules that already lead to the new source. $backward = 0; $frontier = array( $source ); $visited = array( $source => true ); while ( $backward <= self::MAX_CHAIN ) { $next = array(); foreach ( $edges as $from => $to ) { if ( in_array( $to, $frontier, true ) && ! isset( $visited[ $from ] ) ) { $next[] = $from; $visited[ $from ] = true; } } if ( empty( $next ) ) { break; } ++$backward; $frontier = $next; } if ( $forward + $backward > self::MAX_CHAIN ) { return new \WP_Error( 'unbreak_redirect_chain', /* translators: %d: maximum number of hops. */ sprintf( __( 'This redirect would create a chain of more than %d hops. Point it at the final destination instead.', 'unbreak' ), self::MAX_CHAIN ), array( 'status' => 400 ) ); } return null; } /** * Cast a DB row for the API. * * @param array $row Row. * @return array */ public static function shape( array $row ): array { return array( 'id' => (int) $row['id'], 'source_path' => (string) $row['source_path'], 'target_url' => (string) $row['target_url'], 'code' => (int) $row['code'], 'match_mode' => (string) $row['match_mode'], 'hits' => isset( $row['hits'] ) ? (int) $row['hits'] : 0, 'last_hit_at' => isset( $row['last_hit_at'] ) ? $row['last_hit_at'] : null, 'created_at' => isset( $row['created_at'] ) ? $row['created_at'] : null, 'enabled' => isset( $row['enabled'] ) ? (bool) (int) $row['enabled'] : true, ); } }