0 ) { $refusals[] = self::refusal( 'proved_genuine', __( 'Requests with this name were proved to come from a genuine crawler.', 'agentimus' ) ); } if ( ! empty( $others ) ) { $refusals[] = self::refusal( 'other_clients', __( 'The rule would also catch other clients using this name.', 'agentimus' ) ); } if ( $unspelled || '' === $form ) { $refusals[] = self::refusal( 'no_safe_form', __( 'This client writes its name in a way the rule cannot match exactly.', 'agentimus' ) ); } $out = self::result( $token, $form, $refusals, $others ); $out['unseen'] = $unseen; return $out; } /** * The one rule's expression for every name on it. * * Always leaves out verified bots, `robots.txt` (a crawler may still read the * polite rules) and certificate checks. * * @param array $names Name => form. * @return string '' when no name is valid: no names, no rule. */ public static function expression( array $names ) { $matches = array(); foreach ( $names as $token => $form ) { $token = strtolower( (string) $token ); if ( ! self::is_name( $token ) ) { continue; // Nothing but a checked name may ever reach the rule text. } if ( self::FORM_COMPATIBLE !== $form ) { $matches[] = 'lower(http.user_agent) contains " ' . $token . '/"'; $matches[] = 'lower(http.user_agent) contains "(' . $token . '/"'; $matches[] = 'starts_with(lower(http.user_agent), "' . $token . '/")'; } if ( self::FORM_VERSIONED !== $form ) { foreach ( self::compatible_texts( $token ) as $text ) { $matches[] = 'lower(http.user_agent) contains "' . $text . '"'; } } } if ( empty( $matches ) ) { return ''; } return '(' . implode( ' or ', $matches ) . ')' . ' and not cf.client.bot' . ' and http.request.uri.path ne "/robots.txt"' . ' and not starts_with(http.request.uri.path, "/.well-known/acme-challenge/")'; } /** * Whether these names fit in one rule. * * @param array $names Name => form. * @return bool */ public static function fits( array $names ) { return strlen( self::expression( $names ) ) <= self::MAX_EXPRESSION; } /** * About how many names the rule can hold, given what is on it. An estimate, * since it depends on how long the next names are; {@see fits()} decides. * * @param array $names Name => form. * @return int */ public static function capacity( array $names ) { $one = strlen( self::expression( array( 'abcdefghijkl' => self::FORM_VERSIONED ) ) ); $two = strlen( self::expression( array( 'abcdefghijkl' => self::FORM_VERSIONED, 'mnopqrstuvwx' => self::FORM_VERSIONED ) ) ); $per = $two - $one; $used = empty( $names ) ? $one - $per : strlen( self::expression( $names ) ); return count( $names ) + (int) floor( max( 0, self::MAX_EXPRESSION - $used ) / $per ); } /** * Whether this user agent is this client's own: the name is its product name, * and it is not a browser that happens to carry the name. * * @param string $token The lowercase name. * @param string $ua The user agent. * @return bool */ private static function is_own( $token, $ua ) { // One definition of "product name": a protected engine and a generic tool answer something else here. if ( Guard::suggest_token( $ua ) !== $token ) { return false; } $ua = strtolower( $ua ); // Crawlers that copy a browser's user agent say `compatible;`. A browser does not. return false !== strpos( $ua, 'compatible;' ) || 1 !== preg_match( '#(?:chrome|firefox|edg|crios|fxios)/|version/\d.*safari/#', $ua ); } /** * What the rule would do with this user agent: the same test as * {@see expression()}, in PHP. * * @param string $token The lowercase name. * @param string $form A FORM_ constant. * @param string $ua The user agent. * @return bool */ private static function matches( $token, $form, $ua ) { $ua = strtolower( $ua ); if ( self::FORM_COMPATIBLE !== $form && ( false !== strpos( $ua, ' ' . $token . '/' ) || false !== strpos( $ua, '(' . $token . '/' ) || 0 === strpos( $ua, $token . '/' ) ) ) { return true; } if ( self::FORM_VERSIONED !== $form ) { foreach ( self::compatible_texts( $token ) as $text ) { if ( false !== strpos( $ua, $text ) ) { return true; } } } return false; } /** * How a client with no version writes its name. The end of the name is part * of the match, so `compatible; name` cannot also catch `compatible; name-two`. * * @param string $token The lowercase name. * @return string[] */ private static function compatible_texts( $token ) { return array( 'compatible; ' . $token . ';', 'compatible; ' . $token . ')', 'compatible;' . $token . ';', 'compatible;' . $token . ')', ); } /** * Whether a name is letters, digits and the few marks a product name uses. * This is also what keeps quotes out of the rule text. * * @param string $token The lowercase name. * @return bool */ private static function is_name( $token ) { return 1 === preg_match( '/^[a-z0-9][a-z0-9._+-]*$/', $token ); } /** * Whether a name is inside one of these, or one of these is inside it. * * @param string $token The lowercase name. * @param string[] $names Names, any case. * @return bool */ public static function overlaps( $token, array $names ) { foreach ( $names as $name ) { $name = strtolower( trim( (string) $name ) ); if ( '' !== $name && ( false !== strpos( $token, $name ) || false !== strpos( $name, $token ) ) ) { return true; } } return false; } /** * @param string $code Stable code for callers and tests. * @param string $message What to tell the owner. * @return array */ private static function refusal( $code, $message ) { return array( 'code' => $code, 'message' => $message ); } /** * @param string $token The lowercase name. * @param string $form The form, or ''. * @param array $refusals The refusals. * @param string[] $others Other clients the name also fits. * @return array */ private static function result( $token, $form, array $refusals, array $others ) { return array( 'ok' => empty( $refusals ) && '' !== $form, 'token' => $token, 'form' => $form, 'unseen' => false, 'refusals' => $refusals, 'otherClients' => $others, ); } }