PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.3
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.3
1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
xspeed / includes / class-cloudflare.php

class-cloudflare.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.3.3, at includes/class-cloudflare.php

201 lines 6.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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