diff --git a/src/wp-includes/block-editor.php b/src/wp-includes/block-editor.php index bbb139d756c1a..acf7b5d55089b 100644 --- a/src/wp-includes/block-editor.php +++ b/src/wp-includes/block-editor.php @@ -410,6 +410,16 @@ function get_block_editor_settings( array $custom_settings, $block_editor_contex $block_classes['css'] = $actual_css; $global_styles[] = $block_classes; } + + /* + * Add the custom CSS as a separate stylesheet so any invalid CSS + * entered by users does not break other global styles. + */ + $editor_settings['styles'][] = array( + 'css' => wp_get_global_styles_custom_css(), + '__unstableType' => 'user', + 'isGlobalStyles' => true, + ); } else { // If there is no `theme.json` file, ensure base layout styles are still available. $block_classes = array( diff --git a/src/wp-includes/class-wp-theme-json.php b/src/wp-includes/class-wp-theme-json.php index 71801df2af174..2908fb94d7e69 100644 --- a/src/wp-includes/class-wp-theme-json.php +++ b/src/wp-includes/class-wp-theme-json.php @@ -425,6 +425,7 @@ class WP_Theme_JSON { 'textDecoration' => null, 'textTransform' => null, ), + 'css' => null, ); /** @@ -1005,6 +1006,31 @@ public function get_stylesheet( $types = array( 'variables', 'styles', 'presets' return $stylesheet; } + /** + * Returns the global styles custom css. + * + * @since 6.2.0 + * + * @return string + */ + public function get_custom_css() { + // Add the global styles root CSS. + $stylesheet = _wp_array_get( $this->theme_json, array( 'styles', 'css' ), '' ); + + // Add the global styles block CSS. + if ( isset( $this->theme_json['styles']['blocks'] ) ) { + foreach ( $this->theme_json['styles']['blocks'] as $name => $node ) { + $custom_block_css = _wp_array_get( $this->theme_json, array( 'styles', 'blocks', $name, 'css' ) ); + if ( $custom_block_css ) { + $selector = static::$blocks_metadata[ $name ]['selector']; + $stylesheet .= $this->process_blocks_custom_css( $custom_block_css, $selector ); + } + } + } + + return $stylesheet; + } + /** * Returns the page templates of the active theme. * @@ -2740,7 +2766,12 @@ public static function remove_insecure_properties( $theme_json ) { continue; } - $output = static::remove_insecure_styles( $input ); + // The global styles custom CSS is not sanitized, but can only be edited by users with 'edit_css' capability. + if ( isset( $input['css'] ) && current_user_can( 'edit_css' ) ) { + $output = $input; + } else { + $output = static::remove_insecure_styles( $input ); + } /* * Get a reference to element name from path. diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 4e6340700cc15..09be9e576b6d2 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -577,6 +577,9 @@ add_action( 'wp_enqueue_scripts', 'wp_enqueue_global_styles' ); add_action( 'wp_footer', 'wp_enqueue_global_styles', 1 ); +// Global styles custom CSS. +add_action( 'wp_enqueue_scripts', 'wp_enqueue_global_styles_custom_css' ); + // Block supports, and other styles parsed and stored in the Style Engine. add_action( 'wp_enqueue_scripts', 'wp_enqueue_stored_styles' ); add_action( 'wp_footer', 'wp_enqueue_stored_styles', 1 ); diff --git a/src/wp-includes/global-styles-and-settings.php b/src/wp-includes/global-styles-and-settings.php index 8195aaaa34b2a..fe2393abea974 100644 --- a/src/wp-includes/global-styles-and-settings.php +++ b/src/wp-includes/global-styles-and-settings.php @@ -225,6 +225,60 @@ function wp_get_global_stylesheet( $types = array() ) { return $stylesheet; } +/** + * Gets the global styles custom css from theme.json. + * + * @since 6.2.0 + * + * @return string Stylesheet. + */ +function wp_get_global_styles_custom_css() { + if ( ! wp_theme_has_theme_json() ) { + return ''; + } + /* + * Ignore cache when `WP_DEBUG` is enabled, so it doesn't interfere with the theme + * developer's workflow. + * + * @todo Replace `WP_DEBUG` once an "in development mode" check is available in Core. + */ + $can_use_cached = ! WP_DEBUG; + + /* + * By using the 'theme_json' group, this data is marked to be non-persistent across requests. + * @see `wp_cache_add_non_persistent_groups()`. + * + * The rationale for this is to make sure derived data from theme.json + * is always fresh from the potential modifications done via hooks + * that can use dynamic data (modify the stylesheet depending on some option, + * settings depending on user permissions, etc.). + * See some of the existing hooks to modify theme.json behavior: + * @see https://make.wordpress.org/core/2022/10/10/filters-for-theme-json-data/ + * + * A different alternative considered was to invalidate the cache upon certain + * events such as options add/update/delete, user meta, etc. + * It was judged not enough, hence this approach. + * @see https://github.com/WordPress/gutenberg/pull/45372 + */ + $cache_key = 'wp_get_global_styles_custom_css'; + $cache_group = 'theme_json'; + if ( $can_use_cached ) { + $cached = wp_cache_get( $cache_key, $cache_group ); + if ( $cached ) { + return $cached; + } + } + + $tree = WP_Theme_JSON_Resolver::get_merged_data(); + $stylesheet = $tree->get_custom_css(); + + if ( $can_use_cached ) { + wp_cache_set( $cache_key, $stylesheet, $cache_group ); + } + + return $stylesheet; +} + /** * Returns a string containing the SVGs to be referenced as filters (duotone). * @@ -369,5 +423,6 @@ function wp_clean_theme_json_cache() { wp_cache_delete( 'wp_get_global_styles_svg_filters', 'theme_json' ); wp_cache_delete( 'wp_get_global_settings_custom', 'theme_json' ); wp_cache_delete( 'wp_get_global_settings_theme', 'theme_json' ); + wp_cache_delete( 'wp_get_global_styles_custom_css', 'theme_json' ); WP_Theme_JSON_Resolver::clean_cached_data(); } diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-controller.php index b504e23c35ba6..c8eee60ed3632 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-controller.php @@ -268,6 +268,10 @@ public function update_item( $request ) { } $changes = $this->prepare_item_for_database( $request ); + if ( is_wp_error( $changes ) ) { + return $changes; + } + $result = wp_update_post( wp_slash( (array) $changes ), true, false ); if ( is_wp_error( $result ) ) { return $result; @@ -290,9 +294,10 @@ public function update_item( $request ) { * Prepares a single global styles config for update. * * @since 5.9.0 + * @since 6.2.0 Added validation of styles.css property. * * @param WP_REST_Request $request Request object. - * @return stdClass Changes to pass to wp_update_post. + * @return stdClass|WP_Error Prepared item on success. WP_Error on when the custom CSS is not valid. */ protected function prepare_item_for_database( $request ) { $changes = new stdClass(); @@ -312,6 +317,12 @@ protected function prepare_item_for_database( $request ) { if ( isset( $request['styles'] ) || isset( $request['settings'] ) ) { $config = array(); if ( isset( $request['styles'] ) ) { + if ( isset( $request['styles']['css'] ) ) { + $css_validation_result = $this->validate_custom_css( $request['styles']['css'] ); + if ( is_wp_error( $css_validation_result ) ) { + return $css_validation_result; + } + } $config['styles'] = $request['styles']; } elseif ( isset( $existing_config['styles'] ) ) { $config['styles'] = $existing_config['styles']; @@ -657,4 +668,25 @@ public function get_theme_items( $request ) { return $response; } + + /** + * Validate style.css as valid CSS. + * + * Currently just checks for invalid markup. + * + * @since 6.2.0 + * + * @param string $css CSS to validate. + * @return true|WP_Error True if the input was validated, otherwise WP_Error. + */ + private function validate_custom_css( $css ) { + if ( preg_match( '#?\w+#', $css ) ) { + return new WP_Error( + 'rest_custom_css_illegal_markup', + __( 'Markup is not allowed in CSS.' ), + array( 'status' => 400 ) + ); + } + return true; + } } diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 63f5c4fe28f39..2245b08c5717d 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2454,6 +2454,27 @@ function wp_enqueue_global_styles() { wp_add_global_styles_for_blocks(); } +/** + * Enqueues the global styles custom css defined via theme.json. + * + * @since 6.2.0 + */ +function wp_enqueue_global_styles_custom_css() { + if ( ! wp_is_block_theme() ) { + return; + } + + // Don't enqueue Customizer's custom CSS separately. + remove_action( 'wp_head', 'wp_custom_css_cb', 101 ); + + $custom_css = wp_get_custom_css(); + $custom_css .= wp_get_global_styles_custom_css(); + + if ( ! empty( $custom_css ) ) { + wp_add_inline_style( 'global-styles', $custom_css ); + } +} + /** * Renders the SVG filters supplied by theme.json. * diff --git a/tests/phpunit/tests/rest-api/rest-global-styles-controller.php b/tests/phpunit/tests/rest-api/rest-global-styles-controller.php index 8fc86d2205c7f..f20fde92ae663 100644 --- a/tests/phpunit/tests/rest-api/rest-global-styles-controller.php +++ b/tests/phpunit/tests/rest-api/rest-global-styles-controller.php @@ -532,4 +532,43 @@ public function test_assign_edit_css_action_admin() { $this->assertArrayHasKey( 'https://api.w.org/action-edit-css', $links ); } } + + /** + * @covers WP_REST_Global_Styles_Controller::update_item + * @ticket 57536 + */ + public function test_update_item_valid_styles_css() { + wp_set_current_user( self::$admin_id ); + if ( is_multisite() ) { + grant_super_admin( self::$admin_id ); + } + $request = new WP_REST_Request( 'PUT', '/wp/v2/global-styles/' . self::$global_styles_id ); + $request->set_body_params( + array( + 'styles' => array( 'css' => 'body { color: red; }' ), + ) + ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertSame( 'body { color: red; }', $data['styles']['css'] ); + } + + /** + * @covers WP_REST_Global_Styles_Controller::update_item + * @ticket 57536 + */ + public function test_update_item_invalid_styles_css() { + wp_set_current_user( self::$admin_id ); + if ( is_multisite() ) { + grant_super_admin( self::$admin_id ); + } + $request = new WP_REST_Request( 'PUT', '/wp/v2/global-styles/' . self::$global_styles_id ); + $request->set_body_params( + array( + 'styles' => array( 'css' => '
test
body { color: red; }' ), + ) + ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_custom_css_illegal_markup', $response, 400 ); + } } diff --git a/tests/phpunit/tests/theme/wpThemeJson.php b/tests/phpunit/tests/theme/wpThemeJson.php index 113a1f01800ab..efa709ef756dc 100644 --- a/tests/phpunit/tests/theme/wpThemeJson.php +++ b/tests/phpunit/tests/theme/wpThemeJson.php @@ -14,6 +14,36 @@ */ class Tests_Theme_wpThemeJson extends WP_UnitTestCase { + /** + * Administrator ID. + * + * @var int + */ + private static $administrator_id; + + /** + * User ID. + * + * @var int + */ + private static $user_id; + + public static function set_up_before_class() { + parent::set_up_before_class(); + + static::$administrator_id = self::factory()->user->create( + array( + 'role' => 'administrator', + ) + ); + + if ( is_multisite() ) { + grant_super_admin( self::$administrator_id ); + } + + static::$user_id = self::factory()->user->create(); + } + /** * @ticket 52991 * @ticket 54336 @@ -4505,4 +4535,93 @@ public function test_get_shadow_styles_for_blocks() { $this->assertSame( $expected_styles, $theme_json->get_stylesheet() ); } + + /** + * @ticket 57536 + */ + public function test_get_custom_css_handles_global_custom_css() { + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'css' => 'body { color:purple; }', + ), + ) + ); + $custom_css = 'body { color:purple; }'; + $this->assertSame( $custom_css, $theme_json->get_custom_css() ); + } + + /** + * Tests that custom CSS is kept for users with correct capabilities and removed for others. + * + * @ticket 57536 + * + * @dataProvider data_custom_css_for_user_caps + * + * @param string $user_property The property name for current user. + * @param array $expected Expected results. + */ + public function test_custom_css_for_user_caps( $user_property, array $expected ) { + wp_set_current_user( static::${$user_property} ); + + $actual = WP_Theme_JSON::remove_insecure_properties( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'css' => 'body { color:purple; }', + 'blocks' => array( + 'core/separator' => array( + 'color' => array( + 'background' => 'blue', + ), + ), + ), + ), + ) + ); + + $this->assertSameSetsWithIndex( $expected, $actual ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_custom_css_for_user_caps() { + return array( + 'allows custom css for users with caps' => array( + 'user_property' => 'administrator_id', + 'expected' => array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'css' => 'body { color:purple; }', + 'blocks' => array( + 'core/separator' => array( + 'color' => array( + 'background' => 'blue', + ), + ), + ), + ), + ), + ), + 'removes custom css for users without caps' => array( + 'user_property' => 'user_id', + 'expected' => array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'core/separator' => array( + 'color' => array( + 'background' => 'blue', + ), + ), + ), + ), + ), + ), + ); + } }