PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.22
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.22
2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 2.6.4 2.6.3 2.5.23 All 138 releases
metasync / includes / class-metasync-edge-cache-purge.php

class-metasync-edge-cache-purge.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.6.22, at includes/class-metasync-edge-cache-purge.php

632 lines 22.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MetaSync Edge Cache / CDN Purge Handler
4 *
5 * Purges external CDN caches (Cloudflare, Fastly, Akamai, Sucuri, Sevalla)
6 * and hosting-level caches (Cloudways Varnish, Flywheel) when OTTO updates pages.
7 *
8 * Mirrors the singleton structure of Metasync_Cache_Purge but handles only
9 * edge/CDN providers that require external API calls.
10 *
11 * @package Metasync
12 * @subpackage Metasync/includes
13 * @since 2.8.0
14 */
15
16 if (!defined('ABSPATH')) {
17 exit;
18 }
19
20 class Metasync_Edge_Cache_Purge {
21
22 /**
23 * Singleton instance.
24 *
25 * @var self|null
26 */
27 private static $instance = null;
28
29 /**
30 * Cached settings from metasync_edge_cache_options.
31 *
32 * @var array|null
33 */
34 private $settings = null;
35
36 /**
37 * HTTP timeout for all external API calls (seconds).
38 */
39 const API_TIMEOUT = 5;
40
41 /**
42 * Cloudflare max tags per purge request.
43 */
44 const CF_MAX_TAGS_PER_REQUEST = 30;
45
46 /**
47 * Get singleton instance.
48 *
49 * @return self
50 */
51 public static function get_instance() {
52 if (null === self::$instance) {
53 self::$instance = new self();
54 }
55 return self::$instance;
56 }
57
58 /**
59 * Private constructor.
60 */
61 private function __construct() {}
62
63 /**
64 * Detect and persist Cloudways Varnish presence.
65 *
66 * Called once on `init` so the settings UI can show the toggle
67 * even on admin pages where X-Varnish header isn't present.
68 */
69 public static function detect_cloudways() {
70 if (!empty($_SERVER['HTTP_X_VARNISH']) && !get_option('metasync_cloudways_detected')) {
71 update_option('metasync_cloudways_detected', true, true);
72 }
73 }
74
75 // ──────────────────────────────────────────────────────────────
76 // Public API
77 // ──────────────────────────────────────────────────────────────
78
79 /**
80 * Static wrapper: purge edge caches for the given URLs.
81 *
82 * Safe to call from anywhere — failures are logged, never thrown.
83 *
84 * Accepts either a single URL string or an array of URLs. The argument is
85 * intentionally untyped: a strict `array` hint would raise a TypeError at
86 * the call boundary (before the body runs), which the try/catch below could
87 * never intercept — contradicting the "never thrown" contract above. Any
88 * non-string, non-array input is normalised away and ignored.
89 *
90 * @param mixed $urls Absolute URL(s) that were modified by OTTO — a string
91 * or an array of strings; anything else is ignored.
92 */
93 public static function purge($urls) {
94 // Normalise a single URL string to a one-element array.
95 if (is_string($urls)) {
96 $urls = array($urls);
97 }
98
99 if (!is_array($urls) || empty($urls)) {
100 return;
101 }
102
103 try {
104 self::get_instance()->purge_urls($urls);
105 } catch (\Throwable $e) {
106 self::log_error('purge', $e->getMessage());
107 }
108 }
109
110 /**
111 * Purge edge caches for a list of URLs.
112 *
113 * Resolves URLs to post IDs where possible for tag-based purging.
114 * Falls back to URL-based purging when IDs can't be resolved.
115 *
116 * @param array $urls Absolute URLs modified by OTTO.
117 */
118 public function purge_urls(array $urls) {
119 $settings = $this->get_settings();
120
121 // Resolve URLs → post IDs for tag-based providers
122 $post_ids = array();
123 $unresolved_urls = array();
124
125 foreach ($urls as $url) {
126 $post_id = url_to_postid($url);
127 if ($post_id > 0) {
128 $post_ids[] = $post_id;
129 } else {
130 $unresolved_urls[] = $url;
131 }
132 }
133
134 $post_ids = array_unique($post_ids);
135
136 // Tag-based CDN providers (prefer tags, fall back to URLs)
137 if (!empty($settings['cloudflare_enabled']) && $this->has_credentials('cloudflare')) {
138 $this->purge_cloudflare($post_ids, $unresolved_urls, $settings);
139 }
140
141 if (!empty($settings['fastly_enabled']) && $this->has_credentials('fastly')) {
142 $this->purge_fastly($post_ids, $settings);
143 }
144
145 if (!empty($settings['akamai_enabled']) && $this->has_credentials('akamai')) {
146 $this->purge_akamai($post_ids, $settings);
147 }
148
149 // Full-flush providers (fire once per batch, not per URL)
150 if (!empty($settings['sucuri_enabled']) && $this->has_credentials('sucuri')) {
151 $this->purge_sucuri($settings);
152 }
153
154 if (!empty($settings['sevalla_enabled']) && $this->has_credentials('sevalla')) {
155 // Only if KinstaCache mu-plugin is NOT available
156 if (!class_exists('KinstaCache')) {
157 $this->purge_sevalla($settings);
158 }
159 }
160
161 // Hosting-level providers
162 if (!empty($settings['cloudways_enabled'])) {
163 $this->purge_cloudways($urls);
164 }
165
166 if (!empty($settings['flywheel_enabled']) && defined('FLYWHEEL_CONFIG_DIR')) {
167 $this->purge_flywheel();
168 }
169 }
170
171 /**
172 * Purge edge caches by post IDs (tag-based).
173 *
174 * @param array $post_ids WordPress post IDs.
175 */
176 public function purge_by_post_ids(array $post_ids) {
177 if (empty($post_ids)) {
178 return;
179 }
180
181 $settings = $this->get_settings();
182
183 if (!empty($settings['cloudflare_enabled']) && $this->has_credentials('cloudflare')) {
184 $this->purge_cloudflare($post_ids, array(), $settings);
185 }
186
187 if (!empty($settings['fastly_enabled']) && $this->has_credentials('fastly')) {
188 $this->purge_fastly($post_ids, $settings);
189 }
190
191 if (!empty($settings['akamai_enabled']) && $this->has_credentials('akamai')) {
192 $this->purge_akamai($post_ids, $settings);
193 }
194 }
195
196 // ──────────────────────────────────────────────────────────────
197 // CDN Provider Implementations
198 // ──────────────────────────────────────────────────────────────
199
200 /**
201 * Purge Cloudflare via Cache-Tag API.
202 *
203 * Uses tag-based purge for resolved post IDs (up to 30 tags per request).
204 * Falls back to URL-based purge for unresolved URLs.
205 *
206 * @see https://developers.cloudflare.com/api/resources/cache/methods/purge/
207 *
208 * @param array $post_ids Resolved post IDs.
209 * @param array $fallback_urls URLs that couldn't be resolved to post IDs.
210 * @param array $settings Edge cache settings.
211 */
212 private function purge_cloudflare(array $post_ids, array $fallback_urls, array $settings) {
213 $zone_id = $settings['cloudflare_zone_id'];
214 $api_token = $settings['cloudflare_api_token'];
215 $endpoint = 'https://api.cloudflare.com/client/v4/zones/' . urlencode($zone_id) . '/purge_cache';
216
217 $headers = array(
218 'Authorization' => 'Bearer ' . $api_token,
219 'Content-Type' => 'application/json',
220 );
221
222 // Tag-based purge (chunked to 30 per request)
223 if (!empty($post_ids)) {
224 $tags = array_map(function ($id) {
225 return 'metasync-post-' . $id;
226 }, $post_ids);
227
228 foreach (array_chunk($tags, self::CF_MAX_TAGS_PER_REQUEST) as $chunk) {
229 $response = wp_remote_post($endpoint, array(
230 'headers' => $headers,
231 'body' => wp_json_encode(array('tags' => $chunk)),
232 'timeout' => self::API_TIMEOUT,
233 ));
234
235 if (is_wp_error($response)) {
236 self::log_error('Cloudflare tag purge', $response->get_error_message());
237 } else {
238 $code = wp_remote_retrieve_response_code($response);
239 if ($code < 200 || $code >= 300) {
240 self::log_error('Cloudflare tag purge', 'HTTP ' . $code);
241 }
242 }
243 }
244 }
245
246 // URL-based fallback for unresolved URLs
247 if (!empty($fallback_urls)) {
248 foreach (array_chunk($fallback_urls, self::CF_MAX_TAGS_PER_REQUEST) as $chunk) {
249 $response = wp_remote_post($endpoint, array(
250 'headers' => $headers,
251 'body' => wp_json_encode(array('files' => $chunk)),
252 'timeout' => self::API_TIMEOUT,
253 ));
254
255 if (is_wp_error($response)) {
256 self::log_error('Cloudflare URL purge', $response->get_error_message());
257 } else {
258 $code = wp_remote_retrieve_response_code($response);
259 if ($code < 200 || $code >= 300) {
260 self::log_error('Cloudflare URL purge', 'HTTP ' . $code);
261 }
262 }
263 }
264 }
265 }
266
267 /**
268 * Purge Fastly via Surrogate-Key API.
269 *
270 * Uses soft purge (marks stale) for graceful invalidation.
271 *
272 * @see https://www.fastly.com/documentation/reference/api/purging/
273 *
274 * @param array $post_ids Resolved post IDs.
275 * @param array $settings Edge cache settings.
276 */
277 private function purge_fastly(array $post_ids, array $settings) {
278 if (empty($post_ids)) {
279 return;
280 }
281
282 $service_id = $settings['fastly_service_id'];
283 $api_token = $settings['fastly_api_token'];
284 $endpoint = 'https://api.fastly.com/service/' . urlencode($service_id) . '/purge';
285
286 $keys = array_map(function ($id) {
287 return 'metasync-post-' . $id;
288 }, $post_ids);
289
290 $response = wp_remote_post($endpoint, array(
291 'headers' => array(
292 'Fastly-Key' => $api_token,
293 'Content-Type' => 'application/json',
294 'Fastly-Soft-Purge' => '1',
295 ),
296 'body' => wp_json_encode(array('surrogate_keys' => $keys)),
297 'timeout' => self::API_TIMEOUT,
298 ));
299
300 if (is_wp_error($response)) {
301 self::log_error('Fastly purge', $response->get_error_message());
302 } else {
303 $code = wp_remote_retrieve_response_code($response);
304 if ($code < 200 || $code >= 300) {
305 self::log_error('Fastly purge', 'HTTP ' . $code);
306 }
307 }
308 }
309
310 /**
311 * Purge Akamai via CCU v3 Fast Purge API (tag-based invalidation).
312 *
313 * Uses EdgeGrid HMAC signing for authentication.
314 *
315 * @see https://techdocs.akamai.com/purge-cache/reference/invalidate-tag
316 *
317 * @param array $post_ids Resolved post IDs.
318 * @param array $settings Edge cache settings.
319 */
320 private function purge_akamai(array $post_ids, array $settings) {
321 if (empty($post_ids)) {
322 return;
323 }
324
325 $tags = array_map(function ($id) {
326 return 'metasync-post-' . $id;
327 }, $post_ids);
328
329 $host = rtrim($settings['akamai_host'], '/');
330 $path = '/ccu/v3/invalidate/tag/production';
331 $url = 'https://' . $host . $path;
332 $body = wp_json_encode(array('objects' => $tags));
333 $content_type = 'application/json';
334
335 $auth_header = $this->sign_akamai_request(
336 'POST',
337 'https',
338 $host,
339 $path,
340 $body,
341 $content_type,
342 $settings['akamai_client_token'],
343 $settings['akamai_client_secret'],
344 $settings['akamai_access_token']
345 );
346
347 if (empty($auth_header)) {
348 self::log_error('Akamai purge', 'Failed to generate EdgeGrid signature');
349 return;
350 }
351
352 $response = wp_remote_post($url, array(
353 'headers' => array(
354 'Authorization' => $auth_header,
355 'Content-Type' => $content_type,
356 ),
357 'body' => $body,
358 'timeout' => self::API_TIMEOUT,
359 ));
360
361 if (is_wp_error($response)) {
362 self::log_error('Akamai purge', $response->get_error_message());
363 } else {
364 $code = wp_remote_retrieve_response_code($response);
365 // Akamai returns 201 on success
366 if ($code < 200 || $code >= 300) {
367 self::log_error('Akamai purge', 'HTTP ' . $code);
368 }
369 }
370 }
371
372 /**
373 * Purge Sucuri WAF cache (full flush).
374 *
375 * Sucuri does not support tag-based or URL-based selective purging.
376 * Fires once per batch, not per URL.
377 *
378 * @see https://docs.sucuri.net/website-firewall/api/
379 *
380 * @param array $settings Edge cache settings.
381 */
382 private function purge_sucuri(array $settings) {
383 $response = wp_remote_get(
384 add_query_arg(array(
385 'k' => $settings['sucuri_api_key'],
386 's' => $settings['sucuri_api_secret'],
387 'a' => 'clear_cache',
388 ), 'https://waf.sucuri.net/api'),
389 array('timeout' => self::API_TIMEOUT)
390 );
391
392 if (is_wp_error($response)) {
393 self::log_error('Sucuri purge', $response->get_error_message());
394 } else {
395 $code = wp_remote_retrieve_response_code($response);
396 if ($code < 200 || $code >= 300) {
397 self::log_error('Sucuri purge', 'HTTP ' . $code);
398 }
399 }
400 }
401
402 /**
403 * Purge Sevalla / Kinsta edge cache via API.
404 *
405 * Tries the v3 edge-cache-specific endpoint first (purge-cache),
406 * then falls back to v2 general cache clear if v3 returns 404.
407 *
408 * Only called when KinstaCache mu-plugin is NOT available.
409 *
410 * @see https://api-docs.sevalla.com/v3/applications/purge-edge-cache
411 * @see https://docs.sevalla.com/applications/edge-caching
412 *
413 * @param array $settings Edge cache settings.
414 */
415 private function purge_sevalla(array $settings) {
416 $app_id = $settings['sevalla_application_id'];
417 $headers = array(
418 'Authorization' => 'Bearer ' . $settings['sevalla_api_key'],
419 );
420
421 // v3: Edge-cache-specific endpoint (preferred)
422 $v3_endpoint = 'https://api.sevalla.com/v3/applications/' . urlencode($app_id) . '/purge-cache';
423
424 $response = wp_remote_post($v3_endpoint, array(
425 'headers' => $headers,
426 'timeout' => self::API_TIMEOUT,
427 ));
428
429 if (is_wp_error($response)) {
430 self::log_error('Sevalla v3 purge', $response->get_error_message());
431 return;
432 }
433
434 $code = wp_remote_retrieve_response_code($response);
435
436 // v3 succeeded
437 if ($code >= 200 && $code < 300) {
438 return;
439 }
440
441 // v3 not available — fall back to v2 general cache clear
442 if ($code === 404) {
443 self::log_error('Sevalla purge', 'v3 endpoint not found, falling back to v2');
444 $v2_endpoint = 'https://api.sevalla.com/v2/applications/' . urlencode($app_id) . '/clear-cache';
445
446 $response = wp_remote_post($v2_endpoint, array(
447 'headers' => $headers,
448 'timeout' => self::API_TIMEOUT,
449 ));
450
451 if (is_wp_error($response)) {
452 self::log_error('Sevalla v2 purge', $response->get_error_message());
453 } else {
454 $code = wp_remote_retrieve_response_code($response);
455 if ($code < 200 || $code >= 300) {
456 self::log_error('Sevalla v2 purge', 'HTTP ' . $code);
457 }
458 }
459 return;
460 }
461
462 // Other error on v3
463 self::log_error('Sevalla purge', 'HTTP ' . $code);
464 }
465
466 /**
467 * Purge Cloudways Varnish cache via HTTP PURGE per URL.
468 *
469 * @param array $urls URLs to purge.
470 */
471 private function purge_cloudways(array $urls) {
472 foreach ($urls as $url) {
473 $parsed = wp_parse_url($url);
474 if (empty($parsed['path'])) {
475 continue;
476 }
477
478 // PURGE request to localhost with the URL path
479 $response = wp_remote_request('http://127.0.0.1:80/', array(
480 'method' => 'PURGE',
481 'headers' => array(
482 'Host' => $parsed['host'] ?? wp_parse_url(home_url(), PHP_URL_HOST),
483 'X-Purge-URL' => $parsed['path'] . (isset($parsed['query']) ? '?' . $parsed['query'] : ''),
484 ),
485 'timeout' => self::API_TIMEOUT,
486 ));
487
488 if (is_wp_error($response)) {
489 self::log_error('Cloudways purge', $response->get_error_message());
490 }
491 }
492 }
493
494 /**
495 * Purge Flywheel cache (full flush).
496 *
497 * Fires once per batch via Flywheel's native action hook.
498 */
499 private function purge_flywheel() {
500 if (has_action('fl_clear_all_cache')) {
501 do_action('fl_clear_all_cache');
502 }
503 }
504
505 // ──────────────────────────────────────────────────────────────
506 // Akamai EdgeGrid HMAC Signing
507 // ──────────────────────────────────────────────────────────────
508
509 /**
510 * Generate Akamai EdgeGrid Authorization header.
511 *
512 * @see https://techdocs.akamai.com/developer/docs/authenticate-with-edgegrid
513 *
514 * @param string $method HTTP method.
515 * @param string $scheme URL scheme (https).
516 * @param string $host EdgeGrid host.
517 * @param string $path Request path.
518 * @param string $body Request body.
519 * @param string $content_type Content-Type header.
520 * @param string $client_token Client token.
521 * @param string $client_secret Client secret.
522 * @param string $access_token Access token.
523 * @return string Authorization header value, or empty string on failure.
524 */
525 private function sign_akamai_request($method, $scheme, $host, $path, $body, $content_type, $client_token, $client_secret, $access_token) {
526 try {
527 $timestamp = gmdate('Ymd\TH:i:s+0000');
528 $nonce = wp_generate_uuid4();
529
530 // Auth header prefix (unsigned)
531 $auth_header = sprintf(
532 'EG1-HMAC-SHA256 client_token=%s;access_token=%s;timestamp=%s;nonce=%s;',
533 $client_token,
534 $access_token,
535 $timestamp,
536 $nonce
537 );
538
539 // Content hash (POST body, max 131072 bytes)
540 $content_hash = '';
541 if ($method === 'POST' && !empty($body)) {
542 $body_to_hash = substr($body, 0, 131072);
543 $content_hash = base64_encode(hash('sha256', $body_to_hash, true));
544 }
545
546 // Data to sign
547 $data_to_sign = implode("\t", array(
548 $method,
549 $scheme,
550 $host,
551 $path,
552 '', // query string (empty for this endpoint)
553 $content_hash,
554 $auth_header,
555 ));
556
557 // Signing key = HMAC-SHA256(client_secret, timestamp)
558 $signing_key = base64_encode(
559 hash_hmac('sha256', $timestamp, base64_decode($client_secret), true)
560 );
561
562 // Signature = HMAC-SHA256(signing_key, data_to_sign)
563 $signature = base64_encode(
564 hash_hmac('sha256', $data_to_sign, base64_decode($signing_key), true)
565 );
566
567 return $auth_header . 'signature=' . $signature;
568 } catch (Exception $e) {
569 self::log_error('Akamai EdgeGrid signing', $e->getMessage());
570 return '';
571 }
572 }
573
574 // ──────────────────────────────────────────────────────────────
575 // Helpers
576 // ──────────────────────────────────────────────────────────────
577
578 /**
579 * Get edge cache settings (cached per request).
580 *
581 * @return array
582 */
583 private function get_settings() {
584 if (null === $this->settings) {
585 $this->settings = class_exists('Metasync_Edge_Cache_Settings')
586 ? Metasync_Edge_Cache_Settings::get_settings()
587 : wp_parse_args(get_option('metasync_edge_cache_options', array()), array());
588 }
589 return $this->settings;
590 }
591
592 /**
593 * Check if required credentials are present for a provider.
594 *
595 * @param string $provider Provider key.
596 * @return bool
597 */
598 private function has_credentials($provider) {
599 $settings = $this->get_settings();
600
601 switch ($provider) {
602 case 'cloudflare':
603 return !empty($settings['cloudflare_zone_id']) && !empty($settings['cloudflare_api_token']);
604 case 'fastly':
605 return !empty($settings['fastly_service_id']) && !empty($settings['fastly_api_token']);
606 case 'akamai':
607 return !empty($settings['akamai_client_token'])
608 && !empty($settings['akamai_access_token'])
609 && !empty($settings['akamai_client_secret'])
610 && !empty($settings['akamai_host']);
611 case 'sucuri':
612 return !empty($settings['sucuri_api_key']) && !empty($settings['sucuri_api_secret']);
613 case 'sevalla':
614 return !empty($settings['sevalla_api_key']) && !empty($settings['sevalla_application_id']);
615 default:
616 return false;
617 }
618 }
619
620 /**
621 * Log an error without exposing credentials.
622 *
623 * @param string $context Provider/operation name.
624 * @param string $message Error message.
625 */
626 private static function log_error($context, $message) {
627 if (defined('WP_DEBUG') && WP_DEBUG) {
628 error_log(sprintf('[MetaSync Edge Cache] %s failed: %s', $context, $message));
629 }
630 }
631 }
632