PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.15
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.15
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 2.6.4 2.6.3 All 139 releases
metasync / robots-txt / class-metasync-robots-txt.php

class-metasync-robots-txt.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.6.15, at robots-txt/class-metasync-robots-txt.php

762 lines 24.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Robots.txt Management Class
5 *
6 * @package Search Atlas SEO
7 * @copyright Copyright (C) 2021-2025, Search Atlas Group - [email protected]
8 * @since 2.5.6
9 */
10
11 // If this file is called directly, abort.
12 if (!defined('WPINC')) {
13 die;
14 }
15
16 class Metasync_Robots_Txt
17 {
18 /**
19 * Instance of this class
20 *
21 * @var Metasync_Robots_Txt
22 */
23 private static $instance = null;
24
25 /**
26 * Database instance
27 *
28 * @var Metasync_Robots_Txt_Database
29 */
30 private $database;
31
32 /**
33 * Robots.txt file path
34 *
35 * @var string
36 */
37 private $robots_file_path;
38
39 /**
40 * Get singleton instance
41 */
42 public static function get_instance()
43 {
44 if (null === self::$instance) {
45 self::$instance = new self();
46 }
47 return self::$instance;
48 }
49
50 /**
51 * Constructor
52 */
53 private function __construct()
54 {
55 $this->robots_file_path = ABSPATH . 'robots.txt';
56
57 // Load database class
58 require_once plugin_dir_path(__FILE__) . 'class-metasync-robots-txt-database.php';
59 $this->database = Metasync_Robots_Txt_Database::get_instance();
60
61 // Register hook to serve virtual robots.txt
62 add_filter('robots_txt', array($this, 'serve_virtual_robots_txt'), 10, 2);
63 }
64
65 /**
66 * Serve virtual robots.txt content via WordPress filter
67 *
68 * @param string $output The robots.txt output
69 * @param bool $public Whether the site is public
70 * @return string The robots.txt content
71 */
72 public function serve_virtual_robots_txt($output, $public)
73 {
74 // Only serve virtual content if physical file doesn't exist
75 if (!file_exists($this->robots_file_path)) {
76 // Check if we have virtual content
77 if ($this->database->is_virtual_mode()) {
78 $virtual_content = $this->database->get_virtual_content();
79 if (false !== $virtual_content) {
80 return $virtual_content;
81 }
82 }
83 }
84
85 // Return default output (WordPress will handle it)
86 return $output;
87 }
88
89 /**
90 * Read robots.txt file
91 *
92 * @return string|WP_Error File contents or error
93 */
94 public function read_robots_file()
95 {
96 // Check virtual content first if virtual mode is active
97 if ($this->database->is_virtual_mode()) {
98 $virtual_content = $this->database->get_virtual_content();
99 if (false !== $virtual_content) {
100 return $virtual_content;
101 }
102 }
103
104 // Check if physical file exists
105 if (!file_exists($this->robots_file_path)) {
106 // Check if we have virtual content as fallback
107 $virtual_content = $this->database->get_virtual_content();
108 if (false !== $virtual_content) {
109 return $virtual_content;
110 }
111 // Return default robots.txt content
112 return $this->get_default_robots_content();
113 }
114
115 // Try WP_Filesystem first
116 if (function_exists('WP_Filesystem')) {
117 require_once ABSPATH . 'wp-admin/includes/file.php';
118 WP_Filesystem();
119 global $wp_filesystem;
120
121 if ($wp_filesystem && $wp_filesystem->exists($this->robots_file_path)) {
122 $content = $wp_filesystem->get_contents($this->robots_file_path);
123 if (false !== $content) {
124 return $content;
125 }
126 }
127 }
128
129 // Fallback to native PHP file operations
130 $content = @file_get_contents($this->robots_file_path);
131
132 if (false === $content) {
133 // Check virtual content as last resort
134 $virtual_content = $this->database->get_virtual_content();
135 if (false !== $virtual_content) {
136 return $virtual_content;
137 }
138 return new WP_Error('read_error', esc_html__('Could not read robots.txt file.', 'metasync'));
139 }
140
141 return $content;
142 }
143
144 /**
145 * Write robots.txt file
146 *
147 * @param string $content Content to write
148 * @return bool|WP_Error True on success, WP_Error on failure
149 */
150 public function write_robots_file($content)
151 {
152 // Create backup before saving
153 $current_content = $this->read_robots_file();
154 if (!is_wp_error($current_content) && !empty(trim($current_content))) {
155 $this->database->create_backup($current_content);
156 }
157
158 // Try WP_Filesystem first
159 if (function_exists('WP_Filesystem')) {
160 require_once ABSPATH . 'wp-admin/includes/file.php';
161 WP_Filesystem();
162 global $wp_filesystem;
163
164 if ($wp_filesystem) {
165 $result = $wp_filesystem->put_contents(
166 $this->robots_file_path,
167 $content,
168 FS_CHMOD_FILE
169 );
170
171 if ($result) {
172 // Clear virtual mode if file was successfully written
173 $this->database->clear_virtual_content();
174 return true;
175 }
176 }
177 }
178
179 // Fallback to native PHP file operations
180 $result = @file_put_contents($this->robots_file_path, $content);
181
182 if (false !== $result) {
183 // Set secure permissions: 0644 (rw-r--r--)
184 // Owner can read/write, group and others can only read
185 // This is the WordPress standard for files
186 @chmod($this->robots_file_path, 0644);
187 // Clear virtual mode if file was successfully written
188 $this->database->clear_virtual_content();
189 return true;
190 }
191
192 // File write failed - fallback to virtual file storage
193 $virtual_stored = $this->database->store_virtual_content($content);
194 if ($virtual_stored) {
195 $this->database->set_virtual_mode(true);
196 // Return success but indicate it's virtual
197 return true;
198 }
199
200 return new WP_Error('write_error', esc_html__('Could not write to robots.txt file. Please check file permissions.', 'metasync'));
201 }
202
203 /**
204 * Validate robots.txt content
205 *
206 * @param string $content Content to validate
207 * @return array Array of validation results
208 */
209 public function validate_content($content)
210 {
211 $warnings = array();
212 $errors = array();
213
214 // Normalize content for easier checking
215 $normalized_content = preg_replace('/\s+/', ' ', strtolower($content));
216
217 // Check for complete site disallow (blocking everything)
218 if (preg_match('/user-agent:\s*\*.*?disallow:\s*\/(\s|$)/i', $normalized_content)) {
219 // Check if it's ONLY blocking / without any other paths
220 if (!preg_match('/disallow:\s*\/\w+/i', $content)) {
221 $warnings[] = esc_html__('Warning: You are blocking all crawlers from your entire site (Disallow: /). This will prevent search engines from indexing your content.', 'metasync');
222 }
223 }
224
225 // Check for wp-admin disallow without admin-ajax.php exception
226 $has_wp_admin_disallow = preg_match('/Disallow:\s*\/wp-admin\/?$/im', $content);
227 $has_admin_ajax_allow = preg_match('/Allow:\s*\/wp-admin\/admin-ajax\.php/i', $content);
228
229 if ($has_wp_admin_disallow && !$has_admin_ajax_allow) {
230 $warnings[] = esc_html__('Warning: Blocking /wp-admin/ without allowing /wp-admin/admin-ajax.php may interfere with AJAX functionality on your site.', 'metasync');
231 }
232
233 // Check for basic syntax errors
234 $lines = explode("\n", $content);
235 $user_agent_found = false;
236
237 foreach ($lines as $line_num => $line) {
238 $line = trim($line);
239
240 // Skip empty lines and comments
241 if (empty($line) || strpos($line, '#') === 0) {
242 continue;
243 }
244
245 // Check for valid directives
246 if (!preg_match('/^(User-agent|Disallow|Allow|Sitemap|Crawl-delay):/i', $line)) {
247 $errors[] = sprintf(
248 esc_html__('Line %d: Invalid directive. Valid directives are: User-agent, Disallow, Allow, Sitemap, Crawl-delay', 'metasync'),
249 $line_num + 1
250 );
251 }
252
253 // Track if User-agent is present
254 if (preg_match('/^User-agent:/i', $line)) {
255 $user_agent_found = true;
256 }
257 }
258
259 // Check if content has at least one User-agent
260 if (!empty(trim($content)) && !$user_agent_found) {
261 $errors[] = esc_html__('robots.txt must contain at least one User-agent directive.', 'metasync');
262 }
263
264 return array(
265 'valid' => empty($errors),
266 'warnings' => $warnings,
267 'errors' => $errors
268 );
269 }
270
271 /**
272 * Get default robots.txt content
273 *
274 * @return string Default content
275 */
276 public function get_default_robots_content()
277 {
278 $site_url = get_site_url();
279
280 return "User-agent: *\n" .
281 "Disallow: /wp-admin/\n" .
282 "Allow: /wp-admin/admin-ajax.php\n" .
283 "Disallow: /wp-includes/\n\n" .
284 "Sitemap: {$site_url}/sitemap_index.xml";
285 }
286
287 /**
288 * Get backup history
289 *
290 * @param int $limit Number of backups to retrieve
291 * @return array Array of backups
292 */
293 public function get_backup_history($limit = 10)
294 {
295 return $this->database->get_backups($limit);
296 }
297
298 /**
299 * Restore from backup
300 *
301 * @param int $backup_id Backup ID
302 * @return bool|WP_Error True on success, WP_Error on failure
303 */
304 public function restore_backup($backup_id)
305 {
306 $backup = $this->database->get_backup($backup_id);
307
308 if (!$backup) {
309 return new WP_Error('backup_not_found', esc_html__('Backup not found.', 'metasync'));
310 }
311
312 return $this->write_robots_file($backup['content']);
313 }
314
315 /**
316 * Delete a backup
317 *
318 * @param int $backup_id Backup ID
319 * @return bool True on success, false on failure
320 */
321 public function delete_backup($backup_id)
322 {
323 return $this->database->delete_backup($backup_id);
324 }
325
326 /**
327 * Check if robots.txt file exists (physical or virtual)
328 *
329 * @return bool True if exists (physical or virtual), false otherwise
330 */
331 public function file_exists()
332 {
333 // Check physical file first
334 if (file_exists($this->robots_file_path)) {
335 return true;
336 }
337
338 // Check virtual content
339 if ($this->database->is_virtual_mode()) {
340 $virtual_content = $this->database->get_virtual_content();
341 if (false !== $virtual_content) {
342 return true;
343 }
344 }
345
346 return false;
347 }
348
349 /**
350 * Get file permissions
351 *
352 * @return string|bool File permissions or false
353 */
354 public function get_file_permissions()
355 {
356 if (!function_exists('WP_Filesystem')) {
357 require_once ABSPATH . 'wp-admin/includes/file.php';
358 }
359
360 WP_Filesystem();
361 global $wp_filesystem;
362
363 if (!$wp_filesystem || !$wp_filesystem->exists($this->robots_file_path)) {
364 return false;
365 }
366
367 return substr(sprintf('%o', fileperms($this->robots_file_path)), -4);
368 }
369
370 /**
371 * Check if file is writable
372 *
373 * @return bool True if writable, false otherwise
374 */
375 public function is_writable()
376 {
377 // If file exists, check if it's writable
378 if (file_exists($this->robots_file_path)) {
379 return is_writable($this->robots_file_path);
380 }
381
382 // If file doesn't exist, check if parent directory is writable
383 // If not writable, virtual mode will be used
384 return is_writable(ABSPATH);
385 }
386
387 /**
388 * Check if virtual mode is active
389 *
390 * @return bool True if virtual mode is active
391 */
392 public function is_virtual_mode()
393 {
394 return $this->database->is_virtual_mode();
395 }
396
397 /**
398 * Update or add sitemap URL in robots.txt
399 *
400 * @param string $sitemap_url The sitemap URL to add/update
401 * @return array Result with 'success' boolean and 'action' string ('added', 'updated', 'unchanged', 'created', or 'error')
402 */
403 public function update_sitemap_url($sitemap_url)
404 {
405 // Check if we can write to robots.txt
406 if (!$this->is_writable()) {
407 return [
408 'success' => false,
409 'action' => 'error',
410 'message' => esc_html__('Cannot write to robots.txt. Please check file permissions.', 'metasync')
411 ];
412 }
413
414 // Check if file exists
415 $file_exists = $this->file_exists();
416
417 // Read current content
418 $current_content = $this->read_robots_file();
419
420 if (is_wp_error($current_content)) {
421 // If error reading, use default content
422 $current_content = $this->get_default_robots_content();
423 }
424
425 // Normalize the sitemap URL
426 $sitemap_url = esc_url_raw($sitemap_url);
427 $sitemap_line = "Sitemap: {$sitemap_url}";
428
429 // Check if there's already a Sitemap line
430 $has_sitemap = preg_match('/^Sitemap:\s*.+$/im', $current_content);
431
432 if ($has_sitemap) {
433 // Check if the sitemap URL is already correct
434 if (preg_match('/^Sitemap:\s*' . preg_quote($sitemap_url, '/') . '\s*$/im', $current_content)) {
435 // If file doesn't exist, we still need to create it even if content matches
436 if (!$file_exists) {
437 $result = $this->write_robots_file($current_content);
438 if (is_wp_error($result)) {
439 return [
440 'success' => false,
441 'action' => 'error',
442 'message' => $result->get_error_message()
443 ];
444 }
445 return [
446 'success' => true,
447 'action' => 'created',
448 'message' => esc_html__('robots.txt file has been created with sitemap URL.', 'metasync')
449 ];
450 }
451 return [
452 'success' => true,
453 'action' => 'unchanged',
454 'message' => esc_html__('Sitemap URL already exists in robots.txt.', 'metasync')
455 ];
456 }
457
458 // Update existing Sitemap line(s) - replace all with the new one
459 $new_content = preg_replace('/^Sitemap:\s*.+$/im', $sitemap_line, $current_content);
460
461 // Remove duplicate sitemap lines (keep only the first one)
462 $lines = explode("\n", $new_content);
463 $sitemap_found = false;
464 $filtered_lines = [];
465 foreach ($lines as $line) {
466 if (preg_match('/^Sitemap:/i', trim($line))) {
467 if (!$sitemap_found) {
468 $filtered_lines[] = $sitemap_line;
469 $sitemap_found = true;
470 }
471 // Skip duplicate sitemap lines
472 } else {
473 $filtered_lines[] = $line;
474 }
475 }
476 $new_content = implode("\n", $filtered_lines);
477 $action = 'updated';
478 } else {
479 // Add sitemap line at the end
480 $new_content = rtrim($current_content) . "\n\n" . $sitemap_line;
481 $action = 'added';
482 }
483
484 // Write the updated content
485 $result = $this->write_robots_file($new_content);
486
487 if (is_wp_error($result)) {
488 return [
489 'success' => false,
490 'action' => 'error',
491 'message' => $result->get_error_message()
492 ];
493 }
494
495 $messages = [
496 'added' => esc_html__('Sitemap URL has been added to robots.txt.', 'metasync'),
497 'updated' => esc_html__('Sitemap URL has been updated in robots.txt.', 'metasync'),
498 'created' => esc_html__('robots.txt file has been created with sitemap URL.', 'metasync'),
499 ];
500
501 return [
502 'success' => true,
503 'action' => $action,
504 'message' => $messages[$action]
505 ];
506 }
507
508 /**
509 * Check if robots.txt contains the sitemap URL
510 *
511 * @param string $sitemap_url The sitemap URL to check for
512 * @return bool True if the sitemap URL exists in robots.txt
513 */
514 public function has_sitemap_url($sitemap_url)
515 {
516 $content = $this->read_robots_file();
517
518 if (is_wp_error($content)) {
519 return false;
520 }
521
522 return (bool) preg_match('/^Sitemap:\s*' . preg_quote($sitemap_url, '/') . '\s*$/im', $content);
523 }
524
525 /**
526 * Add a sitemap URL to robots.txt without replacing existing ones.
527 *
528 * @param string $sitemap_url The sitemap URL to add.
529 * @return array Result with 'success' boolean and 'action' string ('added' or 'unchanged').
530 */
531 public function add_sitemap_url($sitemap_url)
532 {
533 $content = $this->read_robots_file();
534
535 if (is_wp_error($content)) {
536 return [
537 'success' => false,
538 'action' => 'error',
539 'message' => $content->get_error_message(),
540 ];
541 }
542
543 $sitemap_url = esc_url_raw($sitemap_url);
544
545 // Check if the specific sitemap line already exists
546 if (preg_match('/^Sitemap:\s*' . preg_quote($sitemap_url, '/') . '\s*$/im', $content)) {
547 return [
548 'success' => true,
549 'action' => 'unchanged',
550 'message' => esc_html__('Sitemap URL already exists in robots.txt.', 'metasync'),
551 ];
552 }
553
554 // Append the new Sitemap line
555 $content = rtrim($content) . "\nSitemap: " . $sitemap_url;
556
557 $result = $this->write_robots_file($content);
558
559 if (is_wp_error($result)) {
560 return [
561 'success' => false,
562 'action' => 'error',
563 'message' => $result->get_error_message(),
564 ];
565 }
566
567 return [
568 'success' => true,
569 'action' => 'added',
570 'message' => esc_html__('Sitemap URL has been added to robots.txt.', 'metasync'),
571 ];
572 }
573
574 /**
575 * Remove a specific sitemap URL from robots.txt.
576 *
577 * @param string $sitemap_url The sitemap URL to remove.
578 * @return array Result with 'success' boolean and 'action' string ('removed' or 'not_found').
579 */
580 public function remove_sitemap_url($sitemap_url)
581 {
582 $content = $this->read_robots_file();
583
584 if (is_wp_error($content)) {
585 return [
586 'success' => false,
587 'action' => 'error',
588 'message' => $content->get_error_message(),
589 ];
590 }
591
592 $sitemap_url = esc_url_raw($sitemap_url);
593 $pattern = '/^Sitemap:\s*' . preg_quote($sitemap_url, '/') . '\s*$/im';
594
595 if (!preg_match($pattern, $content)) {
596 return [
597 'success' => true,
598 'action' => 'not_found',
599 'message' => esc_html__('Sitemap URL not found in robots.txt.', 'metasync'),
600 ];
601 }
602
603 $new_content = preg_replace($pattern, '', $content);
604 // Clean up extra blank lines
605 $new_content = preg_replace("/\n{3,}/", "\n\n", $new_content);
606 $new_content = rtrim($new_content) . "\n";
607
608 $result = $this->write_robots_file($new_content);
609
610 if (is_wp_error($result)) {
611 return [
612 'success' => false,
613 'action' => 'error',
614 'message' => $result->get_error_message(),
615 ];
616 }
617
618 return [
619 'success' => true,
620 'action' => 'removed',
621 'message' => esc_html__('Sitemap URL has been removed from robots.txt.', 'metasync'),
622 ];
623 }
624
625 /**
626 * Add a reference to /llms.txt in robots.txt.
627 *
628 * robots.txt has no official directive for LLMs.txt, so the reference is
629 * written as a comment (ignored by crawlers but visible to humans and
630 * tools that scan robots.txt for related assets).
631 *
632 * @param string $llms_url Absolute URL to the LLMs.txt file.
633 * @return array Result with 'success' and 'action' keys.
634 */
635 public function add_llms_txt_url($llms_url)
636 {
637 $content = $this->read_robots_file();
638
639 if (is_wp_error($content)) {
640 return [
641 'success' => false,
642 'action' => 'error',
643 'message' => $content->get_error_message(),
644 ];
645 }
646
647 $llms_url = esc_url_raw($llms_url);
648 $line = '# LLMs.txt: ' . $llms_url;
649
650 if (false !== strpos($content, $line)) {
651 return [
652 'success' => true,
653 'action' => 'unchanged',
654 'message' => esc_html__('LLMs.txt reference already exists in robots.txt.', 'metasync'),
655 ];
656 }
657
658 $content = rtrim($content) . "\n" . $line . "\n";
659
660 $result = $this->write_robots_file($content);
661
662 if (is_wp_error($result)) {
663 return [
664 'success' => false,
665 'action' => 'error',
666 'message' => $result->get_error_message(),
667 ];
668 }
669
670 return [
671 'success' => true,
672 'action' => 'added',
673 'message' => esc_html__('LLMs.txt reference has been added to robots.txt.', 'metasync'),
674 ];
675 }
676
677 /**
678 * Remove a /llms.txt reference from robots.txt.
679 *
680 * @param string $llms_url Absolute URL of the LLMs.txt file to remove.
681 * @return array Result with 'success' and 'action' keys.
682 */
683 public function remove_llms_txt_url($llms_url)
684 {
685 $content = $this->read_robots_file();
686
687 if (is_wp_error($content)) {
688 return [
689 'success' => false,
690 'action' => 'error',
691 'message' => $content->get_error_message(),
692 ];
693 }
694
695 $llms_url = esc_url_raw($llms_url);
696 $pattern = '/^#\s*LLMs\.txt:\s*' . preg_quote($llms_url, '/') . '\s*$/im';
697
698 if (!preg_match($pattern, $content)) {
699 return [
700 'success' => true,
701 'action' => 'not_found',
702 'message' => esc_html__('LLMs.txt reference not found in robots.txt.', 'metasync'),
703 ];
704 }
705
706 $new_content = preg_replace($pattern, '', $content);
707 $new_content = preg_replace("/\n{3,}/", "\n\n", $new_content);
708 $new_content = rtrim($new_content) . "\n";
709
710 $result = $this->write_robots_file($new_content);
711
712 if (is_wp_error($result)) {
713 return [
714 'success' => false,
715 'action' => 'error',
716 'message' => $result->get_error_message(),
717 ];
718 }
719
720 return [
721 'success' => true,
722 'action' => 'removed',
723 'message' => esc_html__('LLMs.txt reference has been removed from robots.txt.', 'metasync'),
724 ];
725 }
726
727 /**
728 * Get the current sitemap URL from robots.txt
729 *
730 * @return string|false The sitemap URL or false if not found
731 */
732 public function get_sitemap_url_from_robots()
733 {
734 $content = $this->read_robots_file();
735
736 if (is_wp_error($content)) {
737 return false;
738 }
739
740 if (preg_match('/^Sitemap:\s*(.+)$/im', $content, $matches)) {
741 return trim($matches[1]);
742 }
743
744 return false;
745 }
746
747 /**
748 * Render the admin page
749 *
750 * @param object $admin Admin class instance
751 * @param string $current_content Current robots.txt content
752 * @param array $backups Backup history
753 * @param bool $file_exists Whether the file exists
754 * @param bool $is_writable Whether the file is writable
755 */
756 public function render($admin, $current_content, $backups, $file_exists, $is_writable)
757 {
758 // Load the view template
759 require_once plugin_dir_path(__FILE__) . 'views/admin-page.php';
760 }
761 }
762