name = __( 'Robots.txt', 'all-in-one-seo-pack' ); // Human-readable name of the plugin $this->prefix = 'aiosp_robots_'; // option prefix $this->file = __FILE__; // the current file parent::__construct(); $help_text = array( 'type' => __( 'Rule Type', 'all-in-one-seo-pack' ), 'agent' => __( 'User Agent', 'all-in-one-seo-pack' ), 'path' => __( 'Directory Path', 'all-in-one-seo-pack' ), ); $this->default_options = array( 'usage' => array( 'type' => 'html', 'label' => 'none', 'default' => __( 'Use the rule builder below to add/delete rules.', 'all-in-one-seo-pack' ), 'save' => false, ), ); $this->rule_fields = array( 'agent' => array( 'name' => __( 'User Agent', 'all-in-one-seo-pack' ), 'type' => 'text', 'label' => 'top', 'save' => false, ), 'type' => array( 'name' => __( 'Rule', 'all-in-one-seo-pack' ), 'type' => 'select', 'initial_options' => array( 'allow' => __( 'Allow', 'all-in-one-seo-pack' ), 'disallow' => __( 'Disallow', 'all-in-one-seo-pack' ) ), 'label' => 'top', 'save' => false, ), 'path' => array( 'name' => __( 'Directory Path', 'all-in-one-seo-pack' ), 'type' => 'text', 'label' => 'top', 'save' => false, ), 'Submit' => array( 'type' => 'submit', 'class' => 'button-primary add-edit-rule', 'name' => __( 'Add Rule', 'all-in-one-seo-pack' ) . ' »', 'style' => 'margin-left: 20px;', 'label' => 'none', 'save' => false, 'value' => 1, ), "{$this->prefix}id" => array( 'type' => 'hidden', 'class' => 'edit-rule-id', 'save' => false, 'value' => '', ), 'rules' => array( 'name' => __( 'Configured Rules', 'all-in-one-seo-pack' ), 'type' => 'custom', 'save' => true, ), 'robots.txt' => array( 'name' => __( 'Robots.txt', 'all-in-one-seo-pack' ), 'type' => 'custom', 'save' => true, ), ); add_filter( $this->prefix . 'submit_options', array( $this, 'submit_options'), 10, 2 ); $this->default_options = array_merge( $this->default_options, $this->rule_fields ); if ( ! empty( $help_text ) ) { foreach ( $help_text as $k => $v ) { $this->default_options[ $k ]['help_text'] = $v; } } $this->layout = array( 'default' => array( 'name' => __( 'Create a Robots.txt File', 'all-in-one-seo-pack' ), 'help_link' => 'https://semperplugins.com/documentation/robots-txt-module/', 'options' => array_merge( array( 'usage' ), array_keys( $this->rule_fields ) ), ), ); // load initial options / set defaults $this->update_options(); add_filter( $this->prefix . 'output_option', array( $this, 'display_custom_options' ), 10, 2 ); add_filter( $this->prefix . 'update_options', array( $this, 'filter_options' ) ); add_filter( $this->prefix . 'display_options', array( $this, 'filter_display_options' ) ); add_action( 'wp_ajax_aioseop_ajax_delete_rule', array( $this, 'ajax_delete_rule' ) ); add_action( 'wp_ajax_aioseop_ajax_robots_physical', array( $this, 'ajax_action_physical_file' ) ); add_filter( 'robots_txt', array( $this, 'robots_txt' ), 10, 2 ); // We want to define this because calling admin init in the unit tests causes an error and does not call this method. if ( defined( 'AIOSEOP_UNIT_TESTING' ) ) { add_action( "aioseop_ut_{$this->prefix}admin_init", array( $this, 'import_default_robots' ) ); } } function physical_file_check() { if ( $this->has_physical_file() ) { if ( ( is_multisite() && is_network_admin() ) || ( ! is_multisite() && current_user_can( 'manage_options') ) ) { $this->default_options['usage']['default'] .= '

' . sprintf( __( 'A physical file exists. Do you want to %simport and delete%s it, %sdelete%s it or continue using it?', 'all-in-one-seo-pack' ), '', '', '', '' ) . '

'; } else { $this->default_options['usage']['default'] .= '

' . __( 'A physical file exists. This feature cannot be used.', 'all-in-one-seo-pack' ) . '

'; } return; } else { add_action( 'admin_init', array( $this, 'import_default_robots' ) ); } } function filter_display_options( $options ) { $errors = get_transient( "{$this->prefix}errors" . get_current_user_id() ); if ( false !== $errors ) { if ( is_array( $errors ) ) { $errors = implode( '
', $errors ); } echo sprintf( '

%s

', $errors ); } return $options; } /** * First time import of the default robots.txt rules. */ function import_default_robots() { $options = $this->get_option_for_blog( $this->get_network_id() ); if ( array_key_exists( 'default', $options ) ) { return; } $default = $this->do_robots(); $lines = explode( "\n", $default ); $rules = $this->extract_rules( $lines ); aiosp_log("adding default rules: " . print_r($rules,true)); global $aioseop_options; $aioseop_options['modules']["{$this->prefix}options"]['default'] = $rules; update_option( 'aioseop_options', $aioseop_options ); } function submit_options( $submit_options, $location ) { unset( $submit_options['Submit'] ); unset( $submit_options['Submit_Default'] ); return $submit_options; } function ajax_action_physical_file() { aioseop_ajax_init(); $action = $_POST['options']; switch ( $action ) { case 'import': $this->import_default_robots(); if ( ! $this->import_physical_file() ) { wp_send_json_success( array( 'message' => __( 'Unable to read file', 'all-in-one-seo-pack' ) ) ); } // fall-through. case 'delete': if ( ! $this->delete_physical_file() ) { wp_send_json_success( array( 'message' => __( 'Unable to delete file', 'all-in-one-seo-pack' ) ) ); } break; } wp_send_json_success(); } private function import_physical_file() { $wp_filesystem = $this->get_filesystem_object(); $file = trailingslashit( $wp_filesystem->abspath() ) . 'robots.txt'; if ( ! $wp_filesystem->is_readable( $file ) ) { return false; } $lines = $wp_filesystem->get_contents_array( $file ); if ( ! $lines ) { return true; } $rules = $this->extract_rules( $lines ); aiosp_log("importing rules: " . print_r($rules,true)); global $aioseop_options; $aioseop_options['modules']["{$this->prefix}options"]["{$this->prefix}rules"] = $rules; update_option( 'aioseop_options', $aioseop_options ); return true; } private function extract_rules( array $lines ) { $rules = array(); $user_agent = null; $rule = array(); $blog_rules = $this->get_all_rules(); foreach ( $lines as $line ) { if ( empty( $line ) ) { continue; } $array = array_map( 'trim', explode( ':', $line ) ); if ( $array && count( $array ) !== 2 ) { aiosp_log( "Ignoring $line from robots.txt" ); continue; } $operand = $array[0]; switch ( strtolower( $operand ) ) { case 'user-agent': $user_agent = $array[1]; break; case 'disallow': // fall-through. case 'allow': $rule[ 'agent' ] = $user_agent; $rule[ 'type' ] = $operand; $rule[ 'path' ] = $array[1]; break; } if ( $rule ) { $rule = $this->validate_rule( $blog_rules, $rule ); if ( is_wp_error( $rule ) ) { $this->add_error( $rule ); } else { $rules[] = $rule; } $rule = array(); } } return $rules; } private function delete_physical_file() { $wp_filesystem = $this->get_filesystem_object(); $file = trailingslashit( $wp_filesystem->abspath() ) . 'robots.txt'; return $wp_filesystem->delete( $file ); } private function has_physical_file() { $access_type = get_filesystem_method(); if ( 'direct' === $access_type ) { $wp_filesystem = $this->get_filesystem_object(); $file = trailingslashit( $wp_filesystem->abspath() ) . 'robots.txt'; return $wp_filesystem->exists( $file ); } } function robots_txt( $output, $public ) { return $output . "\r\n" . $this->get_rules(); } private function get_rules() { $robots = array(); $blog_rules = $this->get_all_rules( is_multisite() ? $this->get_network_id() : null ); if ( is_multisite() && $this->get_network_id() != get_current_blog_id() ) { $blog_rules = array_merge( $blog_rules, $this->get_all_rules( get_current_blog_id() ) ); } $rules = array(); foreach ( $blog_rules as $rule ) { $condition = sprintf( '%s: %s', $rule['type'], $rule['path'] ); $agent = $rule['agent']; if ( ! array_key_exists( $agent, $rules ) ) { $rules[$agent] = array(); } $rules[ $agent ][] = $condition; } foreach( $rules as $agent => $conditions ) { $robots[] = sprintf( 'User-agent: %s', $agent ); $robots[] = implode( "\r\n", $conditions ); $robots[] = ""; } return implode( "\r\n", $robots ); } private function get_network_id() { if ( is_multisite() ) { return get_network()->site_id; } return get_current_blog_id(); } private function get_option_for_blog( $id = null ) { if ( is_null( $id ) ) { $id = get_current_blog_id(); } if ( is_multisite() ) { switch_to_blog( $id ); } $options = get_option('aioseop_options'); if ( is_multisite() ) { restore_current_blog(); } return array_key_exists( 'modules', $options ) && array_key_exists( "{$this->prefix}options", $options['modules'] ) ? $options['modules']["{$this->prefix}options"] : array(); } /** * Get all rules defined for the blog. */ private function get_all_rules( $id = null ) { $options = $this->get_option_for_blog( $id ); return array_key_exists( "{$this->prefix}rules", $options ) ? $options[ "{$this->prefix}rules" ] : array(); } /** * Get the default robot rules that were saved in the first initialization. */ private function get_default_rules() { $options = $this->get_option_for_blog( $this->get_network_id() ); return array_key_exists( 'default', $options ) ? $options[ 'default' ] : array(); } function ajax_delete_rule() { aioseop_ajax_init(); $id = $_POST['options']; $this->delete_rule( $id ); } private function delete_rule( $id ) { global $aioseop_options; $deleted_rule = null; // first check the defined rules. $blog_rules = $this->get_all_rules(); $rules = array(); foreach ( $blog_rules as $rule ) { if ( $id === $rule['id'] ) { $deleted_rule = $rule; continue; } $rules[] = $rule; } $aioseop_options['modules']["{$this->prefix}options"]["{$this->prefix}rules"] = $rules; update_option( 'aioseop_options', $aioseop_options ); return $deleted_rule; } private function add_error( $error ) { $errors = get_transient( "{$this->prefix}errors" . get_current_user_id() ); if ( false === $errors ) { $errors = array(); } $errors[] = $error->get_error_message(); // set the error in a transient. set_transient( "{$this->prefix}errors" . get_current_user_id(), $errors, 5 ); } /** * Filter options. * * @param $options * * @return mixed */ function filter_options( $options ) { $modify = isset( $_POST[ "{$this->prefix}id" ] ) && ! empty( $_POST[ "{$this->prefix}id" ] ); $deleted_rule = null; if ( $modify ) { // let's first delete the original rule and save it temporarily so that we can add it back in case of an error with the new rule. $deleted_rule = $this->delete_rule( $_POST[ "{$this->prefix}id" ] ); } $blog_rules = $this->get_all_rules(); if ( ! empty( $_POST[ "{$this->prefix}path" ] ) ) { foreach ( array_keys( $this->rule_fields ) as $field ) { $post_field = $this->prefix . "" . $field; if ( ! empty( $_POST[ $post_field ] ) ) { $_POST[ $post_field ] = esc_attr( wp_kses_post( $_POST[ $post_field ] ) ); } else { $_POST[ $post_field ] = ''; } } $new_rule = array( 'path' => $_POST[ "{$this->prefix}path" ], 'type' => $_POST[ "{$this->prefix}type" ], 'agent' => $_POST[ "{$this->prefix}agent" ], ); $rule = $this->validate_rule( $blog_rules, $new_rule ); if ( is_wp_error( $rule ) ) { $this->add_error( $rule ); if ( $deleted_rule ) { $blog_rules[] = $deleted_rule; } } else { $blog_rules[] = $rule; } } // testing only - to clear the rules. //$blog_rules = array(); $options[ "{$this->prefix}rules" ] = $blog_rules; return $options; } private function sanitize_path( $path ) { // if path does not have a trailing wild card (*) or does not refer to a file (with extension), add trailing slash. if ( '*' !== substr( $path, -1 ) && false === strpos( $path, '.' ) ) { $path = trailingslashit( $path ); } // if path does not have a leading slash, add it. if ( '/' !== substr( $path, 0, 1 ) ) { $path = '/' . $path; } // convert everything to lower case. $path = strtolower( $path ); return $path; } private function create_rule_id( $type, $agent, $path ) { return md5( $type . $agent . $path ); } private function validate_rule( $rules, $new_rule ) { if ( empty( $new_rule[ 'agent' ] ) ) { return new WP_Error('invalid', __( 'User Agent cannot be empty', 'all-in-one-seo-pack' ) ); } if ( empty( $new_rule[ 'path' ] ) ) { return new WP_Error('invalid', __( 'Directory Path cannot be empty', 'all-in-one-seo-pack' ) ); } $default = $this->get_default_rules(); $network = $this->get_all_rules( $this->get_network_id() ); if ( ! is_array( $network ) ) { $network = array(); } $network = array_merge( $default, $network, $rules ); // sanitize path. $path = $this->sanitize_path( $new_rule[ 'path' ] ); // generate id to check uniqueness and also for purposes of deletion. $id = $this->create_rule_id( $new_rule[ 'type' ], $new_rule[ 'agent' ], $path ); if ( is_array( $rules ) ) { $ids = wp_list_pluck( $rules, 'id' ); if ( in_array( $id, $ids ) ) { aiosp_log("rejected: same rule id exists - " . print_r($new_rule,true) . " vs. " . print_r($rules,true)); return new WP_Error('duplicate', sprintf( __( 'Identical rule exists: %s', 'all-in-one-seo-pack' ), $new_rule[ 'path' ] ) ); } } if ( $network ) { $nw_agent_paths = array(); foreach ( $network as $n ) { $nw_agent_paths[] = $n['agent'] . $n['path']; } // the same rule cannot be duplicated by the Admin. $agent_path = $new_rule[ 'agent' ] . $path; if ( in_array( $agent_path, $nw_agent_paths ) ) { aiosp_log("rejected: same agent/path being overridden - " . print_r($new_rule,true) . " vs. " . print_r($rules,true)); return new WP_Error('duplicate', sprintf( __( 'Rule cannot be overridden: %s', 'all-in-one-seo-pack' ), $new_rule[ 'path' ] ) ); } // an identical path as specified by Network Admin cannot be overriden by Admin. $nw_paths = wp_list_pluck( $network, 'path' ); if ( in_array( $path, $nw_paths ) ) { aiosp_log("rejected: same path being overridden - " . print_r($new_rule,true) . " vs. " . print_r($rules,true)); return new WP_Error('duplicate', sprintf( __( 'Path cannot be overridden: %s', 'all-in-one-seo-pack' ), $new_rule[ 'path' ] ) ); } // a wild-carded path specified by the Admin cannot override a path specified by Network Admin. $pattern = str_replace( array( '.', '/', '*', ), array( '\.', '\/', '(.*)', ), $path ); foreach ( $nw_paths as $nw_path ) { $matches = array(); preg_match( "/{$pattern}/", $nw_path, $matches ); if ( ! empty( $matches ) && count( $matches ) >= 2 && ! empty( $matches[1] ) ) { aiosp_log("rejected: wild card path being overridden - " . print_r($new_rule,true) . " vs. " . print_r($rules,true)); return new WP_Error('conflict', sprintf( __( 'Wild-card path cannot be overridden: %s', 'all-in-one-seo-pack' ), $new_rule[ 'path' ] ) ); } } } return array( 'type' => ucwords( $new_rule[ 'type' ] ), 'agent' => $new_rule[ 'agent' ], 'path' => $path, 'id' => $id, ); } private function reorder_rules( $rules ) { if ( is_array( $rules ) ) { uasort( $rules, array( $this, 'sort_rules' ) ); } return $rules; } function sort_rules( $a, $b ) { return $a['agent'] > $b['agent']; } private function get_display_rules( $rules ) { $buf = ''; if ( ! empty( $rules ) ) { $rules = $this->reorder_rules( $rules ); $buf = sprintf( "\n", __( 'Modify Rule', 'all-in-one-seo-pack' ) . ' »' ); $row = "\t \n"; foreach ( $rules as $v ) { $buf .= sprintf( $row, $v['id'], $v['id'], esc_attr( $v['agent'] ), esc_attr( strtolower( $v['type'] ) ), esc_attr( $v['path'] ), $v['agent'], $v['type'], $v['path'] ); } $buf .= "
%s %s %s
\n"; } return $buf; } private function do_robots() { // disable header warnings. error_reporting(0); ob_start(); do_action( 'do_robots' ); if ( is_admin() ) { // conflict with WooCommerce etc. cause the page to render as text/plain. header( 'Content-Type:text/html' ); } return ob_get_clean(); } /** * Custom settings. * * Displays boxes in a table layout. * * @param $buf * @param $args * * @return string */ function display_custom_options( $buf, $args ) { switch ( $args['name'] ) { case "{$this->prefix}rules": $buf .= "
"; $rules = $args['value']; $buf .= $this->get_display_rules( $rules ); $buf .= '
'; break; case "{$this->prefix}robots.txt": $buf .= ""; break; } $args['options']['type'] = 'hidden'; if ( ! empty( $args['value'] ) ) { $args['value'] = wp_json_encode( $args['value'] ); } else { $args['options']['type'] = 'html'; } if ( empty( $args['value'] ) ) { $args['value'] = ''; } $buf .= $this->get_option_html( $args ); return $buf; } /** * Add Menu * * (Parent) Adds the wp-admin menu, and this adds additional menu & load-hooks for * the 1mporting and/or deleting the `robot.txt` file. * * @since 2.7.2 * * @param $parent_slug * @return bool */ public function add_menu( $parent_slug ) { $hook = 'all-in-one-seo_page_' . AIOSEOP_PLUGIN_DIRNAME . '/modules/aioseop_robots'; if ( is_multisite() && is_network_admin() ) { // Add the robots.txt editor into the network admin menu. $hook = add_menu_page( 'Robots.txt Editor', 'Robots.txt Editor', 'edit_themes', plugin_basename( $this->file ), array( $this, 'display_settings_page', ) ); } add_action( 'load-' . $hook, array( $this, 'physical_file_check' ) ); return parent::add_menu( $parent_slug ); } } }