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-otto-cache-manager.php

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

832 lines 40.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 if (!defined('ABSPATH')) {
3 exit;
4 }
5
6 /**
7 * OTTO Cache Management manager.
8 *
9 * Extracted from Metasync_Admin to keep the admin class focused on UI concerns.
10 * Handles OTTO transient cache rendering, clearing, and excluded-URL AJAX operations.
11 *
12 * @package Metasync
13 * @subpackage Metasync/includes
14 */
15 class Metasync_Otto_Cache_Manager
16 {
17 /** @var self|null */
18 private static $instance = null;
19
20 /**
21 * Get singleton instance.
22 *
23 * @return self
24 */
25 public static function instance()
26 {
27 if (self::$instance === null) {
28 self::$instance = new self();
29 }
30 return self::$instance;
31 }
32
33 private function __construct() {}
34
35 /**
36 * Get the admin page slug.
37 */
38 private function get_page_slug()
39 {
40 return Metasync_Admin::$page_slug;
41 }
42
43 // ------------------------------------------------------------------
44 // Rendering
45 // ------------------------------------------------------------------
46
47 /**
48 * Render OTTO cache management section for inclusion in Advanced settings
49 */
50 public function render_otto_cache_management()
51 {
52 if (!class_exists('Metasync_Otto_Transient_Cache')) {
53 echo '<div class="notice notice-error inline"><p>';
54 echo '❌ <strong>Error:</strong> Transient Cache class not found.';
55 echo '</p></div>';
56 return;
57 }
58
59 $cache_count = Metasync_Otto_Transient_Cache::get_cache_count();
60
61 ?>
62 <!-- Cache Plugin Management -->
63 <div style="margin-bottom: 30px;">
64 <h4 style="margin-top: 0; color: var(--dashboard-text-primary);">Clear All Cache Plugins</h4>
65 <p style="margin-bottom: 15px; color: var(--dashboard-text-secondary);">Clear all cache plugins to ensure changes are visible immediately.</p>
66
67 <?php
68 if (class_exists('Metasync_Cache_Purge')) {
69 try {
70 $cache_purge = Metasync_Cache_Purge::get_instance();
71 $active_cache_plugins = $cache_purge->get_active_cache_plugins();
72
73 if (!empty($active_cache_plugins)) {
74 echo '<p style="color: var(--dashboard-text-primary);"><strong>Active Cache Plugins Detected:</strong></p>';
75 echo '<ul style="margin-bottom: 15px; color: var(--dashboard-text-primary);">';
76 foreach ($active_cache_plugins as $plugin_name) {
77 echo '<li>�
78 ' . esc_html($plugin_name) . '</li>';
79 }
80 echo '</ul>';
81 } else {
82 echo '<p style="color: var(--dashboard-text-secondary);">ℹ️ No cache plugins detected.</p>';
83 }
84 } catch (Exception $e) {
85 error_log('MetaSync Cache Status Error: ' . $e->getMessage());
86 echo '<p style="color: var(--dashboard-error);">⚠️ An error occurred while retrieving cache plugin status.</p>';
87 }
88 } else {
89 echo '<p style="color: var(--dashboard-error);">⚠️ Cache Purge class not loaded.</p>';
90 }
91 ?>
92
93 <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="margin-top: 15px;">
94 <input type="hidden" name="action" value="metasync_clear_all_cache_plugins" />
95 <?php wp_nonce_field('metasync_clear_cache_nonce', 'clear_cache_nonce'); ?>
96 <button type="submit" class="metasync-btn-primary" style="background: var(--dashboard-gradient-primary); color: #ffffff; border: none; padding: 10px 20px; border-radius: 8px; font-weight: 500; cursor: pointer; transition: all 0.3s ease; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); display: inline-block; width: auto; min-width: 240px; max-width: fit-content;" onmouseover="this.style.transform='translateY(-2px)'; this.style.boxShadow='0 4px 8px rgba(0, 0, 0, 0.15)';" onmouseout="this.style.transform='translateY(0)'; this.style.boxShadow='0 2px 4px rgba(0, 0, 0, 0.1)';">
97 🔄 Clear All Cache Plugins
98 </button>
99 <p class="description" style="margin-top: 10px; color: var(--dashboard-text-secondary);">This will clear cache from WP Rocket, LiteSpeed, W3 Total Cache, and all other detected cache plugins.</p>
100 </form>
101
102 <?php
103 if (isset($_GET['cache_cleared']) && $_GET['cache_cleared'] == '1') {
104 $cleared = isset($_GET['cleared']) ? intval($_GET['cleared']) : 0;
105 $failed = isset($_GET['failed']) ? intval($_GET['failed']) : 0;
106 $plugins = isset($_GET['plugins']) ? sanitize_text_field($_GET['plugins']) : '';
107
108 if ($cleared > 0) {
109 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
110 echo '�
111 <strong>Success!</strong> Cleared cache for ' . intval( $cleared ) . ' plugin(s)';
112 if ($plugins) {
113 echo ': ' . esc_html($plugins);
114 }
115 echo '</p></div>';
116 } else {
117 echo '<div class="notice notice-info inline" style="margin-top: 15px;"><p>';
118 echo 'ℹ️ No cache plugins found to clear. WordPress object cache was cleared.';
119 echo '</p></div>';
120 }
121
122 if ($failed > 0) {
123 echo '<div class="notice notice-warning inline" style="margin-top: 15px;"><p>';
124 echo '⚠️ Failed to clear ' . intval( $failed ) . ' plugin(s).';
125 echo '</p></div>';
126 }
127 }
128
129 if (isset($_GET['cache_error']) && $_GET['cache_error'] == '1') {
130 $message = isset($_GET['message']) ? urldecode(sanitize_text_field($_GET['message'])) : '';
131 if (empty($message)) {
132 $message = 'An unknown error occurred while clearing cache. Please check error logs for details.';
133 }
134 echo '<div class="notice notice-error inline" style="margin-top: 15px;"><p>';
135 echo ' <strong>Error clearing cache:</strong> ' . esc_html($message);
136 echo '</p></div>';
137 }
138 ?>
139 </div>
140
141 <!-- Hosting Cache Integration -->
142 <?php
143 $hosting_defaults = array('wpengine_enabled' => true, 'kinsta_enabled' => true);
144 $hosting_settings = wp_parse_args(get_option('metasync_hosting_cache_options', array()), $hosting_defaults);
145 $wpe_detected = class_exists('WpeCommon');
146 $kinsta_detected = class_exists('KinstaCache');
147 ?>
148 <div style="margin-bottom: 30px;">
149 <h4 style="margin-top: 0; color: var(--dashboard-text-primary);">Hosting Cache Integration</h4>
150 <p style="margin-bottom: 15px; color: var(--dashboard-text-secondary);">
151 Use your hosting provider's native API to purge the <strong>entire site cache</strong> in one click.
152 These options are independent of cache plugins and target the server-level cache layer.
153 </p>
154
155 <!-- Detection status badges -->
156 <div style="display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 20px;">
157 <span style="display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; border-radius: 6px; font-size: 13px; font-weight: 500;
158 background: <?php echo $wpe_detected ? 'rgba(34,197,94,0.12)' : 'rgba(156,163,175,0.12)'; ?>;
159 color: <?php echo $wpe_detected ? '#22c55e' : 'var(--dashboard-text-secondary)'; ?>;
160 border: 1px solid <?php echo $wpe_detected ? 'rgba(34,197,94,0.3)' : 'rgba(156,163,175,0.3)'; ?>;">
161 <?php echo $wpe_detected ? '�
162 ' : ''; ?> WP Engine <?php echo $wpe_detected ? '(detected)' : '(not detected)'; ?>
163 </span>
164 <span style="display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; border-radius: 6px; font-size: 13px; font-weight: 500;
165 background: <?php echo $kinsta_detected ? 'rgba(34,197,94,0.12)' : 'rgba(156,163,175,0.12)'; ?>;
166 color: <?php echo $kinsta_detected ? '#22c55e' : 'var(--dashboard-text-secondary)'; ?>;
167 border: 1px solid <?php echo $kinsta_detected ? 'rgba(34,197,94,0.3)' : 'rgba(156,163,175,0.3)'; ?>;">
168 <?php echo $kinsta_detected ? '�
169 ' : '⬜'; ?> Kinsta <?php echo $kinsta_detected ? '(detected)' : '(not detected)'; ?>
170 </span>
171 </div>
172
173 <!-- Settings toggles -->
174 <div style="background: rgba(255,255,255,0.02); border: 1px solid var(--dashboard-border); border-radius: 8px; padding: 20px; margin-bottom: 20px;">
175 <h5 style="margin: 0 0 14px 0; color: var(--dashboard-text-primary);">Enable Native Cache Purge</h5>
176
177 <label style="display: flex; align-items: center; gap: 10px; margin-bottom: 12px; cursor: pointer;">
178 <input type="checkbox"
179 id="metasync-hc-wpengine"
180 <?php checked(true, !empty($hosting_settings['wpengine_enabled'])); ?>
181 <?php echo !$wpe_detected ? 'disabled' : ''; ?>
182 style="width: 16px; height: 16px; cursor: <?php echo $wpe_detected ? 'pointer' : 'not-allowed'; ?>;" />
183 <span style="color: var(--dashboard-text-primary); font-weight: 500;">WP Engine</span>
184 <span style="color: var(--dashboard-text-secondary); font-size: 12px;">— purges Varnish + Memcached</span>
185 </label>
186
187 <label style="display: flex; align-items: center; gap: 10px; margin-bottom: 16px; cursor: pointer;">
188 <input type="checkbox"
189 id="metasync-hc-kinsta"
190 <?php checked(true, !empty($hosting_settings['kinsta_enabled'])); ?>
191 <?php echo !$kinsta_detected ? 'disabled' : ''; ?>
192 style="width: 16px; height: 16px; cursor: <?php echo $kinsta_detected ? 'pointer' : 'not-allowed'; ?>;" />
193 <span style="color: var(--dashboard-text-primary); font-weight: 500;">Kinsta</span>
194 <span style="color: var(--dashboard-text-secondary); font-size: 12px;">— purges full-page cache (kinsta_cache_purge_full)</span>
195 </label>
196
197 <div style="display: flex; align-items: center; gap: 12px;">
198 <button type="button"
199 id="metasync-hc-save-btn"
200 class="metasync-btn-primary"
201 style="background: var(--dashboard-gradient-primary); color: #ffffff; border: none; padding: 9px 18px; border-radius: 8px; font-weight: 500; cursor: pointer; transition: all 0.3s ease;"
202 onmouseover="this.style.transform='translateY(-1px)';"
203 onmouseout="this.style.transform='translateY(0)';">
204 Save Settings
205 </button>
206 <span id="metasync-hc-save-msg" style="display: none; font-size: 13px;"></span>
207 </div>
208
209 <input type="hidden" id="metasync-hc-nonce" value="<?php echo wp_create_nonce('metasync_hosting_cache_nonce'); ?>" />
210 </div>
211
212 <!-- Purge button -->
213 <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
214 <input type="hidden" name="action" value="metasync_purge_hosting_cache" />
215 <?php wp_nonce_field('metasync_hosting_cache_purge_nonce', 'hosting_cache_purge_nonce'); ?>
216 <button type="submit"
217 class="metasync-btn-primary"
218 style="background: var(--dashboard-gradient-primary); color: #ffffff; border: none; padding: 10px 20px; border-radius: 8px; font-weight: 500; cursor: pointer; transition: all 0.3s ease; box-shadow: 0 2px 4px rgba(0,0,0,0.1); display: inline-block; min-width: 240px; max-width: fit-content;"
219 onmouseover="this.style.transform='translateY(-2px)'; this.style.boxShadow='0 4px 8px rgba(0,0,0,0.15)';"
220 onmouseout="this.style.transform='translateY(0)'; this.style.boxShadow='0 2px 4px rgba(0,0,0,0.1)';">
221 <span class="dashicons dashicons-update" style="margin-top:3px;font-size:15px;width:15px;height:15px;"></span> Purge Entire Hosting Cache
222 </button>
223 <p class="description" style="margin-top: 10px; color: var(--dashboard-text-secondary);">
224 Triggers a full-site cache purge using the native WP Engine and/or Kinsta APIs (based on toggles above).
225 </p>
226 </form>
227
228 <?php
229 // Hosting cache result messages
230 if (isset($_GET['hosting_cache_cleared']) && $_GET['hosting_cache_cleared'] == '1') {
231 $hc_cleared = isset($_GET['hc_cleared']) ? sanitize_text_field(urldecode($_GET['hc_cleared'])) : '';
232 $hc_failed = isset($_GET['hc_failed']) ? sanitize_text_field(urldecode($_GET['hc_failed'])) : '';
233 $hc_not_detected = isset($_GET['hc_not_detected']) ? sanitize_text_field(urldecode($_GET['hc_not_detected'])) : '';
234
235 if ($hc_cleared) {
236 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
237 echo '�
238 <strong>Success!</strong> Purged hosting cache on: ' . esc_html($hc_cleared);
239 echo '</p></div>';
240 }
241 if ($hc_failed) {
242 echo '<div class="notice notice-error inline" style="margin-top: 10px;"><p>';
243 echo ' <strong>Failed</strong> to purge: ' . esc_html($hc_failed);
244 echo '</p></div>';
245 }
246 if ($hc_not_detected && !$hc_cleared && !$hc_failed) {
247 echo '<div class="notice notice-info inline" style="margin-top: 10px;"><p>';
248 echo 'ℹ️ No enabled hosting providers were detected on this server (' . esc_html($hc_not_detected) . ').';
249 echo '</p></div>';
250 }
251 }
252 ?>
253
254 <script>
255 jQuery(document).ready(function($) {
256 $('#metasync-hc-save-btn').on('click', function() {
257 var $btn = $(this);
258 var $msg = $('#metasync-hc-save-msg');
259
260 $btn.prop('disabled', true).text('Saving…');
261
262 $.ajax({
263 url: ajaxurl,
264 type: 'POST',
265 data: {
266 action: 'metasync_save_hosting_cache_settings',
267 hosting_cache_nonce: $('#metasync-hc-nonce').val(),
268 wpengine_enabled: $('#metasync-hc-wpengine').is(':checked') ? '1' : '0',
269 kinsta_enabled: $('#metasync-hc-kinsta').is(':checked') ? '1' : '0',
270 },
271 success: function(response) {
272 if (response.success) {
273 $msg.text('�
274 Saved').css('color', '#22c55e').show();
275 } else {
276 $msg.text('' + (response.data.message || 'Save failed')).css('color', '#ef4444').show();
277 }
278 },
279 error: function() {
280 $msg.text('❌ Request failed').css('color', '#ef4444').show();
281 },
282 complete: function() {
283 $btn.prop('disabled', false).text('Save Settings');
284 setTimeout(function() { $msg.fadeOut(); }, 4000);
285 }
286 });
287 });
288 });
289 </script>
290 </div>
291
292 <!-- OTTO Transient Cache -->
293 <div style="margin-bottom: 30px;">
294 <h4 style="margin-top: 0; color: var(--dashboard-text-primary);"><?php echo esc_html(Metasync::get_whitelabel_otto_name()); ?> Transient Cache</h4>
295 <p style="margin-bottom: 15px; color: var(--dashboard-text-secondary);">
296 Manage <?php echo esc_html(Metasync::get_whitelabel_otto_name()); ?> suggestions cache. Clearing cache will force fresh API calls on next page load.
297 </p>
298
299 <div style="background: rgba(255, 255, 255, 0.05); padding: 12px; border-radius: 4px; margin-bottom: 20px; border: 1px solid var(--dashboard-border);">
300 <strong style="color: var(--dashboard-text-primary);">Current Cache Status:</strong>
301 <span style="color: var(--dashboard-accent);"><?php echo esc_html($cache_count); ?> cached entries</span>
302 </div>
303
304 <?php
305 if (isset($_GET['otto_cache_cleared']) && $_GET['otto_cache_cleared'] == '1') {
306 $cleared_count = isset($_GET['count']) ? intval($_GET['count']) : 0;
307 $url = isset($_GET['url']) ? urldecode(sanitize_text_field($_GET['url'])) : '';
308
309 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
310 if (!empty($url)) {
311 echo '�
312 <strong>Success!</strong> Cleared cache for URL: <code>' . esc_html($url) . '</code> (' . intval( $cleared_count ) . ' entries)';
313 } else {
314 echo '�
315 <strong>Success!</strong> Cleared entire transient cache (' . intval( $cleared_count ) . ' entries)';
316 }
317 echo '</p></div>';
318 }
319
320 if (isset($_GET['otto_cache_error']) && $_GET['otto_cache_error'] == '1') {
321 $message = isset($_GET['message']) ? urldecode(sanitize_text_field($_GET['message'])) : 'An unknown error occurred.';
322 echo '<div class="notice notice-error inline" style="margin-top: 15px;"><p>';
323 echo '❌ <strong>Error:</strong> ' . esc_html($message);
324 echo '</p></div>';
325 }
326 ?>
327
328 <!-- Clear Entire Cache -->
329 <div style="margin-bottom: 30px; padding: 20px; border: 1px solid var(--dashboard-border); border-radius: 4px; background: rgba(255, 255, 255, 0.02);">
330 <h5 style="margin-top: 0; color: var(--dashboard-text-primary);">Clear Entire Transient Cache</h5>
331 <p style="color: var(--dashboard-text-secondary); margin-bottom: 15px;">
332 This will clear all <?php echo esc_html(Metasync::get_whitelabel_otto_name()); ?> transient cache entries (suggestions, locks, stale cache, rate limits).
333 </p>
334 <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>"
335 onsubmit="return confirm('Are you sure you want to clear the entire transient cache? This will force fresh API calls for all URLs.');">
336 <input type="hidden" name="action" value="metasync_clear_otto_cache_all" />
337 <?php wp_nonce_field('metasync_clear_otto_cache_nonce', 'clear_otto_cache_nonce'); ?>
338 <button type="submit" class="metasync-btn-primary" style="background: var(--dashboard-gradient-primary); color: #ffffff; border: none; padding: 10px 20px; border-radius: 8px; font-weight: 500; cursor: pointer; transition: all 0.3s ease; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); display: inline-block; width: auto; min-width: 240px; max-width: fit-content;" onmouseover="this.style.transform='translateY(-2px)'; this.style.boxShadow='0 4px 8px rgba(0, 0, 0, 0.15)';" onmouseout="this.style.transform='translateY(0)'; this.style.boxShadow='0 2px 4px rgba(0, 0, 0, 0.1)';">
339 🗑 Clear Entire Cache
340 </button>
341 </form>
342 </div>
343
344 <!-- Clear Cache by URL -->
345 <div style="padding: 20px; border: 1px solid var(--dashboard-border); border-radius: 4px; background: rgba(255, 255, 255, 0.02);">
346 <h5 style="margin-top: 0; color: var(--dashboard-text-primary);">Clear Cache by URL</h5>
347 <p style="color: var(--dashboard-text-secondary); margin-bottom: 15px;">
348 Enter a specific URL to clear its cached <?php echo esc_html(Metasync::get_whitelabel_otto_name()); ?> suggestions. Use the full URL including protocol (e.g., https://example.com/page/).
349 </p>
350 <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
351 <input type="hidden" name="action" value="metasync_clear_otto_cache_url" />
352 <?php wp_nonce_field('metasync_clear_otto_cache_nonce', 'clear_otto_cache_nonce'); ?>
353 <table class="form-table">
354 <tr>
355 <th scope="row">
356 <label for="otto_cache_url" style="color: var(--dashboard-text-primary);">URL to Clear</label>
357 </th>
358 <td>
359 <input type="url"
360 id="otto_cache_url"
361 name="otto_cache_url"
362 value="<?php echo isset($_GET['url']) ? esc_attr(urldecode(sanitize_text_field($_GET['url']))) : ''; ?>"
363 class="regular-text"
364 placeholder="https://example.com/page/"
365 required />
366 <p class="description" style="color: var(--dashboard-text-secondary);">Enter the full URL of the page whose cache you want to clear.</p>
367 </td>
368 </tr>
369 </table>
370 <button type="submit" class="metasync-btn-primary" style="background: var(--dashboard-gradient-primary); color: #ffffff; border: none; padding: 10px 20px; border-radius: 8px; font-weight: 500; cursor: pointer; transition: all 0.3s ease; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); display: inline-block; width: auto; max-width: fit-content;" onmouseover="this.style.transform='translateY(-2px)'; this.style.boxShadow='0 4px 8px rgba(0, 0, 0, 0.15)';" onmouseout="this.style.transform='translateY(0)'; this.style.boxShadow='0 2px 4px rgba(0, 0, 0, 0.1)';">
371 🗑 Clear Cache for URL
372 </button>
373 </form>
374 </div>
375 </div>
376 <?php
377 }
378
379 // ------------------------------------------------------------------
380 // Admin-post handlers (form submissions)
381 // ------------------------------------------------------------------
382
383 /**
384 * WordPress standard handler for clearing all cache plugins (admin_post hook)
385 */
386 public function handle_clear_all_cache_plugins() {
387 if (!isset($_POST['clear_cache_nonce']) || !wp_verify_nonce($_POST['clear_cache_nonce'], 'metasync_clear_cache_nonce')) {
388 wp_die('Security check failed');
389 }
390
391 if (!Metasync::current_user_has_plugin_access()) {
392 wp_die('You do not have permission to perform this action');
393 }
394
395 $redirect_url = admin_url('admin.php?page=' . $this->get_page_slug() . '&tab=advanced');
396
397 if (class_exists('Metasync_Cache_Purge')) {
398 try {
399 $cache_purge = Metasync_Cache_Purge::get_instance();
400 $results = $cache_purge->clear_all_caches('manual_admin');
401
402 $cleared_count = count($results['cleared']);
403 $failed_count = count($results['failed']);
404
405 $redirect_url .= '&cache_cleared=1&cleared=' . $cleared_count . '&failed=' . $failed_count;
406 if (!empty($results['cleared'])) {
407 $redirect_url .= '&plugins=' . urlencode(implode(',', $results['cleared']));
408 }
409 } catch (Exception $e) {
410 error_log('MetaSync Cache Clear Error: ' . $e->getMessage());
411 $redirect_url .= '&cache_error=1&message=' . urlencode('An error occurred while clearing the cache. Please try again.');
412 }
413 } else {
414 $redirect_url .= '&cache_error=1&message=' . urlencode('Cache Purge class not available');
415 }
416
417 wp_safe_redirect($redirect_url);
418 exit;
419 }
420
421 /**
422 * WordPress standard handler for clearing OTTO cache (admin_post hook)
423 */
424 public function handle_clear_otto_cache_all() {
425 if (!isset($_POST['clear_otto_cache_nonce']) || !wp_verify_nonce($_POST['clear_otto_cache_nonce'], 'metasync_clear_otto_cache_nonce')) {
426 wp_die('Security check failed');
427 }
428
429 if (!Metasync::current_user_has_plugin_access()) {
430 wp_die('You do not have permission to perform this action');
431 }
432
433 $redirect_url = admin_url('admin.php?page=' . $this->get_page_slug() . '&tab=advanced');
434
435 if (class_exists('Metasync_Otto_Transient_Cache')) {
436 try {
437 $result = Metasync_Otto_Transient_Cache::clear_all_transients();
438 $redirect_url .= '&otto_cache_cleared=1&count=' . $result['cleared_count'];
439
440 # Clearing the OTTO suggestions transient is not enough on its
441 # own. The OTTO-modified HTML (e.g. an injected hidden H1 from a module
442 # that has since been unapproved in the dashboard) is frozen in the host
443 # page cache and any edge CDN. Until those layers are purged, PHP never
444 # re-renders the page, so the stale injection keeps serving even though
445 # the suggestions cache is now empty. Purge the page caches too so the
446 # next request regenerates with the current (clean) OTTO state.
447 self::purge_page_caches_all();
448 } catch (Exception $e) {
449 error_log('MetaSync OTTO Cache Clear Error: ' . $e->getMessage());
450 $redirect_url .= '&otto_cache_error=1&message=' . urlencode('An error occurred while clearing the OTTO cache. Please try again.');
451 }
452 } else {
453 $redirect_url .= '&otto_cache_error=1&message=' . urlencode('Transient Cache class not found');
454 }
455
456 delete_transient('metasync_otto_js_detected');
457
458 wp_safe_redirect($redirect_url);
459 exit;
460 }
461
462 /**
463 * WordPress standard handler for clearing OTTO cache by URL (admin_post hook)
464 */
465 public function handle_clear_otto_cache_url() {
466 if (!isset($_POST['clear_otto_cache_nonce']) || !wp_verify_nonce($_POST['clear_otto_cache_nonce'], 'metasync_clear_otto_cache_nonce')) {
467 wp_die('Security check failed');
468 }
469
470 if (!Metasync::current_user_has_plugin_access()) {
471 wp_die('You do not have permission to perform this action');
472 }
473
474 $redirect_url = admin_url('admin.php?page=' . $this->get_page_slug() . '&tab=advanced');
475 $url = isset($_POST['otto_cache_url']) ? trim($_POST['otto_cache_url']) : '';
476
477 if (empty($url)) {
478 wp_safe_redirect($redirect_url . '&otto_cache_error=1&message=' . urlencode('URL is required'));
479 exit;
480 }
481
482 if (!filter_var($url, FILTER_VALIDATE_URL)) {
483 wp_safe_redirect($redirect_url . '&otto_cache_error=1&message=' . urlencode('Invalid URL format'));
484 exit;
485 }
486
487 if (class_exists('Metasync_Otto_Transient_Cache')) {
488 try {
489 $result = Metasync_Otto_Transient_Cache::clear_url_transient($url);
490
491 if ($result['success']) {
492 $redirect_url .= '&otto_cache_cleared=1&count=' . $result['cleared_count'] . '&url=' . urlencode($url);
493
494 # Also purge the host page cache and edge CDN for this URL so
495 # the frozen OTTO-modified HTML (e.g. a hidden injected H1 from a module
496 # unapproved in the dashboard) is regenerated. Clearing the suggestions
497 # transient alone leaves the stale HTML live in the page cache because
498 # PHP never re-renders the cached page.
499 self::purge_page_caches_for_url($url);
500 } else {
501 $redirect_url .= '&otto_cache_error=1&message=' . urlencode($result['message']);
502 }
503 } catch (Exception $e) {
504 error_log('MetaSync OTTO Cache Clear Error: ' . $e->getMessage());
505 $redirect_url .= '&otto_cache_error=1&message=' . urlencode('An error occurred while clearing the OTTO cache for this URL. Please try again.');
506 }
507 } else {
508 $redirect_url .= '&otto_cache_error=1&message=' . urlencode('Transient Cache class not found');
509 }
510
511 delete_transient('metasync_otto_js_detected');
512
513 wp_safe_redirect($redirect_url);
514 exit;
515 }
516
517 // ------------------------------------------------------------------
518 // Page-cache propagation
519 // ------------------------------------------------------------------
520
521 /**
522 * Purge the host page cache and edge CDN after clearing OTTO suggestions
523 * for a single URL.
524 *
525 * Clearing the OTTO suggestions transient only invalidates the data layer.
526 * The rendered, OTTO-modified HTML is still frozen in the host page cache
527 * (WP Rocket, Kinsta/WP Engine FastCGI, etc.) and any edge CDN, so PHP never
528 * re-runs and the stale injection keeps serving. Purging both page layers
529 * forces a clean regeneration on the next request.
530 *
531 * Unlike the crawl-notify webhook (which defers the same purge to a
532 * background job because it runs under a strict response-time budget), this
533 * is a deliberate, user-initiated admin action, so the purge runs inline —
534 * the admin expects to wait for the clear to complete. The purge is not
535 * followed by a warm: the goal here is to drop stale OTTO HTML, not to
536 * pre-populate fresh content, so a cold cache that repopulates on the next
537 * request is the desired outcome.
538 *
539 * @param string $url Absolute URL whose page caches should be purged.
540 * @return void
541 */
542 public static function purge_page_caches_for_url($url)
543 {
544 if (class_exists('Metasync_Cache_Purge')) {
545 Metasync_Cache_Purge::purge_single_url($url);
546 }
547 if (class_exists('Metasync_Edge_Cache_Purge')) {
548 Metasync_Edge_Cache_Purge::purge(array($url));
549 }
550 }
551
552 /**
553 * Purge all host page caches and trigger edge CDN flush after clearing every
554 * OTTO suggestions transient.
555 *
556 * Edge coverage caveat: full-flush providers (Sucuri/Sevalla/Cloudways, etc.)
557 * ignore the URL list and clear everything, so the home-URL sentinel below is
558 * enough to fire them. Tag-based providers (Cloudflare/Fastly/Akamai), however,
559 * purge per-URL/per-tag — passing only the home URL purges just the homepage on
560 * those CDNs. The host page cache is still flushed site-wide by purge_all(), so
561 * the stale OTTO HTML is gone at the origin; on tag-based CDNs, non-home pages
562 * clear on their normal TTL. Clearing a specific page's CDN entry immediately is
563 * what the per-URL "Clear Cache for URL" action is for.
564 *
565 * @return void
566 */
567 public static function purge_page_caches_all()
568 {
569 if (class_exists('Metasync_Cache_Purge')) {
570 Metasync_Cache_Purge::purge_all('otto_manual_clear');
571 }
572 if (class_exists('Metasync_Edge_Cache_Purge')) {
573 Metasync_Edge_Cache_Purge::purge(array(home_url('/')));
574 }
575 }
576
577 // ------------------------------------------------------------------
578 // AJAX handlers – excluded URLs
579 // ------------------------------------------------------------------
580
581 /**
582 * AJAX handler to add excluded URL for OTTO
583 */
584 public function ajax_otto_add_excluded_url()
585 {
586 if (!Metasync::current_user_has_plugin_access()) {
587 wp_send_json_error(['message' => 'Insufficient permissions']);
588 return;
589 }
590
591 if (!check_ajax_referer('metasync_otto_excluded_urls', 'nonce', false)) {
592 wp_send_json_error(['message' => 'Invalid security token']);
593 return;
594 }
595
596 $url_pattern = isset($_POST['url_pattern']) ? sanitize_text_field($_POST['url_pattern']) : '';
597 $pattern_type = isset($_POST['pattern_type']) ? sanitize_text_field($_POST['pattern_type']) : 'exact';
598 $description = isset($_POST['description']) ? sanitize_textarea_field($_POST['description']) : '';
599
600 if (empty($url_pattern)) {
601 wp_send_json_error(['message' => 'URL pattern is required']);
602 return;
603 }
604
605 $valid_types = ['exact', 'contain', 'start', 'end', 'regex'];
606 if (!in_array($pattern_type, $valid_types)) {
607 wp_send_json_error(['message' => 'Invalid pattern type']);
608 return;
609 }
610
611 if ($pattern_type === 'regex') {
612 // Limit pattern length to prevent ReDoS via catastrophic backtracking
613 if (strlen($url_pattern) > 500) {
614 wp_send_json_error(['message' => 'Regular expression pattern is too long (max 500 characters)']);
615 return;
616 }
617
618 // Reject patterns with nested quantifiers that could cause ReDoS
619 $raw = preg_replace('/^\S(.*)\S[a-zA-Z]*$/', '$1', $url_pattern);
620 if (preg_match('/(\([^)]*[+*][^)]*\))[+*?{]|(\[[^\]]*\])[+*][+*?{]/', $raw)) {
621 wp_send_json_error(['message' => 'Pattern contains potentially unsafe nested quantifiers']);
622 return;
623 }
624
625 $test_pattern = $url_pattern;
626 $delimiter_chars = ['/', '#', '~', '%', '@'];
627 $has_valid_delimiters = false;
628
629 if (strlen($test_pattern) >= 2) {
630 $first_char = $test_pattern[0];
631 if (in_array($first_char, $delimiter_chars)) {
632 $last_pos = strrpos($test_pattern, $first_char);
633 if ($last_pos > 0) {
634 $has_valid_delimiters = true;
635 }
636 }
637 }
638
639 if (!$has_valid_delimiters) {
640 $test_pattern = '/' . $test_pattern . '/';
641 }
642
643 if (!Metasync_Regex_Validator::is_valid($test_pattern)) {
644 wp_send_json_error(['message' => 'Invalid regular expression pattern']);
645 return;
646 }
647 }
648
649 require_once plugin_dir_path(dirname(__FILE__)) . 'otto/class-metasync-otto-excluded-urls-database.php';
650 $db = new Metasync_Otto_Excluded_URLs_Database();
651
652 $result = $db->add([
653 'url_pattern' => $url_pattern,
654 'pattern_type' => $pattern_type,
655 'description' => $description,
656 'status' => 'active',
657 ]);
658
659 if ($result === 'duplicate') {
660 wp_send_json_error([
661 'message' => 'This URL pattern already exists in the exclusion list',
662 'code' => 'duplicate'
663 ]);
664 return;
665 }
666
667 if ($result === 'reactivated') {
668 $db->clear_cache();
669
670 if ($pattern_type === 'exact') {
671 try {
672 if (class_exists('Metasync_Cache_Purge')) {
673 $cache_purge = Metasync_Cache_Purge::get_instance();
674 $cache_purge->clear_url_cache($url_pattern);
675 }
676 wp_cache_flush();
677 } catch (Exception $e) {
678 error_log('MetaSync: Failed to clear cache for reactivated URL: ' . $e->getMessage());
679 }
680 }
681
682 wp_send_json_success([
683 'message' => 'Previously inactive URL pattern has been reactivated. Cache cleared.',
684 ]);
685 return;
686 }
687
688 if ($result === true) {
689 $db->clear_cache();
690
691 if ($pattern_type === 'exact') {
692 try {
693 if (class_exists('Metasync_Cache_Purge')) {
694 $cache_purge = Metasync_Cache_Purge::get_instance();
695 $cache_purge->clear_url_cache($url_pattern);
696 }
697
698 wp_cache_flush();
699 } catch (Exception $e) {
700 error_log('MetaSync: Failed to clear cache for excluded URL: ' . $e->getMessage());
701 }
702 }
703
704 wp_send_json_success([
705 'message' => sprintf('URL excluded from %s successfully. Cache cleared.', Metasync::get_whitelabel_otto_name()),
706 ]);
707 } else {
708 wp_send_json_error(['message' => 'Failed to add excluded URL']);
709 }
710 }
711
712 /**
713 * AJAX handler to delete excluded URL for OTTO
714 */
715 public function ajax_otto_delete_excluded_url()
716 {
717 if (!Metasync::current_user_has_plugin_access()) {
718 wp_send_json_error(['message' => 'Insufficient permissions']);
719 return;
720 }
721
722 if (!check_ajax_referer('metasync_otto_excluded_urls', 'nonce', false)) {
723 wp_send_json_error(['message' => 'Invalid security token']);
724 return;
725 }
726
727 $id = isset($_POST['id']) ? intval($_POST['id']) : 0;
728
729 if ($id <= 0) {
730 wp_send_json_error(['message' => 'Invalid ID']);
731 return;
732 }
733
734 require_once plugin_dir_path(dirname(__FILE__)) . 'otto/class-metasync-otto-excluded-urls-database.php';
735 $db = new Metasync_Otto_Excluded_URLs_Database();
736
737 $result = $db->delete([$id]);
738
739 if ($result) {
740 wp_send_json_success([
741 'message' => 'Excluded URL deleted successfully',
742 ]);
743 } else {
744 wp_send_json_error(['message' => 'Failed to delete excluded URL']);
745 }
746 }
747
748 /**
749 * AJAX handler to recheck if an excluded URL is now available
750 */
751 public function ajax_otto_recheck_excluded_url()
752 {
753 if (!Metasync::current_user_has_plugin_access()) {
754 wp_send_json_error(['message' => 'Insufficient permissions']);
755 return;
756 }
757
758 if (!check_ajax_referer('metasync_otto_excluded_urls', 'nonce', false)) {
759 wp_send_json_error(['message' => 'Invalid security token']);
760 return;
761 }
762
763 $id = isset($_POST['id']) ? intval($_POST['id']) : 0;
764 if ($id <= 0) {
765 wp_send_json_error(['message' => 'Invalid ID']);
766 return;
767 }
768
769 require_once plugin_dir_path(dirname(__FILE__)) . 'otto/class-metasync-otto-excluded-urls-database.php';
770 require_once plugin_dir_path(dirname(__FILE__)) . 'otto/otto_pixel.php';
771
772 $db = new Metasync_Otto_Excluded_URLs_Database();
773 $row = $db->get_record_by_id($id);
774
775 if (!$row || empty($row->url_pattern)) {
776 wp_send_json_error(['message' => 'Excluded URL not found']);
777 return;
778 }
779
780 $url = trim($row->url_pattern);
781 $available = metasync_otto_is_url_available($url);
782
783 wp_send_json_success([
784 'available' => $available,
785 'url' => $url,
786 ]);
787 }
788
789 /**
790 * AJAX handler to get excluded URLs with pagination
791 */
792 public function ajax_otto_get_excluded_urls()
793 {
794 if (!Metasync::current_user_has_plugin_access()) {
795 wp_send_json_error(['message' => 'Insufficient permissions']);
796 return;
797 }
798
799 if (!check_ajax_referer('metasync_otto_excluded_urls', 'nonce', false)) {
800 wp_send_json_error(['message' => 'Invalid security token']);
801 return;
802 }
803
804 $page = isset($_POST['page']) ? intval($_POST['page']) : 1;
805 $per_page = isset($_POST['per_page']) ? intval($_POST['per_page']) : 10;
806
807 if ($page < 1) {
808 $page = 1;
809 }
810 if ($per_page < 1 || $per_page > 100) {
811 $per_page = 10;
812 }
813
814 require_once plugin_dir_path(dirname(__FILE__)) . 'otto/class-metasync-otto-excluded-urls-database.php';
815 $db = new Metasync_Otto_Excluded_URLs_Database();
816
817 $records = $db->get_paginated_records($per_page, $page);
818 $total_count = $db->get_total_count();
819 $total_pages = ceil($total_count / $per_page);
820
821 wp_send_json_success([
822 'records' => $records,
823 'pagination' => [
824 'current_page' => $page,
825 'per_page' => $per_page,
826 'total_count' => $total_count,
827 'total_pages' => $total_pages,
828 ],
829 ]);
830 }
831 }
832