PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.10
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.10
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.10, at includes/class-metasync-edge-cache-purge.php

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