} */ public function evaluate( Rule $rule, Visitor $visitor ): array { $results = []; $pass = true; foreach ( $rule->conditions as $condition ) { $ok = $this->check( $condition, $visitor ); if ( ! empty( $condition['not'] ) && 'not_found' !== ( $condition['type'] ?? '' ) ) { $ok = ! $ok; } $results[] = [ 'type' => (string) ( $condition['type'] ?? '' ), 'pass' => $ok, 'label' => self::describe( $condition ), 'actual' => self::actual( $condition, $visitor ), ]; $pass = $pass && $ok; } return [ 'pass' => $pass, 'results' => $results ]; } /** * @param array $c */ private function check( array $c, Visitor $visitor ): bool { $value = $c['value'] ?? ''; switch ( $c['type'] ?? '' ) { case 'login': return 'out' === $value ? ! $visitor->logged_in : $visitor->logged_in; case 'role': return [] !== array_intersect( (array) $value, $visitor->roles ); case 'device': return 'mobile' === $value ? $visitor->mobile : ! $visitor->mobile; case 'referrer': return $this->text( (string) ( $c['op'] ?? 'contains' ), $visitor->referrer, (string) $value ); case 'agent': return $this->text( (string) ( $c['op'] ?? 'contains' ), $visitor->user_agent, (string) $value ); case 'language': return $this->language( $visitor->language, (array) $value ); case 'cookie': $name = (string) ( $c['name'] ?? '' ); if ( ! array_key_exists( $name, $visitor->cookies ) ) { return false; } return 'equals' === ( $c['op'] ?? 'exists' ) ? $visitor->cookies[ $name ] === (string) $value : true; case 'ip': foreach ( (array) $value as $range ) { if ( self::ip_in( $visitor->ip, (string) $range ) ) { return true; } } return false; case 'country': return '' !== $visitor->country && in_array( $visitor->country, array_map( 'strtoupper', (array) $value ), true ); case 'not_found': return true === $visitor->not_found; } // A condition this version does not understand — from a newer export, say — must // not quietly pass, or a rule meant for some visitors would fire for all of them. return false; } private function text( string $op, string $haystack, string $needle ): bool { switch ( $op ) { case 'empty': return '' === $haystack; case 'equals': return 0 === strcasecmp( $haystack, $needle ); case 'regex': return '' !== $needle && 1 === @preg_match( self::delimit( $needle, false ), $haystack ); // phpcs:ignore WordPress.PHP.NoSilencedErrors -- validated on save; a bad pattern must fail closed, not warn on the front end. default: return '' !== $needle && false !== stripos( $haystack, $needle ); } } /** * Accept-Language is a ranked list ("fr-CH, fr;q=0.9, en;q=0.8"). A rule for "fr" * should match a Swiss French browser; a rule for "fr-CH" should not match a French * one. So a code matches a preference that is either the same or more specific. * * Only the visitor's first choice counts. Almost every browser lists English somewhere * as a fallback, and matching any entry would send half the world to the English page. * * @param list $codes */ private function language( string $header, array $codes ): bool { $first = strtolower( trim( explode( ';', explode( ',', $header )[0] )[0] ) ); if ( '' === $first ) { return false; } foreach ( $codes as $code ) { $code = strtolower( trim( (string) $code ) ); if ( '' !== $code && ( $first === $code || str_starts_with( $first, $code . '-' ) ) ) { return true; } } return false; } public static function ip_in( string $ip, string $range ): bool { $range = trim( $range ); if ( '' === $ip || '' === $range ) { return false; } // Silenced throughout: an invalid address on either side simply does not match, // and inet_pton() reports that as a warning on the front end otherwise. if ( ! str_contains( $range, '/' ) ) { $a = @inet_pton( $ip ); // phpcs:ignore WordPress.PHP.NoSilencedErrors $b = @inet_pton( $range ); // phpcs:ignore WordPress.PHP.NoSilencedErrors return false !== $a && $a === $b; } [ $subnet, $bits ] = explode( '/', $range, 2 ); $ip_bin = @inet_pton( $ip ); // phpcs:ignore WordPress.PHP.NoSilencedErrors $subnet_bin = @inet_pton( $subnet ); // phpcs:ignore WordPress.PHP.NoSilencedErrors if ( false === $ip_bin || false === $subnet_bin || strlen( $ip_bin ) !== strlen( $subnet_bin ) ) { return false; } $bits = max( 0, min( strlen( $ip_bin ) * 8, (int) $bits ) ); $bytes = intdiv( $bits, 8 ); $rest = $bits % 8; if ( substr( $ip_bin, 0, $bytes ) !== substr( $subnet_bin, 0, $bytes ) ) { return false; } if ( 0 === $rest ) { return true; } $mask = ( 0xFF << ( 8 - $rest ) ) & 0xFF; return ( ord( $ip_bin[ $bytes ] ) & $mask ) === ( ord( $subnet_bin[ $bytes ] ) & $mask ); } /** * Wrap a pattern somebody typed in delimiters, so nobody has to know what those are. * "@" because it almost never appears in an address, and any that do are escaped. */ public static function delimit( string $pattern, bool $case_sensitive ): string { return '@' . str_replace( '@', '\@', $pattern ) . '@u' . ( $case_sensitive ? '' : 'i' ); } /** * Clean conditions from the form or an import. Anything unrecognised is dropped here * rather than stored, so check()'s fail-closed default is a last resort, not a habit. * * @param mixed $input * @return list>|\WP_Error */ public static function sanitize( mixed $input ): array|\WP_Error { if ( ! is_array( $input ) ) { return []; } $out = []; foreach ( $input as $c ) { if ( ! is_array( $c ) || ! in_array( $c['type'] ?? '', self::TYPES, true ) ) { continue; } $type = (string) $c['type']; $clean = [ 'type' => $type ]; if ( ! empty( $c['not'] ) && 'not_found' !== $type ) { $clean['not'] = true; } switch ( $type ) { case 'login': $clean['value'] = 'out' === ( $c['value'] ?? '' ) ? 'out' : 'in'; break; case 'device': $clean['value'] = 'desktop' === ( $c['value'] ?? '' ) ? 'desktop' : 'mobile'; break; case 'role': case 'language': case 'ip': case 'country': $list = is_array( $c['value'] ?? null ) ? $c['value'] : explode( ',', (string) ( $c['value'] ?? '' ) ); $list = array_values( array_filter( array_map( static fn( $v ) => sanitize_text_field( (string) $v ), $list ) ) ); if ( 'role' === $type ) { $list = array_map( 'sanitize_key', $list ); } if ( 'country' === $type ) { $list = array_map( 'strtoupper', $list ); } if ( [] === $list ) { return new \WP_Error( 'codebold_condition_empty', /* translators: %s: a condition, e.g. "Country" */ sprintf( __( 'The "%s" condition needs at least one value.', 'codebold-redirects' ), self::type_label( $type ) ) ); } $clean['value'] = $list; break; case 'referrer': case 'agent': $op = in_array( $c['op'] ?? '', [ 'contains', 'equals', 'regex', 'empty' ], true ) ? $c['op'] : 'contains'; $clean['op'] = $op; $clean['value'] = 'empty' === $op ? '' : sanitize_text_field( (string) ( $c['value'] ?? '' ) ); if ( 'empty' !== $op && '' === $clean['value'] ) { return new \WP_Error( 'codebold_condition_empty', /* translators: %s: a condition, e.g. "Referrer" */ sprintf( __( 'The "%s" condition needs something to look for.', 'codebold-redirects' ), self::type_label( $type ) ) ); } if ( 'regex' === $op && false === @preg_match( self::delimit( $clean['value'], false ), '' ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors return new \WP_Error( 'codebold_condition_regex', __( 'That condition pattern is not a valid regular expression.', 'codebold-redirects' ) ); } break; case 'cookie': $clean['name'] = sanitize_text_field( (string) ( $c['name'] ?? '' ) ); $clean['op'] = 'equals' === ( $c['op'] ?? '' ) ? 'equals' : 'exists'; if ( '' === $clean['name'] ) { return new \WP_Error( 'codebold_condition_empty', __( 'The cookie condition needs the name of a cookie.', 'codebold-redirects' ) ); } if ( 'equals' === $clean['op'] ) { $clean['value'] = sanitize_text_field( (string) ( $c['value'] ?? '' ) ); // An empty value would never match a cookie that is set, so the rule // would silently never fire. Say so instead. if ( '' === $clean['value'] ) { return new \WP_Error( 'codebold_condition_empty', __( 'The cookie condition needs the value to compare with — or choose "is set" instead of "equals".', 'codebold-redirects' ) ); } } break; } $out[] = $clean; } return $out; } /** * What this visitor actually is, in the terms of one condition — the other half of * the tester's answer. "Needs: visitor logged out" alone leaves the reader to work out * why it failed; "(this visitor is logged in)" does not. * * @param array $c */ public static function actual( array $c, Visitor $visitor ): string { return match ( $c['type'] ?? '' ) { 'login' => $visitor->logged_in ? __( 'this visitor is logged in', 'codebold-redirects' ) : __( 'this visitor is logged out', 'codebold-redirects' ), 'device' => $visitor->mobile ? __( 'this visitor is on a phone or tablet', 'codebold-redirects' ) : __( 'this visitor is on a computer', 'codebold-redirects' ), 'role' => [] === $visitor->roles ? __( 'this visitor has no role', 'codebold-redirects' ) /* translators: %s: role names */ : sprintf( __( 'this visitor has the role %s', 'codebold-redirects' ), implode( ', ', $visitor->roles ) ), 'referrer' => '' === $visitor->referrer ? __( 'this visitor did not come from another page', 'codebold-redirects' ) /* translators: %s: an address */ : sprintf( __( 'this visitor came from %s', 'codebold-redirects' ), $visitor->referrer ), 'agent' => '' === $visitor->user_agent ? __( 'this visitor sent no browser name', 'codebold-redirects' ) /* translators: %s: a browser identification string */ : sprintf( __( 'this visitor\'s browser is %s', 'codebold-redirects' ), mb_substr( $visitor->user_agent, 0, 80 ) ), 'language' => '' === $visitor->language ? __( 'this visitor\'s browser sent no language', 'codebold-redirects' ) /* translators: %s: a language code */ : sprintf( __( 'this visitor\'s browser language is %s', 'codebold-redirects' ), trim( explode( ';', explode( ',', $visitor->language )[0] )[0] ) ), 'country' => '' === $visitor->country ? __( 'this visitor\'s country is unknown', 'codebold-redirects' ) /* translators: %s: a country code */ : sprintf( __( 'this visitor is in %s', 'codebold-redirects' ), $visitor->country ), 'cookie' => ! array_key_exists( (string) ( $c['name'] ?? '' ), $visitor->cookies ) ? __( 'this visitor does not have that cookie', 'codebold-redirects' ) /* translators: %s: a cookie value */ : sprintf( __( 'this visitor\'s cookie is "%s"', 'codebold-redirects' ), $visitor->cookies[ (string) $c['name'] ] ), 'ip' => '' === $visitor->ip ? __( 'this visitor\'s IP address is unknown', 'codebold-redirects' ) /* translators: %s: an IP address */ : sprintf( __( 'this visitor\'s IP address is %s', 'codebold-redirects' ), $visitor->ip ), 'not_found' => true === $visitor->not_found ? __( 'the page does not exist', 'codebold-redirects' ) : __( 'the page exists', 'codebold-redirects' ), default => '', }; } public static function type_label( string $type ): string { return match ( $type ) { 'login' => __( 'Login status', 'codebold-redirects' ), 'role' => __( 'Role', 'codebold-redirects' ), 'device' => __( 'Device', 'codebold-redirects' ), 'referrer' => __( 'Came from', 'codebold-redirects' ), 'agent' => __( 'Browser', 'codebold-redirects' ), 'language' => __( 'Language', 'codebold-redirects' ), 'cookie' => __( 'Cookie', 'codebold-redirects' ), 'ip' => __( 'IP address', 'codebold-redirects' ), 'country' => __( 'Country', 'codebold-redirects' ), 'not_found' => __( 'Page not found', 'codebold-redirects' ), default => $type, }; } /** * One condition in words, for the tester and the CLI. * * @param array $c */ public static function describe( array $c ): string { $not = ! empty( $c['not'] ); $value = $c['value'] ?? ''; $list = is_array( $value ) ? implode( ', ', $value ) : (string) $value; $text = match ( $c['type'] ?? '' ) { 'login' => 'out' === $value ? __( 'visitor is logged out', 'codebold-redirects' ) : __( 'visitor is logged in', 'codebold-redirects' ), 'device' => 'desktop' === $value ? __( 'visitor is on a computer', 'codebold-redirects' ) : __( 'visitor is on a phone or tablet', 'codebold-redirects' ), /* translators: %s: role names */ 'role' => sprintf( __( 'visitor has the role %s', 'codebold-redirects' ), $list ), /* translators: %s: language codes */ 'language' => sprintf( __( 'browser language is %s', 'codebold-redirects' ), $list ), /* translators: %s: IP addresses */ 'ip' => sprintf( __( 'IP address is %s', 'codebold-redirects' ), $list ), /* translators: %s: country codes */ 'country' => sprintf( __( 'country is %s', 'codebold-redirects' ), $list ), 'not_found' => __( 'the page does not exist', 'codebold-redirects' ), 'cookie' => 'equals' === ( $c['op'] ?? '' ) /* translators: 1: cookie name, 2: value */ ? sprintf( __( 'cookie %1$s is "%2$s"', 'codebold-redirects' ), (string) ( $c['name'] ?? '' ), $list ) /* translators: %s: cookie name */ : sprintf( __( 'cookie %s is set', 'codebold-redirects' ), (string) ( $c['name'] ?? '' ) ), 'referrer', 'agent' => self::describe_text( $c ), default => (string) ( $c['type'] ?? '' ), }; /* translators: %s: a condition in words, e.g. "visitor is logged in" */ return $not ? sprintf( __( 'NOT: %s', 'codebold-redirects' ), $text ) : $text; } /** * @param array $c */ private static function describe_text( array $c ): string { $what = 'referrer' === $c['type'] ? __( 'came from', 'codebold-redirects' ) : __( 'browser', 'codebold-redirects' ); $value = (string) ( $c['value'] ?? '' ); return match ( $c['op'] ?? 'contains' ) { /* translators: %s: the words for referrer or browser */ 'empty' => sprintf( __( '%s is empty', 'codebold-redirects' ), $what ), /* translators: 1: the words for referrer or browser, 2: text */ 'equals' => sprintf( __( '%1$s is "%2$s"', 'codebold-redirects' ), $what, $value ), /* translators: 1: the words for referrer or browser, 2: a pattern */ 'regex' => sprintf( __( '%1$s matches %2$s', 'codebold-redirects' ), $what, $value ), /* translators: 1: the words for referrer or browser, 2: text */ default => sprintf( __( '%1$s contains "%2$s"', 'codebold-redirects' ), $what, $value ), }; } }