Skip to content
194 changes: 182 additions & 12 deletions php/class-wp-seo-settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ class WP_SEO_Settings {
*/
public $options = array();

/**
* Storage unit for the current network option values of the plugin.
*
* @var array.
*/
public $network_options = array();

/**
* Taxonomies with archive pages, which can have meta fields set for them.
*
Expand Down Expand Up @@ -66,6 +73,8 @@ class WP_SEO_Settings {

const SLUG = 'wp-seo';

const NETWORK_SLUG = 'wp-seo-network';

/**
* Unused.
*
Expand Down Expand Up @@ -118,6 +127,8 @@ protected function setup() {
add_action( 'admin_menu', array( $this, 'add_options_page' ) );
add_action( 'admin_init', array( $this, 'register_settings' ) );
add_action( 'load-settings_page_' . $this::SLUG, array( $this, 'add_help_tab' ) );
add_action( 'network_admin_menu', array( $this, 'add_network_options_page' ) );
add_action( 'network_admin_edit_wp-seo-network', array( $this, 'save_network_settings' ) );
}
}

Expand Down Expand Up @@ -185,6 +196,27 @@ public function get_option( $key, $default = null ) {
return isset( $this->options[ $key ] ) ? $this->options[ $key ] : $default;
}

/**
* Set $network_options with the current network database value.
*/
public function set_network_options() {
$this->network_options = get_site_option( $this::NETWORK_SLUG, array() );
}

/**
* Get a network option value.
*
* @param string $key The option key sought.
* @param mixed $default Optional default.
* @return mixed The value, or null on failure.
*/
public function get_network_option( $key, $default = null ) {
if ( empty( $this->network_options ) ) {
$this->set_network_options();
}
return isset( $this->network_options[ $key ] ) ? $this->network_options[ $key ] : $default;
}

/**
* Get the $taxonomies property.
*
Expand Down Expand Up @@ -257,6 +289,74 @@ public function add_options_page() {
add_options_page( __( 'WP SEO Settings', 'wp-seo' ), __( 'SEO', 'wp-seo' ), $this->options_capability, $this::SLUG, array( $this, 'view_settings_page' ) );
}

/**
* Register the plugin network options page.
*/
public function add_network_options_page() {
add_submenu_page( 'settings.php', __( 'WP SEO Settings', 'wp-seo' ), __( 'SEO', 'wp-seo' ), 'manage_network_options', $this::SLUG, array( $this, 'view_network_settings_page' ) );
}

/**
* Render the network settings page for robots.txt network prefix/suffix fields.
*/
public function view_network_settings_page() {
?>
<div class="wrap" id="wp_seo_network_settings">
<h2><?php esc_html_e( 'WP SEO Network Settings', 'wp-seo' ); ?></h2>
<?php if ( filter_input( INPUT_GET, 'updated' ) ) : ?>
<div class="notice notice-success is-dismissible"><p><?php esc_html_e( 'Network settings saved.', 'wp-seo' ); ?></p></div>
<?php endif; ?>
<form action="edit.php?action=wp-seo-network" method="POST">
<?php wp_nonce_field( 'wp-seo-network-options' ); ?>
<?php
/** This filter is documented in php/class-wp-seo-settings.php */
if ( apply_filters( 'wp_seo_use_settings_accordions', true ) ) {
global $wp_settings_sections;
foreach ( (array) $wp_settings_sections[ $this::NETWORK_SLUG ] as $section ) {
add_meta_box( $section['id'], $section['title'], array( $this, 'settings_meta_box' ), $this::NETWORK_SLUG, 'advanced', 'default', array_merge( $section, array( 'slug' => $this::NETWORK_SLUG ) ) );
}
do_accordion_sections( $this::NETWORK_SLUG, 'advanced', null );
} else {
do_settings_sections( $this::NETWORK_SLUG );
}
?>
<?php submit_button(); ?>
</form>
</div>
<?php
}

/**
* Handle saving the network robots.txt settings.
*
* Double-gated: only processes when the request originates from the network
* admin (via the network_admin_edit_wp-seo-network action) and the current
* user has manage_network_options. This prevents network settings from being
* updated via the regular per-site settings form, even by users who hold
* network admin credentials.
*/
public function save_network_settings() {
check_admin_referer( 'wp-seo-network-options' );

if ( ! is_network_admin() || ! current_user_can( 'manage_network_options' ) ) {
wp_die( esc_html__( 'You do not have permission to perform this action.', 'wp-seo' ) );
}

$in = isset( $_POST[ $this::SLUG ] ) && is_array( $_POST[ $this::SLUG ] ) ? $_POST[ $this::SLUG ] : array();

if ( empty( $this->network_options ) ) {
$this->set_network_options();
}

$this->network_options['robots_txt_network_prefix'] = isset( $in['robots_txt_network_prefix'] ) && is_string( $in['robots_txt_network_prefix'] ) ? sanitize_text_field( $in['robots_txt_network_prefix'] ) : '';
$this->network_options['robots_txt_network_suffix'] = isset( $in['robots_txt_network_suffix'] ) && is_string( $in['robots_txt_network_suffix'] ) ? sanitize_text_field( $in['robots_txt_network_suffix'] ) : '';

update_site_option( $this::NETWORK_SLUG, $this->network_options );

wp_safe_redirect( add_query_arg( array( 'page' => $this::SLUG, 'updated' => 'true' ), network_admin_url( 'settings.php' ) ) );
exit;
}

/**
* Add tabs to the help menu on the plugin options page.
*/
Expand Down Expand Up @@ -328,6 +428,18 @@ public function register_settings() {

add_settings_section( 'arbitrary', __( 'Other Meta Tags', 'wp-seo' ), false, $this::SLUG );
add_settings_field( 'arbitrary_tags', __( 'Tags', 'wp-seo' ), array( $this, 'field' ), $this::SLUG, 'arbitrary', array( 'type' => 'repeatable', 'field' => 'arbitrary_tags', 'repeat' => array( 'name' => __( 'Name', 'lin' ), 'content' => __( 'Content', 'lin' ) ) ) );

add_settings_section( 'robots', __( 'Robots.txt', 'wp-seo' ), false, $this::SLUG );
add_settings_field( 'robots_example', __( 'Robots.txt Example', 'wp-seo' ), array( $this, 'example_robots_txt' ), $this::SLUG, 'robots' );
add_settings_field( 'robots_txt_prefix', __( 'Add to start of Robots.txt', 'wp-seo' ), array( $this, 'field' ), $this::SLUG, 'robots', array( 'type' => 'textarea', 'field' => 'robots_txt_prefix' ) );
add_settings_field( 'robots_txt_suffix', __( 'Add to end of Robots.txt', 'wp-seo' ), array( $this, 'field' ), $this::SLUG, 'robots', array( 'type' => 'textarea', 'field' => 'robots_txt_suffix' ) );

if ( is_network_admin() ) {
add_settings_section( 'robots', __( 'Robots.txt', 'wp-seo' ), false, $this::NETWORK_SLUG );
add_settings_field( 'robots_example', __( 'Robots.txt Example', 'wp-seo' ), array( $this, 'example_robots_txt' ), $this::NETWORK_SLUG, 'robots' );
add_settings_field( 'robots_txt_network_prefix', __( 'Add to start of Robots.txt (Network)', 'wp-seo' ), array( $this, 'field_network' ), $this::NETWORK_SLUG, 'robots', array( 'type' => 'textarea', 'field' => 'robots_txt_network_prefix' ) );
add_settings_field( 'robots_txt_network_suffix', __( 'Add to end of Robots.txt (Network)', 'wp-seo' ), array( $this, 'field_network' ), $this::NETWORK_SLUG, 'robots', array( 'type' => 'textarea', 'field' => 'robots_txt_network_suffix' ) );
}
}

/**
Expand Down Expand Up @@ -358,6 +470,27 @@ public function example_url( $text, $url = false ) {
echo '</p>';
}

/**
* Display the compiled Robots.txt contents in an uneditable field.
*/
public function example_robots_txt() {
ob_start();
/* Error suppression is used here because `do_robots` calls `header` which
* throws a warning due to headers already having been sent. There is no
* way around this, without recreating the function and maintaining our
* own version of it entirely. At least as of WordPress 6.9.4.
*/
@do_robots();
$robots = ob_get_clean();
$this->render_textarea([
'field' => 'robots_txt',
'disabled' => true,
'rows' => 10,
],
$robots
);
}

/**
* Display an example URL for individual posts.
*
Expand Down Expand Up @@ -448,6 +581,34 @@ public function example_404_page() {
* }
*/
public function field( $args ) {
$this->render_field( $args, array( $this, 'get_option' ) );
}

/**
* Display a network settings field.
*
* @param array $args Field arguments. @see render_field().
*/
public function field_network( $args ) {
$this->render_field( $args, array( $this, 'get_network_option' ) );
}

/**
* Render a settings field using the provided getter callable.
*
* Shared implementation for field() and field_network(). Callers pass the
* appropriate getter (get_option or get_network_option) so lazy-loading and
* value lookup are handled by the getter regardless of storage source.
*
* @param array $args {
* Field arguments.
*
* @type string $field The option key to pass to $getter.
* @type string $type Optional field type. Defaults to 'text'.
* }
* @param callable $getter Callable that accepts ( $key, $default ) and returns the value.
*/
protected function render_field( $args, callable $getter ) {
if ( empty( $args['field'] ) ) {
return;
}
Expand All @@ -456,7 +617,7 @@ public function field( $args ) {
$args['type'] = 'text';
}

$value = ! empty( $this->options[ $args['field'] ] ) ? $this->options[ $args['field'] ] : '';
$value = call_user_func( $getter, $args['field'], '' );

switch ( $args['type'] ) {
case 'textarea' :
Expand Down Expand Up @@ -524,11 +685,12 @@ public function render_textarea( $args, $value ) {
) );

printf(
'<textarea name="%s[%s]" rows="%d" cols="%d">%s</textarea>',
'<textarea name="%s[%s]" rows="%d" cols="%d"%s>%s</textarea>',
esc_attr( $this::SLUG ),
esc_attr( $args['field'] ),
esc_attr( $args['rows'] ),
esc_attr( $args['cols'] ),
( ! empty( $args['disabled'] ) ? ' disabled' : '' ),
esc_textarea( $value )
);
}
Expand Down Expand Up @@ -682,9 +844,9 @@ public function view_settings_page() {
if ( apply_filters( 'wp_seo_use_settings_accordions', true ) ) {
global $wp_settings_sections;
foreach ( (array) $wp_settings_sections[ $this::SLUG ] as $section ) {
add_meta_box( $section['id'], $section['title'], array( $this, 'settings_meta_box' ), 'wp-seo', 'advanced', 'default', $section );
add_meta_box( $section['id'], $section['title'], array( $this, 'settings_meta_box' ), $this::SLUG, 'advanced', 'default', array_merge( $section, array( 'slug' => $this::SLUG ) ) );
}
do_accordion_sections( 'wp-seo', 'advanced', null );
do_accordion_sections( $this::SLUG, 'advanced', null );
} else {
do_settings_sections( $this::SLUG );
}
Expand All @@ -698,23 +860,27 @@ public function view_settings_page() {
/**
* Render a section's fields as a meta box.
*
* @param mixed $object Unused. Data passed from do_accordion_sections().
* @param array $box {
* @param mixed $_object Unused. Data passed from do_accordion_sections().
* @param array $box {
* An array of meta box arguments.
*
* @type string $id @see add_meta_box().
* @type string $title @see add_meta_box().
* @type callback $callback @see add_meta_box().
* @type array $args @see add_meta_box(), add_settings_section().
* @type string $id @see add_meta_box().
* @type string $title @see add_meta_box().
* @type callback $callback @see add_meta_box().
* @type array $args @see add_meta_box(), add_settings_section().
* @type string $slug The settings page slug to pass to do_settings_fields().
* Defaults to SLUG.
* }
*/
public function settings_meta_box( $object, $box ) {
public function settings_meta_box( $_object, $box ) {
if ( is_callable( $box['args']['callback'] ) ) {
call_user_func( $box['args']['callback'], $box['args'] );
}

$slug = isset( $box['args']['slug'] ) ? $box['args']['slug'] : $this::SLUG;

echo '<table class="form-table">';
do_settings_fields( $this::SLUG, $box['args']['id'] );
do_settings_fields( $slug, $box['args']['id'] );
echo '</table>';
}

Expand Down Expand Up @@ -760,6 +926,10 @@ public function sanitize_options( $in ) {
$sanitize_as_text_field[] = 'search_title';
$sanitize_as_text_field[] = '404_title';

// Robots.txt fields.
$sanitize_as_text_field[] = 'robots_txt_prefix';
$sanitize_as_text_field[] = 'robots_txt_suffix';

foreach ( $sanitize_as_text_field as $field ) {
$out[ $field ] = isset( $in[ $field ] ) && is_string( $in[ $field ] ) ? sanitize_text_field( $in[ $field ] ) : null;
}
Expand Down
50 changes: 50 additions & 0 deletions php/class-wp-seo.php
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ protected function setup() {
add_filter( 'pre_get_document_title', array( $this, 'pre_get_document_title' ), 20 );
add_filter( 'wp_title', array( $this, 'wp_title' ), 20, 2 );
add_filter( 'wp_head', array( $this, 'wp_head' ), 5 );
add_filter( 'robots_txt', array( $this, 'robots_txt' ) );
}

/**
Expand Down Expand Up @@ -616,6 +617,55 @@ public function wp_head() {
}

}

/**
* Build the prefix and suffix for the Robots.txt file, and return it.
*
* @param string $robots The robots.txt file contents.
* @return string
*/
public function robots_txt( string $robots ): string {
/**
* Filters the network-level Robots.txt Prefix value for WP SEO.
*
* @param string $prefix The robots.txt network prefix, added in the WP SEO network settings page.
*/
$robots_network_prefix = apply_filters( 'wp_seo_robots_txt_network_prefix', WP_SEO_Settings()->get_network_option( 'robots_txt_network_prefix', '' ) );

/**
* Filters the network-level Robots.txt Suffix value for WP SEO.
*
* @param string $suffix The robots.txt network suffix, added in the WP SEO network settings page.
*/
$robots_network_suffix = apply_filters( 'wp_seo_robots_txt_network_suffix', WP_SEO_Settings()->get_network_option( 'robots_txt_network_suffix', '' ) );

/**
* Filters the Robots.txt Prefix value for WP SEO.
*
* @param string $prefix The robots.txt prefix, added in the WP SEO settings page.
*/
$robots_prefix = apply_filters( 'wp_seo_robots_txt_prefix', WP_SEO_Settings()->get_option( 'robots_txt_prefix', '' ) );

/**
* Filters the Robots.txt Suffix value for WP SEO.
*
* @param string $suffix The robots.txt suffix, added in the WP SEO settings page.
*/
$robots_suffix = apply_filters( 'wp_seo_robots_txt_suffix', WP_SEO_Settings()->get_option( 'robots_txt_suffix', '' ) );

return implode(
PHP_EOL,
array_filter(
array(
$robots_network_prefix,
$robots_prefix,
$robots,
$robots_suffix,
$robots_network_suffix
)
)
);
}
}

/**
Expand Down