PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
2.7.1 2.7.2 2.7.0 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 All 141 releases
metasync / includes / class-metasync-activator.php

class-metasync-activator.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.2, at includes/class-metasync-activator.php

963 lines 32.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Fired during plugin activation
5 *
6 * @link https://searchatlas.com
7 * @since 1.0.0
8 *
9 * @package Metasync
10 * @subpackage Metasync/includes
11 */
12
13 /**
14 * Fired during plugin activation.
15 *
16 * This class defines all code necessary to run during the plugin's activation.
17 *
18 * @since 1.0.0
19 * @package Metasync
20 * @subpackage Metasync/includes
21 * @author Engineering Team <[email protected]>
22 */
23 class Metasync_Activator
24 {
25
26 /**
27 * Option recording the last failed White Label JSON import.
28 * Stores array{file_hash: string, failed_at: int, error: string}.
29 *
30 * @var string
31 */
32 const IMPORT_FAILURE_OPTION = 'metasync_whitelabel_import_failure';
33
34 /**
35 * Retry cadence (seconds) for a failing White Label import so a
36 * broken file cannot produce a silent retry storm on every admin request.
37 *
38 * @var int
39 */
40 const IMPORT_RETRY_BACKOFF_SECONDS = 3600;
41
42 /**
43 * The white-label settings whose values are mirrored into the
44 * metasync.php plugin header.
45 *
46 * @var string[]
47 */
48 const BRANDING_HEADER_FIELDS = [
49 'white_label_plugin_name',
50 'white_label_plugin_description',
51 'white_label_plugin_author',
52 'white_label_plugin_author_uri',
53 'white_label_plugin_uri',
54 ];
55
56 /**
57 * Machine-readable reason for the most recent import failure, consumed
58 * by render_whitelabel_import_failure_notice().
59 *
60 * @var string
61 */
62 private static $last_import_error = '';
63
64 /**
65 * Field-specific validation errors for the most recent import failure,
66 * keyed by white-label setting name. Populated only when the import was
67 * rejected because a branding value fails the plugin-header contract.
68 *
69 * @var array<string, string>
70 */
71 private static $last_import_invalid_fields = array();
72
73 /**
74 * Canonical list of MetaSync custom WP-Cron hooks.
75 * Shared with Metasync_Deactivator so deactivation cleans up every scheduled hook.
76 *
77 * @since 2.5.x
78 * @var string[]
79 */
80 public static $cron_hooks = [
81 'metasync_sync_log_daily_cleanup',
82 'metasync_announce_cron',
83 'metasync_rate_limit_cleanup',
84 'metasync_heartbeat_cron_check',
85 'metasync_burst_heartbeat',
86 'metasync_check_debug_limits',
87 'metasync_cleanup_transients',
88 'metasync_hidden_post_check',
89 'metasync_otto_recheck_404_exclusions',
90 'metasync_db_cleanup',
91 'metasync_media_batch_optimize_cron',
92 'metasync_speed_cache_cleanup',
93 'metasync_process_seo_job',
94 'metasync_process_otto_crawl_url_job',
95 'metasync_process_otto_batch_cache_job',
96 'metasync_host_blocking_check',
97 'metasync_host_blocking_weekly_check',
98 ];
99
100 /**
101 * Short Description. (use period)
102 *
103 * Long Description.
104 *
105 * @since 1.0.0
106 */
107 public static function activate()
108 {
109 // WordPress core sitemap functionality is required
110 // if (wp_sitemaps_get_server()->sitemaps_enabled() == false) {
111 // add_filter('wp_sitemaps_enabled', '__return_true');
112 // }
113
114 // Generate Plugin Auth Token on first activation
115 self::ensure_plugin_auth_token();
116
117 // Import whitelabel settings only if the JSON file is new or changed
118 // (prevents overwriting admin UI changes on every deactivate/activate cycle)
119 self::check_whitelabel_settings_update(true);
120
121 // Pre-SSO announce: tell backend plugin is installed (PR4 - heartbeat reliability)
122 update_option('metasync_announce_attempt_count', 0);
123 self::send_announce_ping();
124 update_option('metasync_announce_attempt_count', 1);
125
126 // Schedule cron for announce pings 2-5 (every 10 minutes)
127 if (!wp_next_scheduled('metasync_announce_cron')) {
128 wp_schedule_event(time() + 10 * MINUTE_IN_SECONDS, 'metasync_every_10_minutes', 'metasync_announce_cron');
129 }
130
131 // Detect a host that blocks GET/POST between this site and Search Atlas.
132 // Deferred to cron (~10 minutes out) rather than run here — a blocking HTTP call
133 // inside the activation request is exactly what has taken slow hosts down before,
134 // and a firewall-blocked request is the slowest kind there is.
135 if (class_exists('Metasync_Host_Blocking_Check')) {
136 Metasync_Host_Blocking_Check::schedule_initial_check();
137 }
138
139 // Set first activation flag for setup wizard
140 if (!get_option('metasync_first_activation_time')) {
141 update_option('metasync_first_activation_time', current_time('mysql'));
142 update_option('metasync_show_wizard', true);
143 }
144
145 // Auto-disable WP core sitemap if a MetaSync sitemap already exists.
146 // Skip on fresh installs where no sitemap has been generated yet so the site
147 // doesn't end up with zero sitemaps.
148 if (get_option('metasync_sitemap_auto_update', false)
149 || !empty(get_option('metasync_sitemap_files', []))
150 || file_exists(ABSPATH . 'sitemap_index.xml')
151 ) {
152 update_option('metasync_disable_wp_sitemap', true);
153 }
154
155 // Soft flush only (no .htaccess rewrite). A hard flush calls
156 // save_mod_rewrite_rules(), which takes an exclusive flock() on the site's
157 // root .htaccess. On hosts whose cache/optimizer also holds that lock (e.g.
158 // SiteGround + SG Optimizer), the activation request blocks until
159 // max_execution_time and the host returns a 500. MetaSync's rewrite rules are
160 // registered via add_rewrite_rule (stored in the `rewrite_rules` option, NOT
161 // in .htaccess), so a soft flush is sufficient and never touches the file.
162 flush_rewrite_rules(false);
163 }
164
165 /**
166 * Send pre-SSO announce ping to backend (zero-trust; backend rate-limits).
167 * POST /api/wp-plugin-announce/ with url + plugin_version; optional X-Plugin-Token for deduplication.
168 * Callable from activation and from init (rate-limited) when no API key yet.
169 *
170 * @since 2.5.x
171 */
172 public static function send_announce_ping()
173 {
174 $base = 'https://ca.searchatlas.com';
175 if (class_exists('Metasync_Endpoint_Manager')) {
176 $base = Metasync_Endpoint_Manager::get_endpoint('CA_API_DOMAIN');
177 } elseif (class_exists('Metasync')) {
178 $base = Metasync::CA_API_DOMAIN;
179 }
180 $url = rtrim($base, '/') . '/api/wp-plugin-announce/';
181
182 $body = wp_json_encode([
183 'url' => get_home_url(),
184 'plugin_version' => defined('METASYNC_VERSION') ? METASYNC_VERSION : 'unknown',
185 ]);
186
187 $options = get_option('metasync_options', []);
188 $plugin_auth_token = $options['general']['apikey'] ?? '';
189 $headers = [
190 'Content-Type' => 'application/json',
191 ];
192 if (!empty($plugin_auth_token)) {
193 $headers['X-Plugin-Token'] = $plugin_auth_token;
194 }
195
196 wp_remote_post($url, [
197 'body' => $body,
198 'headers' => $headers,
199 'timeout' => 10,
200 'blocking' => false,
201 ]);
202 }
203
204 /**
205 * Ensure Plugin Auth Token exists
206 * Generates a unique Plugin Auth Token during plugin activation
207 */
208 private static function ensure_plugin_auth_token()
209 {
210 $options = get_option('metasync_options', []);
211
212 if (empty($options['general']['apikey'])) {
213 // Generate unique Plugin Auth Token (alphanumeric only)
214 $plugin_auth_token = wp_generate_password(32, false, false);
215
216 // Initialize options structure if needed
217 if (!isset($options['general'])) {
218 $options['general'] = [];
219 }
220
221 // Store Plugin Auth Token
222 $options['general']['apikey'] = $plugin_auth_token;
223 update_option('metasync_options', $options);
224 }
225 }
226
227 /**
228 * Get the path to the whitelabel settings JSON file
229 *
230 * @return string|false Path to the file if it exists, false otherwise
231 * @since 2.5.0
232 */
233 public static function get_whitelabel_settings_file()
234 {
235 $plugin_dir = plugin_dir_path(dirname(__FILE__));
236
237 // Check for whitelabel-settings.json in plugin root
238 $json_file = $plugin_dir . 'whitelabel-settings.json';
239
240 // Also check in a common extracted zip location (if zip was extracted)
241 if (!file_exists($json_file)) {
242 $json_file = $plugin_dir . 'metasync/whitelabel-settings.json';
243 }
244
245 if (!file_exists($json_file)) {
246 return false;
247 }
248
249 return $json_file;
250 }
251
252 /**
253 * Check if whitelabel settings file has been updated and import if needed
254 * This method should be called on init to detect plugin uploads/updates
255 *
256 * @param bool $trusted_update_context True only for core activation/upgrader hooks.
257 * @return bool Whether the file was already current or imported successfully.
258 * @since 2.5.0
259 */
260 public static function check_whitelabel_settings_update($trusted_update_context = false)
261 {
262 // Never run the import from AJAX handlers. admin-ajax requests
263 // (notably the Forgot Password recovery call) run admin_init first, so an
264 // import attempted there already holds the shared recovery lock when the
265 // handler itself runs, and every recovery request returns HTTP 429.
266 // Trusted activation/upgrader contexts run once per update, not per
267 // request, and stay exempt.
268 if (!$trusted_update_context && wp_doing_ajax()) {
269 return false;
270 }
271
272 if (!$trusted_update_context && !current_user_can('manage_options')) {
273 return false;
274 }
275
276 $json_file = self::get_whitelabel_settings_file();
277
278 if ($json_file === false) {
279 return false;
280 }
281
282 // Get current file modification time and content hash
283 $file_mtime = filemtime($json_file);
284 $file_hash = md5_file($json_file);
285 if (!is_int($file_mtime) || !is_string($file_hash)) {
286 return false;
287 }
288
289 // Get stored file info
290 $stored_mtime = get_option('metasync_whitelabel_file_mtime', 0);
291 $stored_hash = get_option('metasync_whitelabel_file_hash', '');
292
293 // Check if file has changed (either modification time or content)
294 if ($file_mtime > $stored_mtime || $file_hash !== $stored_hash) {
295 // A failing import must not retry on every admin request.
296 // Back off per failing file hash; a successful import or a changed
297 // file clears the state and retries immediately.
298 $failure = get_option(self::IMPORT_FAILURE_OPTION, array());
299 if (is_array($failure)
300 && isset($failure['file_hash'], $failure['failed_at'])
301 && (string) $failure['file_hash'] === $file_hash
302 && (time() - (int) $failure['failed_at']) < self::IMPORT_RETRY_BACKOFF_SECONDS) {
303 return false;
304 }
305
306 // File has changed, import settings
307 if (!self::import_whitelabel_settings()) {
308 $failure = array(
309 'file_hash' => $file_hash,
310 'failed_at' => time(),
311 'error' => self::$last_import_error,
312 );
313 if (self::$last_import_error === 'branding_field_invalid' && !empty(self::$last_import_invalid_fields)) {
314 $failure['invalid_fields'] = self::$last_import_invalid_fields;
315 }
316 update_option(self::IMPORT_FAILURE_OPTION, $failure);
317 return false;
318 }
319 delete_option(self::IMPORT_FAILURE_OPTION);
320
321 // Record the file only after the complete import succeeds.
322 update_option('metasync_whitelabel_file_mtime', $file_mtime);
323 update_option('metasync_whitelabel_file_hash', $file_hash);
324 }
325
326 return true;
327 }
328
329 /**
330 * Machine-readable reason of the most recent failed White Label import.
331 *
332 * @return string
333 */
334 public static function get_last_import_error()
335 {
336 return self::$last_import_error;
337 }
338
339 /**
340 * Field-specific branding errors of the most recent failed White Label import.
341 *
342 * @return array<string, string>
343 */
344 public static function get_last_import_invalid_fields()
345 {
346 return self::$last_import_invalid_fields;
347 }
348
349 /**
350 * Admin notice for a White Label JSON import that keeps failing.
351 * Registered from metasync_check_whitelabel_on_admin(); the state is set
352 * by check_whitelabel_settings_update() when an import fails.
353 *
354 * @return void
355 */
356 public static function render_whitelabel_import_failure_notice()
357 {
358 if (!current_user_can('manage_options')) {
359 return;
360 }
361 $failure = get_option(self::IMPORT_FAILURE_OPTION, array());
362 if (!is_array($failure) || empty($failure['file_hash']) || empty($failure['failed_at'])) {
363 return;
364 }
365 $reasons = array(
366 'json_read_failed' => 'the file could not be read',
367 'json_invalid' => 'the file is not valid JSON',
368 'json_structure_invalid' => 'the file structure is invalid',
369 'plugin_file_unreadable' => 'metasync.php could not be read',
370 'branding_field_invalid' => 'the white-label branding values are not valid',
371 'plugin_header_write_failed' => 'the plugin file headers could not be updated (is metasync.php writable?)',
372 'recovery_lock_busy' => 'another password operation was in progress',
373 'options_write_failed' => 'the settings could not be saved',
374 );
375 $reason = isset($failure['error'], $reasons[$failure['error']]) ? $reasons[$failure['error']] : 'an unexpected error occurred';
376
377 // Field-specific detail for a content rejection: name the exact
378 // branding field so the package can be fixed rather than retried blind.
379 echo '<div class="notice notice-error"><p><strong>White Label settings import failed.</strong> '
380 . 'MetaSync could not import <code>whitelabel-settings.json</code>: ' . esc_html($reason)
381 . '. The import will be retried automatically; fix the file and reload to retry immediately.</p>';
382 if (isset($failure['invalid_fields']) && is_array($failure['invalid_fields']) && !empty($failure['invalid_fields'])) {
383 echo '<ul style="margin: 8px 0 0 0; list-style: disc; padding-left: 20px;">';
384 foreach ($failure['invalid_fields'] as $message) {
385 echo '<li>' . esc_html((string) $message) . '</li>';
386 }
387 echo '</ul>';
388 echo '<p style="margin: 8px 0 0 0;">Allowed: single-line text (including valid Unicode) and http(s) URLs. Line breaks, control/invisible characters, comment-ending text, PHP tags, and non-http(s) URLs are rejected.</p>';
389 }
390 echo '</div>';
391 }
392
393 /**
394 * Import whitelabel settings from JSON file if available
395 * Checks for whitelabel-settings.json in the plugin directory or extracted zip
396 *
397 * @return bool Whether the complete import succeeded.
398 * @since 2.5.0
399 */
400 private static function import_whitelabel_settings()
401 {
402 $json_file = self::get_whitelabel_settings_file();
403
404 if ($json_file === false) {
405 return false;
406 }
407
408 self::$last_import_error = 'unknown';
409 self::$last_import_invalid_fields = array();
410
411 // Read JSON file
412 $json_content = file_get_contents($json_file);
413 if ($json_content === false) {
414 self::$last_import_error = 'json_read_failed';
415 return false;
416 }
417
418 // Decode JSON
419 $import_data = json_decode($json_content, true);
420 if ($import_data === null || json_last_error() !== JSON_ERROR_NONE) {
421 self::$last_import_error = 'json_invalid';
422 return false;
423 }
424
425 // Validate import data structure
426 if (!isset($import_data['whitelabel_settings']) || !is_array($import_data['whitelabel_settings'])) {
427 self::$last_import_error = 'json_structure_invalid';
428 return false;
429 }
430
431 $plugin_file = plugin_dir_path(dirname(__FILE__)) . 'metasync.php';
432 $original_plugin_content = file_get_contents($plugin_file);
433 if ($original_plugin_content === false) {
434 self::$last_import_error = 'plugin_file_unreadable';
435 return false;
436 }
437
438 // Get current options
439 $options = get_option('metasync_options', array());
440
441 // Import whitelabel settings
442 if (isset($import_data['whitelabel_settings'])) {
443 $whitelabel_settings = $import_data['whitelabel_settings'];
444
445 // Initialize whitelabel array if needed
446 if (!isset($options['whitelabel'])) {
447 $options['whitelabel'] = array();
448 }
449
450 // Merge imported settings with existing (imported settings take precedence)
451 $options['whitelabel'] = array_merge($options['whitelabel'], $whitelabel_settings);
452
453 // The settings password travels as plaintext in the export file so it
454 // can be imported on a site with different salts — encrypt it at rest.
455 if (!empty($options['whitelabel']['settings_password']) && is_string($options['whitelabel']['settings_password'])) {
456 $options['whitelabel']['settings_password'] = Metasync::encrypt_secret($options['whitelabel']['settings_password']);
457 }
458
459 // Update timestamp
460 $options['whitelabel']['updated_at'] = time();
461 $options['whitelabel']['imported_at'] = current_time('mysql');
462 }
463
464 // Import general settings related to whitelabel
465 if (isset($import_data['general_settings']) && is_array($import_data['general_settings'])) {
466 if (!isset($options['general'])) {
467 $options['general'] = array();
468 }
469
470 // Merge general settings
471 foreach ($import_data['general_settings'] as $key => $value) {
472 $options['general'][$key] = $value;
473 }
474 }
475
476 // Validate and persist the public plugin headers before any other import side effect.
477 $header_data = array(
478 'general_settings' => $options['general'] ?? array(),
479 );
480 if (!self::update_plugin_file_headers($header_data, $plugin_file)) {
481 self::$last_import_error = 'plugin_header_write_failed';
482 // Distinguish a content rejection (invalid branding values) from an
483 // environmental failure (unwritable file, mangled header block) so the
484 // admin notice can point at the offending field.
485 $invalid_fields = self::validate_plugin_header_settings($options['general'] ?? array());
486 if (!empty($invalid_fields)) {
487 self::$last_import_error = 'branding_field_invalid';
488 self::$last_import_invalid_fields = $invalid_fields;
489 }
490 return false;
491 }
492
493 // Restore bundled icon: if the icon value is a __bundled_icon__{ext} marker,
494 // copy the bundled file from the plugin directory to uploads and update the URL.
495 $icon_value = $options['general']['white_label_plugin_menu_icon'] ?? '';
496 if (!empty($icon_value) && strpos($icon_value, '__bundled_icon__') === 0) {
497 $ext = substr($icon_value, strlen('__bundled_icon__'));
498 $ext = preg_replace('/[^a-z0-9]/', '', strtolower($ext)); // sanitize
499 $bundled_file = plugin_dir_path(dirname(__FILE__)) . 'whitelabel-icon.' . $ext;
500
501 if (file_exists($bundled_file) && in_array($ext, ['png', 'svg'], true)) {
502 $upload_dir = wp_upload_dir();
503 $dest_dir = $upload_dir['basedir'] . '/metasync';
504 if (!file_exists($dest_dir)) {
505 wp_mkdir_p($dest_dir);
506 }
507 $dest_file = $dest_dir . '/whitelabel-icon.' . $ext;
508 if (copy($bundled_file, $dest_file)) {
509 $options['general']['white_label_plugin_menu_icon'] = $upload_dir['baseurl'] . '/metasync/whitelabel-icon.' . $ext;
510 } else {
511 // Could not copy — clear the broken marker so default icon shows
512 $options['general']['white_label_plugin_menu_icon'] = '';
513 }
514 } else {
515 // Bundled file missing or unsupported extension — clear the marker
516 $options['general']['white_label_plugin_menu_icon'] = '';
517 }
518 }
519
520 // Save updated options.
521 // when the package carries a settings password this is a
522 // legitimate import write, but the recovery protection filter treats any
523 // unauthenticated password write as a conflict and swaps it back to the
524 // stored password — the verification below could then never pass once a
525 // password is stored, and the retry loop would hold the shared recovery
526 // lock on every admin request, blocking the Forgot Password flow.
527 // Mirror the recovery flow's own persist path: take the lock and
528 // authorize the write for the duration of the save.
529 $carries_password = !empty($options['whitelabel']['settings_password']);
530 $import_lock_owner = '';
531 if ($carries_password) {
532 require_once __DIR__ . '/class-metasync-admin-ajax.php';
533 require_once __DIR__ . '/class-metasync-settings-registration.php';
534 if (!self::class_supports_methods('Metasync_Admin_Ajax', array('acquire_recovery_lock', 'release_recovery_lock'))
535 || !self::class_supports_methods('Metasync_Settings_Registration', array('authorize_recovery_password_write'))) {
536 // The updater can replace plugin files after the old classes were
537 // loaded in this request. Defer the import to the next request.
538 self::$last_import_error = 'recovery_api_unavailable';
539 return false;
540 }
541 if (!Metasync_Admin_Ajax::instance()->acquire_recovery_lock($import_lock_owner)) {
542 // A recovery or settings save is mid-write; retry via the normal
543 // backoff instead of fighting for the lock.
544 self::$last_import_error = 'recovery_lock_busy';
545 return false;
546 }
547 }
548 try {
549 if ($carries_password) {
550 Metasync_Settings_Registration::authorize_recovery_password_write(true);
551 }
552 update_option('metasync_options', $options);
553 } finally {
554 if ($carries_password) {
555 Metasync_Settings_Registration::authorize_recovery_password_write(false);
556 Metasync_Admin_Ajax::instance()->release_recovery_lock($import_lock_owner);
557 }
558 }
559 if (get_option('metasync_options', null) !== $options) {
560 if (self::atomically_replace_plugin_file($plugin_file, $original_plugin_content)) {
561 wp_cache_delete('plugins', 'plugins');
562 }
563 self::$last_import_error = 'options_write_failed';
564 return false;
565 }
566
567 // Optionally delete the JSON file after successful import (uncomment if desired)
568 // unlink($json_file);
569
570 return true;
571 }
572
573 /**
574 * Check runtime class compatibility without letting static analysis fold it.
575 *
576 * @param string $class_name Class name loaded in the current request.
577 * @param string[] $methods Required method names.
578 * @return bool
579 */
580 private static function class_supports_methods($class_name, $methods)
581 {
582 foreach ($methods as $method) {
583 if (!method_exists($class_name, $method)) {
584 return false;
585 }
586 }
587
588 return true;
589 }
590
591 /**
592 * Sync plugin file headers from the current saved options in the database.
593 * Call this after saving whitelabel settings via the admin UI to ensure
594 * the plugin file headers reflect the latest whitelabel values.
595 *
596 * @since 2.5.0
597 */
598 public static function sync_plugin_file_headers()
599 {
600 if (!current_user_can('manage_options')) {
601 return false;
602 }
603
604 $options = get_option('metasync_options', array());
605 $general = $options['general'] ?? array();
606
607 // Build the import_data format expected by update_plugin_file_headers
608 $import_data = array(
609 'general_settings' => $general,
610 );
611
612 return self::update_plugin_file_headers($import_data);
613 }
614
615 /**
616 * Absolute path of the plugin file whose header carries the branding.
617 *
618 * @return string
619 */
620 public static function plugin_header_file_path()
621 {
622 return plugin_dir_path(dirname(__FILE__)) . 'metasync.php';
623 }
624
625 /**
626 * Apply a proposed save's branding to the physical plugin header BEFORE
627 * the options write starts, so an unwritable or malformed target stops
628 * the save while nothing has been persisted yet.
629 *
630 * Returns null when none of the branding values changed (the file is left
631 * untouched — unrelated saves must not rewrite metasync.php), true when
632 * the header now matches the proposed values, and false when the update
633 * was rejected or failed (file unchanged, save must be aborted).
634 *
635 * @param mixed $proposed_general General section proposed for saving.
636 * @param mixed $stored_general General section currently stored.
637 * @return bool|null
638 */
639 public static function apply_plugin_file_headers_for_save($proposed_general, $stored_general)
640 {
641 $proposed_general = is_array($proposed_general) ? $proposed_general : array();
642 $stored_general = is_array($stored_general) ? $stored_general : array();
643
644 $changed = false;
645 foreach (self::BRANDING_HEADER_FIELDS as $key) {
646 if ((string) ($proposed_general[$key] ?? '') !== (string) ($stored_general[$key] ?? '')) {
647 $changed = true;
648 break;
649 }
650 }
651 if (!$changed) {
652 return null;
653 }
654
655 return self::update_plugin_file_headers(array('general_settings' => $proposed_general));
656 }
657
658 /**
659 * Restore a previous metasync.php content after a save that wrote new
660 * headers but then failed to persist the matching options.
661 *
662 * @param string $plugin_file Absolute path to metasync.php.
663 * @param string|false $original_content Content captured before the header write (file_get_contents() result).
664 * @return bool Whether the restore succeeded.
665 */
666 public static function restore_plugin_file_content($plugin_file, $original_content)
667 {
668 if (!is_string($original_content) || $original_content === '') {
669 return false;
670 }
671 if (!self::atomically_replace_plugin_file($plugin_file, $original_content)) {
672 return false;
673 }
674 wp_cache_delete('plugins', 'plugins');
675 return true;
676 }
677
678 /**
679 * Validate the five values that are written into metasync.php's plugin header.
680 *
681 * This is deliberately shared by administrator saves and package imports so
682 * both paths enforce the same contract before options are persisted.
683 *
684 * @param mixed $general General settings submitted or imported.
685 * @return array<string, string> Field-specific errors, keyed by setting name.
686 */
687 public static function validate_plugin_header_settings($general)
688 {
689 $general = is_array($general) ? $general : array();
690 $header_map = array(
691 'white_label_plugin_name' => array(
692 'label' => 'Plugin Name',
693 'default' => 'Search Atlas SEO - OTTO AI SEO Automation for WordPress',
694 'url' => false,
695 ),
696 'white_label_plugin_description' => array(
697 'label' => 'Plugin Description',
698 'default' => 'Search Atlas SEO is an intuitive WordPress Plugin that transforms the most complicated, most labor-intensive SEO tasks into streamlined, straightforward processes. With a few clicks, the meta-bulk update feature automates the re-optimization of meta tags using AI to increase clicks. Stay up-to-date with the freshest Google Search data for your entire site or targeted URLs within the Meta Sync plug-in page.',
699 'url' => false,
700 ),
701 'white_label_plugin_author' => array(
702 'label' => 'Author',
703 'default' => 'Search Atlas',
704 'url' => false,
705 ),
706 'white_label_plugin_author_uri' => array(
707 'label' => 'Author URI',
708 'default' => 'https://searchatlas.com',
709 'url' => true,
710 ),
711 'white_label_plugin_uri' => array(
712 'label' => 'Plugin URI',
713 'default' => 'https://searchatlas.com/',
714 'url' => true,
715 ),
716 );
717 $errors = array();
718
719 foreach ($header_map as $key => $config) {
720 // A present-but-empty string resets to the default; a scalar like
721 // "0" is a real value and must not be conflated with unset (the
722 // classic PHP !empty() trap). Malformed non-strings fall through
723 // to the is_string rejection below.
724 $value = $config['default'];
725 if (array_key_exists($key, $general)) {
726 $raw = $general[$key];
727 if (is_string($raw)) {
728 $value = $raw !== '' ? $raw : $config['default'];
729 } elseif ($raw !== null) {
730 $value = $raw;
731 }
732 }
733 if (!is_string($value) || !self::is_valid_plugin_header_value($value, $config['url'])) {
734 $errors[$key] = sprintf('%s contains invalid characters or format.', $config['label']);
735 }
736 }
737
738 return $errors;
739 }
740
741 /**
742 * Update the main plugin file headers with whitelabel values
743 * WordPress reads plugin metadata directly from the file header comments,
744 * so modifying these ensures whitelabel shows even when the plugin is deactivated.
745 *
746 * @param array $import_data The imported whitelabel data
747 * @since 2.5.0
748 */
749 private static function update_plugin_file_headers($import_data, $plugin_file = null)
750 {
751 $plugin_file = $plugin_file ?: plugin_dir_path(dirname(__FILE__)) . 'metasync.php';
752
753 if (!file_exists($plugin_file) || !wp_is_writable($plugin_file)) {
754 return false;
755 }
756
757 $content = file_get_contents($plugin_file);
758 if ($content === false) {
759 return false;
760 }
761
762 $general = $import_data['general_settings'] ?? array();
763
764 // Map of whitelabel setting keys to plugin header field names
765 // with default values to restore when whitelabel is cleared
766 $header_map = array(
767 'white_label_plugin_name' => array(
768 'header' => 'Plugin Name',
769 'default' => 'Search Atlas SEO - OTTO AI SEO Automation for WordPress',
770 ),
771 'white_label_plugin_description' => array(
772 'header' => 'Description',
773 'default' => 'Search Atlas SEO is an intuitive WordPress Plugin that transforms the most complicated, most labor-intensive SEO tasks into streamlined, straightforward processes. With a few clicks, the meta-bulk update feature automates the re-optimization of meta tags using AI to increase clicks. Stay up-to-date with the freshest Google Search data for your entire site or targeted URLs within the Meta Sync plug-in page.',
774 ),
775 'white_label_plugin_author' => array(
776 'header' => 'Author',
777 'default' => 'Search Atlas',
778 ),
779 'white_label_plugin_author_uri' => array(
780 'header' => 'Author URI',
781 'default' => 'https://searchatlas.com',
782 ),
783 'white_label_plugin_uri' => array(
784 'header' => 'Plugin URI',
785 'default' => 'https://searchatlas.com/',
786 ),
787 );
788
789 $modified = false;
790 $original_content = $content;
791 $url_fields = array('white_label_plugin_author_uri', 'white_label_plugin_uri');
792
793 foreach ($header_map as $setting_key => $field_config) {
794 $header_field = $field_config['header'];
795
796 // Use whitelabel value if set, otherwise restore default. A scalar
797 // like "0" is a real value, not an unset field; an empty string or
798 // null restores the default; malformed non-strings are rejected by
799 // the is_string check below.
800 $new_value = $field_config['default'];
801 if (array_key_exists($setting_key, $general)) {
802 $raw_value = $general[$setting_key];
803 if (is_string($raw_value)) {
804 $new_value = $raw_value !== '' ? $raw_value : $field_config['default'];
805 } elseif ($raw_value !== null) {
806 $new_value = $raw_value;
807 }
808 }
809
810 if (!is_string($new_value) || !self::is_valid_plugin_header_value($new_value, in_array($setting_key, $url_fields, true))) {
811 return false;
812 }
813
814 // Match the header line: " * Field Name: any value"
815 // Handles varying whitespace between field name and value
816 $pattern = '/^(\s*\*\s*' . preg_quote($header_field, '/') . ':\s*)(.+)$/m';
817
818 if (preg_match_all($pattern, $content, $matches) !== 1) {
819 return false;
820 }
821
822 // Only replace if the value actually differs from what's in the file.
823 if (trim($matches[2][0]) !== trim($new_value)) {
824 $content = preg_replace_callback(
825 $pattern,
826 static function ($match) use ($new_value) {
827 return $match[1] . $new_value;
828 },
829 $content,
830 1,
831 $replacement_count
832 );
833
834 if (!is_string($content) || $replacement_count !== 1) {
835 return false;
836 }
837 $modified = true;
838 }
839 }
840
841 if (!self::is_valid_transformed_plugin_file($original_content, $content, $header_map)) {
842 return false;
843 }
844
845 if (!$modified) {
846 return true;
847 }
848
849 if (!self::atomically_replace_plugin_file($plugin_file, $content)) {
850 return false;
851 }
852
853 // Clear WordPress plugin cache so it reads the updated headers.
854 wp_cache_delete('plugins', 'plugins');
855
856 return true;
857 }
858
859 /**
860 * Validate a value before placing it inside the plugin's PHP docblock.
861 *
862 * Public so form/import handlers can pre-validate a value with the exact
863 * contract used when metasync.php is physically rewritten.
864 *
865 * @param string $value Candidate header value.
866 * @param bool $is_url Whether the value must be an http(s) URL.
867 * @return bool
868 */
869 public static function is_valid_plugin_header_value($value, $is_url)
870 {
871 if (
872 $value === ''
873 || preg_match('/^[\p{L}\p{M}\p{N}\p{P}\p{S}\p{Zs}]+$/u', $value) !== 1
874 || preg_match('/\*\/|<\?php|\?>/i', $value) !== 0
875 ) {
876 return false;
877 }
878
879 if (!$is_url) {
880 return true;
881 }
882
883 if (filter_var($value, FILTER_VALIDATE_URL) === false) {
884 return false;
885 }
886
887 $scheme = strtolower((string) wp_parse_url($value, PHP_URL_SCHEME));
888
889 return in_array($scheme, array('http', 'https'), true);
890 }
891
892 /**
893 * Validate the complete transformed PHP file and its header structure.
894 */
895 private static function is_valid_transformed_plugin_file($original, $transformed, $header_map)
896 {
897 if (!is_string($transformed) || strpos($transformed, '<?php') !== 0) {
898 return false;
899 }
900
901 if (
902 substr_count($original, '<?php') !== substr_count($transformed, '<?php')
903 || substr_count($original, '?>') !== substr_count($transformed, '?>')
904 ) {
905 return false;
906 }
907
908 foreach ($header_map as $field_config) {
909 $pattern = '/^\s*\*\s*' . preg_quote($field_config['header'], '/') . ':\s*.+$/m';
910 if (preg_match_all($pattern, $transformed) !== 1) {
911 return false;
912 }
913 }
914
915 try {
916 $tokens = token_get_all($transformed, TOKEN_PARSE);
917 } catch (ParseError $error) {
918 return false;
919 }
920
921 return true;
922 }
923
924 /**
925 * Replace a plugin file from a same-directory temporary file.
926 */
927 private static function atomically_replace_plugin_file($plugin_file, $content, $rename_file = null)
928 {
929 $directory = dirname($plugin_file);
930 $temp_file = tempnam($directory, '.metasync-header-');
931 if ($temp_file === false) {
932 return false;
933 }
934
935 $permissions = fileperms($plugin_file);
936 $written = file_put_contents($temp_file, $content, LOCK_EX);
937 if ($written !== strlen($content)) {
938 @wp_delete_file($temp_file);
939 return false;
940 }
941
942 if ($permissions !== false) {
943 @chmod($temp_file, $permissions & 0777); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- direct POSIX permission change; WP_Filesystem would prompt for FTP credentials on non-direct hosts
944 }
945
946 if ($rename_file === null) {
947 $rename_file = static function ($source, $destination) {
948 return @rename($source, $destination); // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- atomic same-filesystem replace; WP_Filesystem::move() needs credential bootstrapping on non-direct hosts
949 };
950 }
951
952 // The temporary file is on the same filesystem, so a successful rename is
953 // atomic. On failure the original path has not been touched.
954 if ($rename_file($temp_file, $plugin_file)) {
955 return true;
956 }
957
958 @wp_delete_file($temp_file);
959
960 return false;
961 }
962 }
963