}>, errors: list} */ public static function parse( string $content ): array { $rows = []; $errors = []; foreach ( preg_split( '/\R/', $content ) ?: [] as $index => $raw ) { $line = $index + 1; $text = trim( $raw ); if ( '' === $text || str_starts_with( $text, '#' ) ) { continue; } $input = self::apache( $text ) ?? self::nginx( $text ); if ( null === $input ) { // A pasted .htaccess is mostly not redirects — WordPress's own block alone is // a page of RewriteCond and internal RewriteRule lines. Only a line that was // trying to be a redirect and could not be read is worth reporting. if ( preg_match( '/^(Redirect\w*|rewrite|return)\s/i', $text ) ) { $errors[] = [ 'line' => $line, 'message' => __( 'Not a redirect rule we recognise — skipped.', 'codebold-redirects' ) ]; } continue; } $rows[] = [ 'line' => $line, 'input' => $input ]; } return [ 'rows' => $rows, 'errors' => $errors ]; } /** @return list */ private static function words( string $text ): array { preg_match_all( '/"([^"]*)"|(\S+)/', $text, $m, PREG_SET_ORDER ); return array_map( static fn( array $found ): string => '' !== ( $found[1] ?? '' ) ? $found[1] : (string) ( $found[2] ?? '' ), $m ); } /** @return array|null */ private static function apache( string $text ): ?array { $words = self::words( $text ); $verb = strtolower( $words[0] ?? '' ); if ( in_array( $verb, [ 'redirect', 'redirectmatch', 'redirectpermanent', 'redirecttemp' ], true ) ) { array_shift( $words ); $code = match ( $verb ) { 'redirectpermanent' => 301, 'redirecttemp' => 302, default => null, }; if ( null === $code ) { $first = strtolower( $words[0] ?? '' ); $named = [ 'permanent' => 301, 'temp' => 302, 'seeother' => 303, 'gone' => 410 ]; if ( isset( $named[ $first ] ) || ctype_digit( $first ) ) { $code = $named[ $first ] ?? (int) $first; array_shift( $words ); } else { $code = 302; // Apache's own default for a bare Redirect. } } $code = 303 === $code ? 302 : $code; $source = (string) ( $words[0] ?? '' ); $target = (string) ( $words[1] ?? '' ); if ( '' === $source ) { return null; } if ( 'redirectmatch' === $verb ) { return [ 'source' => $source, 'target' => $target, 'status_code' => $code, 'match_type' => 'regex' ]; } // Apache's plain Redirect matches the start of the path: /old also redirects // /old/anything to /new/anything. A wildcard keeps that meaning exactly. return [ 'source' => rtrim( $source, '/' ) . '/*', 'target' => '' === $target ? '' : rtrim( $target, '/' ) . '/*', 'status_code' => $code, 'match_type' => 'wildcard', ]; } if ( 'rewriterule' === $verb ) { $pattern = (string) ( $words[1] ?? '' ); $target = (string) ( $words[2] ?? '' ); $flags = strtoupper( (string) ( $words[3] ?? '' ) ); $gone = (bool) preg_match( '/\bG\b/', $flags ) || preg_match( '/\bR=410\b/', $flags ); if ( ! $gone && ! preg_match( '/\bR(=(\d{3}))?\b/', $flags, $r ) ) { return null; // An internal rewrite, not a redirect. } $code = $gone ? 410 : (int) ( $r[2] ?? 302 ); // .htaccess patterns are matched without the leading slash; ours with it. if ( str_starts_with( $pattern, '^' ) && ! str_starts_with( $pattern, '^/' ) ) { $pattern = '^/' . substr( $pattern, 1 ); } if ( '-' === $target ) { $target = ''; } elseif ( '' !== $target && ! preg_match( '#^(https?:)?//|^/#i', $target ) ) { $target = '/' . $target; } return self::simplify( $pattern, $target, $code ); } return null; } /** @return array|null */ private static function nginx( string $text ): ?array { // The rewrite directive, with "permanent" or "redirect" as its flag. if ( preg_match( '/^rewrite\s+(\S+)\s+(\S+)\s*(permanent|redirect)?\s*;?$/i', $text, $m ) ) { $code = 'permanent' === strtolower( $m[3] ?? '' ) ? 301 : 302; return self::simplify( $m[1], rtrim( $m[2], ';' ), $code ); } // A location block that only returns: exact (=), regex (~ and ~*) or prefix. if ( preg_match( '/^location\s+(=|~\*?|\^~)?\s*(\S+)\s*\{\s*return\s+(\d{3})(?:\s+(\S+?))?\s*;\s*\}$/i', $text, $m ) ) { $modifier = $m[1]; $code = (int) $m[3]; $target = (string) ( $m[4] ?? '' ); if ( '=' === $modifier ) { return [ 'source' => $m[2], 'target' => $target, 'status_code' => $code, 'match_type' => 'exact' ]; } if ( str_starts_with( $modifier, '~' ) ) { return self::simplify( $m[2], $target, $code ); } return [ 'source' => rtrim( $m[2], '/' ) . '/*', 'target' => $target, 'status_code' => $code, 'match_type' => 'wildcard' ]; } return null; } /** * A server regex that is really just an address — "^/old-page/?$" — is stored as an * exact rule, which is faster to match and far easier to read in the list. * * @return array */ private static function simplify( string $pattern, string $target, int $code ): array { $inner = (string) preg_replace( [ '/^\^/', '/\/\?\$$/', '/\$$/' ], '', $pattern ); if ( '' !== $inner && ! preg_match( '/[\\\\()\[\]{}|*+?.^$]/', str_replace( '\.', '', $inner ) ) && str_starts_with( $pattern, '^' ) && str_ends_with( $pattern, '$' ) ) { return [ 'source' => str_replace( '\.', '.', $inner ), 'target' => $target, 'status_code' => $code, 'match_type' => 'exact' ]; } return [ 'source' => $pattern, 'target' => $target, 'status_code' => $code, 'match_type' => 'regex' ]; } /* --------------------------------------------------------------------- * Out * ------------------------------------------------------------------ */ /** * @param iterable $rules */ public static function apache_out( iterable $rules ): string { $lines = [ '# Redirects exported from CodeBold Redirects on ' . gmdate( 'Y-m-d H:i' ) . ' UTC.', '# Paste inside your .htaccess, above the "# BEGIN WordPress" block.', '', ]; foreach ( $rules as $rule ) { $skip = self::unsupported( $rule ); if ( null !== $skip ) { $lines[] = '# Skipped (' . $skip . '): ' . $rule->source; continue; } $regex = self::server_regex( $rule ); $target = self::server_target( $rule ); $flags = $rule->case_sensitive ? '' : '(?i)'; $lines[] = $rule->moves() ? sprintf( 'RedirectMatch %d %s %s', $rule->status_code, self::quote( $flags . $regex ), self::quote( $target ) ) : sprintf( 'RedirectMatch %d %s', $rule->status_code, self::quote( $flags . $regex ) ); } return implode( "\n", $lines ) . "\n"; } /** * @param iterable $rules */ public static function nginx_out( iterable $rules ): string { $lines = [ '# Redirects exported from CodeBold Redirects on ' . gmdate( 'Y-m-d H:i' ) . ' UTC.', '# Paste inside your server { } block, above the location that passes requests to PHP.', '', ]; foreach ( $rules as $rule ) { $skip = self::unsupported( $rule ); if ( null !== $skip ) { $lines[] = '# Skipped (' . $skip . '): ' . $rule->source; continue; } $regex = self::server_regex( $rule ); $modifier = $rule->case_sensitive ? '~' : '~*'; $return = $rule->moves() ? $rule->status_code . ' ' . self::server_target( $rule ) : (string) $rule->status_code; $lines[] = sprintf( 'location %s "%s" { return %s; }', $modifier, str_replace( '"', '\"', $regex ), $return ); } return implode( "\n", $lines ) . "\n"; } /** Why a rule cannot be written as a server rule, if it cannot. */ private static function unsupported( Rule $rule ): ?string { if ( ! $rule->enabled ) { return 'turned off'; } if ( [] !== $rule->conditions ) { return 'has conditions'; } if ( null !== $rule->starts_at || null !== $rule->ends_at ) { return 'scheduled'; } if ( Rule::REGEX !== $rule->match_type && str_contains( $rule->source, '?' ) ) { return 'matches a query string'; } return null; } private static function server_regex( Rule $rule ): string { if ( Rule::REGEX === $rule->match_type ) { return $rule->source; } $path = Address::parse( $rule->source )->path; if ( Rule::WILDCARD === $rule->match_type ) { $tail = ''; if ( str_ends_with( $path, '/*' ) ) { $path = substr( $path, 0, -2 ); $tail = '(?:/(.*))?'; } return '^' . implode( '(.*)', array_map( static fn( string $p ): string => preg_quote( $p, '#' ), explode( '*', $path ) ) ) . $tail . '/?$'; } return '^' . preg_quote( $path, '#' ) . ( '/' === $path ? '$' : '/?$' ); } private static function server_target( Rule $rule ): string { $target = $rule->target; if ( Rule::WILDCARD === $rule->match_type ) { $n = 0; $target = (string) preg_replace_callback( '/\*/', static function () use ( &$n ): string { return '$' . ( ++$n ); }, $target ); } $address = Address::parse( $target ); return $address->has_host() && $address->is_local() ? $address->relative() : $target; } private static function quote( string $value ): string { return preg_match( '/\s/', $value ) ? '"' . str_replace( '"', '\"', $value ) . '"' : $value; } }