PluginProbe
Gutenberg / trunk
Gutenberg vtrunk
24.1.0 24.0.0 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 All 404 releases
gutenberg / lib / class-wp-rest-global-styles-controller-gutenberg.php

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

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