| 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 <support@searchatlas.com> |
| 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 |
* Machine-readable reason for the most recent import failure, consumed |
| 44 |
* by render_whitelabel_import_failure_notice(). |
| 45 |
* |
| 46 |
* @var string |
| 47 |
*/ |
| 48 |
private static $last_import_error = ''; |
| 49 |
|
| 50 |
/** |
| 51 |
* Canonical list of MetaSync custom WP-Cron hooks. |
| 52 |
* Shared with Metasync_Deactivator so deactivation cleans up every scheduled hook. |
| 53 |
* |
| 54 |
* @since 2.5.x |
| 55 |
* @var string[] |
| 56 |
*/ |
| 57 |
public static $cron_hooks = [ |
| 58 |
'metasync_sync_log_daily_cleanup', |
| 59 |
'metasync_announce_cron', |
| 60 |
'metasync_rate_limit_cleanup', |
| 61 |
'metasync_heartbeat_cron_check', |
| 62 |
'metasync_burst_heartbeat', |
| 63 |
'metasync_check_debug_limits', |
| 64 |
'metasync_cleanup_transients', |
| 65 |
'metasync_hidden_post_check', |
| 66 |
'metasync_otto_recheck_404_exclusions', |
| 67 |
'metasync_db_cleanup', |
| 68 |
'metasync_media_batch_optimize_cron', |
| 69 |
'metasync_speed_cache_cleanup', |
| 70 |
'metasync_process_seo_job', |
| 71 |
'metasync_process_otto_crawl_url_job', |
| 72 |
'metasync_process_otto_batch_cache_job', |
| 73 |
'metasync_host_blocking_check', |
| 74 |
'metasync_host_blocking_weekly_check', |
| 75 |
]; |
| 76 |
|
| 77 |
/** |
| 78 |
* Short Description. (use period) |
| 79 |
* |
| 80 |
* Long Description. |
| 81 |
* |
| 82 |
* @since 1.0.0 |
| 83 |
*/ |
| 84 |
public static function activate() |
| 85 |
{ |
| 86 |
// WordPress core sitemap functionality is required |
| 87 |
// if (wp_sitemaps_get_server()->sitemaps_enabled() == false) { |
| 88 |
// add_filter('wp_sitemaps_enabled', '__return_true'); |
| 89 |
// } |
| 90 |
|
| 91 |
// Generate Plugin Auth Token on first activation |
| 92 |
self::ensure_plugin_auth_token(); |
| 93 |
|
| 94 |
// Import whitelabel settings only if the JSON file is new or changed |
| 95 |
// (prevents overwriting admin UI changes on every deactivate/activate cycle) |
| 96 |
self::check_whitelabel_settings_update(true); |
| 97 |
|
| 98 |
// Pre-SSO announce: tell backend plugin is installed (PR4 - heartbeat reliability) |
| 99 |
update_option('metasync_announce_attempt_count', 0); |
| 100 |
self::send_announce_ping(); |
| 101 |
update_option('metasync_announce_attempt_count', 1); |
| 102 |
|
| 103 |
// Schedule cron for announce pings 2-5 (every 10 minutes) |
| 104 |
if (!wp_next_scheduled('metasync_announce_cron')) { |
| 105 |
wp_schedule_event(time() + 10 * MINUTE_IN_SECONDS, 'metasync_every_10_minutes', 'metasync_announce_cron'); |
| 106 |
} |
| 107 |
|
| 108 |
// Detect a host that blocks GET/POST between this site and Search Atlas. |
| 109 |
// Deferred to cron (~10 minutes out) rather than run here — a blocking HTTP call |
| 110 |
// inside the activation request is exactly what has taken slow hosts down before, |
| 111 |
// and a firewall-blocked request is the slowest kind there is. |
| 112 |
if (class_exists('Metasync_Host_Blocking_Check')) { |
| 113 |
Metasync_Host_Blocking_Check::schedule_initial_check(); |
| 114 |
} |
| 115 |
|
| 116 |
// Set first activation flag for setup wizard |
| 117 |
if (!get_option('metasync_first_activation_time')) { |
| 118 |
update_option('metasync_first_activation_time', current_time('mysql')); |
| 119 |
update_option('metasync_show_wizard', true); |
| 120 |
} |
| 121 |
|
| 122 |
// Auto-disable WP core sitemap if a MetaSync sitemap already exists. |
| 123 |
// Skip on fresh installs where no sitemap has been generated yet so the site |
| 124 |
// doesn't end up with zero sitemaps. |
| 125 |
if (get_option('metasync_sitemap_auto_update', false) |
| 126 |
|| !empty(get_option('metasync_sitemap_files', [])) |
| 127 |
|| file_exists(ABSPATH . 'sitemap_index.xml') |
| 128 |
) { |
| 129 |
update_option('metasync_disable_wp_sitemap', true); |
| 130 |
} |
| 131 |
|
| 132 |
// Soft flush only (no .htaccess rewrite). A hard flush calls |
| 133 |
// save_mod_rewrite_rules(), which takes an exclusive flock() on the site's |
| 134 |
// root .htaccess. On hosts whose cache/optimizer also holds that lock (e.g. |
| 135 |
// SiteGround + SG Optimizer), the activation request blocks until |
| 136 |
// max_execution_time and the host returns a 500. MetaSync's rewrite rules are |
| 137 |
// registered via add_rewrite_rule (stored in the `rewrite_rules` option, NOT |
| 138 |
// in .htaccess), so a soft flush is sufficient and never touches the file. |
| 139 |
flush_rewrite_rules(false); |
| 140 |
} |
| 141 |
|
| 142 |
/** |
| 143 |
* Send pre-SSO announce ping to backend (zero-trust; backend rate-limits). |
| 144 |
* POST /api/wp-plugin-announce/ with url + plugin_version; optional X-Plugin-Token for deduplication. |
| 145 |
* Callable from activation and from init (rate-limited) when no API key yet. |
| 146 |
* |
| 147 |
* @since 2.5.x |
| 148 |
*/ |
| 149 |
public static function send_announce_ping() |
| 150 |
{ |
| 151 |
$base = 'https://ca.searchatlas.com'; |
| 152 |
if (class_exists('Metasync_Endpoint_Manager')) { |
| 153 |
$base = Metasync_Endpoint_Manager::get_endpoint('CA_API_DOMAIN'); |
| 154 |
} elseif (class_exists('Metasync')) { |
| 155 |
$base = Metasync::CA_API_DOMAIN; |
| 156 |
} |
| 157 |
$url = rtrim($base, '/') . '/api/wp-plugin-announce/'; |
| 158 |
|
| 159 |
$body = wp_json_encode([ |
| 160 |
'url' => get_home_url(), |
| 161 |
'plugin_version' => defined('METASYNC_VERSION') ? METASYNC_VERSION : 'unknown', |
| 162 |
]); |
| 163 |
|
| 164 |
$options = get_option('metasync_options', []); |
| 165 |
$plugin_auth_token = $options['general']['apikey'] ?? ''; |
| 166 |
$headers = [ |
| 167 |
'Content-Type' => 'application/json', |
| 168 |
]; |
| 169 |
if (!empty($plugin_auth_token)) { |
| 170 |
$headers['X-Plugin-Token'] = $plugin_auth_token; |
| 171 |
} |
| 172 |
|
| 173 |
wp_remote_post($url, [ |
| 174 |
'body' => $body, |
| 175 |
'headers' => $headers, |
| 176 |
'timeout' => 10, |
| 177 |
'blocking' => false, |
| 178 |
]); |
| 179 |
} |
| 180 |
|
| 181 |
/** |
| 182 |
* Ensure Plugin Auth Token exists |
| 183 |
* Generates a unique Plugin Auth Token during plugin activation |
| 184 |
*/ |
| 185 |
private static function ensure_plugin_auth_token() |
| 186 |
{ |
| 187 |
$options = get_option('metasync_options', []); |
| 188 |
|
| 189 |
if (empty($options['general']['apikey'])) { |
| 190 |
// Generate unique Plugin Auth Token (alphanumeric only) |
| 191 |
$plugin_auth_token = wp_generate_password(32, false, false); |
| 192 |
|
| 193 |
// Initialize options structure if needed |
| 194 |
if (!isset($options['general'])) { |
| 195 |
$options['general'] = []; |
| 196 |
} |
| 197 |
|
| 198 |
// Store Plugin Auth Token |
| 199 |
$options['general']['apikey'] = $plugin_auth_token; |
| 200 |
update_option('metasync_options', $options); |
| 201 |
} |
| 202 |
} |
| 203 |
|
| 204 |
/** |
| 205 |
* Get the path to the whitelabel settings JSON file |
| 206 |
* |
| 207 |
* @return string|false Path to the file if it exists, false otherwise |
| 208 |
* @since 2.5.0 |
| 209 |
*/ |
| 210 |
public static function get_whitelabel_settings_file() |
| 211 |
{ |
| 212 |
$plugin_dir = plugin_dir_path(dirname(__FILE__)); |
| 213 |
|
| 214 |
// Check for whitelabel-settings.json in plugin root |
| 215 |
$json_file = $plugin_dir . 'whitelabel-settings.json'; |
| 216 |
|
| 217 |
// Also check in a common extracted zip location (if zip was extracted) |
| 218 |
if (!file_exists($json_file)) { |
| 219 |
$json_file = $plugin_dir . 'metasync/whitelabel-settings.json'; |
| 220 |
} |
| 221 |
|
| 222 |
if (!file_exists($json_file)) { |
| 223 |
return false; |
| 224 |
} |
| 225 |
|
| 226 |
return $json_file; |
| 227 |
} |
| 228 |
|
| 229 |
/** |
| 230 |
* Check if whitelabel settings file has been updated and import if needed |
| 231 |
* This method should be called on init to detect plugin uploads/updates |
| 232 |
* |
| 233 |
* @param bool $trusted_update_context True only for core activation/upgrader hooks. |
| 234 |
* @return bool Whether the file was already current or imported successfully. |
| 235 |
* @since 2.5.0 |
| 236 |
*/ |
| 237 |
public static function check_whitelabel_settings_update($trusted_update_context = false) |
| 238 |
{ |
| 239 |
// Never run the import from AJAX handlers. admin-ajax requests |
| 240 |
// (notably the Forgot Password recovery call) run admin_init first, so an |
| 241 |
// import attempted there already holds the shared recovery lock when the |
| 242 |
// handler itself runs, and every recovery request returns HTTP 429. |
| 243 |
// Trusted activation/upgrader contexts run once per update, not per |
| 244 |
// request, and stay exempt. |
| 245 |
if (!$trusted_update_context && wp_doing_ajax()) { |
| 246 |
return false; |
| 247 |
} |
| 248 |
|
| 249 |
if (!$trusted_update_context && !current_user_can('manage_options')) { |
| 250 |
return false; |
| 251 |
} |
| 252 |
|
| 253 |
$json_file = self::get_whitelabel_settings_file(); |
| 254 |
|
| 255 |
if ($json_file === false) { |
| 256 |
return false; |
| 257 |
} |
| 258 |
|
| 259 |
// Get current file modification time and content hash |
| 260 |
$file_mtime = filemtime($json_file); |
| 261 |
$file_hash = md5_file($json_file); |
| 262 |
if (!is_int($file_mtime) || !is_string($file_hash)) { |
| 263 |
return false; |
| 264 |
} |
| 265 |
|
| 266 |
// Get stored file info |
| 267 |
$stored_mtime = get_option('metasync_whitelabel_file_mtime', 0); |
| 268 |
$stored_hash = get_option('metasync_whitelabel_file_hash', ''); |
| 269 |
|
| 270 |
// Check if file has changed (either modification time or content) |
| 271 |
if ($file_mtime > $stored_mtime || $file_hash !== $stored_hash) { |
| 272 |
// A failing import must not retry on every admin request. |
| 273 |
// Back off per failing file hash; a successful import or a changed |
| 274 |
// file clears the state and retries immediately. |
| 275 |
$failure = get_option(self::IMPORT_FAILURE_OPTION, array()); |
| 276 |
if (is_array($failure) |
| 277 |
&& isset($failure['file_hash'], $failure['failed_at']) |
| 278 |
&& (string) $failure['file_hash'] === $file_hash |
| 279 |
&& (time() - (int) $failure['failed_at']) < self::IMPORT_RETRY_BACKOFF_SECONDS) { |
| 280 |
return false; |
| 281 |
} |
| 282 |
|
| 283 |
// File has changed, import settings |
| 284 |
if (!self::import_whitelabel_settings()) { |
| 285 |
update_option(self::IMPORT_FAILURE_OPTION, array( |
| 286 |
'file_hash' => $file_hash, |
| 287 |
'failed_at' => time(), |
| 288 |
'error' => self::$last_import_error, |
| 289 |
)); |
| 290 |
return false; |
| 291 |
} |
| 292 |
delete_option(self::IMPORT_FAILURE_OPTION); |
| 293 |
|
| 294 |
// Record the file only after the complete import succeeds. |
| 295 |
update_option('metasync_whitelabel_file_mtime', $file_mtime); |
| 296 |
update_option('metasync_whitelabel_file_hash', $file_hash); |
| 297 |
} |
| 298 |
|
| 299 |
return true; |
| 300 |
} |
| 301 |
|
| 302 |
/** |
| 303 |
* Machine-readable reason of the most recent failed White Label import. |
| 304 |
* |
| 305 |
* @return string |
| 306 |
*/ |
| 307 |
public static function get_last_import_error() |
| 308 |
{ |
| 309 |
return self::$last_import_error; |
| 310 |
} |
| 311 |
|
| 312 |
/** |
| 313 |
* Admin notice for a White Label JSON import that keeps failing. |
| 314 |
* Registered from metasync_check_whitelabel_on_admin(); the state is set |
| 315 |
* by check_whitelabel_settings_update() when an import fails. |
| 316 |
* |
| 317 |
* @return void |
| 318 |
*/ |
| 319 |
public static function render_whitelabel_import_failure_notice() |
| 320 |
{ |
| 321 |
if (!current_user_can('manage_options')) { |
| 322 |
return; |
| 323 |
} |
| 324 |
$failure = get_option(self::IMPORT_FAILURE_OPTION, array()); |
| 325 |
if (!is_array($failure) || empty($failure['file_hash']) || empty($failure['failed_at'])) { |
| 326 |
return; |
| 327 |
} |
| 328 |
$reasons = array( |
| 329 |
'json_read_failed' => 'the file could not be read', |
| 330 |
'json_invalid' => 'the file is not valid JSON', |
| 331 |
'json_structure_invalid' => 'the file structure is invalid', |
| 332 |
'plugin_file_unreadable' => 'metasync.php could not be read', |
| 333 |
'plugin_header_write_failed' => 'the plugin file headers could not be updated (is metasync.php writable?)', |
| 334 |
'recovery_lock_busy' => 'another password operation was in progress', |
| 335 |
'options_write_failed' => 'the settings could not be saved', |
| 336 |
); |
| 337 |
$reason = isset($failure['error'], $reasons[$failure['error']]) ? $reasons[$failure['error']] : 'an unexpected error occurred'; |
| 338 |
echo '<div class="notice notice-error"><p><strong>White Label settings import failed.</strong> ' |
| 339 |
. 'MetaSync could not import <code>whitelabel-settings.json</code>: ' . esc_html($reason) |
| 340 |
. '. The import will be retried automatically; fix the file and reload to retry immediately.</p></div>'; |
| 341 |
} |
| 342 |
|
| 343 |
/** |
| 344 |
* Import whitelabel settings from JSON file if available |
| 345 |
* Checks for whitelabel-settings.json in the plugin directory or extracted zip |
| 346 |
* |
| 347 |
* @return bool Whether the complete import succeeded. |
| 348 |
* @since 2.5.0 |
| 349 |
*/ |
| 350 |
private static function import_whitelabel_settings() |
| 351 |
{ |
| 352 |
$json_file = self::get_whitelabel_settings_file(); |
| 353 |
|
| 354 |
if ($json_file === false) { |
| 355 |
return false; |
| 356 |
} |
| 357 |
|
| 358 |
self::$last_import_error = 'unknown'; |
| 359 |
|
| 360 |
// Read JSON file |
| 361 |
$json_content = file_get_contents($json_file); |
| 362 |
if ($json_content === false) { |
| 363 |
self::$last_import_error = 'json_read_failed'; |
| 364 |
return false; |
| 365 |
} |
| 366 |
|
| 367 |
// Decode JSON |
| 368 |
$import_data = json_decode($json_content, true); |
| 369 |
if ($import_data === null || json_last_error() !== JSON_ERROR_NONE) { |
| 370 |
self::$last_import_error = 'json_invalid'; |
| 371 |
return false; |
| 372 |
} |
| 373 |
|
| 374 |
// Validate import data structure |
| 375 |
if (!isset($import_data['whitelabel_settings']) || !is_array($import_data['whitelabel_settings'])) { |
| 376 |
self::$last_import_error = 'json_structure_invalid'; |
| 377 |
return false; |
| 378 |
} |
| 379 |
|
| 380 |
$plugin_file = plugin_dir_path(dirname(__FILE__)) . 'metasync.php'; |
| 381 |
$original_plugin_content = file_get_contents($plugin_file); |
| 382 |
if ($original_plugin_content === false) { |
| 383 |
self::$last_import_error = 'plugin_file_unreadable'; |
| 384 |
return false; |
| 385 |
} |
| 386 |
|
| 387 |
// Get current options |
| 388 |
$options = get_option('metasync_options', array()); |
| 389 |
|
| 390 |
// Import whitelabel settings |
| 391 |
if (isset($import_data['whitelabel_settings'])) { |
| 392 |
$whitelabel_settings = $import_data['whitelabel_settings']; |
| 393 |
|
| 394 |
// Initialize whitelabel array if needed |
| 395 |
if (!isset($options['whitelabel'])) { |
| 396 |
$options['whitelabel'] = array(); |
| 397 |
} |
| 398 |
|
| 399 |
// Merge imported settings with existing (imported settings take precedence) |
| 400 |
$options['whitelabel'] = array_merge($options['whitelabel'], $whitelabel_settings); |
| 401 |
|
| 402 |
// The settings password travels as plaintext in the export file so it |
| 403 |
// can be imported on a site with different salts — encrypt it at rest. |
| 404 |
if (!empty($options['whitelabel']['settings_password']) && is_string($options['whitelabel']['settings_password'])) { |
| 405 |
$options['whitelabel']['settings_password'] = Metasync::encrypt_secret($options['whitelabel']['settings_password']); |
| 406 |
} |
| 407 |
|
| 408 |
// Update timestamp |
| 409 |
$options['whitelabel']['updated_at'] = time(); |
| 410 |
$options['whitelabel']['imported_at'] = current_time('mysql'); |
| 411 |
} |
| 412 |
|
| 413 |
// Import general settings related to whitelabel |
| 414 |
if (isset($import_data['general_settings']) && is_array($import_data['general_settings'])) { |
| 415 |
if (!isset($options['general'])) { |
| 416 |
$options['general'] = array(); |
| 417 |
} |
| 418 |
|
| 419 |
// Merge general settings |
| 420 |
foreach ($import_data['general_settings'] as $key => $value) { |
| 421 |
$options['general'][$key] = $value; |
| 422 |
} |
| 423 |
} |
| 424 |
|
| 425 |
// Validate and persist the public plugin headers before any other import side effect. |
| 426 |
$header_data = array( |
| 427 |
'general_settings' => $options['general'] ?? array(), |
| 428 |
); |
| 429 |
if (!self::update_plugin_file_headers($header_data, $plugin_file)) { |
| 430 |
self::$last_import_error = 'plugin_header_write_failed'; |
| 431 |
return false; |
| 432 |
} |
| 433 |
|
| 434 |
// Restore bundled icon: if the icon value is a __bundled_icon__{ext} marker, |
| 435 |
// copy the bundled file from the plugin directory to uploads and update the URL. |
| 436 |
$icon_value = $options['general']['white_label_plugin_menu_icon'] ?? ''; |
| 437 |
if (!empty($icon_value) && strpos($icon_value, '__bundled_icon__') === 0) { |
| 438 |
$ext = substr($icon_value, strlen('__bundled_icon__')); |
| 439 |
$ext = preg_replace('/[^a-z0-9]/', '', strtolower($ext)); // sanitize |
| 440 |
$bundled_file = plugin_dir_path(dirname(__FILE__)) . 'whitelabel-icon.' . $ext; |
| 441 |
|
| 442 |
if (file_exists($bundled_file) && in_array($ext, ['png', 'svg'], true)) { |
| 443 |
$upload_dir = wp_upload_dir(); |
| 444 |
$dest_dir = $upload_dir['basedir'] . '/metasync'; |
| 445 |
if (!file_exists($dest_dir)) { |
| 446 |
wp_mkdir_p($dest_dir); |
| 447 |
} |
| 448 |
$dest_file = $dest_dir . '/whitelabel-icon.' . $ext; |
| 449 |
if (copy($bundled_file, $dest_file)) { |
| 450 |
$options['general']['white_label_plugin_menu_icon'] = $upload_dir['baseurl'] . '/metasync/whitelabel-icon.' . $ext; |
| 451 |
} else { |
| 452 |
// Could not copy — clear the broken marker so default icon shows |
| 453 |
$options['general']['white_label_plugin_menu_icon'] = ''; |
| 454 |
} |
| 455 |
} else { |
| 456 |
// Bundled file missing or unsupported extension — clear the marker |
| 457 |
$options['general']['white_label_plugin_menu_icon'] = ''; |
| 458 |
} |
| 459 |
} |
| 460 |
|
| 461 |
// Save updated options. |
| 462 |
// when the package carries a settings password this is a |
| 463 |
// legitimate import write, but the recovery protection filter treats any |
| 464 |
// unauthenticated password write as a conflict and swaps it back to the |
| 465 |
// stored password — the verification below could then never pass once a |
| 466 |
// password is stored, and the retry loop would hold the shared recovery |
| 467 |
// lock on every admin request, blocking the Forgot Password flow. |
| 468 |
// Mirror the recovery flow's own persist path: take the lock and |
| 469 |
// authorize the write for the duration of the save. |
| 470 |
$carries_password = !empty($options['whitelabel']['settings_password']); |
| 471 |
$import_lock_owner = ''; |
| 472 |
if ($carries_password) { |
| 473 |
require_once __DIR__ . '/class-metasync-admin-ajax.php'; |
| 474 |
require_once __DIR__ . '/class-metasync-settings-registration.php'; |
| 475 |
if (!Metasync_Admin_Ajax::instance()->acquire_recovery_lock($import_lock_owner)) { |
| 476 |
// A recovery or settings save is mid-write; retry via the normal |
| 477 |
// backoff instead of fighting for the lock. |
| 478 |
self::$last_import_error = 'recovery_lock_busy'; |
| 479 |
return false; |
| 480 |
} |
| 481 |
} |
| 482 |
try { |
| 483 |
if ($carries_password) { |
| 484 |
Metasync_Settings_Registration::authorize_recovery_password_write(true); |
| 485 |
} |
| 486 |
update_option('metasync_options', $options); |
| 487 |
} finally { |
| 488 |
if ($carries_password) { |
| 489 |
Metasync_Settings_Registration::authorize_recovery_password_write(false); |
| 490 |
Metasync_Admin_Ajax::instance()->release_recovery_lock($import_lock_owner); |
| 491 |
} |
| 492 |
} |
| 493 |
if (get_option('metasync_options', null) !== $options) { |
| 494 |
if (self::atomically_replace_plugin_file($plugin_file, $original_plugin_content)) { |
| 495 |
wp_cache_delete('plugins', 'plugins'); |
| 496 |
} |
| 497 |
self::$last_import_error = 'options_write_failed'; |
| 498 |
return false; |
| 499 |
} |
| 500 |
|
| 501 |
// Optionally delete the JSON file after successful import (uncomment if desired) |
| 502 |
// unlink($json_file); |
| 503 |
|
| 504 |
return true; |
| 505 |
} |
| 506 |
|
| 507 |
/** |
| 508 |
* Sync plugin file headers from the current saved options in the database. |
| 509 |
* Call this after saving whitelabel settings via the admin UI to ensure |
| 510 |
* the plugin file headers reflect the latest whitelabel values. |
| 511 |
* |
| 512 |
* @since 2.5.0 |
| 513 |
*/ |
| 514 |
public static function sync_plugin_file_headers() |
| 515 |
{ |
| 516 |
if (!current_user_can('manage_options')) { |
| 517 |
return false; |
| 518 |
} |
| 519 |
|
| 520 |
$options = get_option('metasync_options', array()); |
| 521 |
$general = $options['general'] ?? array(); |
| 522 |
|
| 523 |
// Build the import_data format expected by update_plugin_file_headers |
| 524 |
$import_data = array( |
| 525 |
'general_settings' => $general, |
| 526 |
); |
| 527 |
|
| 528 |
return self::update_plugin_file_headers($import_data); |
| 529 |
} |
| 530 |
|
| 531 |
/** |
| 532 |
* Update the main plugin file headers with whitelabel values |
| 533 |
* WordPress reads plugin metadata directly from the file header comments, |
| 534 |
* so modifying these ensures whitelabel shows even when the plugin is deactivated. |
| 535 |
* |
| 536 |
* @param array $import_data The imported whitelabel data |
| 537 |
* @since 2.5.0 |
| 538 |
*/ |
| 539 |
private static function update_plugin_file_headers($import_data, $plugin_file = null) |
| 540 |
{ |
| 541 |
$plugin_file = $plugin_file ?: plugin_dir_path(dirname(__FILE__)) . 'metasync.php'; |
| 542 |
|
| 543 |
if (!file_exists($plugin_file) || !is_writable($plugin_file)) { |
| 544 |
return false; |
| 545 |
} |
| 546 |
|
| 547 |
$content = file_get_contents($plugin_file); |
| 548 |
if ($content === false) { |
| 549 |
return false; |
| 550 |
} |
| 551 |
|
| 552 |
$general = $import_data['general_settings'] ?? array(); |
| 553 |
|
| 554 |
// Map of whitelabel setting keys to plugin header field names |
| 555 |
// with default values to restore when whitelabel is cleared |
| 556 |
$header_map = array( |
| 557 |
'white_label_plugin_name' => array( |
| 558 |
'header' => 'Plugin Name', |
| 559 |
'default' => 'Search Atlas: The Premier AI SEO Plugin for Instant Optimization', |
| 560 |
), |
| 561 |
'white_label_plugin_description' => array( |
| 562 |
'header' => 'Description', |
| 563 |
'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.', |
| 564 |
), |
| 565 |
'white_label_plugin_author' => array( |
| 566 |
'header' => 'Author', |
| 567 |
'default' => 'Search Atlas', |
| 568 |
), |
| 569 |
'white_label_plugin_author_uri' => array( |
| 570 |
'header' => 'Author URI', |
| 571 |
'default' => 'https://searchatlas.com', |
| 572 |
), |
| 573 |
'white_label_plugin_uri' => array( |
| 574 |
'header' => 'Plugin URI', |
| 575 |
'default' => 'https://searchatlas.com/', |
| 576 |
), |
| 577 |
); |
| 578 |
|
| 579 |
$modified = false; |
| 580 |
$original_content = $content; |
| 581 |
$url_fields = array('white_label_plugin_author_uri', 'white_label_plugin_uri'); |
| 582 |
|
| 583 |
foreach ($header_map as $setting_key => $field_config) { |
| 584 |
$header_field = $field_config['header']; |
| 585 |
|
| 586 |
// Use whitelabel value if set, otherwise restore default |
| 587 |
$new_value = !empty($general[$setting_key]) |
| 588 |
? $general[$setting_key] |
| 589 |
: $field_config['default']; |
| 590 |
|
| 591 |
if (!is_string($new_value) || !self::is_valid_plugin_header_value($new_value, in_array($setting_key, $url_fields, true))) { |
| 592 |
return false; |
| 593 |
} |
| 594 |
|
| 595 |
// Match the header line: " * Field Name: any value" |
| 596 |
// Handles varying whitespace between field name and value |
| 597 |
$pattern = '/^(\s*\*\s*' . preg_quote($header_field, '/') . ':\s*)(.+)$/m'; |
| 598 |
|
| 599 |
if (preg_match_all($pattern, $content, $matches) !== 1) { |
| 600 |
return false; |
| 601 |
} |
| 602 |
|
| 603 |
// Only replace if the value actually differs from what's in the file. |
| 604 |
if (trim($matches[2][0]) !== trim($new_value)) { |
| 605 |
$content = preg_replace_callback( |
| 606 |
$pattern, |
| 607 |
static function ($match) use ($new_value) { |
| 608 |
return $match[1] . $new_value; |
| 609 |
}, |
| 610 |
$content, |
| 611 |
1, |
| 612 |
$replacement_count |
| 613 |
); |
| 614 |
|
| 615 |
if (!is_string($content) || $replacement_count !== 1) { |
| 616 |
return false; |
| 617 |
} |
| 618 |
$modified = true; |
| 619 |
} |
| 620 |
} |
| 621 |
|
| 622 |
if (!self::is_valid_transformed_plugin_file($original_content, $content, $header_map)) { |
| 623 |
return false; |
| 624 |
} |
| 625 |
|
| 626 |
if (!$modified) { |
| 627 |
return true; |
| 628 |
} |
| 629 |
|
| 630 |
if (!self::atomically_replace_plugin_file($plugin_file, $content)) { |
| 631 |
return false; |
| 632 |
} |
| 633 |
|
| 634 |
// Clear WordPress plugin cache so it reads the updated headers. |
| 635 |
wp_cache_delete('plugins', 'plugins'); |
| 636 |
|
| 637 |
return true; |
| 638 |
} |
| 639 |
|
| 640 |
/** |
| 641 |
* Validate a value before placing it inside the plugin's PHP docblock. |
| 642 |
*/ |
| 643 |
private static function is_valid_plugin_header_value($value, $is_url) |
| 644 |
{ |
| 645 |
if ( |
| 646 |
$value === '' |
| 647 |
|| preg_match('/^[\p{L}\p{M}\p{N}\p{P}\p{S}\p{Zs}]+$/u', $value) !== 1 |
| 648 |
|| preg_match('/\*\/|<\?php|\?>/i', $value) !== 0 |
| 649 |
) { |
| 650 |
return false; |
| 651 |
} |
| 652 |
|
| 653 |
if (!$is_url) { |
| 654 |
return true; |
| 655 |
} |
| 656 |
|
| 657 |
if (filter_var($value, FILTER_VALIDATE_URL) === false) { |
| 658 |
return false; |
| 659 |
} |
| 660 |
|
| 661 |
$scheme = strtolower((string) parse_url($value, PHP_URL_SCHEME)); |
| 662 |
|
| 663 |
return in_array($scheme, array('http', 'https'), true); |
| 664 |
} |
| 665 |
|
| 666 |
/** |
| 667 |
* Validate the complete transformed PHP file and its header structure. |
| 668 |
*/ |
| 669 |
private static function is_valid_transformed_plugin_file($original, $transformed, $header_map) |
| 670 |
{ |
| 671 |
if (!is_string($transformed) || strpos($transformed, '<?php') !== 0) { |
| 672 |
return false; |
| 673 |
} |
| 674 |
|
| 675 |
if ( |
| 676 |
substr_count($original, '<?php') !== substr_count($transformed, '<?php') |
| 677 |
|| substr_count($original, '?>') !== substr_count($transformed, '?>') |
| 678 |
) { |
| 679 |
return false; |
| 680 |
} |
| 681 |
|
| 682 |
foreach ($header_map as $field_config) { |
| 683 |
$pattern = '/^\s*\*\s*' . preg_quote($field_config['header'], '/') . ':\s*.+$/m'; |
| 684 |
if (preg_match_all($pattern, $transformed) !== 1) { |
| 685 |
return false; |
| 686 |
} |
| 687 |
} |
| 688 |
|
| 689 |
try { |
| 690 |
$tokens = token_get_all($transformed, TOKEN_PARSE); |
| 691 |
} catch (ParseError $error) { |
| 692 |
return false; |
| 693 |
} |
| 694 |
|
| 695 |
return true; |
| 696 |
} |
| 697 |
|
| 698 |
/** |
| 699 |
* Replace a plugin file from a same-directory temporary file. |
| 700 |
*/ |
| 701 |
private static function atomically_replace_plugin_file($plugin_file, $content, $rename_file = null) |
| 702 |
{ |
| 703 |
$directory = dirname($plugin_file); |
| 704 |
$temp_file = tempnam($directory, '.metasync-header-'); |
| 705 |
if ($temp_file === false) { |
| 706 |
return false; |
| 707 |
} |
| 708 |
|
| 709 |
$permissions = fileperms($plugin_file); |
| 710 |
$written = file_put_contents($temp_file, $content, LOCK_EX); |
| 711 |
if ($written !== strlen($content)) { |
| 712 |
@unlink($temp_file); |
| 713 |
return false; |
| 714 |
} |
| 715 |
|
| 716 |
if ($permissions !== false) { |
| 717 |
@chmod($temp_file, $permissions & 0777); |
| 718 |
} |
| 719 |
|
| 720 |
if ($rename_file === null) { |
| 721 |
$rename_file = static function ($source, $destination) { |
| 722 |
return @rename($source, $destination); |
| 723 |
}; |
| 724 |
} |
| 725 |
|
| 726 |
// The temporary file is on the same filesystem, so a successful rename is |
| 727 |
// atomic. On failure the original path has not been touched. |
| 728 |
if ($rename_file($temp_file, $plugin_file)) { |
| 729 |
return true; |
| 730 |
} |
| 731 |
|
| 732 |
@unlink($temp_file); |
| 733 |
|
| 734 |
return false; |
| 735 |
} |
| 736 |
} |
| 737 |
|