PluginProbe
Gutenberg / 23.6.2
Gutenberg v23.6.2
23.9.1 23.9.0 23.8.0 23.7.2 23.7.1 23.7.0 23.6.1 23.6.2 23.6.0 23.5.3 23.5.2 23.5.1 23.5.0 23.4.0 23.3.2 23.3.1 23.3.0 23.2.0 23.2.1 23.2.2 23.1.1 23.1.0 23.0.1 12.6.0 7.4.0 All 402 releases
gutenberg / lib / class-wp-rest-global-styles-controller-gutenberg.php

class-wp-rest-global-styles-controller-gutenberg.php in Gutenberg 23.6.2, at lib/class-wp-rest-global-styles-controller-gutenberg.php

785 lines 24.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * REST API: Bundle WP_Theme_JSON class instead of inheriting per WordPress version class
4 *
5 * Changes to this class should be synced to the corresponding class
6 * in WordPress core: src/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-controller.php.
7 *
8 * @package gutenberg
9 * @subpackage REST_API
10 * @since 5.9.0
11 */
12
13 /**
14 * Base Global Styles REST API Controller.
15 */
16 class WP_REST_Global_Styles_Controller_Gutenberg extends WP_REST_Posts_Controller {
17
18 /**
19 * Whether the controller supports batching.
20 *
21 * @since 6.6.0
22 * @var array
23 */
24 protected $allow_batch = array( 'v1' => false );
25
26 /**
27 * Constructor.
28 *
29 * @since 5.9.0
30 */
31 /**
32 * Constructor.
33 *
34 * @since 6.6.0
35 *
36 * @param string $post_type Post type.
37 */
38 public function __construct( $post_type = 'wp_global_styles' ) {
39 parent::__construct( $post_type );
40 }
41
42 /**
43 * Registers the controllers routes.
44 *
45 * @since 5.9.0
46 */
47 public function register_routes() {
48 register_rest_route(
49 $this->namespace,
50 '/' . $this->rest_base . '/themes/(?P<stylesheet>[\/\s%\w\.\(\)\[\]\@_\-]+)/variations',
51 array(
52 array(
53 'methods' => WP_REST_Server::READABLE,
54 'callback' => array( $this, 'get_theme_items' ),
55 'permission_callback' => array( $this, 'get_theme_items_permissions_check' ),
56 'args' => array(
57 'stylesheet' => array(
58 'description' => __( 'The theme identifier', 'gutenberg' ),
59 'type' => 'string',
60 ),
61 ),
62 'allow_batch' => $this->allow_batch,
63 ),
64 ),
65 /*
66 * $override is set to true to avoid conflicts with the core endpoint.
67 * Do not sync to WordPress core.
68 */
69 true
70 );
71
72 // List themes global styles.
73 register_rest_route(
74 $this->namespace,
75 // The route.
76 sprintf(
77 '/%s/themes/(?P<stylesheet>%s)',
78 $this->rest_base,
79 /*
80 * Matches theme's directory: `/themes/<subdirectory>/<theme>/` or `/themes/<theme>/`.
81 * Excludes invalid directory name characters: `/:<>*?"|`.
82 */
83 '[^\/:<>\*\?"\|]+(?:\/[^\/:<>\*\?"\|]+)?'
84 ),
85 array(
86 array(
87 'methods' => WP_REST_Server::READABLE,
88 'callback' => array( $this, 'get_theme_item' ),
89 'permission_callback' => array( $this, 'get_theme_item_permissions_check' ),
90 'args' => array(
91 'stylesheet' => array(
92 'description' => __( 'The theme identifier', 'gutenberg' ),
93 'type' => 'string',
94 'sanitize_callback' => array( $this, '_sanitize_global_styles_callback' ),
95 ),
96 ),
97 'allow_batch' => $this->allow_batch,
98 ),
99 ),
100 /*
101 * $override is set to true to avoid conflicts with the core endpoint.
102 * Do not sync to WordPress core.
103 */
104 true
105 );
106
107 // Lists/updates a single global style variation based on the given id.
108 register_rest_route(
109 $this->namespace,
110 '/' . $this->rest_base . '/(?P<id>[\/\w-]+)',
111 array(
112 array(
113 'methods' => WP_REST_Server::READABLE,
114 'callback' => array( $this, 'get_item' ),
115 'permission_callback' => array( $this, 'get_item_permissions_check' ),
116 'args' => array(
117 'id' => array(
118 'description' => __( 'The id of a template', 'gutenberg' ),
119 'type' => 'string',
120 'sanitize_callback' => array( $this, '_sanitize_global_styles_callback' ),
121 ),
122 ),
123 ),
124 array(
125 'methods' => WP_REST_Server::EDITABLE,
126 'callback' => array( $this, 'update_item' ),
127 'permission_callback' => array( $this, 'update_item_permissions_check' ),
128 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
129 ),
130 'schema' => array( $this, 'get_public_item_schema' ),
131 'allow_batch' => $this->allow_batch,
132 ),
133 /*
134 * $override is set to true to avoid conflicts with the core endpoint.
135 * Do not sync to WordPress core.
136 */
137 true
138 );
139 }
140
141 /**
142 * Sanitize the global styles ID or stylesheet to decode endpoint.
143 * For example, `wp/v2/global-styles/twentytwentytwo%200.4.0`
144 * would be decoded to `twentytwentytwo 0.4.0`.
145 *
146 * @since 5.9.0
147 *
148 * @param string $id_or_stylesheet Global styles ID or stylesheet.
149 * @return string Sanitized global styles ID or stylesheet.
150 */
151 public function _sanitize_global_styles_callback( $id_or_stylesheet ) {
152 return urldecode( $id_or_stylesheet );
153 }
154
155 /**
156 * Get the post, if the ID is valid.
157 *
158 * @since 5.9.0
159 *
160 * @param int $id Supplied ID.
161 * @return WP_Post|WP_Error Post object if ID is valid, WP_Error otherwise.
162 */
163 protected function get_post( $id ) {
164 $error = new WP_Error(
165 'rest_global_styles_not_found',
166 __( 'No global styles config exist with that id.', 'gutenberg' ),
167 array( 'status' => 404 )
168 );
169
170 $id = (int) $id;
171 if ( $id <= 0 ) {
172 return $error;
173 }
174
175 $post = get_post( $id );
176 if ( empty( $post ) || empty( $post->ID ) || $this->post_type !== $post->post_type ) {
177 return $error;
178 }
179
180 return $post;
181 }
182
183 /**
184 * Checks if a given request has access to read a single global style.
185 *
186 * @since 5.9.0
187 *
188 * @param WP_REST_Request $request Full details about the request.
189 * @return true|WP_Error True if the request has read access, WP_Error object otherwise.
190 */
191 public function get_item_permissions_check( $request ) {
192 $post = $this->get_post( $request['id'] );
193 if ( is_wp_error( $post ) ) {
194 return $post;
195 }
196
197 if ( 'edit' === $request['context'] && $post && ! $this->check_update_permission( $post ) ) {
198 return new WP_Error(
199 'rest_forbidden_context',
200 __( 'Sorry, you are not allowed to edit this global style.', 'gutenberg' ),
201 array( 'status' => rest_authorization_required_code() )
202 );
203 }
204
205 if ( ! $this->check_read_permission( $post ) ) {
206 return new WP_Error(
207 'rest_cannot_view',
208 __( 'Sorry, you are not allowed to view this global style.', 'gutenberg' ),
209 array( 'status' => rest_authorization_required_code() )
210 );
211 }
212
213 return true;
214 }
215
216 /**
217 * Checks if a global style can be read.
218 *
219 * @since 5.9.0
220 *
221 * @param WP_Post $post Post object.
222 * @return bool Whether the post can be read.
223 */
224 public function check_read_permission( $post ) {
225 return current_user_can( 'read_post', $post->ID );
226 }
227
228 /**
229 * Checks if a given request has access to write a single global styles config.
230 *
231 * @since 5.9.0
232 *
233 * @param WP_REST_Request $request Full details about the request.
234 * @return true|WP_Error True if the request has write access for the item, WP_Error object otherwise.
235 */
236 public function update_item_permissions_check( $request ) {
237 $post = $this->get_post( $request['id'] );
238 if ( is_wp_error( $post ) ) {
239 return $post;
240 }
241
242 if ( $post && ! $this->check_update_permission( $post ) ) {
243 return new WP_Error(
244 'rest_cannot_edit',
245 __( 'Sorry, you are not allowed to edit this global style.', 'gutenberg' ),
246 array( 'status' => rest_authorization_required_code() )
247 );
248 }
249
250 return true;
251 }
252
253 /**
254 * Prepares a single global styles config for update.
255 *
256 * @since 5.9.0
257 * @since 6.2.0 Added validation of styles.css property.
258 * @since 6.6.0 Added registration of block style variations from theme.json sources (theme.json, user theme.json, partials).
259 *
260 * @param WP_REST_Request $request Request object.
261 * @return stdClass|WP_Error Prepared item on success. WP_Error on when the custom CSS is not valid.
262 */
263 protected function prepare_item_for_database( $request ) {
264 $changes = new stdClass();
265 $changes->ID = $request['id'];
266
267 $post = get_post( $request['id'] );
268 $existing_config = array();
269 if ( $post ) {
270 $existing_config = json_decode( $post->post_content, true );
271 $json_decoding_error = json_last_error();
272 if ( JSON_ERROR_NONE !== $json_decoding_error || ! isset( $existing_config['isGlobalStylesUserThemeJSON'] ) ||
273 ! $existing_config['isGlobalStylesUserThemeJSON'] ) {
274 $existing_config = array();
275 }
276 }
277
278 if ( isset( $request['styles'] ) || isset( $request['settings'] ) ) {
279 $config = array();
280 if ( isset( $request['styles'] ) ) {
281 if ( isset( $request['styles']['css'] ) ) {
282 $css_validation_result = $this->validate_custom_css( $request['styles']['css'] );
283 if ( is_wp_error( $css_validation_result ) ) {
284 return $css_validation_result;
285 }
286 }
287 $config['styles'] = $request['styles'];
288 } elseif ( isset( $existing_config['styles'] ) ) {
289 $config['styles'] = $existing_config['styles'];
290 }
291
292 // Register theme-defined variations e.g. from block style variation partials under `/styles`.
293 $variations = WP_Theme_JSON_Resolver_Gutenberg::get_style_variations( 'block' );
294 gutenberg_register_block_style_variations_from_theme_json_partials( $variations );
295
296 if ( isset( $request['settings'] ) ) {
297 $config['settings'] = $request['settings'];
298 } elseif ( isset( $existing_config['settings'] ) ) {
299 $config['settings'] = $existing_config['settings'];
300 }
301 $config['isGlobalStylesUserThemeJSON'] = true;
302 $config['version'] = WP_Theme_JSON_Gutenberg::LATEST_SCHEMA;
303 /**
304 * JSON encode the data stored in post content.
305 * Escape characters that are likely to be mangled by HTML filters: "<>&".
306 *
307 * This data is later re-encoded by {@see gutenberg_filter_global_styles_post()}.
308 * The escaping is also applied here as a precaution.
309 */
310 $changes->post_content = wp_json_encode( $config, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP );
311 }
312
313 // Post title.
314 if ( isset( $request['title'] ) ) {
315 if ( is_string( $request['title'] ) ) {
316 $changes->post_title = $request['title'];
317 } elseif ( ! empty( $request['title']['raw'] ) ) {
318 $changes->post_title = $request['title']['raw'];
319 }
320 }
321
322 return $changes;
323 }
324
325 /**
326 * Prepare a global styles config output for response.
327 *
328 * @since 5.9.0
329 * @since 6.2.0 Handling of style.css was added to WP_Theme_JSON.
330 * @since 6.6.0 Added custom relative theme file URIs to `_links`.
331 *
332 * @param WP_Post $post Global Styles post object.
333 * @param WP_REST_Request $request Request object.
334 * @return WP_REST_Response Response object.
335 */
336 public function prepare_item_for_response( $post, $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
337 $raw_config = json_decode( $post->post_content, true );
338 $is_global_styles_user_theme_json = isset( $raw_config['isGlobalStylesUserThemeJSON'] ) && true === $raw_config['isGlobalStylesUserThemeJSON'];
339 $config = array();
340 $theme_json = null;
341 if ( $is_global_styles_user_theme_json ) {
342 $theme_json = new WP_Theme_JSON_Gutenberg( $raw_config, 'custom' );
343 $config = $theme_json->get_raw_data();
344 }
345
346 // Base fields for every post.
347 $data = array();
348 $fields = $this->get_fields_for_response( $request );
349
350 if ( rest_is_field_included( 'id', $fields ) ) {
351 $data['id'] = $post->ID;
352 }
353
354 if ( rest_is_field_included( 'title', $fields ) ) {
355 $data['title'] = array();
356 }
357 if ( rest_is_field_included( 'title.raw', $fields ) ) {
358 $data['title']['raw'] = $post->post_title;
359 }
360 if ( rest_is_field_included( 'title.rendered', $fields ) ) {
361 add_filter( 'protected_title_format', array( $this, 'protected_title_format' ) );
362 add_filter( 'private_title_format', array( $this, 'protected_title_format' ) );
363
364 $data['title']['rendered'] = get_the_title( $post->ID );
365
366 remove_filter( 'protected_title_format', array( $this, 'protected_title_format' ) );
367 remove_filter( 'private_title_format', array( $this, 'protected_title_format' ) );
368 }
369
370 if ( rest_is_field_included( 'settings', $fields ) ) {
371 $data['settings'] = ! empty( $config['settings'] ) && $is_global_styles_user_theme_json ? $config['settings'] : new stdClass();
372 }
373
374 if ( rest_is_field_included( 'styles', $fields ) ) {
375 $data['styles'] = ! empty( $config['styles'] ) && $is_global_styles_user_theme_json ? $config['styles'] : new stdClass();
376 }
377
378 $context = ! empty( $request['context'] ) ? $request['context'] : 'view';
379 $data = $this->add_additional_fields_to_object( $data, $request );
380 $data = $this->filter_response_by_context( $data, $context );
381
382 // Wrap the data in a response object.
383 $response = rest_ensure_response( $data );
384
385 if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) {
386 $links = $this->prepare_links( $post->ID );
387 // Only return resolved URIs for get requests to user theme JSON.
388 if ( $theme_json ) {
389 $resolved_theme_uris = WP_Theme_JSON_Resolver_Gutenberg::get_resolved_theme_uris( $theme_json );
390 if ( ! empty( $resolved_theme_uris ) ) {
391 $links['https://api.w.org/theme-file'] = $resolved_theme_uris;
392 }
393 }
394 $response->add_links( $links );
395 if ( ! empty( $links['self']['href'] ) ) {
396 $actions = $this->get_available_actions( $post, $request );
397 $self = $links['self']['href'];
398 foreach ( $actions as $rel ) {
399 $response->add_link( $rel, $self );
400 }
401 }
402 }
403
404 return $response;
405 }
406
407 /**
408 * Prepares links for the request.
409 *
410 * @since 5.9.0
411 * @since 6.3.0 Adds revisions count and rest URL href to version-history.
412 *
413 * @param integer $id ID.
414 * @return array Links for the given post.
415 */
416 protected function prepare_links( $id ) {
417 $base = sprintf( '%s/%s', $this->namespace, $this->rest_base );
418
419 $links = array(
420 'self' => array(
421 'href' => rest_url( trailingslashit( $base ) . $id ),
422 ),
423 'about' => array(
424 'href' => rest_url( 'wp/v2/types/' . $this->post_type ),
425 ),
426 );
427
428 if ( post_type_supports( $this->post_type, 'revisions' ) ) {
429 $revisions = wp_get_latest_revision_id_and_total_count( $id );
430 $revisions_count = ! is_wp_error( $revisions ) ? $revisions['count'] : 0;
431 $revisions_base = sprintf( '/%s/%d/revisions', $base, $id );
432 $links['version-history'] = array(
433 'href' => rest_url( $revisions_base ),
434 'count' => $revisions_count,
435 );
436 }
437
438 return $links;
439 }
440
441 /**
442 * Get the link relations available for the post and current user.
443 *
444 * @since 5.9.0
445 * @since 6.2.0 Added 'edit-css' action.
446 * @since 6.6.0 Added $post and $request parameters.
447 *
448 * @param WP_Post $post Post object.
449 * @param WP_REST_Request $request Request object.
450 * @return array List of link relations.
451 */
452 protected function get_available_actions( $post, $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
453 $rels = array();
454
455 $post_type = get_post_type_object( $post->post_type );
456 if ( current_user_can( $post_type->cap->publish_posts ) ) {
457 $rels[] = 'https://api.w.org/action-publish';
458 }
459
460 if ( current_user_can( 'edit_css' ) ) {
461 $rels[] = 'https://api.w.org/action-edit-css';
462 }
463
464 return $rels;
465 }
466
467 /**
468 * Retrieves the query params for the global styles collection.
469 *
470 * @since 5.9.0
471 *
472 * @return array Collection parameters.
473 */
474 public function get_collection_params() {
475 return array();
476 }
477
478 /**
479 * Retrieves the global styles type' schema, conforming to JSON Schema.
480 *
481 * @since 5.9.0
482 *
483 * @return array Item schema data.
484 */
485 public function get_item_schema() {
486 if ( $this->schema ) {
487 return $this->add_additional_fields_schema( $this->schema );
488 }
489
490 $schema = array(
491 '$schema' => 'http://json-schema.org/draft-04/schema#',
492 'title' => $this->post_type,
493 'type' => 'object',
494 'properties' => array(
495 'id' => array(
496 'description' => __( 'ID of global styles config.', 'gutenberg' ),
497 'type' => 'string',
498 'context' => array( 'embed', 'view', 'edit' ),
499 'readonly' => true,
500 ),
501 'styles' => array(
502 'description' => __( 'Global styles.', 'gutenberg' ),
503 'type' => array( 'object' ),
504 'context' => array( 'view', 'edit' ),
505 ),
506 'settings' => array(
507 'description' => __( 'Global settings.', 'gutenberg' ),
508 'type' => array( 'object' ),
509 'context' => array( 'view', 'edit' ),
510 ),
511 'title' => array(
512 'description' => __( 'Title of the global styles variation.', 'gutenberg' ),
513 'type' => array( 'object', 'string' ),
514 'default' => '',
515 'context' => array( 'embed', 'view', 'edit' ),
516 'properties' => array(
517 'raw' => array(
518 'description' => __( 'Title for the global styles variation, as it exists in the database.', 'gutenberg' ),
519 'type' => 'string',
520 'context' => array( 'view', 'edit', 'embed' ),
521 ),
522 'rendered' => array(
523 'description' => __( 'HTML title for the post, transformed for display.', 'gutenberg' ),
524 'type' => 'string',
525 'context' => array( 'view', 'edit', 'embed' ),
526 'readonly' => true,
527 ),
528 ),
529 ),
530 ),
531 );
532
533 $this->schema = $schema;
534
535 return $this->add_additional_fields_schema( $this->schema );
536 }
537
538 /**
539 * Checks if a given request has access to read a single theme global styles config.
540 *
541 * @since 5.9.0
542 * @since 6.7.0 Allow users with edit post capabilities to view theme global styles.
543 *
544 * @param WP_REST_Request $request Full details about the request.
545 * @return true|WP_Error True if the request has read access for the item, WP_Error object otherwise.
546 */
547 public function get_theme_item_permissions_check( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
548 /*
549 * Verify if the current user has edit_posts capability.
550 */
551 if ( current_user_can( 'edit_posts' ) ) {
552 return true;
553 }
554
555 foreach ( get_post_types( array( 'show_in_rest' => true ), 'objects' ) as $post_type ) {
556 if ( current_user_can( $post_type->cap->edit_posts ) ) {
557 return true;
558 }
559 }
560
561 /*
562 * Verify if the current user has edit_theme_options capability.
563 */
564 if ( current_user_can( 'edit_theme_options' ) ) {
565 return true;
566 }
567
568 return new WP_Error(
569 'rest_cannot_read_global_styles',
570 __( 'Sorry, you are not allowed to access the global styles on this site.', 'gutenberg' ),
571 array(
572 'status' => rest_authorization_required_code(),
573 )
574 );
575 }
576
577 /**
578 * Returns the given theme global styles config.
579 *
580 * @since 5.9.0
581 *
582 * @param WP_REST_Request $request The request instance.
583 * @return WP_REST_Response|WP_Error
584 */
585 public function get_theme_item( $request ) {
586 if ( get_stylesheet() !== $request['stylesheet'] ) {
587 // This endpoint only supports the active theme for now.
588 return new WP_Error(
589 'rest_theme_not_found',
590 __( 'Theme not found.', 'gutenberg' ),
591 array( 'status' => 404 )
592 );
593 }
594
595 $theme = WP_Theme_JSON_Resolver_Gutenberg::get_merged_data( 'theme' );
596 $fields = $this->get_fields_for_response( $request );
597 $data = array();
598
599 if ( rest_is_field_included( 'settings', $fields ) ) {
600 $data['settings'] = $theme->get_settings();
601 }
602
603 if ( rest_is_field_included( 'styles', $fields ) ) {
604 $raw_data = $theme->get_raw_data();
605 $data['styles'] = $raw_data['styles'] ?? array();
606 }
607
608 $context = ! empty( $request['context'] ) ? $request['context'] : 'view';
609 $data = $this->add_additional_fields_to_object( $data, $request );
610 $data = $this->filter_response_by_context( $data, $context );
611 $response = rest_ensure_response( $data );
612
613 if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) {
614 $links = array(
615 'self' => array(
616 'href' => rest_url( sprintf( '%s/%s/themes/%s', $this->namespace, $this->rest_base, $request['stylesheet'] ) ),
617 ),
618 );
619 $resolved_theme_uris = WP_Theme_JSON_Resolver_Gutenberg::get_resolved_theme_uris( $theme );
620 if ( ! empty( $resolved_theme_uris ) ) {
621 $links['https://api.w.org/theme-file'] = $resolved_theme_uris;
622 }
623
624 $response->add_links( $links );
625 }
626
627 return $response;
628 }
629
630 /**
631 * Checks if a given request has access to read a single theme global styles config.
632 *
633 * @since 6.0.0
634 *
635 * @param WP_REST_Request $request Full details about the request.
636 * @return true|WP_Error True if the request has read access for the item, WP_Error object otherwise.
637 */
638 public function get_theme_items_permissions_check( $request ) {
639 return $this->get_theme_item_permissions_check( $request );
640 }
641
642 /**
643 * Returns the given theme global styles variations.
644 *
645 * @since 6.0.0
646 * @since 6.2.0 Returns parent theme variations, if they exist.
647 * @since 6.4.0 Removed unnecessary local variable.
648 * @since 6.6.0 Added custom relative theme file URIs to `_links` for each item.
649 *
650 * @param WP_REST_Request $request The request instance.
651 *
652 * @return WP_REST_Response|WP_Error
653 */
654 public function get_theme_items( $request ) {
655 if ( get_stylesheet() !== $request['stylesheet'] ) {
656 // This endpoint only supports the active theme for now.
657 return new WP_Error(
658 'rest_theme_not_found',
659 __( 'Theme not found.', 'gutenberg' ),
660 array( 'status' => 404 )
661 );
662 }
663
664 $response = array();
665
666 // Register theme-defined variations e.g. from block style variation partials under `/styles`.
667 $partials = WP_Theme_JSON_Resolver_Gutenberg::get_style_variations( 'block' );
668 gutenberg_register_block_style_variations_from_theme_json_partials( $partials );
669
670 $variations = WP_Theme_JSON_Resolver_Gutenberg::get_style_variations();
671
672 // Add resolved theme asset links.
673 foreach ( $variations as $variation ) {
674 $variation_theme_json = new WP_Theme_JSON_Gutenberg( $variation );
675 $resolved_theme_uris = WP_Theme_JSON_Resolver_Gutenberg::get_resolved_theme_uris( $variation_theme_json );
676 $data = rest_ensure_response( $variation );
677 if ( ! empty( $resolved_theme_uris ) ) {
678 $data->add_links(
679 array(
680 'https://api.w.org/theme-file' => $resolved_theme_uris,
681 )
682 );
683 }
684 $response[] = $this->prepare_response_for_collection( $data );
685 }
686
687 return rest_ensure_response( $response );
688 }
689
690 /**
691 * Validate style.css as valid CSS.
692 *
693 * Currently just checks that CSS will not break an HTML STYLE tag.
694 *
695 * @since 6.2.0
696 * @since 6.4.0 Changed method visibility to protected.
697 * @since 7.0.0 Only restricts contents which risk prematurely closing the STYLE element,
698 * either through a STYLE end tag or a prefix of one which might become a
699 * full end tag when combined with the contents of other styles.
700 *
701 * @param mixed $css CSS to validate.
702 * @return true|WP_Error True if the input was validated, otherwise WP_Error.
703 */
704 protected function validate_custom_css( $css ) {
705 if ( ! is_string( $css ) ) {
706 return new WP_Error(
707 'rest_custom_css_invalid_type',
708 __( 'CSS must be a string.', 'gutenberg' ),
709 array( 'status' => 400 )
710 );
711 }
712
713 $length = strlen( $css );
714 for (
715 $at = strcspn( $css, '<' );
716 $at < $length;
717 $at += strcspn( $css, '<', ++$at )
718 ) {
719 $remaining_strlen = $length - $at;
720 /**
721 * Custom CSS text is expected to render inside an HTML STYLE element.
722 * A STYLE closing tag must not appear within the CSS text because it
723 * would close the element prematurely.
724 *
725 * The text must also *not* end with a partial closing tag (e.g., `<`,
726 * `</`, … `</style`) because subsequent styles which are concatenated
727 * could complete it, forming a valid `</style>` tag.
728 *
729 * Example:
730 *
731 * $style_a = 'p { font-weight: bold; </sty';
732 * $style_b = 'le> gotcha!';
733 * $combined = "{$style_a}{$style_b}";
734 *
735 * $style_a = 'p { font-weight: bold; </style';
736 * $style_b = 'p > b { color: red; }';
737 * $combined = "{$style_a}\n{$style_b}";
738 *
739 * Note how in the second example, both of the style contents are benign
740 * when analyzed on their own. The first style was likely the result of
741 * improper truncation, while the second is perfectly sound. It was only
742 * through concatenation that these two scripts combined to form content
743 * that would have broken out of the containing STYLE element, thus
744 * corrupting the page and potentially introducing security issues.
745 *
746 * @link https://html.spec.whatwg.org/multipage/parsing.html#rawtext-end-tag-name-state
747 */
748 $possible_style_close_tag = 0 === substr_compare(
749 $css,
750 '</style',
751 $at,
752 min( 7, $remaining_strlen ),
753 true
754 );
755 if ( $possible_style_close_tag ) {
756 if ( $remaining_strlen < 8 ) {
757 return new WP_Error(
758 'rest_custom_css_illegal_markup',
759 sprintf(
760 /* translators: %s is the CSS that was provided. */
761 __( 'The CSS must not end in "%s".', 'gutenberg' ),
762 esc_html( substr( $css, $at ) )
763 ),
764 array( 'status' => 400 )
765 );
766 }
767
768 if ( 1 === strspn( $css, " \t\f\r\n/>", $at + 7, 1 ) ) {
769 return new WP_Error(
770 'rest_custom_css_illegal_markup',
771 sprintf(
772 /* translators: %s is the CSS that was provided. */
773 __( 'The CSS must not contain "%s".', 'gutenberg' ),
774 esc_html( substr( $css, $at, 8 ) )
775 ),
776 array( 'status' => 400 )
777 );
778 }
779 }
780 }
781
782 return true;
783 }
784 }
785