| 1 |
<?php |
| 2 |
/** |
| 3 |
* Cloudflare — thin wrapper around the Cloudflare v4 API for purging, |
| 4 |
* dev-mode toggle, and zone verification. |
| 5 |
* |
| 6 |
* Authentication: two supported modes — |
| 7 |
* token → Authorization: Bearer <api_token> (preferred — scoped) |
| 8 |
* key → X-Auth-Email + X-Auth-Key (legacy "Global API Key") |
| 9 |
* |
| 10 |
* We don't store or send the auth headers anywhere outside the |
| 11 |
* outgoing API request. No logging. The Free tier exposes Auto Purge |
| 12 |
* on Update + dev mode + manual purge; APO / Edge Cache TTL stay in |
| 13 |
* the Pro plugin per FEATURES.md "Cloudflare Integration" §8-10. |
| 14 |
* |
| 15 |
* @package XSpeed |
| 16 |
*/ |
| 17 |
|
| 18 |
declare(strict_types=1); |
| 19 |
|
| 20 |
namespace XSpeed; |
| 21 |
|
| 22 |
defined( 'ABSPATH' ) || exit; |
| 23 |
|
| 24 |
final class Cloudflare { |
| 25 |
|
| 26 |
private const API_BASE = 'https://api.cloudflare.com/client/v4'; |
| 27 |
|
| 28 |
/** |
| 29 |
* Verify the configured credentials by hitting GET /zones/{id}. |
| 30 |
* Returns parsed zone payload on success. |
| 31 |
* |
| 32 |
* @return array{ok:bool,status:int,body:array} |
| 33 |
*/ |
| 34 |
public static function verify( array $opts ): array { |
| 35 |
$zone = (string) ( $opts['zone_id'] ?? '' ); |
| 36 |
if ( '' === $zone ) { |
| 37 |
return self::fail( 0, 'Zone ID is empty.' ); |
| 38 |
} |
| 39 |
return self::request( $opts, 'GET', '/zones/' . rawurlencode( $zone ) ); |
| 40 |
} |
| 41 |
|
| 42 |
/** |
| 43 |
* Purge everything in the configured zone. Equivalent to clicking |
| 44 |
* "Purge Everything" in the Cloudflare dashboard. |
| 45 |
* |
| 46 |
* @return array{ok:bool,status:int,body:array} |
| 47 |
*/ |
| 48 |
public static function purge_all( array $opts ): array { |
| 49 |
$zone = (string) ( $opts['zone_id'] ?? '' ); |
| 50 |
if ( '' === $zone ) { |
| 51 |
return self::fail( 0, 'Zone ID is empty.' ); |
| 52 |
} |
| 53 |
return self::request( |
| 54 |
$opts, |
| 55 |
'POST', |
| 56 |
'/zones/' . rawurlencode( $zone ) . '/purge_cache', |
| 57 |
array( 'purge_everything' => true ) |
| 58 |
); |
| 59 |
} |
| 60 |
|
| 61 |
/** |
| 62 |
* Files sent per `purge_cache` call. |
| 63 |
* |
| 64 |
* Cloudflare's own limit is higher and varies by plan; 30 is what its |
| 65 |
* official WordPress plugin chunks at, and staying there keeps the |
| 66 |
* request body small on hosts with a modest `max_execution_time`. |
| 67 |
*/ |
| 68 |
private const PURGE_FILES_PER_CALL = 30; |
| 69 |
|
| 70 |
/** |
| 71 |
* Purge a specific list of URLs. |
| 72 |
* |
| 73 |
* Sent in chunks, so a list longer than one call still arrives in full. |
| 74 |
* It used to be truncated to the first 30 instead, and the |
| 75 |
* caller was told the purge succeeded — so on a site with more than 30 |
| 76 |
* affected pages the rest silently stayed at the edge. Nothing in the |
| 77 |
* result said which, because as far as the response was concerned the |
| 78 |
* call did succeed. |
| 79 |
* |
| 80 |
* Stops at the first failure and returns it, rather than carrying on and |
| 81 |
* reporting the last outcome: the usual reason a chunk fails is the token |
| 82 |
* or the zone, which the next chunk would hit too. |
| 83 |
* |
| 84 |
* @param string[] $urls |
| 85 |
* @return array{ok:bool,status:int,body:array} |
| 86 |
*/ |
| 87 |
public static function purge_urls( array $opts, array $urls ): array { |
| 88 |
$urls = array_values( array_unique( array_filter( array_map( 'strval', $urls ) ) ) ); |
| 89 |
if ( empty( $urls ) ) { |
| 90 |
return self::fail( 0, 'No URLs supplied.' ); |
| 91 |
} |
| 92 |
$zone = (string) ( $opts['zone_id'] ?? '' ); |
| 93 |
if ( '' === $zone ) { |
| 94 |
return self::fail( 0, 'Zone ID is empty.' ); |
| 95 |
} |
| 96 |
|
| 97 |
$result = array(); |
| 98 |
foreach ( array_chunk( $urls, self::PURGE_FILES_PER_CALL ) as $chunk ) { |
| 99 |
$result = self::request( |
| 100 |
$opts, |
| 101 |
'POST', |
| 102 |
'/zones/' . rawurlencode( $zone ) . '/purge_cache', |
| 103 |
array( 'files' => $chunk ) |
| 104 |
); |
| 105 |
if ( empty( $result['ok'] ) ) { |
| 106 |
return $result; |
| 107 |
} |
| 108 |
} |
| 109 |
|
| 110 |
return $result; |
| 111 |
} |
| 112 |
|
| 113 |
/** |
| 114 |
* Flip development mode on or off. CF auto-disables it after 3 |
| 115 |
* hours; that's the user's behavior, not something we model here. |
| 116 |
* |
| 117 |
* @return array{ok:bool,status:int,body:array} |
| 118 |
*/ |
| 119 |
public static function set_dev_mode( array $opts, bool $on ): array { |
| 120 |
$zone = (string) ( $opts['zone_id'] ?? '' ); |
| 121 |
if ( '' === $zone ) { |
| 122 |
return self::fail( 0, 'Zone ID is empty.' ); |
| 123 |
} |
| 124 |
return self::request( |
| 125 |
$opts, |
| 126 |
'PATCH', |
| 127 |
'/zones/' . rawurlencode( $zone ) . '/settings/development_mode', |
| 128 |
array( 'value' => $on ? 'on' : 'off' ) |
| 129 |
); |
| 130 |
} |
| 131 |
|
| 132 |
/** |
| 133 |
* Build the auth + content headers based on the selected auth_method. |
| 134 |
* Public for tests — they assert the right header pair is set. |
| 135 |
*/ |
| 136 |
public static function build_headers( array $opts ): array { |
| 137 |
$method = (string) ( $opts['auth_method'] ?? 'token' ); |
| 138 |
$base = array( 'Content-Type' => 'application/json' ); |
| 139 |
if ( 'token' === $method ) { |
| 140 |
$token = (string) ( $opts['api_token'] ?? '' ); |
| 141 |
if ( '' === $token ) { |
| 142 |
return $base; |
| 143 |
} |
| 144 |
$base['Authorization'] = 'Bearer ' . $token; |
| 145 |
return $base; |
| 146 |
} |
| 147 |
// key mode. |
| 148 |
$email = (string) ( $opts['email'] ?? '' ); |
| 149 |
$key = (string) ( $opts['api_key'] ?? '' ); |
| 150 |
if ( '' !== $email && '' !== $key ) { |
| 151 |
$base['X-Auth-Email'] = $email; |
| 152 |
$base['X-Auth-Key'] = $key; |
| 153 |
} |
| 154 |
return $base; |
| 155 |
} |
| 156 |
|
| 157 |
/** |
| 158 |
* Core request helper. Returns a normalized envelope: |
| 159 |
* ok → true when HTTP < 400 AND CF body { success: true }. |
| 160 |
* status → HTTP code (0 on transport failure). |
| 161 |
* body → decoded JSON or [ 'message' => $err ] on failure. |
| 162 |
*/ |
| 163 |
private static function request( array $opts, string $method, string $path, ?array $payload = null ): array { |
| 164 |
$headers = self::build_headers( $opts ); |
| 165 |
$args = array( |
| 166 |
'method' => $method, |
| 167 |
'headers' => $headers, |
| 168 |
'timeout' => 12, |
| 169 |
); |
| 170 |
if ( null !== $payload ) { |
| 171 |
$args['body'] = wp_json_encode( $payload ); |
| 172 |
} |
| 173 |
$url = self::API_BASE . $path; |
| 174 |
|
| 175 |
$response = wp_remote_request( $url, $args ); |
| 176 |
if ( is_wp_error( $response ) ) { |
| 177 |
return self::fail( 0, $response->get_error_message() ); |
| 178 |
} |
| 179 |
$status = (int) wp_remote_retrieve_response_code( $response ); |
| 180 |
$body = wp_remote_retrieve_body( $response ); |
| 181 |
$decoded = is_string( $body ) ? json_decode( $body, true ) : null; |
| 182 |
if ( ! is_array( $decoded ) ) { |
| 183 |
$decoded = array( 'message' => is_string( $body ) ? $body : 'Unparseable response' ); |
| 184 |
} |
| 185 |
$ok = ( $status < 400 ) && ! empty( $decoded['success'] ); |
| 186 |
return array( |
| 187 |
'ok' => $ok, |
| 188 |
'status' => $status, |
| 189 |
'body' => $decoded, |
| 190 |
); |
| 191 |
} |
| 192 |
|
| 193 |
private static function fail( int $status, string $message ): array { |
| 194 |
return array( |
| 195 |
'ok' => false, |
| 196 |
'status' => $status, |
| 197 |
'body' => array( 'success' => false, 'message' => $message ), |
| 198 |
); |
| 199 |
} |
| 200 |
} |
| 201 |
|