PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / trunk
Search Atlas SEO – OTTO AI SEO Automation for WordPress vtrunk
2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 2.6.4 2.6.3 2.5.23 All 138 releases
metasync / includes / class-metasync-activator.php

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

737 lines 23.8 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 <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