PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / trunk
Search Atlas SEO – OTTO AI SEO Automation for WordPress vtrunk
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-host-blocking-check.php

class-metasync-host-blocking-check.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress trunk, at includes/class-metasync-host-blocking-check.php

819 lines 30.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Host Blocking Check
4 *
5 * Single owner of the "can this site and Search Atlas reach each other?" probe.
6 *
7 * The probe itself is not new — it has existed as two AJAX-only handlers behind the
8 * Compatibility page's "Host Blocking Test" button. This class extracts that logic so
9 * exactly one code path serves both callers:
10 *
11 * 1. the manual button on the Compatibility page (via Metasync_Admin_Ajax), and
12 * 2. an automatic check that runs ~10 minutes after activation and weekly thereafter.
13 *
14 * WHAT THE PROBE ACTUALLY MEASURES (important, and easy to get wrong):
15 * `wp-check.searchatlas.com/ping` reads the `X-WordPress-Site` header and then calls
16 * `{site}/wp-json/metasync/v1/ping` back with both a GET and a POST, returning the status
17 * code of each leg. So a single call exercises TWO directions:
18 *
19 * - OUTBOUND (this site -> Search Atlas): did our own wp_remote_get/post reach the
20 * checker at all? A WP_Error here means outbound HTTP is failing.
21 * - INBOUND (Search Atlas -> this site): did the checker's callback reach
22 * `metasync/v1/ping`? `results.{get,post}.statusCode !== 200` means something in
23 * front of WordPress (firewall, WAF, CDN rule, security plugin) is blocking it.
24 *
25 * Both directions break the same user-visible features, so both are reported — but the
26 * details string always says which direction failed, because the fix differs.
27 *
28 * FALSE POSITIVES ARE THE PRIMARY RISK. A wrong warning erodes trust in the plugin more
29 * than a missed one, so the classifier only reports `blocked` on positive evidence:
30 *
31 * - transport error + control probe to Search Atlas ALSO fails -> blocked (outbound)
32 * - transport error + control probe SUCCEEDS -> checker is down, NOT blocked
33 * - non-200 from the checker itself -> inconclusive, NOT blocked
34 * - 200 but unparseable/unexpected body -> inconclusive, NOT blocked
35 * - 200 + parsed leg statusCode !== 200 -> blocked (inbound)
36 *
37 * Everything inconclusive fails open (no warning) and is logged instead.
38 *
39 * @package Metasync
40 * @subpackage Metasync/includes
41 * @since 2.8.x
42 */
43
44 if (!defined('ABSPATH')) {
45 exit;
46 }
47
48 /**
49 * Class Metasync_Host_Blocking_Check
50 */
51 class Metasync_Host_Blocking_Check
52 {
53 /**
54 * Singleton instance.
55 *
56 * @var Metasync_Host_Blocking_Check|null
57 */
58 private static $instance = null;
59
60 /**
61 * External connectivity checker.
62 */
63 const CHECK_ENDPOINT = 'https://wp-check.searchatlas.com/ping';
64
65 /**
66 * Minimal stored result: get_blocked, post_blocked, checked_at. Nothing else —
67 * response bodies and headers are deliberately never persisted.
68 */
69 const RESULT_OPTION = 'metasync_last_host_blocking_check';
70
71 /**
72 * Stores the `checked_at` of the result the user dismissed the notice for.
73 * Because each check writes a fresh `checked_at`, a new cycle invalidates an old
74 * dismissal automatically — no cleanup pass, no per-page-load re-evaluation.
75 */
76 const NOTICE_DISMISSED_OPTION = 'metasync_host_blocking_notice_dismissed_cycle';
77
78 /**
79 * One-shot check scheduled ~10 minutes after activation/upgrade.
80 */
81 const HOOK_INITIAL = 'metasync_host_blocking_check';
82
83 /**
84 * Dedicated weekly recheck. Intentionally NOT attached to the heartbeat cron:
85 * that job is paused and rescheduled by connection state, so piggybacking on it
86 * would silently stop covering host blocking on exactly the sites that need it.
87 */
88 const HOOK_WEEKLY = 'metasync_host_blocking_weekly_check';
89
90 /**
91 * Delay before the post-activation check runs.
92 */
93 const INITIAL_DELAY = 600; // 10 * MINUTE_IN_SECONDS
94
95 /**
96 * Request timeout, shared by the probe and the control probe so a merely-slow host
97 * cannot be classified as blocked by one leg timing out earlier than the other.
98 */
99 const REQUEST_TIMEOUT = 30;
100
101 /**
102 * Memoised control-probe outcome per HTTP method (per PHP request).
103 *
104 * Keyed by method, because a host can allow one verb and block the other — that is
105 * precisely the case this check exists to catch.
106 *
107 * @var array<string,bool>
108 */
109 private $control_probe_ok = [];
110
111 /**
112 * Constructor. Protected rather than private so tests can subclass the HTTP seams
113 * (perform_request / perform_control_request) without a live network.
114 */
115 protected function __construct()
116 {
117 $this->init_hooks();
118 }
119
120 /**
121 * Get singleton instance.
122 *
123 * @return Metasync_Host_Blocking_Check
124 */
125 public static function get_instance()
126 {
127 if (self::$instance === null) {
128 self::$instance = new self();
129 }
130 return self::$instance;
131 }
132
133 /**
134 * Register WordPress hooks.
135 */
136 private function init_hooks()
137 {
138 // Both cron hooks run the same check.
139 add_action(self::HOOK_INITIAL, [$this, 'run_scheduled_check']);
140 add_action(self::HOOK_WEEKLY, [$this, 'run_scheduled_check']);
141
142 // Self-heal scheduling on every load (also covers plugin updates, where the
143 // activation hook does not fire).
144 add_action('init', [$this, 'maybe_schedule_checks']);
145
146 if (is_admin()) {
147 add_action('admin_notices', [$this, 'display_notice']);
148 add_action('wp_ajax_metasync_dismiss_host_blocking_notice', [$this, 'ajax_dismiss_notice']);
149 add_action('admin_enqueue_scripts', [$this, 'enqueue_admin_scripts']);
150 }
151 }
152
153 // ------------------------------------------------------------------
154 // Scheduling
155 // ------------------------------------------------------------------
156
157 /**
158 * Ensure both the one-shot and the weekly check are scheduled.
159 *
160 * The one-shot event is only scheduled when no result has ever been stored, so an
161 * established install does not re-run it on every page load.
162 */
163 public function maybe_schedule_checks()
164 {
165 if (!wp_next_scheduled(self::HOOK_WEEKLY)) {
166 // First recurrence a week out; the one-shot below covers "soon".
167 wp_schedule_event(time() + self::INITIAL_DELAY + WEEK_IN_SECONDS, 'metasync_weekly', self::HOOK_WEEKLY);
168 }
169
170 if (get_option(self::RESULT_OPTION, false) === false && !wp_next_scheduled(self::HOOK_INITIAL)) {
171 self::schedule_initial_check();
172 }
173 }
174
175 /**
176 * Schedule the post-activation one-shot check.
177 *
178 * Called from Metasync_Activator::activate(). The check is NEVER run synchronously
179 * during activation: blocking HTTP calls in the activation request have taken sites
180 * down on slow hosts before (see the flush_rewrite_rules note in the activator).
181 */
182 public static function schedule_initial_check()
183 {
184 if (!wp_next_scheduled(self::HOOK_INITIAL)) {
185 wp_schedule_single_event(time() + self::INITIAL_DELAY, self::HOOK_INITIAL);
186 }
187 }
188
189 // ------------------------------------------------------------------
190 // The shared probe
191 // ------------------------------------------------------------------
192
193 /**
194 * Run one leg of the host blocking check.
195 *
196 * This is the single shared implementation used by the manual Compatibility-page
197 * button and by the automatic check. The returned array is a superset of the shape
198 * the Compatibility page's JS already consumes, so the manual UI keeps working.
199 *
200 * @param string $method 'GET' or 'POST'.
201 * @return array{
202 * method:string, status:string, blocked:bool, checker_unreachable:bool,
203 * details:string, response_time:string
204 * }
205 */
206 public function run_check($method = 'GET')
207 {
208 $method = strtoupper((string) $method) === 'POST' ? 'POST' : 'GET';
209
210 $args = [
211 'timeout' => self::REQUEST_TIMEOUT,
212 'user-agent' => 'MetaSync Plugin Host Test',
213 'headers' => [
214 'Accept' => 'application/json',
215 'Content-Type' => 'application/json',
216 'Origin' => home_url(),
217 'Referer' => admin_url(),
218 'X-WordPress-Site' => home_url(),
219 ],
220 ];
221
222 $sent_data = null;
223 if ($method === 'POST') {
224 $sent_data = [
225 'test' => 'host_blocking_test',
226 'timestamp' => current_time('mysql'),
227 'source' => 'metasync_plugin',
228 'method' => 'POST',
229 ];
230 $args['body'] = wp_json_encode($sent_data);
231 }
232
233 $start = microtime(true);
234 $response = $this->perform_request($method, $args);
235 $elapsed = round((microtime(true) - $start) * 1000, 2);
236
237 $result = $this->classify_response($response, $method);
238
239 $result['method'] = $method;
240 $result['response_time'] = $elapsed . 'ms';
241 if ($sent_data !== null) {
242 $result['sent_data'] = $sent_data;
243 }
244
245 return $result;
246 }
247
248 /**
249 * Issue the probe request. Isolated so tests can substitute canned responses.
250 *
251 * @param string $method 'GET' or 'POST'.
252 * @param array $args wp_remote_* arguments.
253 * @return array|WP_Error
254 */
255 protected function perform_request($method, array $args)
256 {
257 return ($method === 'POST')
258 ? wp_remote_post(self::CHECK_ENDPOINT, $args)
259 : wp_remote_get(self::CHECK_ENDPOINT, $args);
260 }
261
262 /**
263 * Issue the outbound control request. Isolated for the same reason.
264 *
265 * @param string $url Control endpoint.
266 * @return array|WP_Error
267 */
268 protected function perform_control_request($url, $method = 'GET')
269 {
270 $args = [
271 'timeout' => self::REQUEST_TIMEOUT,
272 'user-agent' => 'MetaSync Plugin Host Test',
273 'redirection' => 0,
274 ];
275
276 if ($method === 'POST') {
277 $args['headers'] = ['Content-Type' => 'application/json'];
278 $args['body'] = wp_json_encode(['source' => 'metasync_plugin', 'test' => 'control']);
279
280 return wp_remote_post($url, $args);
281 }
282
283 return wp_remote_get($url, $args);
284 }
285
286 /**
287 * Normalise the headers bag from wp_remote_retrieve_headers() into a plain array.
288 *
289 * Modern WordPress returns a CaseInsensitiveDictionary; very old versions and some
290 * HTTP transports hand back a plain array.
291 *
292 * @param mixed $headers Whatever wp_remote_retrieve_headers() returned.
293 * @return array
294 */
295 private function headers_to_array($headers)
296 {
297 if (is_array($headers)) {
298 return $headers;
299 }
300
301 if (is_object($headers) && is_callable([$headers, 'getAll'])) {
302 return (array) call_user_func([$headers, 'getAll']);
303 }
304
305 return [];
306 }
307
308 /**
309 * Write a diagnostic line. Isolated so tests can capture it instead of polluting
310 * output, and so a future logger swap has one call site.
311 *
312 * @param string $message Message to log.
313 */
314 protected function log($message)
315 {
316 error_log('[MetaSync HOST_BLOCKING] ' . $message);
317 }
318
319 /**
320 * Turn a wp_remote_* return value into a blocked / not-blocked / inconclusive verdict.
321 *
322 * Kept separate from the HTTP call so the decision table can be unit tested without
323 * network access.
324 *
325 * @param array|WP_Error $response Raw wp_remote_* result.
326 * @param string $method 'GET' or 'POST'.
327 * @return array
328 */
329 public function classify_response($response, $method)
330 {
331 $method = strtoupper((string) $method) === 'POST' ? 'POST' : 'GET';
332 $leg = strtolower($method);
333
334 // ── Transport failure: outbound blocked, or the checker itself is down? ──
335 if (is_wp_error($response)) {
336 $error_message = $response->get_error_message();
337
338 if ($this->control_probe_succeeds($method)) {
339 // Search Atlas answers this same verb, so outbound HTTP works for it and
340 // the checker service is simply unavailable. Not a host problem.
341 return [
342 'status' => 'warning',
343 'blocked' => false,
344 'checker_unreachable' => true,
345 'error' => $error_message,
346 'details' => sprintf(
347 'Could not reach the connectivity checker (%1$s). A %2$s request to %3$s succeeded, so this is NOT being reported as host blocking — the checker service was unavailable. Error: %4$s',
348 self::CHECK_ENDPOINT,
349 $method,
350 $this->get_control_endpoint(),
351 $error_message
352 ),
353 ];
354 }
355
356 return [
357 'status' => 'error',
358 'blocked' => true,
359 'checker_unreachable' => false,
360 'error' => $error_message,
361 'details' => sprintf(
362 'Outbound %1$s requests are failing. Neither the connectivity checker nor %2$s could be reached from this server with a %1$s request, which means this host is blocking outbound %1$s traffic. Error: %3$s',
363 $method,
364 $this->get_control_endpoint(),
365 $error_message
366 ),
367 ];
368 }
369
370 $status_code = wp_remote_retrieve_response_code($response);
371 $body = wp_remote_retrieve_body($response);
372 $headers = wp_remote_retrieve_headers($response);
373
374 $base = [
375 'status_code' => $status_code,
376 'body' => $body,
377 'headers' => $this->headers_to_array($headers),
378 ];
379
380 // ── The checker answered, but not with a usable result ──
381 if ((int) $status_code !== 200) {
382 return array_merge($base, [
383 'status' => 'warning',
384 'blocked' => false,
385 'checker_unreachable' => true,
386 'details' => sprintf(
387 'The connectivity checker returned HTTP %1$d, so the %2$s check is inconclusive. The request did leave this server, so this is NOT being reported as host blocking.',
388 (int) $status_code,
389 $method
390 ),
391 ]);
392 }
393
394 $parsed = json_decode($body, true);
395
396 if (!is_array($parsed) || !isset($parsed['results'][$leg]) || !is_array($parsed['results'][$leg])) {
397 return array_merge($base, [
398 'status' => 'warning',
399 'blocked' => false,
400 'checker_unreachable' => true,
401 'parsed_response' => is_array($parsed) ? $parsed : null,
402 'details' => sprintf(
403 'The connectivity checker responded but its payload did not contain a "%1$s" result, so the check is inconclusive. This is NOT being reported as host blocking.',
404 $leg
405 ),
406 ]);
407 }
408
409 $inner_code = isset($parsed['results'][$leg]['statusCode'])
410 ? (int) $parsed['results'][$leg]['statusCode']
411 : 0;
412
413 if ($inner_code === 200) {
414 return array_merge($base, [
415 'status' => 'success',
416 'blocked' => false,
417 'checker_unreachable' => false,
418 'parsed_response' => $parsed,
419 'details' => sprintf('%s request completed successfully in both directions.', $method),
420 ]);
421 }
422
423 // Positive evidence of blocking: we reached Search Atlas, and its callback to
424 // this site did not reach WordPress cleanly.
425 return array_merge($base, [
426 'status' => 'error',
427 'blocked' => true,
428 'checker_unreachable' => false,
429 'parsed_response' => $parsed,
430 'details' => sprintf(
431 '%1$s reached this site with a %2$s request but received HTTP %3$s from %4$s instead of 200 — the request is being blocked before it reaches WordPress (firewall, WAF, CDN rule, or security plugin).',
432 Metasync::get_effective_plugin_name(),
433 $method,
434 $inner_code > 0 ? (string) $inner_code : 'no response',
435 home_url('/wp-json/metasync/v1/ping')
436 ),
437 ]);
438 }
439
440 /**
441 * Control probe: can this server reach Search Atlas at all, using this same verb?
442 *
443 * The verb matters. A host that permits GET but blocks outbound POST is a real and
444 * common configuration, and probing it with GET would "prove" outbound works and
445 * misfile the blocked POST as a checker outage. Any HTTP response — including 4xx
446 * and 405 — proves the verb gets out, so only a transport-level failure counts as
447 * unreachable. Memoised per verb.
448 *
449 * @param string $method 'GET' or 'POST'.
450 * @return bool True when Search Atlas is reachable with this verb.
451 */
452 private function control_probe_succeeds($method)
453 {
454 if (array_key_exists($method, $this->control_probe_ok)) {
455 return $this->control_probe_ok[$method];
456 }
457
458 $response = $this->perform_control_request($this->get_control_endpoint(), $method);
459
460 $this->control_probe_ok[$method] = !is_wp_error($response);
461
462 return $this->control_probe_ok[$method];
463 }
464
465 /**
466 * The endpoint used as the outbound control. Uses the same CA domain the plugin
467 * relies on for announce/heartbeat, so "reachable" means what the plugin needs.
468 *
469 * @return string
470 */
471 private function get_control_endpoint()
472 {
473 $base = 'https://ca.searchatlas.com';
474
475 if (class_exists('Metasync_Endpoint_Manager')) {
476 $base = Metasync_Endpoint_Manager::get_endpoint('CA_API_DOMAIN');
477 } elseif (class_exists('Metasync')) {
478 $base = Metasync::CA_API_DOMAIN;
479 }
480
481 return rtrim($base, '/') . '/';
482 }
483
484 // ------------------------------------------------------------------
485 // Automatic check
486 // ------------------------------------------------------------------
487
488 /**
489 * Cron callback for both the post-activation and the weekly check.
490 *
491 * Runs the same shared probe the manual button uses, then stores a minimal result.
492 */
493 public function run_scheduled_check()
494 {
495 if (!$this->site_is_publicly_reachable()) {
496 $this->log('Skipped automatic check: site host is not publicly reachable (local/private environment).');
497 return;
498 }
499
500 $get = $this->run_check('GET');
501 $post = $this->run_check('POST');
502
503 $result = [
504 'get_blocked' => !empty($get['blocked']),
505 'post_blocked' => !empty($post['blocked']),
506 'checked_at' => current_time('mysql'),
507 ];
508
509 update_option(self::RESULT_OPTION, $result, true);
510
511 if (!empty($get['checker_unreachable']) || !empty($post['checker_unreachable'])) {
512 $this->log(sprintf(
513 'Inconclusive automatic check (checker unavailable) — no warning shown. GET: %s | POST: %s',
514 $get['details'],
515 $post['details']
516 ));
517 }
518
519 if ($result['get_blocked'] || $result['post_blocked']) {
520 $this->log(sprintf(
521 'Blocking detected (GET blocked: %s, POST blocked: %s). GET: %s | POST: %s',
522 $result['get_blocked'] ? 'yes' : 'no',
523 $result['post_blocked'] ? 'yes' : 'no',
524 $get['details'],
525 $post['details']
526 ));
527 }
528
529 return $result;
530 }
531
532 /**
533 * Whether the inbound leg of the check can possibly succeed.
534 *
535 * On localhost, *.local/*.test, or a private-range IP the checker cannot call back,
536 * so every automatic run would report blocking. The manual button stays available on
537 * those sites — the developer running it knows what the result means.
538 *
539 * @param string|null $home_url Site URL to evaluate; defaults to this site's home_url().
540 * @return bool
541 */
542 public function site_is_publicly_reachable($home_url = null)
543 {
544 if ($home_url === null) {
545 if (function_exists('wp_get_environment_type') && wp_get_environment_type() === 'local') {
546 return false;
547 }
548 $home_url = home_url();
549 }
550
551 $host = strtolower((string) wp_parse_url($home_url, PHP_URL_HOST));
552
553 if ($host === '' || $host === 'localhost' || $host === '::1') {
554 return false;
555 }
556
557 foreach (['.local', '.test', '.localhost', '.invalid', '.example', '.internal'] as $suffix) {
558 if (substr($host, -strlen($suffix)) === $suffix) {
559 return false;
560 }
561 }
562
563 if (filter_var($host, FILTER_VALIDATE_IP)) {
564 // Reject loopback/private/reserved ranges; a public IP is fine.
565 if (!filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
566 return false;
567 }
568 }
569
570 return true;
571 }
572
573 /**
574 * Last stored automatic result, or null when the check has never run.
575 *
576 * @return array|null
577 */
578 public function get_last_result()
579 {
580 $stored = get_option(self::RESULT_OPTION, false);
581
582 if (!is_array($stored) || !isset($stored['checked_at'])) {
583 return null;
584 }
585
586 return [
587 'get_blocked' => !empty($stored['get_blocked']),
588 'post_blocked' => !empty($stored['post_blocked']),
589 'checked_at' => (string) $stored['checked_at'],
590 ];
591 }
592
593 // ------------------------------------------------------------------
594 // Admin notice
595 // ------------------------------------------------------------------
596
597 /**
598 * Whether the warning notice should render.
599 *
600 * Two autoloaded option reads and a string comparison — the blocking verdict itself
601 * is only ever computed by the cron, never on a page load.
602 *
603 * @return bool
604 */
605 public function should_display_notice()
606 {
607 if (!current_user_can('manage_options')) {
608 return false;
609 }
610
611 $result = $this->get_last_result();
612
613 if ($result === null) {
614 return false;
615 }
616
617 if (!$result['get_blocked'] && !$result['post_blocked']) {
618 return false;
619 }
620
621 // Dismissals are recorded against the check cycle they were made for, so the
622 // notice stays gone until a later check produces a new `checked_at`.
623 return get_option(self::NOTICE_DISMISSED_OPTION, '') !== $result['checked_at'];
624 }
625
626 /**
627 * Whether the current admin screen is one of this plugin's own pages.
628 *
629 * Matched against Metasync_Admin::$page_slug rather than a hardcoded 'metasync'
630 * string, because that slug is whitelabellable — it defaults to `searchatlas` and
631 * a whitelabelled install renames it. Sub-pages are `{slug}-compatibility` and so on.
632 *
633 * @return bool
634 */
635 public function is_plugin_admin_page()
636 {
637 if (empty($_GET['page'])) {
638 return false;
639 }
640
641 $page = sanitize_text_field(wp_unslash($_GET['page']));
642 $slug = class_exists('Metasync_Admin') && !empty(Metasync_Admin::$page_slug)
643 ? Metasync_Admin::$page_slug
644 : 'searchatlas';
645
646 return $page === $slug || strpos($page, $slug . '-') === 0;
647 }
648
649 /**
650 * admin_notices callback.
651 *
652 * Scoped to this plugin's own admin pages, matching the placement of the API-backoff
653 * notice, so the warning never appears on unrelated screens.
654 */
655 public function display_notice()
656 {
657 if (!$this->is_plugin_admin_page() || !$this->should_display_notice()) {
658 return;
659 }
660
661 $this->render_notice($this->get_last_result());
662 }
663
664 /**
665 * Render the warning notice.
666 *
667 * Follows the dismissible-notice convention used by Metasync_API_Backoff_Notices:
668 * a `notice notice-warning is-dismissible` wrapper plus an AJAX call on dismiss.
669 *
670 * @param array $result Stored check result.
671 */
672 private function render_notice(array $result)
673 {
674 $blocked_methods = [];
675 if ($result['get_blocked']) {
676 $blocked_methods[] = 'GET';
677 }
678 if ($result['post_blocked']) {
679 $blocked_methods[] = 'POST';
680 }
681
682 $plugin_name = Metasync::get_effective_plugin_name();
683 $otto_name = Metasync::get_whitelabel_otto_name();
684 $sync_name = $this->get_article_sync_name();
685
686 $compatibility_url = $this->get_compatibility_url();
687 ?>
688 <div id="metasync-host-blocking-notice" class="notice notice-warning is-dismissible metasync-host-blocking-notice">
689 <div style="display: flex; align-items: flex-start; gap: 12px; padding: 4px 0;">
690 <div style="font-size: 24px; line-height: 1;">⚠️</div>
691 <div style="flex: 1;">
692 <p style="margin: 0 0 8px 0; font-weight: 600; font-size: 14px;">
693 <?php echo esc_html(sprintf(
694 /* translators: 1: plugin name, 2: blocked HTTP methods, e.g. "GET and POST" */
695 __('%1$s: this server is blocking %2$s requests to and from our services', 'metasync'),
696 $plugin_name,
697 implode(' and ', $blocked_methods)
698 )); ?>
699 </p>
700 <p style="margin: 0 0 8px 0; font-size: 13px;">
701 <?php echo esc_html(sprintf(
702 /* translators: 1: article-sync feature name, 2: OTTO product name */
703 __('Some plugin functionality may not work correctly, such as %1$s or %2$s applying SEO suggestions. This is usually caused by a firewall, WAF, or security rule on your hosting account, and your hosting provider can allow the requests.', 'metasync'),
704 $sync_name,
705 $otto_name
706 )); ?>
707 </p>
708 <p style="margin: 0; font-size: 13px; color: #646970;">
709 <?php if ($compatibility_url !== '') : ?>
710 <a href="<?php echo esc_url($compatibility_url); ?>"><?php esc_html_e('View details and re-run the connectivity test', 'metasync'); ?></a>
711 &nbsp;·&nbsp;
712 <?php endif; ?>
713 <?php echo esc_html(sprintf(__('Last checked: %s', 'metasync'), $result['checked_at'])); ?>
714 </p>
715 </div>
716 </div>
717 </div>
718 <?php
719 }
720
721 /**
722 * How to refer to the article-sync feature in user-facing copy.
723 *
724 * "Content Genius" is a Search Atlas product name. There is no whitelabel field for
725 * it (only `white_label_plugin_name` and `whitelabel_otto_name` exist), so on a
726 * whitelabelled install naming it would leak the vendor the reseller is hiding.
727 * Fall back to a neutral description there.
728 *
729 * @return string
730 */
731 private function get_article_sync_name()
732 {
733 $is_whitelabelled = Metasync::get_effective_plugin_name() !== 'Search Atlas';
734
735 return $is_whitelabelled
736 ? __('article syncing', 'metasync')
737 : __('Content Genius syncing articles', 'metasync');
738 }
739
740 /**
741 * URL of the Compatibility page, or '' when this install cannot reach it.
742 *
743 * The slug is whitelabellable (`white_label_plugin_menu_slug` -> Metasync_Admin::$page_slug),
744 * and a whitelabelled install can also hide the page outright via `hide_compatibility`.
745 * Linking to a hidden page would land the user on a permissions error, so the caller
746 * drops the link when this returns ''. Uses the same predicate the navigation uses so
747 * the two cannot drift apart.
748 *
749 * @return string
750 */
751 private function get_compatibility_url()
752 {
753 if (class_exists('Metasync_Access_Control') && !Metasync_Access_Control::user_can_access('hide_compatibility')) {
754 return '';
755 }
756
757 $slug = class_exists('Metasync_Admin') && !empty(Metasync_Admin::$page_slug)
758 ? Metasync_Admin::$page_slug
759 : 'searchatlas';
760
761 return admin_url('admin.php?page=' . $slug . '-compatibility');
762 }
763
764 /**
765 * Enqueue the dismiss handler.
766 *
767 * @param string $hook Current admin page hook.
768 */
769 public function enqueue_admin_scripts($hook)
770 {
771 if (!$this->is_plugin_admin_page() || !$this->should_display_notice()) {
772 return;
773 }
774
775 wp_add_inline_script('jquery', $this->get_notice_script(), 'after');
776 }
777
778 /**
779 * Inline JS for the dismiss button.
780 *
781 * @return string
782 */
783 private function get_notice_script()
784 {
785 return "
786 jQuery(document).ready(function($) {
787 $(document).on('click', '.metasync-host-blocking-notice .notice-dismiss', function() {
788 $.post(ajaxurl, {
789 action: 'metasync_dismiss_host_blocking_notice',
790 nonce: '" . wp_create_nonce('metasync_host_blocking_notice') . "'
791 });
792 });
793 });
794 ";
795 }
796
797 /**
798 * AJAX handler: remember the dismissal against the current check cycle.
799 */
800 public function ajax_dismiss_notice()
801 {
802 check_ajax_referer('metasync_host_blocking_notice', 'nonce');
803
804 if (!current_user_can('manage_options')) {
805 wp_send_json_error(['message' => 'Insufficient permissions']);
806 }
807
808 $result = $this->get_last_result();
809
810 if ($result === null) {
811 wp_send_json_error(['message' => 'No stored check result']);
812 }
813
814 update_option(self::NOTICE_DISMISSED_OPTION, $result['checked_at'], true);
815
816 wp_send_json_success(['message' => 'Notice dismissed until the next scheduled check']);
817 }
818 }
819