PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.0
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.0
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.0, at includes/class-metasync-activator.php

762 lines 24.7 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 * 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 (!self::class_supports_methods('Metasync_Admin_Ajax', array('acquire_recovery_lock', 'release_recovery_lock'))
476 || !self::class_supports_methods('Metasync_Settings_Registration', array('authorize_recovery_password_write'))) {
477 // The updater can replace plugin files after the old classes were
478 // loaded in this request. Defer the import to the next request.
479 self::$last_import_error = 'recovery_api_unavailable';
480 return false;
481 }
482 if (!Metasync_Admin_Ajax::instance()->acquire_recovery_lock($import_lock_owner)) {
483 // A recovery or settings save is mid-write; retry via the normal
484 // backoff instead of fighting for the lock.
485 self::$last_import_error = 'recovery_lock_busy';
486 return false;
487 }
488 }
489 try {
490 if ($carries_password) {
491 Metasync_Settings_Registration::authorize_recovery_password_write(true);
492 }
493 update_option('metasync_options', $options);
494 } finally {
495 if ($carries_password) {
496 Metasync_Settings_Registration::authorize_recovery_password_write(false);
497 Metasync_Admin_Ajax::instance()->release_recovery_lock($import_lock_owner);
498 }
499 }
500 if (get_option('metasync_options', null) !== $options) {
501 if (self::atomically_replace_plugin_file($plugin_file, $original_plugin_content)) {
502 wp_cache_delete('plugins', 'plugins');
503 }
504 self::$last_import_error = 'options_write_failed';
505 return false;
506 }
507
508 // Optionally delete the JSON file after successful import (uncomment if desired)
509 // unlink($json_file);
510
511 return true;
512 }
513
514 /**
515 * Check runtime class compatibility without letting static analysis fold it.
516 *
517 * @param string $class_name Class name loaded in the current request.
518 * @param string[] $methods Required method names.
519 * @return bool
520 */
521 private static function class_supports_methods($class_name, $methods)
522 {
523 foreach ($methods as $method) {
524 if (!method_exists($class_name, $method)) {
525 return false;
526 }
527 }
528
529 return true;
530 }
531
532 /**
533 * Sync plugin file headers from the current saved options in the database.
534 * Call this after saving whitelabel settings via the admin UI to ensure
535 * the plugin file headers reflect the latest whitelabel values.
536 *
537 * @since 2.5.0
538 */
539 public static function sync_plugin_file_headers()
540 {
541 if (!current_user_can('manage_options')) {
542 return false;
543 }
544
545 $options = get_option('metasync_options', array());
546 $general = $options['general'] ?? array();
547
548 // Build the import_data format expected by update_plugin_file_headers
549 $import_data = array(
550 'general_settings' => $general,
551 );
552
553 return self::update_plugin_file_headers($import_data);
554 }
555
556 /**
557 * Update the main plugin file headers with whitelabel values
558 * WordPress reads plugin metadata directly from the file header comments,
559 * so modifying these ensures whitelabel shows even when the plugin is deactivated.
560 *
561 * @param array $import_data The imported whitelabel data
562 * @since 2.5.0
563 */
564 private static function update_plugin_file_headers($import_data, $plugin_file = null)
565 {
566 $plugin_file = $plugin_file ?: plugin_dir_path(dirname(__FILE__)) . 'metasync.php';
567
568 if (!file_exists($plugin_file) || !is_writable($plugin_file)) {
569 return false;
570 }
571
572 $content = file_get_contents($plugin_file);
573 if ($content === false) {
574 return false;
575 }
576
577 $general = $import_data['general_settings'] ?? array();
578
579 // Map of whitelabel setting keys to plugin header field names
580 // with default values to restore when whitelabel is cleared
581 $header_map = array(
582 'white_label_plugin_name' => array(
583 'header' => 'Plugin Name',
584 'default' => 'Search Atlas: The Premier AI SEO Plugin for Instant Optimization',
585 ),
586 'white_label_plugin_description' => array(
587 'header' => 'Description',
588 '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.',
589 ),
590 'white_label_plugin_author' => array(
591 'header' => 'Author',
592 'default' => 'Search Atlas',
593 ),
594 'white_label_plugin_author_uri' => array(
595 'header' => 'Author URI',
596 'default' => 'https://searchatlas.com',
597 ),
598 'white_label_plugin_uri' => array(
599 'header' => 'Plugin URI',
600 'default' => 'https://searchatlas.com/',
601 ),
602 );
603
604 $modified = false;
605 $original_content = $content;
606 $url_fields = array('white_label_plugin_author_uri', 'white_label_plugin_uri');
607
608 foreach ($header_map as $setting_key => $field_config) {
609 $header_field = $field_config['header'];
610
611 // Use whitelabel value if set, otherwise restore default
612 $new_value = !empty($general[$setting_key])
613 ? $general[$setting_key]
614 : $field_config['default'];
615
616 if (!is_string($new_value) || !self::is_valid_plugin_header_value($new_value, in_array($setting_key, $url_fields, true))) {
617 return false;
618 }
619
620 // Match the header line: " * Field Name: any value"
621 // Handles varying whitespace between field name and value
622 $pattern = '/^(\s*\*\s*' . preg_quote($header_field, '/') . ':\s*)(.+)$/m';
623
624 if (preg_match_all($pattern, $content, $matches) !== 1) {
625 return false;
626 }
627
628 // Only replace if the value actually differs from what's in the file.
629 if (trim($matches[2][0]) !== trim($new_value)) {
630 $content = preg_replace_callback(
631 $pattern,
632 static function ($match) use ($new_value) {
633 return $match[1] . $new_value;
634 },
635 $content,
636 1,
637 $replacement_count
638 );
639
640 if (!is_string($content) || $replacement_count !== 1) {
641 return false;
642 }
643 $modified = true;
644 }
645 }
646
647 if (!self::is_valid_transformed_plugin_file($original_content, $content, $header_map)) {
648 return false;
649 }
650
651 if (!$modified) {
652 return true;
653 }
654
655 if (!self::atomically_replace_plugin_file($plugin_file, $content)) {
656 return false;
657 }
658
659 // Clear WordPress plugin cache so it reads the updated headers.
660 wp_cache_delete('plugins', 'plugins');
661
662 return true;
663 }
664
665 /**
666 * Validate a value before placing it inside the plugin's PHP docblock.
667 */
668 private static function is_valid_plugin_header_value($value, $is_url)
669 {
670 if (
671 $value === ''
672 || preg_match('/^[\p{L}\p{M}\p{N}\p{P}\p{S}\p{Zs}]+$/u', $value) !== 1
673 || preg_match('/\*\/|<\?php|\?>/i', $value) !== 0
674 ) {
675 return false;
676 }
677
678 if (!$is_url) {
679 return true;
680 }
681
682 if (filter_var($value, FILTER_VALIDATE_URL) === false) {
683 return false;
684 }
685
686 $scheme = strtolower((string) parse_url($value, PHP_URL_SCHEME));
687
688 return in_array($scheme, array('http', 'https'), true);
689 }
690
691 /**
692 * Validate the complete transformed PHP file and its header structure.
693 */
694 private static function is_valid_transformed_plugin_file($original, $transformed, $header_map)
695 {
696 if (!is_string($transformed) || strpos($transformed, '<?php') !== 0) {
697 return false;
698 }
699
700 if (
701 substr_count($original, '<?php') !== substr_count($transformed, '<?php')
702 || substr_count($original, '?>') !== substr_count($transformed, '?>')
703 ) {
704 return false;
705 }
706
707 foreach ($header_map as $field_config) {
708 $pattern = '/^\s*\*\s*' . preg_quote($field_config['header'], '/') . ':\s*.+$/m';
709 if (preg_match_all($pattern, $transformed) !== 1) {
710 return false;
711 }
712 }
713
714 try {
715 $tokens = token_get_all($transformed, TOKEN_PARSE);
716 } catch (ParseError $error) {
717 return false;
718 }
719
720 return true;
721 }
722
723 /**
724 * Replace a plugin file from a same-directory temporary file.
725 */
726 private static function atomically_replace_plugin_file($plugin_file, $content, $rename_file = null)
727 {
728 $directory = dirname($plugin_file);
729 $temp_file = tempnam($directory, '.metasync-header-');
730 if ($temp_file === false) {
731 return false;
732 }
733
734 $permissions = fileperms($plugin_file);
735 $written = file_put_contents($temp_file, $content, LOCK_EX);
736 if ($written !== strlen($content)) {
737 @unlink($temp_file);
738 return false;
739 }
740
741 if ($permissions !== false) {
742 @chmod($temp_file, $permissions & 0777);
743 }
744
745 if ($rename_file === null) {
746 $rename_file = static function ($source, $destination) {
747 return @rename($source, $destination);
748 };
749 }
750
751 // The temporary file is on the same filesystem, so a successful rename is
752 // atomic. On failure the original path has not been touched.
753 if ($rename_file($temp_file, $plugin_file)) {
754 return true;
755 }
756
757 @unlink($temp_file);
758
759 return false;
760 }
761 }
762