trim( (string) get_option( self::OPTION_ZONE, '' ) ), 'token' => trim( CSDT_Secrets::get( self::OPTION_TOKEN ) ), ); } /** Whether both credentials are saved. */ public static function is_configured(): bool { $c = self::credentials(); return '' !== $c['zone'] && '' !== $c['token']; } /** * The comment endpoint's path on this site: /wp-comments-post.php, or under the * WordPress directory on a subdirectory install. Reduced to characters a path can hold so * it is safe inside the rule's quoted string. */ public static function comment_path(): string { $path = ''; if ( function_exists( 'site_url' ) ) { // The WordPress directory's own path, then the file name: core has no function for this endpoint. $path = rtrim( (string) wp_parse_url( site_url(), PHP_URL_PATH ), '/' ) . '/wp-comments-post.php'; } $path = (string) preg_replace( '#[^A-Za-z0-9/._~-]#', '', $path ); if ( '' === $path || '/wp-comments-post.php' !== substr( $path, -21 ) ) { $path = '/wp-comments-post.php'; } return $path; } /** * The rule's filter expression. Path only: Cloudflare's Free plan accepts * nothing but the URI path in a rate-limiting expression, and comments are * only ever posted to this file, so adding the method would buy nothing. */ public static function expression(): string { return '(http.request.uri.path eq "' . self::comment_path() . '")'; } /** * Our rule, as the Rulesets API takes it. * * @return array */ public static function rule(): array { return array( 'ref' => self::REF, 'description' => self::REF, 'expression' => self::expression(), 'action' => 'block', 'enabled' => true, 'ratelimit' => array( 'characteristics' => array( 'ip.src', 'cf.colo.id' ), 'period' => self::PERIOD, 'requests_per_period' => self::REQUESTS_PER_PERIOD, 'mitigation_timeout' => self::MITIGATION_TIMEOUT, ), ); } /** * Whether a rule in a ruleset is ours. * * @param mixed $rule One rule from Cloudflare. */ public static function is_ours( $rule ): bool { return is_array( $rule ) && ( self::REF === ( $rule['ref'] ?? '' ) || self::REF === ( $rule['description'] ?? '' ) ); } /** * Our rule in a ruleset, or null. * * @param array $ruleset The `result` of a ruleset response. * @return array|null */ public static function find_ours( array $ruleset ): ?array { foreach ( (array) ( $ruleset['rules'] ?? array() ) as $rule ) { if ( self::is_ours( $rule ) ) { return $rule; } } return null; } /** * One API call. * * @param string $method HTTP method. * @param string $path Path under API, starting with /zones/. * @param array|null $body JSON body, or null for none. * @return array A CSDT_Http::json() result. */ private static function call( string $method, string $path, ?array $body = null ): array { $c = self::credentials(); $args = array( 'method' => $method, 'user-agent' => CSDT_DevTools::USER_AGENT, 'timeout' => 15, 'headers' => array( 'Authorization' => 'Bearer ' . $c['token'], 'Content-Type' => 'application/json', ), ); if ( null !== $body ) { $args['body'] = wp_json_encode( $body ); } // Every path is /zones/{zone}/ plus one of: rulesets/phases/http_ratelimit/entrypoint // (GET, PUT), rulesets/{id}/rules (POST), rulesets/{id}/rules/{rule} (DELETE). // wporg-disclosed: /rulesets/phases/http_ratelimit/entrypoint /rulesets/ return CSDT_Http::json( wp_remote_request( self::API . $path, $args ) ); } /** * Cloudflare's own explanation, verbatim, or a description of what answered instead. * * @param array $r A CSDT_Http::json() result. */ public static function error_text( array $r ): string { $lines = array(); $hint = ''; if ( is_array( $r['data'] ) && ! empty( $r['data']['errors'] ) && is_array( $r['data']['errors'] ) ) { foreach ( $r['data']['errors'] as $e ) { if ( is_array( $e ) && isset( $e['message'] ) && is_string( $e['message'] ) ) { $code = isset( $e['code'] ) ? (int) $e['code'] : 0; $lines[] = $e['message'] . ( $code ? ' (code ' . $code . ')' : '' ); if ( '' === $hint ) { $hint = self::code_hint( $code ); } } } } if ( $lines ) { $text = 'Cloudflare: ' . implode( '; ', $lines ); return '' === $hint ? $text : $text . ' ' . $hint; } return CSDT_Http::describe( $r ); } /** * A plain-language next step for the Cloudflare API error codes we can act on, or '' when * the message speaks for itself. Shared by every feature that calls error_text(), so the * wording names no one feature's permission; each panel states the exact permission it needs. * * @param int $code Cloudflare's numeric error code. */ private static function code_hint( int $code ): string { switch ( $code ) { case 10000: // Authentication error: token missing, wrong or expired. case 9106: // Missing credentials headers. case 9109: // Unauthorized for this resource (token lacks the permission). return __( 'The API token is missing, expired or not allowed for this zone. In Cloudflare create a token with the permission this feature needs, save it on the Images tab (Cloudflare Cache Purge), then use Test connection.', 'cloudscale-cyber-devtools' ); case 7000: // No route for the path, usually a wrong zone ID. case 7003: return __( 'Check the saved Cloudflare zone ID is the one for this site.', 'cloudscale-cyber-devtools' ); default: return ''; } } /** Whether a value is a Cloudflare identifier (32 hex characters). */ private static function is_id( $id ): bool { return is_string( $id ) && 1 === preg_match( '/^[a-f0-9]{32}$/i', $id ); } /** * The zone's rate-limiting entrypoint. * * @return array{ok:bool,exists:bool,ruleset:?array,error:string} */ public static function entrypoint(): array { $c = self::credentials(); if ( '' === $c['zone'] || '' === $c['token'] ) { return array( 'ok' => false, 'exists' => false, 'ruleset' => null, 'error' => __( 'Save a Cloudflare zone ID and API token first (Images tab, Cloudflare Cache Purge).', 'cloudscale-cyber-devtools' ) ); } if ( ! self::is_id( $c['zone'] ) ) { return array( 'ok' => false, 'exists' => false, 'ruleset' => null, 'error' => __( 'The saved Cloudflare zone ID is not a 32-character hex string.', 'cloudscale-cyber-devtools' ) ); } $r = self::call( 'GET', '/zones/' . $c['zone'] . '/rulesets/phases/' . self::PHASE . '/entrypoint' ); if ( 404 === $r['code'] ) { return array( 'ok' => true, 'exists' => false, 'ruleset' => null, 'error' => '' ); } if ( 200 === $r['code'] && ! empty( $r['data']['success'] ) && is_array( $r['data']['result'] ?? null ) && self::is_id( $r['data']['result']['id'] ?? null ) ) { return array( 'ok' => true, 'exists' => true, 'ruleset' => $r['data']['result'], 'error' => '' ); } return array( 'ok' => false, 'exists' => false, 'ruleset' => null, 'error' => self::error_text( $r ) ); } /** * Whether our rule exists, without changing anything. * * @return array{ok:bool,state:string,message:string} */ public static function status(): array { $ep = self::entrypoint(); if ( ! $ep['ok'] ) { return self::remember( false, 'error', $ep['error'] ); } if ( $ep['exists'] && null !== self::find_ours( (array) $ep['ruleset'] ) ) { return self::remember( true, 'present', __( 'The comment rate-limit rule is in place on Cloudflare.', 'cloudscale-cyber-devtools' ) ); } return self::remember( true, 'absent', __( 'No comment rate-limit rule on Cloudflare.', 'cloudscale-cyber-devtools' ) ); } /** * Adds our rule, leaving every other rule as it was. * * @return array{ok:bool,state:string,message:string} */ public static function add(): array { $ep = self::entrypoint(); if ( ! $ep['ok'] ) { return self::remember( false, 'error', $ep['error'] ); } $zone = self::credentials()['zone']; if ( $ep['exists'] ) { if ( null !== self::find_ours( (array) $ep['ruleset'] ) ) { return self::remember( true, 'present', __( 'The comment rate-limit rule was already in place. Nothing was changed.', 'cloudscale-cyber-devtools' ) ); } $r = self::call( 'POST', '/zones/' . $zone . '/rulesets/' . $ep['ruleset']['id'] . '/rules', self::rule() ); } else { $r = self::call( 'PUT', '/zones/' . $zone . '/rulesets/phases/' . self::PHASE . '/entrypoint', array( 'rules' => array( self::rule() ) ) ); } if ( $r['code'] >= 200 && $r['code'] < 300 && ! empty( $r['data']['success'] ) ) { return self::remember( true, 'present', sprintf( /* translators: 1: requests allowed, 2: period in seconds */ __( 'Added. Cloudflare now refuses comment submissions from an address that sends more than %1$d in %2$d seconds.', 'cloudscale-cyber-devtools' ), self::REQUESTS_PER_PERIOD, self::PERIOD ) ); } return self::remember( false, 'error', self::error_text( $r ) ); } /** * Removes our rule and nothing else. * * @return array{ok:bool,state:string,message:string} */ public static function remove(): array { $ep = self::entrypoint(); if ( ! $ep['ok'] ) { return self::remember( false, 'error', $ep['error'] ); } $ours = $ep['exists'] ? self::find_ours( (array) $ep['ruleset'] ) : null; if ( null === $ours ) { return self::remember( true, 'absent', __( 'There was no comment rate-limit rule to remove. Nothing was changed.', 'cloudscale-cyber-devtools' ) ); } if ( ! self::is_id( $ours['id'] ?? null ) ) { return self::remember( false, 'error', __( 'Cloudflare returned the rule without a usable id, so it was not removed.', 'cloudscale-cyber-devtools' ) ); } $zone = self::credentials()['zone']; $r = self::call( 'DELETE', '/zones/' . $zone . '/rulesets/' . $ep['ruleset']['id'] . '/rules/' . $ours['id'] ); if ( $r['code'] >= 200 && $r['code'] < 300 && ! empty( $r['data']['success'] ) ) { return self::remember( true, 'absent', __( 'Removed. Other rules on the zone were left as they were.', 'cloudscale-cyber-devtools' ) ); } return self::remember( false, 'error', self::error_text( $r ) ); } /** * Stores the outcome for the panel's status line and returns it. * * @param bool $ok Whether the call succeeded. * @param string $state present|absent|error. * @param string $message What to show. * @return array{ok:bool,state:string,message:string} */ private static function remember( bool $ok, string $state, string $message ): array { $out = array( 'ok' => $ok, 'state' => $state, 'message' => $message, ); update_option( self::OPTION_STATE, $out + array( 'at' => time() ), false ); return $out; } /** * The last known state, or null when nobody has checked yet. * * @return array{ok:bool,state:string,message:string,at:int}|null */ public static function last_state(): ?array { $s = get_option( self::OPTION_STATE, null ); return is_array( $s ) && isset( $s['state'], $s['message'], $s['at'] ) ? $s : null; } }