PluginProbe
Gutenberg / 22.9.0
Gutenberg v22.9.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 12.6.0 7.4.0 All 402 releases
gutenberg / lib / experimental / content-guidelines / class-gutenberg-content-guidelines-rest-controller.php

class-gutenberg-content-guidelines-rest-controller.php in Gutenberg 22.9.0, at lib/experimental/content-guidelines/class-gutenberg-content-guidelines-rest-controller.php

682 lines 20.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Content Guidelines REST API Controller.
4 *
5 * Extends WP_REST_Posts_Controller to inherit standard WordPress CRUD behavior,
6 * permission checks, and response formatting. Follows the pattern used by
7 * WP_REST_Global_Styles_Controller.
8 *
9 * @package gutenberg
10 */
11
12 if ( ! defined( 'ABSPATH' ) ) {
13 exit;
14 }
15
16 /**
17 * REST API controller for Content Guidelines.
18 */
19 class Gutenberg_Content_Guidelines_REST_Controller extends WP_REST_Posts_Controller {
20
21 /**
22 * Maximum length for guideline text strings.
23 *
24 * @var int
25 */
26 const MAX_GUIDELINE_LENGTH = 5000;
27
28 /**
29 * Maximum length for category label strings.
30 *
31 * @var int
32 */
33 const MAX_LABEL_LENGTH = 200;
34
35 /**
36 * Constructor.
37 */
38 public function __construct() {
39 parent::__construct( Gutenberg_Content_Guidelines_Post_Type::POST_TYPE );
40 }
41
42 /**
43 * Registers the routes for content guidelines.
44 *
45 * Calls parent to register standard /{id} CRUD routes, then overrides the
46 * collection route with a singleton GET endpoint.
47 */
48 public function register_routes() {
49 parent::register_routes();
50
51 // Override collection route with singleton GET + create.
52 register_rest_route(
53 $this->namespace,
54 '/' . $this->rest_base,
55 array(
56 array(
57 'methods' => WP_REST_Server::READABLE,
58 'callback' => array( $this, 'get_guidelines' ),
59 'permission_callback' => array( $this, 'get_guidelines_permissions_check' ),
60 'args' => array(
61 'category' => array(
62 'description' => __( 'Limit response to a specific guideline category.', 'gutenberg' ),
63 'type' => 'string',
64 'enum' => Gutenberg_Content_Guidelines_Post_Type::VALID_CATEGORIES,
65 'sanitize_callback' => 'sanitize_text_field',
66 ),
67 'block' => array(
68 'description' => __( 'Limit response to guidelines for a specific block type.', 'gutenberg' ),
69 'type' => 'string',
70 'sanitize_callback' => 'sanitize_text_field',
71 ),
72 'status' => array(
73 'description' => __( 'Limit response to guidelines with a specific status.', 'gutenberg' ),
74 'type' => 'string',
75 'enum' => Gutenberg_Content_Guidelines_Post_Type::VALID_STATUSES,
76 'sanitize_callback' => 'sanitize_text_field',
77 ),
78 ),
79 ),
80 array(
81 'methods' => WP_REST_Server::CREATABLE,
82 'callback' => array( $this, 'create_item' ),
83 'permission_callback' => array( $this, 'create_item_permissions_check' ),
84 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ),
85 ),
86 'schema' => array( $this, 'get_public_item_schema' ),
87 ),
88 true
89 );
90 }
91
92 /**
93 * Retrieves the query params for the collection.
94 *
95 * Overridden to return empty since we use a singleton pattern, not a collection.
96 *
97 * @return array Empty collection parameters.
98 */
99 public function get_collection_params() {
100 return array();
101 }
102
103 /**
104 * Checks if a given request has access to read the singleton guidelines.
105 *
106 * @param WP_REST_Request $request Full details about the request.
107 * @return true|WP_Error True if the request has read access, WP_Error object otherwise.
108 */
109 public function get_guidelines_permissions_check( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
110 $post_type = get_post_type_object( $this->post_type );
111 if ( ! current_user_can( $post_type->cap->read ) ) {
112 return new WP_Error(
113 'rest_forbidden',
114 __( 'Sorry, you are not allowed to view the guidelines.', 'gutenberg' ),
115 array( 'status' => rest_authorization_required_code() )
116 );
117 }
118
119 return true;
120 }
121
122 /**
123 * Gets the singleton content guidelines.
124 *
125 * Supports query parameters:
126 * - ?status=publish|draft - Filter by status
127 * - ?category=copy|images|site|blocks|additional - Return only specific category
128 * - ?block=core/paragraph - Return only specific block's guidelines
129 *
130 * @param WP_REST_Request $request Full details about the request.
131 * @return WP_REST_Response Response object.
132 */
133 public function get_guidelines( $request ) {
134 $status_filter = $request->get_param( 'status' );
135 $post = $this->get_guidelines_post( $status_filter );
136
137 if ( ! $post ) {
138 $empty_status = $status_filter ? $status_filter : 'draft';
139 return rest_ensure_response(
140 array(
141 'id' => 0,
142 'status' => $empty_status,
143 'guideline_categories' => new stdClass(),
144 )
145 );
146 }
147
148 return $this->prepare_item_for_response( $post, $request );
149 }
150
151 /**
152 * Creates content guidelines.
153 *
154 * Enforces singleton pattern — only one guidelines post per site.
155 *
156 * @param WP_REST_Request $request Full details about the request.
157 * @return WP_REST_Response|WP_Error Response object on success, or WP_Error on failure.
158 */
159 public function create_item( $request ) {
160 $existing = $this->get_guidelines_post();
161 if ( $existing ) {
162 return new WP_Error(
163 'rest_guidelines_exists',
164 __( 'Guidelines already exist. Use PATCH to update.', 'gutenberg' ),
165 array( 'status' => 400 )
166 );
167 }
168
169 $prepared = $this->prepare_item_for_database( $request );
170 $prepared->post_type = $this->post_type;
171 $prepared->post_title = __( 'Guidelines', 'gutenberg' );
172
173 if ( ! isset( $prepared->post_status ) ) {
174 $prepared->post_status = 'draft';
175 }
176
177 $post_id = wp_insert_post( wp_slash( (array) $prepared ), true );
178
179 if ( is_wp_error( $post_id ) ) {
180 return $post_id;
181 }
182
183 if ( isset( $request['guideline_categories'] ) ) {
184 $categories = $this->sanitize_guideline_categories( $request['guideline_categories'] );
185 $this->save_guideline_categories_to_meta( $post_id, $categories );
186 }
187
188 $post = get_post( $post_id );
189
190 $request->set_param( 'context', 'edit' );
191 $response = $this->prepare_item_for_response( $post, $request );
192 $response = rest_ensure_response( $response );
193 $response->set_status( 201 );
194 $response->header( 'Location', rest_url( sprintf( '%s/%s/%d', $this->namespace, $this->rest_base, $post_id ) ) );
195
196 return $response;
197 }
198
199 /**
200 * Updates content guidelines.
201 *
202 * Saves guideline categories to meta before updating the post so that
203 * the revision captures the updated meta values.
204 *
205 * @param WP_REST_Request $request Full details about the request.
206 * @return WP_REST_Response|WP_Error Response object on success, or WP_Error on failure.
207 */
208 public function update_item( $request ) {
209 $post = $this->get_post( $request['id'] );
210 if ( is_wp_error( $post ) ) {
211 return $post;
212 }
213
214 // Save guideline categories to meta first (so revision captures them).
215 if ( isset( $request['guideline_categories'] ) ) {
216 $categories = $this->sanitize_guideline_categories( $request['guideline_categories'] );
217 $this->save_guideline_categories_to_meta( $post->ID, $categories );
218 }
219
220 $prepared = $this->prepare_item_for_database( $request );
221 $prepared->ID = $post->ID;
222
223 // Trigger a post update to create a revision with the meta changes.
224 $result = wp_update_post( wp_slash( (array) $prepared ), true );
225
226 if ( is_wp_error( $result ) ) {
227 return $result;
228 }
229
230 $post = get_post( $post->ID );
231
232 $request->set_param( 'context', 'edit' );
233
234 return $this->prepare_item_for_response( $post, $request );
235 }
236
237 /**
238 * Prepares a single guidelines post for database.
239 *
240 * Returns a stdClass with standard post fields. Guideline categories
241 * are handled separately via save_guideline_categories_to_meta().
242 *
243 * @param WP_REST_Request $request Request object.
244 * @return stdClass Prepared post data.
245 */
246 protected function prepare_item_for_database( $request ) {
247 $prepared = new stdClass();
248
249 if ( isset( $request['id'] ) ) {
250 $prepared->ID = $request['id'];
251 }
252
253 if ( isset( $request['status'] ) ) {
254 $prepared->post_status = $request['status'];
255 }
256
257 return $prepared;
258 }
259
260 /**
261 * Prepares a single guidelines output for response.
262 *
263 * Builds the guideline_categories structured response from post meta
264 * and includes standard _links.
265 *
266 * @param WP_Post $post Post object.
267 * @param WP_REST_Request $request Request object.
268 * @return WP_REST_Response Response object.
269 */
270 public function prepare_item_for_response( $post, $request ) {
271 $fields = $this->get_fields_for_response( $request );
272 $data = array();
273
274 if ( rest_is_field_included( 'id', $fields ) ) {
275 $data['id'] = $post->ID;
276 }
277
278 if ( rest_is_field_included( 'status', $fields ) ) {
279 $data['status'] = $post->post_status;
280 }
281
282 if ( rest_is_field_included( 'guideline_categories', $fields ) ) {
283 $guideline_categories = Gutenberg_Content_Guidelines_Post_Type::get_guideline_categories_from_meta( $post->ID );
284
285 // Handle ?block filter.
286 $block_filter = $request->get_param( 'block' );
287 if ( $block_filter && ! empty( $guideline_categories ) ) {
288 if ( isset( $guideline_categories['blocks'][ $block_filter ] ) ) {
289 $guideline_categories = array(
290 'blocks' => array(
291 $block_filter => $guideline_categories['blocks'][ $block_filter ],
292 ),
293 );
294 } else {
295 $guideline_categories = new stdClass();
296 }
297 } elseif ( $request->get_param( 'category' ) ) {
298 // Handle ?category filter.
299 $category_filter = $request->get_param( 'category' );
300 if ( isset( $guideline_categories[ $category_filter ] ) ) {
301 $guideline_categories = array(
302 $category_filter => $guideline_categories[ $category_filter ],
303 );
304 } else {
305 $guideline_categories = new stdClass();
306 }
307 }
308
309 if ( empty( $guideline_categories ) ) {
310 $guideline_categories = new stdClass();
311 }
312
313 $data['guideline_categories'] = $guideline_categories;
314 }
315
316 if ( rest_is_field_included( 'date', $fields ) ) {
317 $data['date'] = $this->prepare_date_response( $post->post_date_gmt, $post->post_date );
318 }
319
320 if ( rest_is_field_included( 'date_gmt', $fields ) ) {
321 $data['date_gmt'] = $this->prepare_date_response( $post->post_date_gmt );
322 }
323
324 if ( rest_is_field_included( 'modified', $fields ) ) {
325 $data['modified'] = $this->prepare_date_response( $post->post_modified_gmt, $post->post_modified );
326 }
327
328 if ( rest_is_field_included( 'modified_gmt', $fields ) ) {
329 $data['modified_gmt'] = $this->prepare_date_response( $post->post_modified_gmt );
330 }
331
332 if ( rest_is_field_included( 'author', $fields ) ) {
333 $data['author'] = (int) $post->post_author;
334 }
335
336 $context = ! empty( $request['context'] ) ? $request['context'] : 'view';
337 $data = $this->add_additional_fields_to_object( $data, $request );
338 $data = $this->filter_response_by_context( $data, $context );
339
340 $response = rest_ensure_response( $data );
341
342 if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) {
343 $response->add_links( $this->prepare_links( $post->ID ) );
344 }
345
346 return $response;
347 }
348
349 /**
350 * Prepares links for the request.
351 *
352 * Includes self, about, and version-history links.
353 *
354 * @param int $id Post ID.
355 * @return array Links for the given post.
356 */
357 protected function prepare_links( $id ) {
358 $base = sprintf( '%s/%s', $this->namespace, $this->rest_base );
359
360 $links = array(
361 'self' => array(
362 'href' => rest_url( trailingslashit( $base ) . $id ),
363 ),
364 'about' => array(
365 'href' => rest_url( 'wp/v2/types/' . $this->post_type ),
366 ),
367 );
368
369 if ( post_type_supports( $this->post_type, 'revisions' ) ) {
370 $revisions = wp_get_latest_revision_id_and_total_count( $id );
371 $revisions_count = ! is_wp_error( $revisions ) ? $revisions['count'] : 0;
372 $revisions_base = sprintf( '/%s/%d/revisions', $base, $id );
373
374 $links['version-history'] = array(
375 'href' => rest_url( $revisions_base ),
376 'count' => $revisions_count,
377 );
378 }
379
380 return $links;
381 }
382
383 /**
384 * Saves guideline categories to post meta.
385 *
386 * @param int $post_id Post ID.
387 * @param array $categories Sanitized guideline categories.
388 */
389 protected function save_guideline_categories_to_meta( $post_id, $categories ) {
390 // Save standard categories.
391 foreach ( Gutenberg_Content_Guidelines_Post_Type::CATEGORY_META_KEYS as $category ) {
392 if ( isset( $categories[ $category ] ) ) {
393 $meta_key = '_content_guideline_' . $category;
394 $value = $categories[ $category ]['guidelines'] ?? '';
395 update_post_meta( $post_id, $meta_key, $value );
396 }
397 }
398
399 // Handle block-specific guidelines as individual meta keys.
400 if ( isset( $categories['blocks'] ) && is_array( $categories['blocks'] ) ) {
401 foreach ( $categories['blocks'] as $block_name => $block_data ) {
402 $meta_key = Gutenberg_Content_Guidelines_Post_Type::block_name_to_meta_key( $block_name );
403 $value = $block_data['guidelines'] ?? '';
404
405 if ( ! empty( $value ) ) {
406 update_post_meta( $post_id, $meta_key, $value );
407 } else {
408 delete_post_meta( $post_id, $meta_key );
409 }
410 }
411 }
412 }
413
414 /**
415 * Sanitizes guideline categories data.
416 *
417 * @param mixed $categories Raw guideline categories from the request.
418 * @return array Sanitized guideline categories.
419 */
420 protected function sanitize_guideline_categories( $categories ) {
421 if ( ! is_array( $categories ) ) {
422 return array();
423 }
424
425 $valid_categories = Gutenberg_Content_Guidelines_Post_Type::VALID_CATEGORIES;
426 $sanitized = array_intersect_key( $categories, array_flip( $valid_categories ) );
427
428 foreach ( $sanitized as $key => &$category ) {
429 if ( ! is_array( $category ) ) {
430 unset( $sanitized[ $key ] );
431 continue;
432 }
433
434 if ( 'blocks' === $key ) {
435 $category = $this->sanitize_blocks_category( $category );
436 } else {
437 $category = $this->sanitize_standard_category( $category );
438 }
439 }
440 unset( $category );
441
442 return $sanitized;
443 }
444
445 /**
446 * Sanitizes a standard (non-blocks) guideline category.
447 *
448 * @param array $category Raw category data.
449 * @return array Sanitized category data.
450 */
451 private function sanitize_standard_category( $category ) {
452 $sanitized = array_intersect_key( $category, array_flip( array( 'label', 'guidelines' ) ) );
453
454 foreach ( $sanitized as $key => &$value ) {
455 $value = is_string( $value ) ? sanitize_textarea_field( $value ) : '';
456 $max = 'label' === $key ? self::MAX_LABEL_LENGTH : self::MAX_GUIDELINE_LENGTH;
457 if ( mb_strlen( $value, 'UTF-8' ) > $max ) {
458 $value = mb_substr( $value, 0, $max, 'UTF-8' );
459 }
460 }
461 unset( $value );
462
463 return $sanitized;
464 }
465
466 /**
467 * Sanitizes the blocks guideline category.
468 *
469 * @param array $blocks Raw blocks category data.
470 * @return array Sanitized blocks category data.
471 */
472 private function sanitize_blocks_category( $blocks ) {
473 $sanitized = array();
474
475 foreach ( $blocks as $block_name => $block_data ) {
476 // Matches the block name validation in WP_Block_Type_Registry::register().
477 if ( ! is_string( $block_name ) || ! preg_match( '/^[a-z0-9-]+\/[a-z0-9-]+$/', $block_name ) ) {
478 continue;
479 }
480
481 if ( ! is_array( $block_data ) ) {
482 continue;
483 }
484
485 $sanitized_block = array_intersect_key( $block_data, array_flip( array( 'guidelines' ) ) );
486
487 if ( isset( $sanitized_block['guidelines'] ) ) {
488 $sanitized_block['guidelines'] = is_string( $sanitized_block['guidelines'] )
489 ? sanitize_textarea_field( $sanitized_block['guidelines'] )
490 : '';
491 if ( mb_strlen( $sanitized_block['guidelines'], 'UTF-8' ) > self::MAX_GUIDELINE_LENGTH ) {
492 $sanitized_block['guidelines'] = mb_substr( $sanitized_block['guidelines'], 0, self::MAX_GUIDELINE_LENGTH, 'UTF-8' );
493 }
494 }
495
496 $sanitized[ $block_name ] = $sanitized_block;
497 }
498
499 return $sanitized;
500 }
501
502 /**
503 * Gets the single guidelines post.
504 *
505 * @param string|null $status_filter Optional. Filter by status ('publish' or 'draft').
506 * @return WP_Post|null The guidelines post or null if not found.
507 */
508 protected function get_guidelines_post( $status_filter = null ) {
509 $post_status = array( 'publish', 'draft' );
510
511 if ( $status_filter ) {
512 $post_status = $status_filter;
513 }
514
515 $posts = get_posts(
516 array(
517 'post_type' => $this->post_type,
518 'post_status' => $post_status,
519 'posts_per_page' => 1,
520 'orderby' => 'date',
521 'order' => 'DESC',
522 'no_found_rows' => true,
523 )
524 );
525
526 return ! empty( $posts ) ? $posts[0] : null;
527 }
528
529 /**
530 * Retrieves the guidelines schema, conforming to JSON Schema.
531 *
532 * @return array Item schema data.
533 */
534 public function get_item_schema() {
535 if ( $this->schema ) {
536 return $this->add_additional_fields_schema( $this->schema );
537 }
538
539 $this->schema = array(
540 '$schema' => 'http://json-schema.org/draft-04/schema#',
541 'title' => 'content-guidelines',
542 'type' => 'object',
543 'properties' => array(
544 'id' => array(
545 'description' => __( 'Unique identifier for the guidelines.', 'gutenberg' ),
546 'type' => 'integer',
547 'context' => array( 'view', 'edit' ),
548 'readonly' => true,
549 ),
550 'status' => array(
551 'description' => __( 'The status of the guidelines (draft or publish).', 'gutenberg' ),
552 'type' => 'string',
553 'enum' => Gutenberg_Content_Guidelines_Post_Type::VALID_STATUSES,
554 'context' => array( 'view', 'edit' ),
555 ),
556 'guideline_categories' => array(
557 'description' => __( 'The guideline categories and their content.', 'gutenberg' ),
558 'type' => 'object',
559 'context' => array( 'view', 'edit' ),
560 'arg_options' => array(
561 'validate_callback' => static function ( $value ) {
562 if ( ! is_array( $value ) && ! is_object( $value ) ) {
563 return new WP_Error(
564 'rest_invalid_param',
565 __( 'guideline_categories must be a JSON object.', 'gutenberg' ),
566 array( 'status' => 400 )
567 );
568 }
569 return true;
570 },
571 'sanitize_callback' => static function ( $value ) {
572 return (array) $value;
573 },
574 ),
575 'properties' => array(
576 'copy' => array(
577 'type' => 'object',
578 'properties' => array(
579 'label' => array(
580 'type' => 'string',
581 'maxLength' => self::MAX_LABEL_LENGTH,
582 ),
583 'guidelines' => array(
584 'type' => 'string',
585 'maxLength' => self::MAX_GUIDELINE_LENGTH,
586 ),
587 ),
588 ),
589 'images' => array(
590 'type' => 'object',
591 'properties' => array(
592 'label' => array(
593 'type' => 'string',
594 'maxLength' => self::MAX_LABEL_LENGTH,
595 ),
596 'guidelines' => array(
597 'type' => 'string',
598 'maxLength' => self::MAX_GUIDELINE_LENGTH,
599 ),
600 ),
601 ),
602 'site' => array(
603 'type' => 'object',
604 'properties' => array(
605 'label' => array(
606 'type' => 'string',
607 'maxLength' => self::MAX_LABEL_LENGTH,
608 ),
609 'guidelines' => array(
610 'type' => 'string',
611 'maxLength' => self::MAX_GUIDELINE_LENGTH,
612 ),
613 ),
614 ),
615 'blocks' => array(
616 'type' => 'object',
617 'additionalProperties' => array(
618 'type' => 'object',
619 'properties' => array(
620 'guidelines' => array(
621 'type' => 'string',
622 'maxLength' => self::MAX_GUIDELINE_LENGTH,
623 ),
624 ),
625 ),
626 ),
627 'additional' => array(
628 'type' => 'object',
629 'properties' => array(
630 'label' => array(
631 'type' => 'string',
632 'maxLength' => self::MAX_LABEL_LENGTH,
633 ),
634 'guidelines' => array(
635 'type' => 'string',
636 'maxLength' => self::MAX_GUIDELINE_LENGTH,
637 ),
638 ),
639 ),
640 ),
641 ),
642 'date' => array(
643 'description' => __( 'The date the guidelines were created, in the site\'s timezone.', 'gutenberg' ),
644 'type' => 'string',
645 'format' => 'date-time',
646 'context' => array( 'view', 'edit' ),
647 'readonly' => true,
648 ),
649 'date_gmt' => array(
650 'description' => __( 'The date the guidelines were created, as GMT.', 'gutenberg' ),
651 'type' => 'string',
652 'format' => 'date-time',
653 'context' => array( 'view', 'edit' ),
654 'readonly' => true,
655 ),
656 'modified' => array(
657 'description' => __( 'The date the guidelines were last modified, in the site\'s timezone.', 'gutenberg' ),
658 'type' => 'string',
659 'format' => 'date-time',
660 'context' => array( 'view', 'edit' ),
661 'readonly' => true,
662 ),
663 'modified_gmt' => array(
664 'description' => __( 'The date the guidelines were last modified, as GMT.', 'gutenberg' ),
665 'type' => 'string',
666 'format' => 'date-time',
667 'context' => array( 'view', 'edit' ),
668 'readonly' => true,
669 ),
670 'author' => array(
671 'description' => __( 'The ID of the author of the guidelines.', 'gutenberg' ),
672 'type' => 'integer',
673 'context' => array( 'view', 'edit' ),
674 'readonly' => true,
675 ),
676 ),
677 );
678
679 return $this->add_additional_fields_schema( $this->schema );
680 }
681 }
682