*/ class Metasync_Activator { /** * Option recording the last failed White Label JSON import. * Stores array{file_hash: string, failed_at: int, error: string}. * * @var string */ const IMPORT_FAILURE_OPTION = 'metasync_whitelabel_import_failure'; /** * Retry cadence (seconds) for a failing White Label import so a * broken file cannot produce a silent retry storm on every admin request. * * @var int */ const IMPORT_RETRY_BACKOFF_SECONDS = 3600; /** * The white-label settings whose values are mirrored into the * metasync.php plugin header. * * @var string[] */ const BRANDING_HEADER_FIELDS = [ 'white_label_plugin_name', 'white_label_plugin_description', 'white_label_plugin_author', 'white_label_plugin_author_uri', 'white_label_plugin_uri', ]; /** * Machine-readable reason for the most recent import failure, consumed * by render_whitelabel_import_failure_notice(). * * @var string */ private static $last_import_error = ''; /** * Field-specific validation errors for the most recent import failure, * keyed by white-label setting name. Populated only when the import was * rejected because a branding value fails the plugin-header contract. * * @var array */ private static $last_import_invalid_fields = array(); /** * Canonical list of MetaSync custom WP-Cron hooks. * Shared with Metasync_Deactivator so deactivation cleans up every scheduled hook. * * @since 2.5.x * @var string[] */ public static $cron_hooks = [ 'metasync_sync_log_daily_cleanup', 'metasync_announce_cron', 'metasync_rate_limit_cleanup', 'metasync_heartbeat_cron_check', 'metasync_burst_heartbeat', 'metasync_check_debug_limits', 'metasync_cleanup_transients', 'metasync_hidden_post_check', 'metasync_otto_recheck_404_exclusions', 'metasync_db_cleanup', 'metasync_media_batch_optimize_cron', 'metasync_speed_cache_cleanup', 'metasync_process_seo_job', 'metasync_process_otto_crawl_url_job', 'metasync_process_otto_batch_cache_job', 'metasync_host_blocking_check', 'metasync_host_blocking_weekly_check', ]; /** * Short Description. (use period) * * Long Description. * * @since 1.0.0 */ public static function activate() { // WordPress core sitemap functionality is required // if (wp_sitemaps_get_server()->sitemaps_enabled() == false) { // add_filter('wp_sitemaps_enabled', '__return_true'); // } // Generate Plugin Auth Token on first activation self::ensure_plugin_auth_token(); // Import whitelabel settings only if the JSON file is new or changed // (prevents overwriting admin UI changes on every deactivate/activate cycle) self::check_whitelabel_settings_update(true); // Pre-SSO announce: tell backend plugin is installed (PR4 - heartbeat reliability) update_option('metasync_announce_attempt_count', 0); self::send_announce_ping(); update_option('metasync_announce_attempt_count', 1); // Schedule cron for announce pings 2-5 (every 10 minutes) if (!wp_next_scheduled('metasync_announce_cron')) { wp_schedule_event(time() + 10 * MINUTE_IN_SECONDS, 'metasync_every_10_minutes', 'metasync_announce_cron'); } // Detect a host that blocks GET/POST between this site and Search Atlas. // Deferred to cron (~10 minutes out) rather than run here — a blocking HTTP call // inside the activation request is exactly what has taken slow hosts down before, // and a firewall-blocked request is the slowest kind there is. if (class_exists('Metasync_Host_Blocking_Check')) { Metasync_Host_Blocking_Check::schedule_initial_check(); } // Set first activation flag for setup wizard if (!get_option('metasync_first_activation_time')) { update_option('metasync_first_activation_time', current_time('mysql')); update_option('metasync_show_wizard', true); } // Auto-disable WP core sitemap if a MetaSync sitemap already exists. // Skip on fresh installs where no sitemap has been generated yet so the site // doesn't end up with zero sitemaps. if (get_option('metasync_sitemap_auto_update', false) || !empty(get_option('metasync_sitemap_files', [])) || file_exists(ABSPATH . 'sitemap_index.xml') ) { update_option('metasync_disable_wp_sitemap', true); } // Soft flush only (no .htaccess rewrite). A hard flush calls // save_mod_rewrite_rules(), which takes an exclusive flock() on the site's // root .htaccess. On hosts whose cache/optimizer also holds that lock (e.g. // SiteGround + SG Optimizer), the activation request blocks until // max_execution_time and the host returns a 500. MetaSync's rewrite rules are // registered via add_rewrite_rule (stored in the `rewrite_rules` option, NOT // in .htaccess), so a soft flush is sufficient and never touches the file. flush_rewrite_rules(false); } /** * Send pre-SSO announce ping to backend (zero-trust; backend rate-limits). * POST /api/wp-plugin-announce/ with url + plugin_version; optional X-Plugin-Token for deduplication. * Callable from activation and from init (rate-limited) when no API key yet. * * @since 2.5.x */ public static function send_announce_ping() { $base = 'https://ca.searchatlas.com'; if (class_exists('Metasync_Endpoint_Manager')) { $base = Metasync_Endpoint_Manager::get_endpoint('CA_API_DOMAIN'); } elseif (class_exists('Metasync')) { $base = Metasync::CA_API_DOMAIN; } $url = rtrim($base, '/') . '/api/wp-plugin-announce/'; $body = wp_json_encode([ 'url' => get_home_url(), 'plugin_version' => defined('METASYNC_VERSION') ? METASYNC_VERSION : 'unknown', ]); $options = get_option('metasync_options', []); $plugin_auth_token = $options['general']['apikey'] ?? ''; $headers = [ 'Content-Type' => 'application/json', ]; if (!empty($plugin_auth_token)) { $headers['X-Plugin-Token'] = $plugin_auth_token; } wp_remote_post($url, [ 'body' => $body, 'headers' => $headers, 'timeout' => 10, 'blocking' => false, ]); } /** * Ensure Plugin Auth Token exists * Generates a unique Plugin Auth Token during plugin activation */ private static function ensure_plugin_auth_token() { $options = get_option('metasync_options', []); if (empty($options['general']['apikey'])) { // Generate unique Plugin Auth Token (alphanumeric only) $plugin_auth_token = wp_generate_password(32, false, false); // Initialize options structure if needed if (!isset($options['general'])) { $options['general'] = []; } // Store Plugin Auth Token $options['general']['apikey'] = $plugin_auth_token; update_option('metasync_options', $options); } } /** * Get the path to the whitelabel settings JSON file * * @return string|false Path to the file if it exists, false otherwise * @since 2.5.0 */ public static function get_whitelabel_settings_file() { $plugin_dir = plugin_dir_path(dirname(__FILE__)); // Check for whitelabel-settings.json in plugin root $json_file = $plugin_dir . 'whitelabel-settings.json'; // Also check in a common extracted zip location (if zip was extracted) if (!file_exists($json_file)) { $json_file = $plugin_dir . 'metasync/whitelabel-settings.json'; } if (!file_exists($json_file)) { return false; } return $json_file; } /** * Check if whitelabel settings file has been updated and import if needed * This method should be called on init to detect plugin uploads/updates * * @param bool $trusted_update_context True only for core activation/upgrader hooks. * @return bool Whether the file was already current or imported successfully. * @since 2.5.0 */ public static function check_whitelabel_settings_update($trusted_update_context = false) { // Never run the import from AJAX handlers. admin-ajax requests // (notably the Forgot Password recovery call) run admin_init first, so an // import attempted there already holds the shared recovery lock when the // handler itself runs, and every recovery request returns HTTP 429. // Trusted activation/upgrader contexts run once per update, not per // request, and stay exempt. if (!$trusted_update_context && wp_doing_ajax()) { return false; } if (!$trusted_update_context && !current_user_can('manage_options')) { return false; } $json_file = self::get_whitelabel_settings_file(); if ($json_file === false) { return false; } // Get current file modification time and content hash $file_mtime = filemtime($json_file); $file_hash = md5_file($json_file); if (!is_int($file_mtime) || !is_string($file_hash)) { return false; } // Get stored file info $stored_mtime = get_option('metasync_whitelabel_file_mtime', 0); $stored_hash = get_option('metasync_whitelabel_file_hash', ''); // Check if file has changed (either modification time or content) if ($file_mtime > $stored_mtime || $file_hash !== $stored_hash) { // A failing import must not retry on every admin request. // Back off per failing file hash; a successful import or a changed // file clears the state and retries immediately. $failure = get_option(self::IMPORT_FAILURE_OPTION, array()); if (is_array($failure) && isset($failure['file_hash'], $failure['failed_at']) && (string) $failure['file_hash'] === $file_hash && (time() - (int) $failure['failed_at']) < self::IMPORT_RETRY_BACKOFF_SECONDS) { return false; } // File has changed, import settings if (!self::import_whitelabel_settings()) { $failure = array( 'file_hash' => $file_hash, 'failed_at' => time(), 'error' => self::$last_import_error, ); if (self::$last_import_error === 'branding_field_invalid' && !empty(self::$last_import_invalid_fields)) { $failure['invalid_fields'] = self::$last_import_invalid_fields; } update_option(self::IMPORT_FAILURE_OPTION, $failure); return false; } delete_option(self::IMPORT_FAILURE_OPTION); // Record the file only after the complete import succeeds. update_option('metasync_whitelabel_file_mtime', $file_mtime); update_option('metasync_whitelabel_file_hash', $file_hash); } return true; } /** * Machine-readable reason of the most recent failed White Label import. * * @return string */ public static function get_last_import_error() { return self::$last_import_error; } /** * Field-specific branding errors of the most recent failed White Label import. * * @return array */ public static function get_last_import_invalid_fields() { return self::$last_import_invalid_fields; } /** * Admin notice for a White Label JSON import that keeps failing. * Registered from metasync_check_whitelabel_on_admin(); the state is set * by check_whitelabel_settings_update() when an import fails. * * @return void */ public static function render_whitelabel_import_failure_notice() { if (!current_user_can('manage_options')) { return; } $failure = get_option(self::IMPORT_FAILURE_OPTION, array()); if (!is_array($failure) || empty($failure['file_hash']) || empty($failure['failed_at'])) { return; } $reasons = array( 'json_read_failed' => 'the file could not be read', 'json_invalid' => 'the file is not valid JSON', 'json_structure_invalid' => 'the file structure is invalid', 'plugin_file_unreadable' => 'metasync.php could not be read', 'branding_field_invalid' => 'the white-label branding values are not valid', 'plugin_header_write_failed' => 'the plugin file headers could not be updated (is metasync.php writable?)', 'recovery_lock_busy' => 'another password operation was in progress', 'options_write_failed' => 'the settings could not be saved', ); $reason = isset($failure['error'], $reasons[$failure['error']]) ? $reasons[$failure['error']] : 'an unexpected error occurred'; // Field-specific detail for a content rejection: name the exact // branding field so the package can be fixed rather than retried blind. echo '

White Label settings import failed. ' . 'MetaSync could not import whitelabel-settings.json: ' . esc_html($reason) . '. The import will be retried automatically; fix the file and reload to retry immediately.

'; if (isset($failure['invalid_fields']) && is_array($failure['invalid_fields']) && !empty($failure['invalid_fields'])) { echo '
    '; foreach ($failure['invalid_fields'] as $message) { echo '
  • ' . esc_html((string) $message) . '
  • '; } echo '
'; echo '

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.

'; } echo '
'; } /** * Import whitelabel settings from JSON file if available * Checks for whitelabel-settings.json in the plugin directory or extracted zip * * @return bool Whether the complete import succeeded. * @since 2.5.0 */ private static function import_whitelabel_settings() { $json_file = self::get_whitelabel_settings_file(); if ($json_file === false) { return false; } self::$last_import_error = 'unknown'; self::$last_import_invalid_fields = array(); // Read JSON file $json_content = file_get_contents($json_file); if ($json_content === false) { self::$last_import_error = 'json_read_failed'; return false; } // Decode JSON $import_data = json_decode($json_content, true); if ($import_data === null || json_last_error() !== JSON_ERROR_NONE) { self::$last_import_error = 'json_invalid'; return false; } // Validate import data structure if (!isset($import_data['whitelabel_settings']) || !is_array($import_data['whitelabel_settings'])) { self::$last_import_error = 'json_structure_invalid'; return false; } $plugin_file = plugin_dir_path(dirname(__FILE__)) . 'metasync.php'; $original_plugin_content = file_get_contents($plugin_file); if ($original_plugin_content === false) { self::$last_import_error = 'plugin_file_unreadable'; return false; } // Get current options $options = get_option('metasync_options', array()); // Import whitelabel settings if (isset($import_data['whitelabel_settings'])) { $whitelabel_settings = $import_data['whitelabel_settings']; // Initialize whitelabel array if needed if (!isset($options['whitelabel'])) { $options['whitelabel'] = array(); } // Merge imported settings with existing (imported settings take precedence) $options['whitelabel'] = array_merge($options['whitelabel'], $whitelabel_settings); // The settings password travels as plaintext in the export file so it // can be imported on a site with different salts — encrypt it at rest. if (!empty($options['whitelabel']['settings_password']) && is_string($options['whitelabel']['settings_password'])) { $options['whitelabel']['settings_password'] = Metasync::encrypt_secret($options['whitelabel']['settings_password']); } // Update timestamp $options['whitelabel']['updated_at'] = time(); $options['whitelabel']['imported_at'] = current_time('mysql'); } // Import general settings related to whitelabel if (isset($import_data['general_settings']) && is_array($import_data['general_settings'])) { if (!isset($options['general'])) { $options['general'] = array(); } // Merge general settings foreach ($import_data['general_settings'] as $key => $value) { $options['general'][$key] = $value; } } // Validate and persist the public plugin headers before any other import side effect. $header_data = array( 'general_settings' => $options['general'] ?? array(), ); if (!self::update_plugin_file_headers($header_data, $plugin_file)) { self::$last_import_error = 'plugin_header_write_failed'; // Distinguish a content rejection (invalid branding values) from an // environmental failure (unwritable file, mangled header block) so the // admin notice can point at the offending field. $invalid_fields = self::validate_plugin_header_settings($options['general'] ?? array()); if (!empty($invalid_fields)) { self::$last_import_error = 'branding_field_invalid'; self::$last_import_invalid_fields = $invalid_fields; } return false; } // Restore bundled icon: if the icon value is a __bundled_icon__{ext} marker, // copy the bundled file from the plugin directory to uploads and update the URL. $icon_value = $options['general']['white_label_plugin_menu_icon'] ?? ''; if (!empty($icon_value) && strpos($icon_value, '__bundled_icon__') === 0) { $ext = substr($icon_value, strlen('__bundled_icon__')); $ext = preg_replace('/[^a-z0-9]/', '', strtolower($ext)); // sanitize $bundled_file = plugin_dir_path(dirname(__FILE__)) . 'whitelabel-icon.' . $ext; if (file_exists($bundled_file) && in_array($ext, ['png', 'svg'], true)) { $upload_dir = wp_upload_dir(); $dest_dir = $upload_dir['basedir'] . '/metasync'; if (!file_exists($dest_dir)) { wp_mkdir_p($dest_dir); } $dest_file = $dest_dir . '/whitelabel-icon.' . $ext; if (copy($bundled_file, $dest_file)) { $options['general']['white_label_plugin_menu_icon'] = $upload_dir['baseurl'] . '/metasync/whitelabel-icon.' . $ext; } else { // Could not copy — clear the broken marker so default icon shows $options['general']['white_label_plugin_menu_icon'] = ''; } } else { // Bundled file missing or unsupported extension — clear the marker $options['general']['white_label_plugin_menu_icon'] = ''; } } // Save updated options. // when the package carries a settings password this is a // legitimate import write, but the recovery protection filter treats any // unauthenticated password write as a conflict and swaps it back to the // stored password — the verification below could then never pass once a // password is stored, and the retry loop would hold the shared recovery // lock on every admin request, blocking the Forgot Password flow. // Mirror the recovery flow's own persist path: take the lock and // authorize the write for the duration of the save. $carries_password = !empty($options['whitelabel']['settings_password']); $import_lock_owner = ''; if ($carries_password) { require_once __DIR__ . '/class-metasync-admin-ajax.php'; require_once __DIR__ . '/class-metasync-settings-registration.php'; if (!self::class_supports_methods('Metasync_Admin_Ajax', array('acquire_recovery_lock', 'release_recovery_lock')) || !self::class_supports_methods('Metasync_Settings_Registration', array('authorize_recovery_password_write'))) { // The updater can replace plugin files after the old classes were // loaded in this request. Defer the import to the next request. self::$last_import_error = 'recovery_api_unavailable'; return false; } if (!Metasync_Admin_Ajax::instance()->acquire_recovery_lock($import_lock_owner)) { // A recovery or settings save is mid-write; retry via the normal // backoff instead of fighting for the lock. self::$last_import_error = 'recovery_lock_busy'; return false; } } try { if ($carries_password) { Metasync_Settings_Registration::authorize_recovery_password_write(true); } update_option('metasync_options', $options); } finally { if ($carries_password) { Metasync_Settings_Registration::authorize_recovery_password_write(false); Metasync_Admin_Ajax::instance()->release_recovery_lock($import_lock_owner); } } if (get_option('metasync_options', null) !== $options) { if (self::atomically_replace_plugin_file($plugin_file, $original_plugin_content)) { wp_cache_delete('plugins', 'plugins'); } self::$last_import_error = 'options_write_failed'; return false; } // Optionally delete the JSON file after successful import (uncomment if desired) // unlink($json_file); return true; } /** * Check runtime class compatibility without letting static analysis fold it. * * @param string $class_name Class name loaded in the current request. * @param string[] $methods Required method names. * @return bool */ private static function class_supports_methods($class_name, $methods) { foreach ($methods as $method) { if (!method_exists($class_name, $method)) { return false; } } return true; } /** * Sync plugin file headers from the current saved options in the database. * Call this after saving whitelabel settings via the admin UI to ensure * the plugin file headers reflect the latest whitelabel values. * * @since 2.5.0 */ public static function sync_plugin_file_headers() { if (!current_user_can('manage_options')) { return false; } $options = get_option('metasync_options', array()); $general = $options['general'] ?? array(); // Build the import_data format expected by update_plugin_file_headers $import_data = array( 'general_settings' => $general, ); return self::update_plugin_file_headers($import_data); } /** * Absolute path of the plugin file whose header carries the branding. * * @return string */ public static function plugin_header_file_path() { return plugin_dir_path(dirname(__FILE__)) . 'metasync.php'; } /** * Apply a proposed save's branding to the physical plugin header BEFORE * the options write starts, so an unwritable or malformed target stops * the save while nothing has been persisted yet. * * Returns null when none of the branding values changed (the file is left * untouched — unrelated saves must not rewrite metasync.php), true when * the header now matches the proposed values, and false when the update * was rejected or failed (file unchanged, save must be aborted). * * @param mixed $proposed_general General section proposed for saving. * @param mixed $stored_general General section currently stored. * @return bool|null */ public static function apply_plugin_file_headers_for_save($proposed_general, $stored_general) { $proposed_general = is_array($proposed_general) ? $proposed_general : array(); $stored_general = is_array($stored_general) ? $stored_general : array(); $changed = false; foreach (self::BRANDING_HEADER_FIELDS as $key) { if ((string) ($proposed_general[$key] ?? '') !== (string) ($stored_general[$key] ?? '')) { $changed = true; break; } } if (!$changed) { return null; } return self::update_plugin_file_headers(array('general_settings' => $proposed_general)); } /** * Restore a previous metasync.php content after a save that wrote new * headers but then failed to persist the matching options. * * @param string $plugin_file Absolute path to metasync.php. * @param string|false $original_content Content captured before the header write (file_get_contents() result). * @return bool Whether the restore succeeded. */ public static function restore_plugin_file_content($plugin_file, $original_content) { if (!is_string($original_content) || $original_content === '') { return false; } if (!self::atomically_replace_plugin_file($plugin_file, $original_content)) { return false; } wp_cache_delete('plugins', 'plugins'); return true; } /** * Validate the five values that are written into metasync.php's plugin header. * * This is deliberately shared by administrator saves and package imports so * both paths enforce the same contract before options are persisted. * * @param mixed $general General settings submitted or imported. * @return array Field-specific errors, keyed by setting name. */ public static function validate_plugin_header_settings($general) { $general = is_array($general) ? $general : array(); $header_map = array( 'white_label_plugin_name' => array( 'label' => 'Plugin Name', 'default' => 'Search Atlas SEO - OTTO AI SEO Automation for WordPress', 'url' => false, ), 'white_label_plugin_description' => array( 'label' => 'Plugin Description', '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.', 'url' => false, ), 'white_label_plugin_author' => array( 'label' => 'Author', 'default' => 'Search Atlas', 'url' => false, ), 'white_label_plugin_author_uri' => array( 'label' => 'Author URI', 'default' => 'https://searchatlas.com', 'url' => true, ), 'white_label_plugin_uri' => array( 'label' => 'Plugin URI', 'default' => 'https://searchatlas.com/', 'url' => true, ), ); $errors = array(); foreach ($header_map as $key => $config) { // A present-but-empty string resets to the default; a scalar like // "0" is a real value and must not be conflated with unset (the // classic PHP !empty() trap). Malformed non-strings fall through // to the is_string rejection below. $value = $config['default']; if (array_key_exists($key, $general)) { $raw = $general[$key]; if (is_string($raw)) { $value = $raw !== '' ? $raw : $config['default']; } elseif ($raw !== null) { $value = $raw; } } if (!is_string($value) || !self::is_valid_plugin_header_value($value, $config['url'])) { $errors[$key] = sprintf('%s contains invalid characters or format.', $config['label']); } } return $errors; } /** * Update the main plugin file headers with whitelabel values * WordPress reads plugin metadata directly from the file header comments, * so modifying these ensures whitelabel shows even when the plugin is deactivated. * * @param array $import_data The imported whitelabel data * @since 2.5.0 */ private static function update_plugin_file_headers($import_data, $plugin_file = null) { $plugin_file = $plugin_file ?: plugin_dir_path(dirname(__FILE__)) . 'metasync.php'; if (!file_exists($plugin_file) || !wp_is_writable($plugin_file)) { return false; } $content = file_get_contents($plugin_file); if ($content === false) { return false; } $general = $import_data['general_settings'] ?? array(); // Map of whitelabel setting keys to plugin header field names // with default values to restore when whitelabel is cleared $header_map = array( 'white_label_plugin_name' => array( 'header' => 'Plugin Name', 'default' => 'Search Atlas SEO - OTTO AI SEO Automation for WordPress', ), 'white_label_plugin_description' => array( 'header' => 'Description', '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.', ), 'white_label_plugin_author' => array( 'header' => 'Author', 'default' => 'Search Atlas', ), 'white_label_plugin_author_uri' => array( 'header' => 'Author URI', 'default' => 'https://searchatlas.com', ), 'white_label_plugin_uri' => array( 'header' => 'Plugin URI', 'default' => 'https://searchatlas.com/', ), ); $modified = false; $original_content = $content; $url_fields = array('white_label_plugin_author_uri', 'white_label_plugin_uri'); foreach ($header_map as $setting_key => $field_config) { $header_field = $field_config['header']; // Use whitelabel value if set, otherwise restore default. A scalar // like "0" is a real value, not an unset field; an empty string or // null restores the default; malformed non-strings are rejected by // the is_string check below. $new_value = $field_config['default']; if (array_key_exists($setting_key, $general)) { $raw_value = $general[$setting_key]; if (is_string($raw_value)) { $new_value = $raw_value !== '' ? $raw_value : $field_config['default']; } elseif ($raw_value !== null) { $new_value = $raw_value; } } if (!is_string($new_value) || !self::is_valid_plugin_header_value($new_value, in_array($setting_key, $url_fields, true))) { return false; } // Match the header line: " * Field Name: any value" // Handles varying whitespace between field name and value $pattern = '/^(\s*\*\s*' . preg_quote($header_field, '/') . ':\s*)(.+)$/m'; if (preg_match_all($pattern, $content, $matches) !== 1) { return false; } // Only replace if the value actually differs from what's in the file. if (trim($matches[2][0]) !== trim($new_value)) { $content = preg_replace_callback( $pattern, static function ($match) use ($new_value) { return $match[1] . $new_value; }, $content, 1, $replacement_count ); if (!is_string($content) || $replacement_count !== 1) { return false; } $modified = true; } } if (!self::is_valid_transformed_plugin_file($original_content, $content, $header_map)) { return false; } if (!$modified) { return true; } if (!self::atomically_replace_plugin_file($plugin_file, $content)) { return false; } // Clear WordPress plugin cache so it reads the updated headers. wp_cache_delete('plugins', 'plugins'); return true; } /** * Validate a value before placing it inside the plugin's PHP docblock. * * Public so form/import handlers can pre-validate a value with the exact * contract used when metasync.php is physically rewritten. * * @param string $value Candidate header value. * @param bool $is_url Whether the value must be an http(s) URL. * @return bool */ public static function is_valid_plugin_header_value($value, $is_url) { if ( $value === '' || preg_match('/^[\p{L}\p{M}\p{N}\p{P}\p{S}\p{Zs}]+$/u', $value) !== 1 || preg_match('/\*\/|<\?php|\?>/i', $value) !== 0 ) { return false; } if (!$is_url) { return true; } if (filter_var($value, FILTER_VALIDATE_URL) === false) { return false; } $scheme = strtolower((string) wp_parse_url($value, PHP_URL_SCHEME)); return in_array($scheme, array('http', 'https'), true); } /** * Validate the complete transformed PHP file and its header structure. */ private static function is_valid_transformed_plugin_file($original, $transformed, $header_map) { if (!is_string($transformed) || strpos($transformed, '') !== substr_count($transformed, '?>') ) { return false; } foreach ($header_map as $field_config) { $pattern = '/^\s*\*\s*' . preg_quote($field_config['header'], '/') . ':\s*.+$/m'; if (preg_match_all($pattern, $transformed) !== 1) { return false; } } try { $tokens = token_get_all($transformed, TOKEN_PARSE); } catch (ParseError $error) { return false; } return true; } /** * Replace a plugin file from a same-directory temporary file. */ private static function atomically_replace_plugin_file($plugin_file, $content, $rename_file = null) { $directory = dirname($plugin_file); $temp_file = tempnam($directory, '.metasync-header-'); if ($temp_file === false) { return false; } $permissions = fileperms($plugin_file); $written = file_put_contents($temp_file, $content, LOCK_EX); if ($written !== strlen($content)) { @wp_delete_file($temp_file); return false; } if ($permissions !== false) { @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 } if ($rename_file === null) { $rename_file = static function ($source, $destination) { 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 }; } // The temporary file is on the same filesystem, so a successful rename is // atomic. On failure the original path has not been touched. if ($rename_file($temp_file, $plugin_file)) { return true; } @wp_delete_file($temp_file); return false; } }