| [ Index ] |
PHP Cross Reference of WordPress Trunk (Updated Daily) |
[Summary view] [Print] [Text view]
1 <?php 2 /** 3 * REST API: WP_REST_Comments_Controller class 4 * 5 * @package WordPress 6 * @subpackage REST_API 7 * @since 4.7.0 8 */ 9 10 /** 11 * Core controller used to access comments via the REST API. 12 * 13 * @since 4.7.0 14 * 15 * @see WP_REST_Controller 16 */ 17 class WP_REST_Comments_Controller extends WP_REST_Controller { 18 19 /** 20 * Instance of a comment meta fields object. 21 * 22 * @since 4.7.0 23 * @var WP_REST_Comment_Meta_Fields 24 */ 25 protected $meta; 26 27 /** 28 * Pre-fetched reaction summaries keyed by note comment ID. 29 * 30 * Populated by get_items() to avoid N+1 queries when listing notes 31 * with their reaction summaries. Reset after each get_items() call. 32 * 33 * @since 7.2.0 34 * @var array<int, array<string, array{count: int, current_user_reaction: int}>>|null 35 */ 36 protected $reaction_summaries = null; 37 38 /** 39 * Retrieves the hex keys of the emoji a note reaction accepts. 40 * 41 * Each key is the emoji's lowercase code points, padded to four digits, 42 * matching the client's `emojiToHexKey()`. A reaction stores its key in 43 * `comment_content`. 44 * 45 * @since 7.2.0 46 * 47 * @return string[] Hex keys for heart, celebration, smile, eyes and rocket. 48 * 49 * @phpstan-return non-empty-list<lowercase-string&non-falsy-string> 50 */ 51 private static function get_note_reaction_keys(): array { 52 return array( '2764', '1f389', '1f604', '1f440', '1f680' ); 53 } 54 55 /** 56 * Constructor. 57 * 58 * @since 4.7.0 59 */ 60 public function __construct() { 61 $this->namespace = 'wp/v2'; 62 $this->rest_base = 'comments'; 63 64 $this->meta = new WP_REST_Comment_Meta_Fields(); 65 } 66 67 /** 68 * Registers the routes for comments. 69 * 70 * @since 4.7.0 71 * 72 * @see register_rest_route() 73 */ 74 public function register_routes() { 75 76 register_rest_route( 77 $this->namespace, 78 '/' . $this->rest_base, 79 array( 80 array( 81 'methods' => WP_REST_Server::READABLE, 82 'callback' => array( $this, 'get_items' ), 83 'permission_callback' => array( $this, 'get_items_permissions_check' ), 84 'args' => $this->get_collection_params(), 85 ), 86 array( 87 'methods' => WP_REST_Server::CREATABLE, 88 'callback' => array( $this, 'create_item' ), 89 'permission_callback' => array( $this, 'create_item_permissions_check' ), 90 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ), 91 ), 92 'schema' => array( $this, 'get_public_item_schema' ), 93 ) 94 ); 95 96 register_rest_route( 97 $this->namespace, 98 '/' . $this->rest_base . '/(?P<id>[\d]+)', 99 array( 100 'args' => array( 101 'id' => array( 102 'description' => __( 'Unique identifier for the comment.' ), 103 'type' => 'integer', 104 ), 105 ), 106 array( 107 'methods' => WP_REST_Server::READABLE, 108 'callback' => array( $this, 'get_item' ), 109 'permission_callback' => array( $this, 'get_item_permissions_check' ), 110 'args' => array( 111 'context' => $this->get_context_param( array( 'default' => 'view' ) ), 112 'password' => array( 113 'description' => __( 'The password for the parent post of the comment (if the post is password protected).' ), 114 'type' => 'string', 115 ), 116 ), 117 ), 118 array( 119 'methods' => WP_REST_Server::EDITABLE, 120 'callback' => array( $this, 'update_item' ), 121 'permission_callback' => array( $this, 'update_item_permissions_check' ), 122 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ), 123 ), 124 array( 125 'methods' => WP_REST_Server::DELETABLE, 126 'callback' => array( $this, 'delete_item' ), 127 'permission_callback' => array( $this, 'delete_item_permissions_check' ), 128 'args' => array( 129 'force' => array( 130 'type' => 'boolean', 131 'default' => false, 132 'description' => __( 'Whether to bypass Trash and force deletion.' ), 133 ), 134 'password' => array( 135 'description' => __( 'The password for the parent post of the comment (if the post is password protected).' ), 136 'type' => 'string', 137 ), 138 ), 139 ), 140 'schema' => array( $this, 'get_public_item_schema' ), 141 ) 142 ); 143 } 144 145 /** 146 * Checks if a given request has access to read comments. 147 * 148 * @since 4.7.0 149 * 150 * @param WP_REST_Request $request Full details about the request. 151 * @return true|WP_Error True if the request has read access, error object otherwise. 152 */ 153 public function get_items_permissions_check( $request ) { 154 $is_note = in_array( $request['type'], array( 'note', 'reaction' ), true ); 155 $is_edit_context = 'edit' === $request['context']; 156 $protected_params = array( 'author', 'author_exclude', 'author_email', 'type', 'status' ); 157 $forbidden_params = array(); 158 159 if ( ! empty( $request['post'] ) ) { 160 foreach ( (array) $request['post'] as $post_id ) { 161 $post = get_post( $post_id ); 162 163 if ( ! empty( $post_id ) && $post && ! $this->check_read_post_permission( $post, $request ) ) { 164 return new WP_Error( 165 'rest_cannot_read_post', 166 __( 'Sorry, you are not allowed to read the post for this comment.' ), 167 array( 'status' => rest_authorization_required_code() ) 168 ); 169 } elseif ( 0 === $post_id && ! current_user_can( 'moderate_comments' ) ) { 170 return new WP_Error( 171 'rest_cannot_read', 172 __( 'Sorry, you are not allowed to read comments without a post.' ), 173 array( 'status' => rest_authorization_required_code() ) 174 ); 175 } 176 177 if ( $post && $is_note && ! $this->check_post_type_supports_notes( $post->post_type ) ) { 178 if ( current_user_can( 'edit_post', $post->ID ) ) { 179 return new WP_Error( 180 'rest_comment_not_supported_post_type', 181 __( 'Sorry, this post type does not support notes.' ), 182 array( 'status' => 403 ) 183 ); 184 } 185 186 foreach ( $protected_params as $param ) { 187 if ( 'status' === $param ) { 188 if ( array( 'approve' ) !== $request[ $param ] ) { 189 $forbidden_params[] = $param; 190 } 191 } elseif ( 'type' === $param ) { 192 if ( 'comment' !== $request[ $param ] ) { 193 $forbidden_params[] = $param; 194 } 195 } elseif ( ! empty( $request[ $param ] ) ) { 196 $forbidden_params[] = $param; 197 } 198 } 199 return new WP_Error( 200 'rest_forbidden_param', 201 /* translators: %s: List of forbidden parameters. */ 202 sprintf( __( 'Query parameter not permitted: %s' ), implode( ', ', $forbidden_params ) ), 203 array( 'status' => rest_authorization_required_code() ) 204 ); 205 } 206 } 207 } 208 209 // Re-map edit context capabilities when requesting `note` for a post. 210 if ( $is_edit_context && $is_note && ! empty( $request['post'] ) ) { 211 foreach ( (array) $request['post'] as $post_id ) { 212 if ( ! current_user_can( 'edit_post', $post_id ) ) { 213 return new WP_Error( 214 'rest_forbidden_context', 215 __( 'Sorry, you are not allowed to edit comments.' ), 216 array( 'status' => rest_authorization_required_code() ) 217 ); 218 } 219 } 220 } elseif ( $is_edit_context && ! current_user_can( 'moderate_comments' ) ) { 221 return new WP_Error( 222 'rest_forbidden_context', 223 __( 'Sorry, you are not allowed to edit comments.' ), 224 array( 'status' => rest_authorization_required_code() ) 225 ); 226 } 227 228 if ( ! current_user_can( 'edit_posts' ) ) { 229 foreach ( $protected_params as $param ) { 230 if ( 'status' === $param ) { 231 if ( array( 'approve' ) !== $request[ $param ] ) { 232 $forbidden_params[] = $param; 233 } 234 } elseif ( 'type' === $param ) { 235 if ( 'comment' !== $request[ $param ] ) { 236 $forbidden_params[] = $param; 237 } 238 } elseif ( ! empty( $request[ $param ] ) ) { 239 $forbidden_params[] = $param; 240 } 241 } 242 243 if ( ! empty( $forbidden_params ) ) { 244 return new WP_Error( 245 'rest_forbidden_param', 246 /* translators: %s: List of forbidden parameters. */ 247 sprintf( __( 'Query parameter not permitted: %s' ), implode( ', ', $forbidden_params ) ), 248 array( 'status' => rest_authorization_required_code() ) 249 ); 250 } 251 } 252 253 return true; 254 } 255 256 /** 257 * Retrieves a list of comment items. 258 * 259 * @since 4.7.0 260 * 261 * @param WP_REST_Request $request Full details about the request. 262 * @return WP_REST_Response|WP_Error Response object on success, or error object on failure. 263 */ 264 public function get_items( $request ) { 265 266 // Retrieve the list of registered collection query parameters. 267 $registered = $this->get_collection_params(); 268 269 /* 270 * This array defines mappings between public API query parameters whose 271 * values are accepted as-passed, and their internal WP_Query parameter 272 * name equivalents (some are the same). Only values which are also 273 * present in $registered will be set. 274 */ 275 $parameter_mappings = array( 276 'author' => 'author__in', 277 'author_email' => 'author_email', 278 'author_exclude' => 'author__not_in', 279 'exclude' => 'comment__not_in', 280 'include' => 'comment__in', 281 'offset' => 'offset', 282 'order' => 'order', 283 'parent' => 'parent__in', 284 'parent_exclude' => 'parent__not_in', 285 'per_page' => 'number', 286 'post' => 'post__in', 287 'search' => 'search', 288 'status' => 'status', 289 'type' => 'type', 290 ); 291 292 $prepared_args = array(); 293 294 /* 295 * For each known parameter which is both registered and present in the request, 296 * set the parameter's value on the query $prepared_args. 297 */ 298 foreach ( $parameter_mappings as $api_param => $wp_param ) { 299 if ( isset( $registered[ $api_param ], $request[ $api_param ] ) ) { 300 $prepared_args[ $wp_param ] = $request[ $api_param ]; 301 } 302 } 303 304 // Ensure certain parameter values default to empty strings. 305 foreach ( array( 'author_email', 'search' ) as $param ) { 306 if ( ! isset( $prepared_args[ $param ] ) ) { 307 $prepared_args[ $param ] = ''; 308 } 309 } 310 311 if ( isset( $registered['orderby'] ) ) { 312 $prepared_args['orderby'] = $this->normalize_query_param( $request['orderby'] ); 313 } 314 315 $prepared_args['no_found_rows'] = false; 316 317 $prepared_args['update_comment_post_cache'] = true; 318 319 $prepared_args['date_query'] = array(); 320 321 // Set before into date query. Date query must be specified as an array of an array. 322 if ( isset( $registered['before'], $request['before'] ) ) { 323 $prepared_args['date_query'][0]['before'] = $request['before']; 324 } 325 326 // Set after into date query. Date query must be specified as an array of an array. 327 if ( isset( $registered['after'], $request['after'] ) ) { 328 $prepared_args['date_query'][0]['after'] = $request['after']; 329 } 330 331 if ( isset( $registered['page'] ) && empty( $request['offset'] ) ) { 332 $prepared_args['offset'] = $prepared_args['number'] * ( absint( $request['page'] ) - 1 ); 333 } 334 335 $is_head_request = $request->is_method( 'HEAD' ); 336 if ( $is_head_request ) { 337 // Force the 'fields' argument. For HEAD requests, only post IDs are required to calculate pagination. 338 $prepared_args['fields'] = 'ids'; 339 // Disable priming comment meta for HEAD requests to improve performance. 340 $prepared_args['update_comment_meta_cache'] = false; 341 } 342 343 /** 344 * Filters WP_Comment_Query arguments when querying comments via the REST API. 345 * 346 * @since 4.7.0 347 * 348 * @link https://developer.wordpress.org/reference/classes/wp_comment_query/ 349 * 350 * @param array $prepared_args Array of arguments for WP_Comment_Query. 351 * @param WP_REST_Request $request The REST API request. 352 */ 353 $prepared_args = apply_filters( 'rest_comment_query', $prepared_args, $request ); 354 355 $query = new WP_Comment_Query(); 356 $query_result = $query->query( $prepared_args ); 357 $comments = array(); 358 359 if ( ! $is_head_request ) { 360 /* 361 * When listing notes that include the reaction_summary field, 362 * pre-fetch all summaries in a single aggregated query to 363 * avoid an N+1 query in prepare_item_for_response(). 364 */ 365 $fields = $this->get_fields_for_response( $request ); 366 if ( 367 ! empty( $request['type'] ) && 368 'note' === $request['type'] && 369 rest_is_field_included( 'reaction_summary', $fields ) 370 ) { 371 $note_ids = array_map( 'intval', wp_list_pluck( $query_result, 'comment_ID' ) ); 372 if ( ! empty( $note_ids ) ) { 373 $this->prefetch_reaction_summaries( $note_ids ); 374 } 375 } 376 377 foreach ( $query_result as $comment ) { 378 if ( ! $this->check_read_permission( $comment, $request ) ) { 379 continue; 380 } 381 382 $data = $this->prepare_item_for_response( $comment, $request ); 383 $comments[] = $this->prepare_response_for_collection( $data ); 384 } 385 386 $this->reaction_summaries = null; 387 } 388 389 $total_comments = (int) $query->found_comments; 390 $max_pages = (int) $query->max_num_pages; 391 392 if ( $total_comments < 1 ) { 393 // Out-of-bounds, run the query without pagination/offset to get the total count. 394 unset( $prepared_args['number'], $prepared_args['offset'] ); 395 396 $query = new WP_Comment_Query(); 397 $prepared_args['count'] = true; 398 $prepared_args['orderby'] = 'none'; 399 $prepared_args['update_comment_meta_cache'] = false; 400 401 $total_comments = $query->query( $prepared_args ); 402 $max_pages = (int) ceil( $total_comments / $request['per_page'] ); 403 } 404 405 $response = $is_head_request ? new WP_REST_Response( array() ) : rest_ensure_response( $comments ); 406 $response->header( 'X-WP-Total', (string) $total_comments ); 407 $response->header( 'X-WP-TotalPages', (string) $max_pages ); 408 409 $base = add_query_arg( urlencode_deep( $request->get_query_params() ), rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ) ); 410 411 if ( $request['page'] > 1 ) { 412 $prev_page = $request['page'] - 1; 413 414 if ( $prev_page > $max_pages ) { 415 $prev_page = $max_pages; 416 } 417 418 $prev_link = add_query_arg( 'page', $prev_page, $base ); 419 $response->link_header( 'prev', $prev_link ); 420 } 421 422 if ( $max_pages > $request['page'] ) { 423 $next_page = $request['page'] + 1; 424 $next_link = add_query_arg( 'page', $next_page, $base ); 425 426 $response->link_header( 'next', $next_link ); 427 } 428 429 return $response; 430 } 431 432 /** 433 * Get the comment, if the ID is valid. 434 * 435 * @since 4.7.2 436 * 437 * @param int $id Supplied ID. 438 * @return WP_Comment|WP_Error Comment object if ID is valid, WP_Error otherwise. 439 */ 440 protected function get_comment( $id ) { 441 $error = new WP_Error( 442 'rest_comment_invalid_id', 443 __( 'Invalid comment ID.' ), 444 array( 'status' => 404 ) 445 ); 446 447 if ( (int) $id <= 0 ) { 448 return $error; 449 } 450 451 $id = (int) $id; 452 $comment = get_comment( $id ); 453 if ( empty( $comment ) ) { 454 return $error; 455 } 456 457 if ( ! empty( $comment->comment_post_ID ) ) { 458 $post = get_post( (int) $comment->comment_post_ID ); 459 460 if ( empty( $post ) ) { 461 return new WP_Error( 462 'rest_post_invalid_id', 463 __( 'Invalid post ID.' ), 464 array( 'status' => 404 ) 465 ); 466 } 467 } 468 469 return $comment; 470 } 471 472 /** 473 * Checks if a given request has access to read the comment. 474 * 475 * @since 4.7.0 476 * 477 * @param WP_REST_Request $request Full details about the request. 478 * @return true|WP_Error True if the request has read access for the item, error object otherwise. 479 */ 480 public function get_item_permissions_check( $request ) { 481 $comment = $this->get_comment( $request['id'] ); 482 if ( is_wp_error( $comment ) ) { 483 return $comment; 484 } 485 486 // Re-map edit context capabilities when requesting `note` or `reaction` type. 487 $edit_cap = in_array( $comment->comment_type, array( 'note', 'reaction' ), true ) ? array( 'edit_comment', $comment->comment_ID ) : array( 'moderate_comments' ); 488 if ( ! empty( $request['context'] ) && 'edit' === $request['context'] && ! current_user_can( ...$edit_cap ) ) { 489 return new WP_Error( 490 'rest_forbidden_context', 491 __( 'Sorry, you are not allowed to edit comments.' ), 492 array( 'status' => rest_authorization_required_code() ) 493 ); 494 } 495 496 $post = get_post( $comment->comment_post_ID ); 497 498 if ( ! $this->check_read_permission( $comment, $request ) ) { 499 return new WP_Error( 500 'rest_cannot_read', 501 __( 'Sorry, you are not allowed to read this comment.' ), 502 array( 'status' => rest_authorization_required_code() ) 503 ); 504 } 505 506 if ( $post && ! $this->check_read_post_permission( $post, $request ) ) { 507 return new WP_Error( 508 'rest_cannot_read_post', 509 __( 'Sorry, you are not allowed to read the post for this comment.' ), 510 array( 'status' => rest_authorization_required_code() ) 511 ); 512 } 513 514 return true; 515 } 516 517 /** 518 * Retrieves a comment. 519 * 520 * @since 4.7.0 521 * 522 * @param WP_REST_Request $request Full details about the request. 523 * @return WP_REST_Response|WP_Error Response object on success, or error object on failure. 524 */ 525 public function get_item( $request ) { 526 $comment = $this->get_comment( $request['id'] ); 527 if ( is_wp_error( $comment ) ) { 528 return $comment; 529 } 530 531 $data = $this->prepare_item_for_response( $comment, $request ); 532 $response = rest_ensure_response( $data ); 533 534 return $response; 535 } 536 537 /** 538 * Checks if a given request has access to create a comment. 539 * 540 * @since 4.7.0 541 * 542 * @param WP_REST_Request $request Full details about the request. 543 * @return true|WP_Error True if the request has access to create items, error object otherwise. 544 */ 545 public function create_item_permissions_check( $request ) { 546 $is_note = ! empty( $request['type'] ) && in_array( $request['type'], array( 'note', 'reaction' ), true ); 547 548 if ( ! is_user_logged_in() && $is_note ) { 549 return new WP_Error( 550 'rest_comment_login_required', 551 __( 'Sorry, you must be logged in to comment.' ), 552 array( 'status' => 401 ) 553 ); 554 } 555 556 if ( ! is_user_logged_in() ) { 557 if ( get_option( 'comment_registration' ) ) { 558 return new WP_Error( 559 'rest_comment_login_required', 560 __( 'Sorry, you must be logged in to comment.' ), 561 array( 'status' => 401 ) 562 ); 563 } 564 565 /** 566 * Filters whether comments can be created via the REST API without authentication. 567 * 568 * Enables creating comments for anonymous users. 569 * 570 * @since 4.7.0 571 * 572 * @param bool $allow_anonymous Whether to allow anonymous comments to 573 * be created. Default `false`. 574 * @param WP_REST_Request $request Request used to generate the 575 * response. 576 */ 577 $allow_anonymous = apply_filters( 'rest_allow_anonymous_comments', false, $request ); 578 579 if ( ! $allow_anonymous ) { 580 return new WP_Error( 581 'rest_comment_login_required', 582 __( 'Sorry, you must be logged in to comment.' ), 583 array( 'status' => 401 ) 584 ); 585 } 586 } 587 588 // Limit who can set comment `author`, `author_ip` or `status` to anything other than the default. 589 if ( isset( $request['author'] ) && get_current_user_id() !== $request['author'] && ! current_user_can( 'moderate_comments' ) ) { 590 return new WP_Error( 591 'rest_comment_invalid_author', 592 /* translators: %s: Request parameter. */ 593 sprintf( __( "Sorry, you are not allowed to edit '%s' for comments." ), 'author' ), 594 array( 'status' => rest_authorization_required_code() ) 595 ); 596 } 597 598 /* 599 * A reaction is always first-person. create_item() enforces one emoji per 600 * user per note against the current user, and update_item() refuses to 601 * reattribute one, so a reaction stored against somebody else would be a 602 * row the uniqueness check and the reaction summary can never see. 603 */ 604 if ( 605 ! empty( $request['type'] ) && 'reaction' === $request['type'] && 606 isset( $request['author'] ) && get_current_user_id() !== (int) $request['author'] 607 ) { 608 return new WP_Error( 609 'rest_comment_invalid_author', 610 __( 'Sorry, you are not allowed to add a reaction on behalf of another user.' ), 611 array( 'status' => rest_authorization_required_code() ) 612 ); 613 } 614 615 if ( isset( $request['author_ip'] ) && ! current_user_can( 'moderate_comments' ) ) { 616 if ( empty( $_SERVER['REMOTE_ADDR'] ) || $request['author_ip'] !== $_SERVER['REMOTE_ADDR'] ) { 617 return new WP_Error( 618 'rest_comment_invalid_author_ip', 619 /* translators: %s: Request parameter. */ 620 sprintf( __( "Sorry, you are not allowed to edit '%s' for comments." ), 'author_ip' ), 621 array( 'status' => rest_authorization_required_code() ) 622 ); 623 } 624 } 625 626 if ( $is_note && ! empty( $request['post'] ) ) { 627 $target_check = $this->check_target_post_permission( (int) $request['post'], $request, true ); 628 629 if ( is_wp_error( $target_check ) ) { 630 return $target_check; 631 } 632 } 633 634 if ( empty( $request['post'] ) ) { 635 return new WP_Error( 636 'rest_comment_invalid_post_id', 637 __( 'Sorry, you are not allowed to create this comment without a post.' ), 638 array( 'status' => 403 ) 639 ); 640 } 641 642 $edit_cap = $is_note ? array( 'edit_post', (int) $request['post'] ) : array( 'moderate_comments' ); 643 if ( isset( $request['status'] ) && ! current_user_can( ...$edit_cap ) ) { 644 return new WP_Error( 645 'rest_comment_invalid_status', 646 /* translators: %s: Request parameter. */ 647 sprintf( __( "Sorry, you are not allowed to edit '%s' for comments." ), 'status' ), 648 array( 'status' => rest_authorization_required_code() ) 649 ); 650 } 651 652 /* 653 * A note's target post was fully checked by check_target_post_permission() 654 * above. Everything below applies to other comments only: a note is allowed 655 * on a draft and on a post whose discussion is closed, and the rest would 656 * repeat what that check already did. 657 */ 658 if ( $is_note ) { 659 return true; 660 } 661 662 $post = get_post( (int) $request['post'] ); 663 664 if ( ! $post ) { 665 return new WP_Error( 666 'rest_comment_invalid_post_id', 667 __( 'Sorry, you are not allowed to create this comment without a post.' ), 668 array( 'status' => 403 ) 669 ); 670 } 671 672 if ( 'draft' === $post->post_status ) { 673 return new WP_Error( 674 'rest_comment_draft_post', 675 __( 'Sorry, you are not allowed to create a comment on this post.' ), 676 array( 'status' => 403 ) 677 ); 678 } 679 680 if ( 'trash' === $post->post_status ) { 681 return new WP_Error( 682 'rest_comment_trash_post', 683 __( 'Sorry, you are not allowed to create a comment on this post.' ), 684 array( 'status' => 403 ) 685 ); 686 } 687 688 if ( ! $this->check_read_post_permission( $post, $request ) ) { 689 return new WP_Error( 690 'rest_cannot_read_post', 691 __( 'Sorry, you are not allowed to read the post for this comment.' ), 692 array( 'status' => rest_authorization_required_code() ) 693 ); 694 } 695 696 if ( ! comments_open( $post->ID ) ) { 697 return new WP_Error( 698 'rest_comment_closed', 699 __( 'Sorry, comments are closed for this item.' ), 700 array( 'status' => 403 ) 701 ); 702 } 703 704 return true; 705 } 706 707 /** 708 * Creates a comment. 709 * 710 * @since 4.7.0 711 * @since 7.2.0 Added support for the `reaction` comment type. 712 * 713 * @param WP_REST_Request $request Full details about the request. 714 * @return WP_REST_Response|WP_Error Response object on success, or error object on failure. 715 */ 716 public function create_item( $request ) { 717 if ( ! empty( $request['id'] ) ) { 718 return new WP_Error( 719 'rest_comment_exists', 720 __( 'Cannot create existing comment.' ), 721 array( 'status' => 400 ) 722 ); 723 } 724 725 // Do not allow comments to be created with a non-core type. 726 if ( ! empty( $request['type'] ) && ! in_array( $request['type'], array( 'comment', 'note', 'reaction' ), true ) ) { 727 return new WP_Error( 728 'rest_invalid_comment_type', 729 __( 'Cannot create a comment with that type.' ), 730 array( 'status' => 400 ) 731 ); 732 } 733 734 /* 735 * The canonical reaction key, populated once validated below so the 736 * stored content matches what was validated (not the raw input). 737 */ 738 $reaction_key = null; 739 740 // Validate reaction-specific constraints. 741 if ( ! empty( $request['type'] ) && 'reaction' === $request['type'] ) { 742 // Reaction parent must be specified. 743 if ( empty( $request['parent'] ) ) { 744 return new WP_Error( 745 'rest_comment_invalid_parent', 746 __( 'A reaction must have a parent note.' ), 747 array( 'status' => 400 ) 748 ); 749 } 750 751 // Reaction parent must exist and be a note. 752 $parent_comment = get_comment( $request['parent'] ); 753 if ( ! $parent_comment || 'note' !== $parent_comment->comment_type ) { 754 return new WP_Error( 755 'rest_comment_invalid_parent', 756 __( 'A reaction must be attached to a note.' ), 757 array( 'status' => 400 ) 758 ); 759 } 760 761 /* 762 * The parent note must belong to the post the reaction targets. 763 * create_item_permissions_check() requires `post` and checks that the 764 * user can edit it, so this runs before the parent's status checks: 765 * a note on another post gets the same error whatever its status, 766 * rather than revealing whether it is trashed, spammed or resolved. 767 */ 768 if ( ! empty( $request['post'] ) && (int) $parent_comment->comment_post_ID !== (int) $request['post'] ) { 769 return new WP_Error( 770 'rest_comment_invalid_parent', 771 __( 'A reaction must be attached to a note on the same post.' ), 772 array( 'status' => 400 ) 773 ); 774 } 775 776 // A reaction under a trashed or spammed note would escape the trash cascade. 777 if ( in_array( $parent_comment->comment_approved, array( 'trash', 'spam' ), true ) ) { 778 return new WP_Error( 779 'rest_comment_invalid_parent', 780 __( 'A reaction cannot be added to a trashed or spam note.' ), 781 array( 'status' => 400 ) 782 ); 783 } 784 785 /* 786 * Resolving a thread approves its root note, and the editor disables 787 * reactions from then on. Hold requests from stale editor sessions 788 * to that too, for the root note and for every reply in its thread. 789 */ 790 $thread_root = $parent_comment; 791 $visited = array( (int) $thread_root->comment_ID => true ); 792 while ( $thread_root->comment_parent ) { 793 $ancestor = get_comment( $thread_root->comment_parent ); 794 795 // Stop at a missing ancestor or a corrupt, cyclic chain. 796 if ( ! $ancestor || isset( $visited[ (int) $ancestor->comment_ID ] ) ) { 797 break; 798 } 799 800 $visited[ (int) $ancestor->comment_ID ] = true; 801 $thread_root = $ancestor; 802 } 803 804 if ( '1' === $thread_root->comment_approved ) { 805 return new WP_Error( 806 'rest_comment_invalid_parent', 807 __( 'A reaction cannot be added to a resolved note.' ), 808 array( 'status' => 400 ) 809 ); 810 } 811 812 /* 813 * Validate the reaction content: the hex key of one of the curated 814 * reaction emoji, as listed by self::get_note_reaction_keys() (e.g. 815 * `2764` for the heart). Raw emoji bytes are rejected because the 816 * comments table is not guaranteed to be utf8mb4 across all WordPress 817 * installs; clients are expected to normalize before submitting. 818 * 819 * Read the content the same two ways prepare_item_for_database() 820 * does, so `content` and `content.raw` are both accepted. 821 */ 822 $raw_content = ''; 823 if ( isset( $request['content'] ) && is_string( $request['content'] ) ) { 824 $raw_content = $request['content']; 825 } elseif ( isset( $request['content']['raw'] ) && is_string( $request['content']['raw'] ) ) { 826 $raw_content = $request['content']['raw']; 827 } 828 829 $emoji_key = trim( wp_strip_all_tags( $raw_content ) ); 830 831 if ( ! in_array( $emoji_key, self::get_note_reaction_keys(), true ) ) { 832 return new WP_Error( 833 'rest_comment_invalid_reaction', 834 __( 'Invalid reaction emoji.' ), 835 array( 'status' => 400 ) 836 ); 837 } 838 839 /* 840 * A reaction is always approved. The uniqueness check, the race 841 * cleanup below and the reaction summary only see approved rows, so 842 * a reaction created in any other status could never be counted, 843 * deduplicated or, since reactions cannot be updated, fixed. 844 */ 845 if ( isset( $request['status'] ) && ! in_array( $request['status'], array( 'approve', 'approved', '1' ), true ) ) { 846 return new WP_Error( 847 'rest_comment_invalid_status', 848 __( 'A reaction cannot be created with that status.' ), 849 array( 'status' => 400 ) 850 ); 851 } 852 853 /* 854 * Enforce uniqueness: one emoji per user per note. 855 * 856 * Scope to active (approved) reactions only — trashed reactions 857 * are invisible to the user and must not block re-adding the 858 * same emoji. 859 */ 860 $existing = get_comments( 861 array( 862 'parent' => $request['parent'], 863 'user_id' => get_current_user_id(), 864 'type' => 'reaction', 865 'status' => 'approve', 866 ) 867 ); 868 869 foreach ( $existing as $existing_reaction ) { 870 if ( wp_strip_all_tags( $existing_reaction->comment_content ) === $emoji_key ) { 871 return new WP_Error( 872 'rest_comment_duplicate_reaction', 873 __( 'You have already reacted with this emoji.' ), 874 array( 'status' => 409 ) 875 ); 876 } 877 } 878 879 $reaction_key = $emoji_key; 880 } 881 882 $prepared_comment = $this->prepare_item_for_database( $request ); 883 if ( is_wp_error( $prepared_comment ) ) { 884 return $prepared_comment; 885 } 886 887 $prepared_comment['comment_type'] = $request['type']; 888 889 /* 890 * Persist the validated, canonical reaction key rather than the raw 891 * request content, so stored values stay consistent for grouping and 892 * counting (e.g. "<b>2764</b>" is stored as "2764"). 893 */ 894 if ( null !== $reaction_key ) { 895 $prepared_comment['comment_content'] = $reaction_key; 896 } 897 898 if ( ! isset( $prepared_comment['comment_content'] ) ) { 899 $prepared_comment['comment_content'] = ''; 900 } 901 902 // Include note metadata into check_is_comment_content_allowed. 903 if ( isset( $request['meta']['_wp_note_status'] ) ) { 904 $prepared_comment['meta']['_wp_note_status'] = $request['meta']['_wp_note_status']; 905 } 906 907 if ( ! $this->check_is_comment_content_allowed( $prepared_comment ) ) { 908 return new WP_Error( 909 'rest_comment_content_invalid', 910 __( 'Invalid comment content.' ), 911 array( 'status' => 400 ) 912 ); 913 } 914 915 // Setting remaining values before wp_insert_comment so we can use wp_allow_comment(). 916 if ( ! isset( $prepared_comment['comment_date_gmt'] ) ) { 917 $prepared_comment['comment_date_gmt'] = current_time( 'mysql', true ); 918 } 919 920 // Set author data if the user's logged in. 921 $missing_author = empty( $prepared_comment['user_id'] ) 922 && empty( $prepared_comment['comment_author'] ) 923 && empty( $prepared_comment['comment_author_email'] ) 924 && empty( $prepared_comment['comment_author_url'] ); 925 926 if ( is_user_logged_in() && $missing_author ) { 927 $user = wp_get_current_user(); 928 929 $prepared_comment['user_id'] = $user->ID; 930 $prepared_comment['comment_author'] = $user->display_name; 931 $prepared_comment['comment_author_email'] = $user->user_email; 932 $prepared_comment['comment_author_url'] = $user->user_url; 933 } 934 935 /* 936 * Pin a reaction to the current user, whatever author details the request 937 * carried. Author fields alone leave `user_id` at 0, which the uniqueness 938 * check and the reaction summary both key on. 939 */ 940 if ( null !== $reaction_key ) { 941 $user = wp_get_current_user(); 942 943 $prepared_comment['user_id'] = $user->ID; 944 $prepared_comment['comment_author'] = $user->display_name; 945 $prepared_comment['comment_author_email'] = $user->user_email; 946 $prepared_comment['comment_author_url'] = $user->user_url; 947 } 948 949 // Honor the discussion setting that requires a name and email address of the comment author. 950 if ( get_option( 'require_name_email' ) ) { 951 if ( empty( $prepared_comment['comment_author'] ) || empty( $prepared_comment['comment_author_email'] ) ) { 952 return new WP_Error( 953 'rest_comment_author_data_required', 954 __( 'Creating a comment requires valid author name and email values.' ), 955 array( 'status' => 400 ) 956 ); 957 } 958 } 959 960 if ( ! isset( $prepared_comment['comment_author_email'] ) ) { 961 $prepared_comment['comment_author_email'] = ''; 962 } 963 964 if ( ! isset( $prepared_comment['comment_author_url'] ) ) { 965 $prepared_comment['comment_author_url'] = ''; 966 } 967 968 if ( ! isset( $prepared_comment['comment_agent'] ) ) { 969 $prepared_comment['comment_agent'] = ''; 970 } 971 972 $check_comment_lengths = wp_check_comment_data_max_lengths( $prepared_comment ); 973 974 if ( is_wp_error( $check_comment_lengths ) ) { 975 $error_code = $check_comment_lengths->get_error_code(); 976 return new WP_Error( 977 $error_code, 978 __( 'Comment field exceeds maximum length allowed.' ), 979 array( 'status' => 400 ) 980 ); 981 } 982 983 // Don't check for duplicates or flooding for notes or reactions. 984 $prepared_comment['comment_approved'] = 985 in_array( $prepared_comment['comment_type'], array( 'note', 'reaction' ), true ) ? 986 '1' : 987 wp_allow_comment( $prepared_comment, true ); 988 989 if ( is_wp_error( $prepared_comment['comment_approved'] ) ) { 990 $error_code = $prepared_comment['comment_approved']->get_error_code(); 991 $error_message = $prepared_comment['comment_approved']->get_error_message(); 992 993 if ( 'comment_duplicate' === $error_code ) { 994 return new WP_Error( 995 $error_code, 996 $error_message, 997 array( 'status' => 409 ) 998 ); 999 } 1000 1001 if ( 'comment_flood' === $error_code ) { 1002 return new WP_Error( 1003 $error_code, 1004 $error_message, 1005 array( 'status' => 400 ) 1006 ); 1007 } 1008 1009 return $prepared_comment['comment_approved']; 1010 } 1011 1012 /** 1013 * Filters a comment before it is inserted via the REST API. 1014 * 1015 * Allows modification of the comment right before it is inserted via wp_insert_comment(). 1016 * Returning a WP_Error value from the filter will short-circuit insertion and allow 1017 * skipping further processing. 1018 * 1019 * @since 4.7.0 1020 * @since 4.8.0 `$prepared_comment` can now be a WP_Error to short-circuit insertion. 1021 * 1022 * @param array|WP_Error $prepared_comment The prepared comment data for wp_insert_comment(). 1023 * @param WP_REST_Request $request Request used to insert the comment. 1024 */ 1025 $prepared_comment = apply_filters( 'rest_pre_insert_comment', $prepared_comment, $request ); 1026 if ( is_wp_error( $prepared_comment ) ) { 1027 return $prepared_comment; 1028 } 1029 1030 $comment_id = wp_insert_comment( wp_filter_comment( wp_slash( (array) $prepared_comment ) ) ); 1031 1032 if ( ! $comment_id ) { 1033 return new WP_Error( 1034 'rest_comment_failed_create', 1035 __( 'Creating comment failed.' ), 1036 array( 'status' => 500 ) 1037 ); 1038 } 1039 1040 /* 1041 * The pre-insert uniqueness check is not atomic, so two concurrent 1042 * requests for the same user/note/emoji can both insert an approved 1043 * row. Converge on a single row deterministically: keep the earliest 1044 * matching reaction (lowest comment ID) and delete any later 1045 * duplicates. Every concurrent request applies the same rule, so they 1046 * all settle on the same surviving row. If this request's own row lost 1047 * the race, repoint the response to the survivor. 1048 */ 1049 if ( null !== $reaction_key ) { 1050 $matching = get_comments( 1051 array( 1052 'parent' => $request['parent'], 1053 'user_id' => get_current_user_id(), 1054 'type' => 'reaction', 1055 'status' => 'approve', 1056 'orderby' => 'comment_ID', 1057 'order' => 'ASC', 1058 ) 1059 ); 1060 $duplicates = array(); 1061 foreach ( $matching as $candidate ) { 1062 if ( wp_strip_all_tags( $candidate->comment_content ) === $reaction_key ) { 1063 $duplicates[] = (int) $candidate->comment_ID; 1064 } 1065 } 1066 1067 /* 1068 * Repoint whenever any matching row survives, not only when this 1069 * request still sees its own duplicate: a competing request may 1070 * already have deleted this request's row, leaving a single 1071 * survivor that is not `$comment_id`. 1072 */ 1073 if ( ! empty( $duplicates ) ) { 1074 $survivor_id = array_shift( $duplicates ); 1075 foreach ( $duplicates as $duplicate_id ) { 1076 wp_delete_comment( $duplicate_id, true ); 1077 } 1078 $comment_id = $survivor_id; 1079 } 1080 } 1081 1082 /* 1083 * Reactions are inserted approved and their status was validated above. 1084 * Skipping them here also keeps a request from changing the status of a 1085 * row that the race cleanup above may have handed it from another request. 1086 */ 1087 if ( isset( $request['status'] ) && null === $reaction_key ) { 1088 $this->handle_status_param( $request['status'], $comment_id ); 1089 } 1090 1091 $comment = get_comment( $comment_id ); 1092 1093 /** 1094 * Fires after a comment is created or updated via the REST API. 1095 * 1096 * @since 4.7.0 1097 * 1098 * @param WP_Comment $comment Inserted or updated comment object. 1099 * @param WP_REST_Request $request Request object. 1100 * @param bool $creating True when creating a comment, false 1101 * when updating. 1102 */ 1103 do_action( 'rest_insert_comment', $comment, $request, true ); 1104 1105 $schema = $this->get_item_schema(); 1106 1107 if ( ! empty( $schema['properties']['meta'] ) && isset( $request['meta'] ) ) { 1108 $meta_update = $this->meta->update_value( $request['meta'], $comment_id ); 1109 1110 if ( is_wp_error( $meta_update ) ) { 1111 return $meta_update; 1112 } 1113 } 1114 1115 $fields_update = $this->update_additional_fields_for_object( $comment, $request ); 1116 1117 if ( is_wp_error( $fields_update ) ) { 1118 return $fields_update; 1119 } 1120 1121 $context = current_user_can( 'moderate_comments' ) ? 'edit' : 'view'; 1122 $request->set_param( 'context', $context ); 1123 1124 /** 1125 * Fires completely after a comment is created or updated via the REST API. 1126 * 1127 * @since 5.0.0 1128 * 1129 * @param WP_Comment $comment Inserted or updated comment object. 1130 * @param WP_REST_Request $request Request object. 1131 * @param bool $creating True when creating a comment, false 1132 * when updating. 1133 */ 1134 do_action( 'rest_after_insert_comment', $comment, $request, true ); 1135 1136 $response = $this->prepare_item_for_response( $comment, $request ); 1137 $response = rest_ensure_response( $response ); 1138 1139 $response->set_status( 201 ); 1140 $response->header( 'Location', rest_url( sprintf( '%s/%s/%d', $this->namespace, $this->rest_base, $comment_id ) ) ); 1141 1142 return $response; 1143 } 1144 1145 /** 1146 * Checks if a given REST request has access to update a comment. 1147 * 1148 * @since 4.7.0 1149 * @since 7.1.1 Target post permissions are checked when a comment's parent post is changed. 1150 * @since 7.2.0 Reactions cannot be updated. 1151 * 1152 * @param WP_REST_Request $request Full details about the request. 1153 * @return true|WP_Error True if the request has access to update the item, error object otherwise. 1154 */ 1155 public function update_item_permissions_check( $request ) { 1156 $comment = $this->get_comment( $request['id'] ); 1157 if ( is_wp_error( $comment ) ) { 1158 return $comment; 1159 } 1160 1161 /* 1162 * Reactions are immutable. create_item() validates the author, parent 1163 * note, target post and canonical emoji hex key as a set, and none of that 1164 * is re-checked here. Allowing an update would let anyone who can edit 1165 * the note's post reattribute a reaction to another user, move it to a 1166 * note on a post they cannot edit, or store a duplicate or invalid 1167 * key. Removing a reaction is a delete. 1168 */ 1169 if ( 'reaction' === $comment->comment_type ) { 1170 return new WP_Error( 1171 'rest_comment_update_not_allowed', 1172 __( 'Reactions cannot be edited. Remove the reaction and add a new one instead.' ), 1173 array( 'status' => 403 ) 1174 ); 1175 } 1176 1177 if ( ! $this->check_edit_permission( $comment ) ) { 1178 return new WP_Error( 1179 'rest_cannot_edit', 1180 __( 'Sorry, you are not allowed to edit this comment.' ), 1181 array( 'status' => rest_authorization_required_code() ) 1182 ); 1183 } 1184 1185 /* 1186 * check_edit_permission() above only establishes that the comment may be 1187 * edited where it currently sits, because 'edit_comment' maps to 'edit_post' 1188 * on the comment's current parent. When the parent is being changed, the new 1189 * parent has to be authorized as well. Without this, a user holding 1190 * edit_comment on their own comment or note could reparent it onto any post, 1191 * including posts they can neither read nor edit. 1192 */ 1193 if ( isset( $request['post'] ) && (int) $request['post'] !== (int) $comment->comment_post_ID ) { 1194 $target_check = $this->check_target_post_permission( 1195 (int) $request['post'], 1196 $request, 1197 'note' === $comment->comment_type 1198 ); 1199 1200 if ( is_wp_error( $target_check ) ) { 1201 return $target_check; 1202 } 1203 } 1204 1205 return true; 1206 } 1207 1208 /** 1209 * Updates a comment. 1210 * 1211 * @since 4.7.0 1212 * 1213 * @param WP_REST_Request $request Full details about the request. 1214 * @return WP_REST_Response|WP_Error Response object on success, or error object on failure. 1215 */ 1216 public function update_item( $request ) { 1217 $comment = $this->get_comment( $request['id'] ); 1218 if ( is_wp_error( $comment ) ) { 1219 return $comment; 1220 } 1221 1222 $id = $comment->comment_ID; 1223 1224 if ( isset( $request['type'] ) && get_comment_type( $id ) !== $request['type'] ) { 1225 return new WP_Error( 1226 'rest_comment_invalid_type', 1227 __( 'Sorry, you are not allowed to change the comment type.' ), 1228 array( 'status' => 404 ) 1229 ); 1230 } 1231 1232 $prepared_args = $this->prepare_item_for_database( $request ); 1233 1234 if ( is_wp_error( $prepared_args ) ) { 1235 return $prepared_args; 1236 } 1237 1238 if ( ! empty( $prepared_args['comment_post_ID'] ) ) { 1239 $post = get_post( $prepared_args['comment_post_ID'] ); 1240 1241 if ( empty( $post ) ) { 1242 return new WP_Error( 1243 'rest_comment_invalid_post_id', 1244 __( 'Invalid post ID.' ), 1245 array( 'status' => 403 ) 1246 ); 1247 } 1248 } 1249 1250 if ( empty( $prepared_args ) && isset( $request['status'] ) ) { 1251 // Only the comment status is being changed. 1252 $change = $this->handle_status_param( $request['status'], $id ); 1253 1254 if ( ! $change ) { 1255 return new WP_Error( 1256 'rest_comment_failed_edit', 1257 __( 'Updating comment status failed.' ), 1258 array( 'status' => 500 ) 1259 ); 1260 } 1261 } elseif ( ! empty( $prepared_args ) ) { 1262 if ( is_wp_error( $prepared_args ) ) { 1263 return $prepared_args; 1264 } 1265 if ( ! $this->check_is_comment_content_allowed( $prepared_args ) ) { 1266 return new WP_Error( 1267 'rest_comment_content_invalid', 1268 __( 'Invalid comment content.' ), 1269 array( 'status' => 400 ) 1270 ); 1271 } 1272 1273 $prepared_args['comment_ID'] = $id; 1274 1275 $check_comment_lengths = wp_check_comment_data_max_lengths( $prepared_args ); 1276 1277 if ( is_wp_error( $check_comment_lengths ) ) { 1278 $error_code = $check_comment_lengths->get_error_code(); 1279 return new WP_Error( 1280 $error_code, 1281 __( 'Comment field exceeds maximum length allowed.' ), 1282 array( 'status' => 400 ) 1283 ); 1284 } 1285 1286 $updated = wp_update_comment( wp_slash( (array) $prepared_args ), true ); 1287 1288 if ( is_wp_error( $updated ) ) { 1289 return new WP_Error( 1290 'rest_comment_failed_edit', 1291 __( 'Updating comment failed.' ), 1292 array( 'status' => 500 ) 1293 ); 1294 } 1295 1296 if ( isset( $request['status'] ) ) { 1297 $this->handle_status_param( $request['status'], $id ); 1298 } 1299 } 1300 1301 $comment = get_comment( $id ); 1302 1303 /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php */ 1304 do_action( 'rest_insert_comment', $comment, $request, false ); 1305 1306 $schema = $this->get_item_schema(); 1307 1308 if ( ! empty( $schema['properties']['meta'] ) && isset( $request['meta'] ) ) { 1309 $meta_update = $this->meta->update_value( $request['meta'], $id ); 1310 1311 if ( is_wp_error( $meta_update ) ) { 1312 return $meta_update; 1313 } 1314 } 1315 1316 $fields_update = $this->update_additional_fields_for_object( $comment, $request ); 1317 1318 if ( is_wp_error( $fields_update ) ) { 1319 return $fields_update; 1320 } 1321 1322 $request->set_param( 'context', 'edit' ); 1323 1324 /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php */ 1325 do_action( 'rest_after_insert_comment', $comment, $request, false ); 1326 1327 $response = $this->prepare_item_for_response( $comment, $request ); 1328 1329 return rest_ensure_response( $response ); 1330 } 1331 1332 /** 1333 * Checks if a given request has access to delete a comment. 1334 * 1335 * @since 4.7.0 1336 * 1337 * @param WP_REST_Request $request Full details about the request. 1338 * @return true|WP_Error True if the request has access to delete the item, error object otherwise. 1339 */ 1340 public function delete_item_permissions_check( $request ) { 1341 $comment = $this->get_comment( $request['id'] ); 1342 if ( is_wp_error( $comment ) ) { 1343 return $comment; 1344 } 1345 1346 if ( ! $this->check_edit_permission( $comment ) ) { 1347 return new WP_Error( 1348 'rest_cannot_delete', 1349 __( 'Sorry, you are not allowed to delete this comment.' ), 1350 array( 'status' => rest_authorization_required_code() ) 1351 ); 1352 } 1353 return true; 1354 } 1355 1356 /** 1357 * Deletes a comment. 1358 * 1359 * @since 4.7.0 1360 * 1361 * @param WP_REST_Request $request Full details about the request. 1362 * @return WP_REST_Response|WP_Error Response object on success, or error object on failure. 1363 */ 1364 public function delete_item( $request ) { 1365 $comment = $this->get_comment( $request['id'] ); 1366 if ( is_wp_error( $comment ) ) { 1367 return $comment; 1368 } 1369 1370 $force = isset( $request['force'] ) ? (bool) $request['force'] : false; 1371 1372 /** 1373 * Filters whether a comment can be trashed via the REST API. 1374 * 1375 * Return false to disable trash support for the comment. 1376 * 1377 * @since 4.7.0 1378 * 1379 * @param bool $supports_trash Whether the comment supports trashing. 1380 * @param WP_Comment $comment The comment object being considered for trashing support. 1381 */ 1382 $supports_trash = apply_filters( 'rest_comment_trashable', ( EMPTY_TRASH_DAYS > 0 ), $comment ); 1383 1384 $request->set_param( 'context', 'edit' ); 1385 1386 if ( $force ) { 1387 $previous = $this->prepare_item_for_response( $comment, $request ); 1388 $result = wp_delete_comment( $comment->comment_ID, true ); 1389 $response = new WP_REST_Response(); 1390 $response->set_data( 1391 array( 1392 'deleted' => true, 1393 'previous' => $previous->get_data(), 1394 ) 1395 ); 1396 } else { 1397 // If this type doesn't support trashing, error out. 1398 if ( ! $supports_trash ) { 1399 return new WP_Error( 1400 'rest_trash_not_supported', 1401 /* translators: %s: force=true */ 1402 sprintf( __( "The comment does not support trashing. Set '%s' to delete." ), 'force=true' ), 1403 array( 'status' => 501 ) 1404 ); 1405 } 1406 1407 if ( 'trash' === $comment->comment_approved ) { 1408 return new WP_Error( 1409 'rest_already_trashed', 1410 __( 'The comment has already been trashed.' ), 1411 array( 'status' => 410 ) 1412 ); 1413 } 1414 1415 $result = wp_trash_comment( $comment->comment_ID ); 1416 $comment = get_comment( $comment->comment_ID ); 1417 $response = $this->prepare_item_for_response( $comment, $request ); 1418 } 1419 1420 if ( ! $result ) { 1421 return new WP_Error( 1422 'rest_cannot_delete', 1423 __( 'The comment cannot be deleted.' ), 1424 array( 'status' => 500 ) 1425 ); 1426 } 1427 1428 /** 1429 * Fires after a comment is deleted via the REST API. 1430 * 1431 * @since 4.7.0 1432 * 1433 * @param WP_Comment $comment The deleted comment data. 1434 * @param WP_REST_Response $response The response returned from the API. 1435 * @param WP_REST_Request $request The request sent to the API. 1436 */ 1437 do_action( 'rest_delete_comment', $comment, $response, $request ); 1438 1439 return $response; 1440 } 1441 1442 /** 1443 * Prepares a single comment output for response. 1444 * 1445 * @since 4.7.0 1446 * @since 5.9.0 Renamed `$comment` to `$item` to match parent class for PHP 8 named parameter support. 1447 * 1448 * @param WP_Comment $item Comment object. 1449 * @param WP_REST_Request $request Request object. 1450 * @return WP_REST_Response Response object. 1451 */ 1452 public function prepare_item_for_response( $item, $request ) { 1453 // Restores the more descriptive, specific name for use within this method. 1454 $comment = $item; 1455 1456 // Don't prepare the response body for HEAD requests. 1457 if ( $request->is_method( 'HEAD' ) ) { 1458 /** This filter is documented in wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php */ 1459 return apply_filters( 'rest_prepare_comment', new WP_REST_Response( array() ), $comment, $request ); 1460 } 1461 1462 $fields = $this->get_fields_for_response( $request ); 1463 $data = array(); 1464 1465 if ( in_array( 'id', $fields, true ) ) { 1466 $data['id'] = (int) $comment->comment_ID; 1467 } 1468 1469 if ( in_array( 'post', $fields, true ) ) { 1470 $data['post'] = (int) $comment->comment_post_ID; 1471 } 1472 1473 if ( in_array( 'parent', $fields, true ) ) { 1474 $data['parent'] = (int) $comment->comment_parent; 1475 } 1476 1477 if ( in_array( 'author', $fields, true ) ) { 1478 $data['author'] = (int) $comment->user_id; 1479 } 1480 1481 if ( in_array( 'author_name', $fields, true ) ) { 1482 $data['author_name'] = $comment->comment_author; 1483 } 1484 1485 if ( in_array( 'author_email', $fields, true ) ) { 1486 $data['author_email'] = $comment->comment_author_email; 1487 } 1488 1489 if ( in_array( 'author_url', $fields, true ) ) { 1490 $data['author_url'] = $comment->comment_author_url; 1491 } 1492 1493 if ( in_array( 'author_ip', $fields, true ) ) { 1494 $data['author_ip'] = $comment->comment_author_IP; 1495 } 1496 1497 if ( in_array( 'author_user_agent', $fields, true ) ) { 1498 $data['author_user_agent'] = $comment->comment_agent; 1499 } 1500 1501 if ( in_array( 'date', $fields, true ) ) { 1502 $data['date'] = mysql_to_rfc3339( $comment->comment_date ); 1503 } 1504 1505 if ( in_array( 'date_gmt', $fields, true ) ) { 1506 $data['date_gmt'] = mysql_to_rfc3339( $comment->comment_date_gmt ); 1507 } 1508 1509 if ( in_array( 'content', $fields, true ) ) { 1510 $data['content'] = array( 1511 /** This filter is documented in wp-includes/comment-template.php */ 1512 'rendered' => apply_filters( 'comment_text', $comment->comment_content, $comment, array() ), 1513 'raw' => $comment->comment_content, 1514 ); 1515 } 1516 1517 if ( in_array( 'link', $fields, true ) ) { 1518 $data['link'] = get_comment_link( $comment ); 1519 } 1520 1521 if ( in_array( 'status', $fields, true ) ) { 1522 $data['status'] = $this->prepare_status_response( $comment->comment_approved ); 1523 } 1524 1525 if ( in_array( 'type', $fields, true ) ) { 1526 $data['type'] = get_comment_type( $comment->comment_ID ); 1527 } 1528 1529 if ( in_array( 'author_avatar_urls', $fields, true ) ) { 1530 $data['author_avatar_urls'] = rest_get_avatar_urls( $comment ); 1531 } 1532 1533 if ( in_array( 'meta', $fields, true ) ) { 1534 $data['meta'] = $this->meta->get_value( $comment->comment_ID, $request ); 1535 } 1536 1537 if ( in_array( 'reaction_summary', $fields, true ) && 'note' === $comment->comment_type ) { 1538 $note_id = (int) $comment->comment_ID; 1539 1540 if ( null !== $this->reaction_summaries && isset( $this->reaction_summaries[ $note_id ] ) ) { 1541 $data['reaction_summary'] = $this->reaction_summaries[ $note_id ]; 1542 } else { 1543 // Single-item path (get_item or single create/update): query individually. 1544 $this->prefetch_reaction_summaries( array( $note_id ) ); 1545 $data['reaction_summary'] = $this->reaction_summaries[ $note_id ] ?? array(); 1546 // Reset so subsequent unrelated calls do not see this entry. 1547 $this->reaction_summaries = null; 1548 } 1549 } 1550 1551 $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; 1552 $data = $this->add_additional_fields_to_object( $data, $request ); 1553 $data = $this->filter_response_by_context( $data, $context ); 1554 1555 // Wrap the data in a response object. 1556 $response = rest_ensure_response( $data ); 1557 1558 if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) { 1559 $links = $this->prepare_links( $comment ); 1560 $response->add_links( $links ); 1561 1562 if ( ! empty( $links['self']['href'] ) ) { 1563 $actions = $this->get_available_actions( $comment, $request ); 1564 1565 $self = $links['self']['href']; 1566 1567 foreach ( $actions as $rel ) { 1568 $response->add_link( $rel, $self ); 1569 } 1570 } 1571 } 1572 1573 /** 1574 * Filters a comment returned from the REST API. 1575 * 1576 * Allows modification of the comment right before it is returned. 1577 * 1578 * @since 4.7.0 1579 * 1580 * @param WP_REST_Response $response The response object. 1581 * @param WP_Comment $comment The original comment object. 1582 * @param WP_REST_Request $request Request used to generate the response. 1583 */ 1584 return apply_filters( 'rest_prepare_comment', $response, $comment, $request ); 1585 } 1586 1587 /** 1588 * Prepares links for the request. 1589 * 1590 * @since 4.7.0 1591 * 1592 * @param WP_Comment $comment Comment object. 1593 * @return array Links for the given comment. 1594 */ 1595 protected function prepare_links( $comment ) { 1596 $links = array( 1597 'self' => array( 1598 'href' => rest_url( sprintf( '%s/%s/%d', $this->namespace, $this->rest_base, $comment->comment_ID ) ), 1599 ), 1600 'collection' => array( 1601 'href' => rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ), 1602 ), 1603 ); 1604 1605 if ( 0 !== (int) $comment->user_id ) { 1606 $links['author'] = array( 1607 'href' => rest_url( 'wp/v2/users/' . $comment->user_id ), 1608 'embeddable' => true, 1609 ); 1610 } 1611 1612 if ( 0 !== (int) $comment->comment_post_ID ) { 1613 $post = get_post( $comment->comment_post_ID ); 1614 $post_route = rest_get_route_for_post( $post ); 1615 1616 if ( ! empty( $post->ID ) && $post_route ) { 1617 $links['up'] = array( 1618 'href' => rest_url( $post_route ), 1619 'embeddable' => true, 1620 'post_type' => $post->post_type, 1621 ); 1622 } 1623 } 1624 1625 if ( 0 !== (int) $comment->comment_parent ) { 1626 $links['in-reply-to'] = array( 1627 'href' => rest_url( sprintf( '%s/%s/%d', $this->namespace, $this->rest_base, $comment->comment_parent ) ), 1628 'embeddable' => true, 1629 ); 1630 } 1631 1632 /* 1633 * Only grab one comment to verify the comment has children. Reactions are 1634 * left out: they are summarized in `reaction_summary`, and counting them 1635 * would advertise a `children` link on a note that has no replies. 1636 */ 1637 $comment_children = $comment->get_children( 1638 array( 1639 'count' => true, 1640 'orderby' => 'none', 1641 'type' => 'all', 1642 'type__not_in' => array( 'reaction' ), 1643 ) 1644 ); 1645 1646 if ( ! empty( $comment_children ) ) { 1647 $args = array( 1648 'parent' => $comment->comment_ID, 1649 ); 1650 1651 $rest_url = add_query_arg( $args, rest_url( $this->namespace . '/' . $this->rest_base ) ); 1652 1653 $links['children'] = array( 1654 'href' => $rest_url, 1655 'embeddable' => true, 1656 ); 1657 } 1658 1659 // Embedding children for notes requires `type` and `status` inheritance. 1660 if ( isset( $links['children'] ) && 'note' === $comment->comment_type ) { 1661 $args = array( 1662 'parent' => $comment->comment_ID, 1663 'type' => $comment->comment_type, 1664 'status' => 'all', 1665 ); 1666 1667 $rest_url = add_query_arg( $args, rest_url( $this->namespace . '/' . $this->rest_base ) ); 1668 1669 $links['children'] = array( 1670 'href' => $rest_url, 1671 'embeddable' => true, 1672 ); 1673 } 1674 1675 return $links; 1676 } 1677 1678 /** 1679 * Gets the link relations available for the comment and current user. 1680 * 1681 * @since 7.2.0 1682 * 1683 * @param WP_Comment $comment Comment object. 1684 * @param WP_REST_Request $request Request object. 1685 * @return string[] List of link relations. 1686 * 1687 * @phpstan-return array<non-falsy-string> 1688 */ 1689 protected function get_available_actions( $comment, $request ) { 1690 if ( 'edit' !== $request['context'] ) { 1691 return array(); 1692 } 1693 1694 $rels = array(); 1695 1696 if ( 'trash' !== $comment->comment_approved && $this->check_edit_permission( $comment ) ) { 1697 /** This filter is documented in wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php */ 1698 if ( apply_filters( 'rest_comment_trashable', ( EMPTY_TRASH_DAYS > 0 ), $comment ) ) { 1699 $rels[] = 'https://api.w.org/action-trash'; 1700 } 1701 } 1702 1703 return $rels; 1704 } 1705 1706 /** 1707 * Prepends internal property prefix to query parameters to match our response fields. 1708 * 1709 * @since 4.7.0 1710 * 1711 * @param string $query_param Query parameter. 1712 * @return string The normalized query parameter. 1713 */ 1714 protected function normalize_query_param( $query_param ) { 1715 $prefix = 'comment_'; 1716 1717 switch ( $query_param ) { 1718 case 'id': 1719 $normalized = $prefix . 'ID'; 1720 break; 1721 case 'post': 1722 $normalized = $prefix . 'post_ID'; 1723 break; 1724 case 'parent': 1725 $normalized = $prefix . 'parent'; 1726 break; 1727 case 'include': 1728 $normalized = 'comment__in'; 1729 break; 1730 default: 1731 $normalized = $prefix . $query_param; 1732 break; 1733 } 1734 1735 return $normalized; 1736 } 1737 1738 /** 1739 * Checks comment_approved to set comment status for single comment output. 1740 * 1741 * @since 4.7.0 1742 * 1743 * @param string $comment_approved Comment status. 1744 * @return string Comment status. 1745 */ 1746 protected function prepare_status_response( $comment_approved ) { 1747 1748 switch ( $comment_approved ) { 1749 case 'hold': 1750 case '0': 1751 $status = 'hold'; 1752 break; 1753 1754 case 'approve': 1755 case '1': 1756 $status = 'approved'; 1757 break; 1758 1759 case 'spam': 1760 case 'trash': 1761 default: 1762 $status = $comment_approved; 1763 break; 1764 } 1765 1766 return $status; 1767 } 1768 1769 /** 1770 * Prepares a single comment to be inserted into the database. 1771 * 1772 * @since 4.7.0 1773 * 1774 * @param WP_REST_Request $request Request object. 1775 * @return array|WP_Error Prepared comment, otherwise WP_Error object. 1776 */ 1777 protected function prepare_item_for_database( $request ) { 1778 $prepared_comment = array(); 1779 1780 /* 1781 * Allow the comment_content to be set via the 'content' or 1782 * the 'content.raw' properties of the Request object. 1783 */ 1784 if ( isset( $request['content'] ) && is_string( $request['content'] ) ) { 1785 $prepared_comment['comment_content'] = trim( $request['content'] ); 1786 } elseif ( isset( $request['content']['raw'] ) && is_string( $request['content']['raw'] ) ) { 1787 $prepared_comment['comment_content'] = trim( $request['content']['raw'] ); 1788 } 1789 1790 if ( isset( $request['post'] ) ) { 1791 $prepared_comment['comment_post_ID'] = (int) $request['post']; 1792 } 1793 1794 if ( isset( $request['parent'] ) ) { 1795 $prepared_comment['comment_parent'] = $request['parent']; 1796 } 1797 1798 if ( isset( $request['author'] ) ) { 1799 $user = new WP_User( $request['author'] ); 1800 1801 if ( $user->exists() ) { 1802 $prepared_comment['user_id'] = $user->ID; 1803 $prepared_comment['comment_author'] = $user->display_name; 1804 $prepared_comment['comment_author_email'] = $user->user_email; 1805 $prepared_comment['comment_author_url'] = $user->user_url; 1806 } else { 1807 return new WP_Error( 1808 'rest_comment_author_invalid', 1809 __( 'Invalid comment author ID.' ), 1810 array( 'status' => 400 ) 1811 ); 1812 } 1813 } 1814 1815 if ( isset( $request['author_name'] ) ) { 1816 $prepared_comment['comment_author'] = $request['author_name']; 1817 } 1818 1819 if ( isset( $request['author_email'] ) ) { 1820 $prepared_comment['comment_author_email'] = $request['author_email']; 1821 } 1822 1823 if ( isset( $request['author_url'] ) ) { 1824 $prepared_comment['comment_author_url'] = $request['author_url']; 1825 } 1826 1827 if ( isset( $request['author_ip'] ) && current_user_can( 'moderate_comments' ) ) { 1828 $prepared_comment['comment_author_IP'] = $request['author_ip']; 1829 } elseif ( ! empty( $_SERVER['REMOTE_ADDR'] ) && rest_is_ip_address( $_SERVER['REMOTE_ADDR'] ) ) { 1830 $prepared_comment['comment_author_IP'] = $_SERVER['REMOTE_ADDR']; 1831 } else { 1832 $prepared_comment['comment_author_IP'] = '127.0.0.1'; 1833 } 1834 1835 if ( ! empty( $request['author_user_agent'] ) ) { 1836 $prepared_comment['comment_agent'] = $request['author_user_agent']; 1837 } elseif ( $request->get_header( 'user_agent' ) ) { 1838 $prepared_comment['comment_agent'] = $request->get_header( 'user_agent' ); 1839 } 1840 1841 if ( ! empty( $request['date'] ) ) { 1842 $date_data = rest_get_date_with_gmt( $request['date'] ); 1843 1844 if ( ! empty( $date_data ) ) { 1845 list( $prepared_comment['comment_date'], $prepared_comment['comment_date_gmt'] ) = $date_data; 1846 } 1847 } elseif ( ! empty( $request['date_gmt'] ) ) { 1848 $date_data = rest_get_date_with_gmt( $request['date_gmt'], true ); 1849 1850 if ( ! empty( $date_data ) ) { 1851 list( $prepared_comment['comment_date'], $prepared_comment['comment_date_gmt'] ) = $date_data; 1852 } 1853 } 1854 1855 /** 1856 * Filters a comment added via the REST API after it is prepared for insertion into the database. 1857 * 1858 * Allows modification of the comment right after it is prepared for the database. 1859 * 1860 * @since 4.7.0 1861 * 1862 * @param array $prepared_comment The prepared comment data for `wp_insert_comment`. 1863 * @param WP_REST_Request $request The current request. 1864 */ 1865 return apply_filters( 'rest_preprocess_comment', $prepared_comment, $request ); 1866 } 1867 1868 /** 1869 * Retrieves the comment's schema, conforming to JSON Schema. 1870 * 1871 * @since 4.7.0 1872 * 1873 * @return array 1874 */ 1875 public function get_item_schema() { 1876 if ( $this->schema ) { 1877 return $this->add_additional_fields_schema( $this->schema ); 1878 } 1879 1880 $schema = array( 1881 '$schema' => 'http://json-schema.org/draft-04/schema#', 1882 'title' => 'comment', 1883 'type' => 'object', 1884 'properties' => array( 1885 'id' => array( 1886 'description' => __( 'Unique identifier for the comment.' ), 1887 'type' => 'integer', 1888 'context' => array( 'view', 'edit', 'embed' ), 1889 'readonly' => true, 1890 ), 1891 'author' => array( 1892 'description' => __( 'The ID of the user object, if author was a user.' ), 1893 'type' => 'integer', 1894 'context' => array( 'view', 'edit', 'embed' ), 1895 ), 1896 'author_email' => array( 1897 'description' => __( 'Email address for the comment author.' ), 1898 'type' => 'string', 1899 'format' => 'email', 1900 'context' => array( 'edit' ), 1901 'arg_options' => array( 1902 'sanitize_callback' => array( $this, 'check_comment_author_email' ), 1903 'validate_callback' => null, // Skip built-in validation of 'email'. 1904 ), 1905 ), 1906 'author_ip' => array( 1907 'description' => __( 'IP address for the comment author.' ), 1908 'type' => 'string', 1909 'format' => 'ip', 1910 'context' => array( 'edit' ), 1911 ), 1912 'author_name' => array( 1913 'description' => __( 'Display name for the comment author.' ), 1914 'type' => 'string', 1915 'context' => array( 'view', 'edit', 'embed' ), 1916 'arg_options' => array( 1917 'sanitize_callback' => 'sanitize_text_field', 1918 ), 1919 ), 1920 'author_url' => array( 1921 'description' => __( 'URL for the comment author.' ), 1922 'type' => 'string', 1923 'format' => 'uri', 1924 'context' => array( 'view', 'edit', 'embed' ), 1925 ), 1926 'author_user_agent' => array( 1927 'description' => __( 'User agent for the comment author.' ), 1928 'type' => 'string', 1929 'context' => array( 'edit' ), 1930 'arg_options' => array( 1931 'sanitize_callback' => 'sanitize_text_field', 1932 ), 1933 ), 1934 'content' => array( 1935 'description' => __( 'The content for the comment.' ), 1936 'type' => 'object', 1937 'context' => array( 'view', 'edit', 'embed' ), 1938 'arg_options' => array( 1939 'sanitize_callback' => null, // Note: sanitization implemented in self::prepare_item_for_database(). 1940 'validate_callback' => null, // Note: validation implemented in self::prepare_item_for_database(). 1941 ), 1942 'properties' => array( 1943 'raw' => array( 1944 'description' => __( 'Content for the comment, as it exists in the database.' ), 1945 'type' => 'string', 1946 'context' => array( 'edit' ), 1947 ), 1948 'rendered' => array( 1949 'description' => __( 'HTML content for the comment, transformed for display.' ), 1950 'type' => 'string', 1951 'context' => array( 'view', 'edit', 'embed' ), 1952 'readonly' => true, 1953 ), 1954 ), 1955 ), 1956 'date' => array( 1957 'description' => __( "The date the comment was published, in the site's timezone." ), 1958 'type' => 'string', 1959 'format' => 'date-time', 1960 'context' => array( 'view', 'edit', 'embed' ), 1961 ), 1962 'date_gmt' => array( 1963 'description' => __( 'The date the comment was published, as GMT.' ), 1964 'type' => 'string', 1965 'format' => 'date-time', 1966 'context' => array( 'view', 'edit' ), 1967 ), 1968 'link' => array( 1969 'description' => __( 'URL to the comment.' ), 1970 'type' => 'string', 1971 'format' => 'uri', 1972 'context' => array( 'view', 'edit', 'embed' ), 1973 'readonly' => true, 1974 ), 1975 'parent' => array( 1976 'description' => __( 'The ID for the parent of the comment.' ), 1977 'type' => 'integer', 1978 'context' => array( 'view', 'edit', 'embed' ), 1979 'default' => 0, 1980 ), 1981 'post' => array( 1982 'description' => __( 'The ID of the associated post object.' ), 1983 'type' => 'integer', 1984 'context' => array( 'view', 'edit' ), 1985 'default' => 0, 1986 ), 1987 'status' => array( 1988 'description' => __( 'State of the comment.' ), 1989 'type' => 'string', 1990 'context' => array( 'view', 'edit' ), 1991 'arg_options' => array( 1992 'sanitize_callback' => 'sanitize_key', 1993 ), 1994 ), 1995 'type' => array( 1996 'description' => __( 'Type of the comment.' ), 1997 'type' => 'string', 1998 'context' => array( 'view', 'edit', 'embed' ), 1999 'readonly' => true, 2000 'default' => 'comment', 2001 ), 2002 'reaction_summary' => array( 2003 'description' => __( 'Aggregated reaction counts for this note, keyed by emoji hex key.' ), 2004 'type' => 'object', 2005 'context' => array( 'view', 'edit' ), 2006 'readonly' => true, 2007 'additionalProperties' => array( 2008 'type' => 'object', 2009 'properties' => array( 2010 'count' => array( 2011 'description' => __( 'Total number of reactions with this emoji.' ), 2012 'type' => 'integer', 2013 'minimum' => 0, 2014 ), 2015 'current_user_reaction' => array( 2016 'description' => __( "The current user's reaction comment ID for this emoji, or 0 if they have not reacted." ), 2017 'type' => 'integer', 2018 'minimum' => 0, 2019 ), 2020 ), 2021 ), 2022 ), 2023 ), 2024 ); 2025 2026 if ( get_option( 'show_avatars' ) ) { 2027 $avatar_properties = array(); 2028 2029 $avatar_sizes = rest_get_avatar_sizes(); 2030 2031 foreach ( $avatar_sizes as $size ) { 2032 $avatar_properties[ $size ] = array( 2033 /* translators: %d: Avatar image size in pixels. */ 2034 'description' => sprintf( __( 'Avatar URL with image size of %d pixels.' ), $size ), 2035 'type' => 'string', 2036 'format' => 'uri', 2037 'context' => array( 'embed', 'view', 'edit' ), 2038 ); 2039 } 2040 2041 $schema['properties']['author_avatar_urls'] = array( 2042 'description' => __( 'Avatar URLs for the comment author.' ), 2043 'type' => 'object', 2044 'context' => array( 'view', 'edit', 'embed' ), 2045 'readonly' => true, 2046 'properties' => $avatar_properties, 2047 ); 2048 } 2049 2050 $schema['properties']['meta'] = $this->meta->get_field_schema(); 2051 2052 $this->schema = $schema; 2053 2054 return $this->add_additional_fields_schema( $this->schema ); 2055 } 2056 2057 /** 2058 * Retrieves the query params for collections. 2059 * 2060 * @since 4.7.0 2061 * 2062 * @return array Comments collection parameters. 2063 */ 2064 public function get_collection_params() { 2065 $query_params = parent::get_collection_params(); 2066 2067 $query_params['context']['default'] = 'view'; 2068 2069 $query_params['after'] = array( 2070 'description' => __( 'Limit response to comments published after a given ISO8601 compliant date.' ), 2071 'type' => 'string', 2072 'format' => 'date-time', 2073 ); 2074 2075 $query_params['author'] = array( 2076 'description' => __( 'Limit result set to comments assigned to specific user IDs. Requires authorization.' ), 2077 'type' => 'array', 2078 'items' => array( 2079 'type' => 'integer', 2080 ), 2081 ); 2082 2083 $query_params['author_exclude'] = array( 2084 'description' => __( 'Ensure result set excludes comments assigned to specific user IDs. Requires authorization.' ), 2085 'type' => 'array', 2086 'items' => array( 2087 'type' => 'integer', 2088 ), 2089 ); 2090 2091 $query_params['author_email'] = array( 2092 'default' => null, 2093 'description' => __( 'Limit result set to that from a specific author email. Requires authorization.' ), 2094 'format' => 'email', 2095 'type' => 'string', 2096 ); 2097 2098 $query_params['before'] = array( 2099 'description' => __( 'Limit response to comments published before a given ISO8601 compliant date.' ), 2100 'type' => 'string', 2101 'format' => 'date-time', 2102 ); 2103 2104 $query_params['exclude'] = array( 2105 'description' => __( 'Ensure result set excludes specific IDs.' ), 2106 'type' => 'array', 2107 'items' => array( 2108 'type' => 'integer', 2109 ), 2110 'default' => array(), 2111 ); 2112 2113 $query_params['include'] = array( 2114 'description' => __( 'Limit result set to specific IDs.' ), 2115 'type' => 'array', 2116 'items' => array( 2117 'type' => 'integer', 2118 ), 2119 'default' => array(), 2120 ); 2121 2122 $query_params['offset'] = array( 2123 'description' => __( 'Offset the result set by a specific number of items.' ), 2124 'type' => 'integer', 2125 ); 2126 2127 $query_params['order'] = array( 2128 'description' => __( 'Order sort attribute ascending or descending.' ), 2129 'type' => 'string', 2130 'default' => 'desc', 2131 'enum' => array( 2132 'asc', 2133 'desc', 2134 ), 2135 ); 2136 2137 $query_params['orderby'] = array( 2138 'description' => __( 'Sort collection by comment attribute.' ), 2139 'type' => 'string', 2140 'default' => 'date_gmt', 2141 'enum' => array( 2142 'date', 2143 'date_gmt', 2144 'id', 2145 'include', 2146 'post', 2147 'parent', 2148 'type', 2149 ), 2150 ); 2151 2152 $query_params['parent'] = array( 2153 'default' => array(), 2154 'description' => __( 'Limit result set to comments of specific parent IDs.' ), 2155 'type' => 'array', 2156 'items' => array( 2157 'type' => 'integer', 2158 ), 2159 ); 2160 2161 $query_params['parent_exclude'] = array( 2162 'default' => array(), 2163 'description' => __( 'Ensure result set excludes specific parent IDs.' ), 2164 'type' => 'array', 2165 'items' => array( 2166 'type' => 'integer', 2167 ), 2168 ); 2169 2170 $query_params['post'] = array( 2171 'default' => array(), 2172 'description' => __( 'Limit result set to comments assigned to specific post IDs.' ), 2173 'type' => 'array', 2174 'items' => array( 2175 'type' => 'integer', 2176 ), 2177 ); 2178 2179 $query_params['status'] = array( 2180 'default' => array( 'approve' ), 2181 'description' => __( 'Limit result set to comments assigned one or more statuses. Requires authorization.' ), 2182 'type' => 'array', 2183 'items' => array( 2184 'type' => 'string', 2185 ), 2186 'sanitize_callback' => array( $this, 'sanitize_comment_statuses' ), 2187 'validate_callback' => 'rest_validate_request_arg', 2188 ); 2189 2190 $query_params['type'] = array( 2191 'default' => 'comment', 2192 'description' => __( 'Limit result set to comments assigned a specific type. Requires authorization.' ), 2193 'sanitize_callback' => 'sanitize_key', 2194 'type' => 'string', 2195 'validate_callback' => 'rest_validate_request_arg', 2196 ); 2197 2198 $query_params['password'] = array( 2199 'description' => __( 'The password for the post if it is password protected.' ), 2200 'type' => 'string', 2201 ); 2202 2203 /** 2204 * Filters REST API collection parameters for the comments controller. 2205 * 2206 * This filter registers the collection parameter, but does not map the 2207 * collection parameter to an internal WP_Comment_Query parameter. Use the 2208 * `rest_comment_query` filter to set WP_Comment_Query parameters. 2209 * 2210 * @since 4.7.0 2211 * 2212 * @param array $query_params JSON Schema-formatted collection parameters. 2213 */ 2214 return apply_filters( 'rest_comment_collection_params', $query_params ); 2215 } 2216 2217 /** 2218 * Sets the comment_status of a given comment object when creating or updating a comment. 2219 * 2220 * @since 4.7.0 2221 * 2222 * @param string|int $new_status New comment status. 2223 * @param int $comment_id Comment ID. 2224 * @return bool Whether the status was changed. 2225 */ 2226 protected function handle_status_param( $new_status, $comment_id ) { 2227 $old_status = wp_get_comment_status( $comment_id ); 2228 2229 if ( $new_status === $old_status ) { 2230 return false; 2231 } 2232 2233 switch ( $new_status ) { 2234 case 'approved': 2235 case 'approve': 2236 case '1': 2237 $changed = wp_set_comment_status( $comment_id, 'approve' ); 2238 break; 2239 case 'hold': 2240 case '0': 2241 $changed = wp_set_comment_status( $comment_id, 'hold' ); 2242 break; 2243 case 'spam': 2244 $changed = wp_spam_comment( $comment_id ); 2245 break; 2246 case 'unspam': 2247 $changed = wp_unspam_comment( $comment_id ); 2248 break; 2249 case 'trash': 2250 $changed = wp_trash_comment( $comment_id ); 2251 break; 2252 case 'untrash': 2253 $changed = wp_untrash_comment( $comment_id ); 2254 break; 2255 default: 2256 $changed = false; 2257 break; 2258 } 2259 2260 return $changed; 2261 } 2262 2263 /** 2264 * Checks if the post can be read. 2265 * 2266 * Correctly handles posts with the inherit status. 2267 * 2268 * @since 4.7.0 2269 * 2270 * @param WP_Post $post Post object. 2271 * @param WP_REST_Request $request Request data to check. 2272 * @return bool Whether post can be read. 2273 */ 2274 protected function check_read_post_permission( $post, $request ) { 2275 $post_type = get_post_type_object( $post->post_type ); 2276 2277 // Return false if custom post type doesn't exist 2278 if ( ! $post_type ) { 2279 return false; 2280 } 2281 2282 $posts_controller = $post_type->get_rest_controller(); 2283 2284 /* 2285 * Ensure the posts controller is specifically a WP_REST_Posts_Controller instance 2286 * before using methods specific to that controller. 2287 */ 2288 if ( ! $posts_controller instanceof WP_REST_Posts_Controller ) { 2289 $posts_controller = new WP_REST_Posts_Controller( $post->post_type ); 2290 } 2291 2292 $has_password_filter = false; 2293 2294 // Only check password if a specific post was queried for or a single comment 2295 $requested_post = ! empty( $request['post'] ) && ( ! is_array( $request['post'] ) || 1 === count( $request['post'] ) ); 2296 $requested_comment = ! empty( $request['id'] ); 2297 if ( ( $requested_post || $requested_comment ) && $posts_controller->can_access_password_content( $post, $request ) ) { 2298 add_filter( 'post_password_required', '__return_false' ); 2299 2300 $has_password_filter = true; 2301 } 2302 2303 if ( post_password_required( $post ) ) { 2304 $result = current_user_can( 'edit_post', $post->ID ); 2305 } else { 2306 $result = $posts_controller->check_read_permission( $post ); 2307 } 2308 2309 if ( $has_password_filter ) { 2310 remove_filter( 'post_password_required', '__return_false' ); 2311 } 2312 2313 return $result; 2314 } 2315 2316 /** 2317 * Pre-fetches reaction summaries for a set of note IDs. 2318 * 2319 * Runs two aggregated queries (one for the per-emoji counts, one for the 2320 * current user's own reactions) and stores the result in 2321 * $this->reaction_summaries, keyed by note comment ID. This lets a 2322 * batched note listing return reaction_summary for many notes without 2323 * issuing a per-note query. 2324 * 2325 * @since 7.2.0 2326 * 2327 * @global wpdb $wpdb WordPress database abstraction object. 2328 * 2329 * @param int[] $note_ids Array of note comment IDs. 2330 */ 2331 protected function prefetch_reaction_summaries( $note_ids ) { 2332 global $wpdb; 2333 2334 $this->reaction_summaries = array(); 2335 2336 if ( empty( $note_ids ) ) { 2337 return; 2338 } 2339 2340 $current_user_id = get_current_user_id(); 2341 2342 $parent_in = $wpdb->prepare( 2343 'comment_parent IN (' . implode( ',', array_fill( 0, count( $note_ids ), '%d' ) ) . ')', 2344 ...$note_ids 2345 ); 2346 2347 // Query 1: Get aggregated counts per emoji per note. 2348 $query = "SELECT comment_parent, comment_content, COUNT(*) AS reaction_count FROM $wpdb->comments WHERE comment_type = 'reaction' AND comment_approved = '1' AND "; 2349 $query .= $parent_in; 2350 $query .= ' GROUP BY comment_parent, comment_content'; 2351 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared 2352 $counts = $wpdb->get_results( $query ); 2353 2354 // Query 2: Get the current user's own reaction IDs (if logged in). 2355 $my_reactions = array(); 2356 if ( $current_user_id ) { 2357 $query = "SELECT comment_ID, comment_parent, comment_content FROM $wpdb->comments WHERE comment_type = 'reaction' AND comment_approved = '1' AND "; 2358 $query .= $parent_in; 2359 $query .= $wpdb->prepare( ' AND user_id = %d', $current_user_id ); 2360 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared 2361 $user_rows = $wpdb->get_results( $query ); 2362 2363 if ( $user_rows ) { 2364 foreach ( $user_rows as $row ) { 2365 $key = (int) $row->comment_parent . ':' . wp_strip_all_tags( $row->comment_content ); 2366 $my_reactions[ $key ] = (int) $row->comment_ID; 2367 } 2368 } 2369 } 2370 2371 // Initialize empty summaries for every requested note ID. 2372 foreach ( $note_ids as $note_id ) { 2373 $this->reaction_summaries[ $note_id ] = array(); 2374 } 2375 2376 if ( ! $counts ) { 2377 return; 2378 } 2379 2380 foreach ( $counts as $row ) { 2381 $note_id = (int) $row->comment_parent; 2382 $emoji_key = wp_strip_all_tags( $row->comment_content ); 2383 $key = $note_id . ':' . $emoji_key; 2384 2385 $this->reaction_summaries[ $note_id ][ $emoji_key ] = array( 2386 'count' => (int) $row->reaction_count, 2387 'current_user_reaction' => $my_reactions[ $key ] ?? 0, 2388 ); 2389 } 2390 } 2391 2392 /** 2393 * Checks if the comment can be read. 2394 * 2395 * @since 4.7.0 2396 * 2397 * @param WP_Comment $comment Comment object. 2398 * @param WP_REST_Request $request Request data to check. 2399 * @return bool Whether the comment can be read. 2400 */ 2401 protected function check_read_permission( $comment, $request ) { 2402 if ( ! in_array( $comment->comment_type, array( 'note', 'reaction' ), true ) && ! empty( $comment->comment_post_ID ) ) { 2403 $post = get_post( $comment->comment_post_ID ); 2404 if ( $post ) { 2405 if ( $this->check_read_post_permission( $post, $request ) && 1 === (int) $comment->comment_approved ) { 2406 return true; 2407 } 2408 } 2409 } 2410 2411 if ( 0 === get_current_user_id() ) { 2412 return false; 2413 } 2414 2415 if ( empty( $comment->comment_post_ID ) && ! current_user_can( 'moderate_comments' ) ) { 2416 return false; 2417 } 2418 2419 if ( ! empty( $comment->user_id ) && get_current_user_id() === (int) $comment->user_id ) { 2420 return true; 2421 } 2422 2423 return current_user_can( 'edit_comment', $comment->comment_ID ); 2424 } 2425 2426 /** 2427 * Checks if a comment can be edited or deleted. 2428 * 2429 * @since 4.7.0 2430 * 2431 * @param WP_Comment $comment Comment object. 2432 * @return bool Whether the comment can be edited or deleted. 2433 */ 2434 protected function check_edit_permission( $comment ) { 2435 if ( 0 === (int) get_current_user_id() ) { 2436 return false; 2437 } 2438 2439 if ( current_user_can( 'moderate_comments' ) ) { 2440 return true; 2441 } 2442 2443 return current_user_can( 'edit_comment', $comment->comment_ID ); 2444 } 2445 2446 /** 2447 * Checks a comment author email for validity. 2448 * 2449 * Accepts either a valid email address or empty string as a valid comment 2450 * author email address. Setting the comment author email to an empty 2451 * string is allowed when a comment is being updated. 2452 * 2453 * @since 4.7.0 2454 * 2455 * @param string $value Author email value submitted. 2456 * @param WP_REST_Request $request Full details about the request. 2457 * @param string $param The parameter name. 2458 * @return string|WP_Error The sanitized email address, if valid, 2459 * otherwise an error. 2460 */ 2461 public function check_comment_author_email( $value, $request, $param ) { 2462 $email = (string) $value; 2463 if ( empty( $email ) ) { 2464 return $email; 2465 } 2466 2467 $check_email = rest_validate_request_arg( $email, $request, $param ); 2468 if ( is_wp_error( $check_email ) ) { 2469 return $check_email; 2470 } 2471 2472 return $email; 2473 } 2474 2475 /** 2476 * If empty comments are not allowed, checks if the provided comment content is not empty. 2477 * 2478 * @since 5.6.0 2479 * 2480 * @param array $prepared_comment The prepared comment data. 2481 * @return bool True if the content is allowed, false otherwise. 2482 */ 2483 protected function check_is_comment_content_allowed( $prepared_comment ) { 2484 if ( ! isset( $prepared_comment['comment_content'] ) ) { 2485 return true; 2486 } 2487 2488 $check = wp_parse_args( 2489 $prepared_comment, 2490 array( 2491 'comment_post_ID' => 0, 2492 'comment_author' => null, 2493 'comment_author_email' => null, 2494 'comment_author_url' => null, 2495 'comment_parent' => 0, 2496 'user_id' => 0, 2497 ) 2498 ); 2499 2500 /** This filter is documented in wp-includes/comment.php */ 2501 $allow_empty = apply_filters( 'allow_empty_comment', false, $check ); 2502 2503 if ( $allow_empty ) { 2504 return true; 2505 } 2506 2507 // Allow empty notes only when resolution metadata is valid. 2508 if ( 2509 isset( $check['comment_type'] ) && 2510 'note' === $check['comment_type'] && 2511 isset( $check['meta']['_wp_note_status'] ) && 2512 in_array( $check['meta']['_wp_note_status'], array( 'resolved', 'reopen' ), true ) 2513 ) { 2514 return true; 2515 } 2516 2517 /* 2518 * Do not allow a comment to be created with missing or empty 2519 * comment_content. See wp_handle_comment_submission(). 2520 */ 2521 return '' !== $check['comment_content']; 2522 } 2523 2524 /** 2525 * Sanitizes a single comment status or a list of comment statuses. 2526 * 2527 * @since 7.2.0 2528 * 2529 * @param string[]|string $statuses Comment status or array of comment statuses. 2530 * @return string[] Sanitized array of comment statuses. 2531 * @phpstan-return list<non-empty-lowercase-string> 2532 */ 2533 public function sanitize_comment_statuses( $statuses ): array { 2534 $statuses = array_unique( array_map( 'sanitize_key', wp_parse_list( $statuses ) ) ); 2535 2536 // Only drop empty values. A literal '0' is a queryable status, as comments are held with `comment_approved` of '0'. 2537 $statuses = array_filter( 2538 $statuses, 2539 static function ( $status ) { 2540 return '' !== $status; 2541 } 2542 ); 2543 2544 return array_values( $statuses ); 2545 } 2546 2547 /** 2548 * Checks that a post can receive a comment or a note from the current user. 2549 * 2550 * Used when creating a note and when changing the parent post of an existing 2551 * comment or note, so that attaching content to a post is authorized the same 2552 * way whichever path it arrives by. 2553 * 2554 * @since 7.1.1 2555 * 2556 * @param int $post_id Target post ID. 2557 * @param WP_REST_Request $request Full details about the request. 2558 * @param bool $is_note Optional. Whether the comment is a note. Default false. 2559 * @return true|WP_Error True if the post can receive the comment, error object otherwise. 2560 */ 2561 protected function check_target_post_permission( int $post_id, WP_REST_Request $request, bool $is_note = false ) { 2562 if ( ! $post_id ) { 2563 return new WP_Error( 2564 'rest_comment_invalid_post_id', 2565 __( 'Sorry, you are not allowed to create this comment without a post.' ), 2566 array( 'status' => 403 ) 2567 ); 2568 } 2569 2570 /* 2571 * Notes are editorial content, so they may only be attached to a post the 2572 * user can edit. Any other comment needs either comment moderation rights 2573 * or edit access to the post, which is what check_edit_permission() grants 2574 * on the post a comment is moving away from. Requiring the same at the 2575 * destination means both ends of a move are authorized alike. 2576 */ 2577 if ( $is_note ) { 2578 $can_target_post = current_user_can( 'edit_post', $post_id ); 2579 } else { 2580 $can_target_post = current_user_can( 'moderate_comments' ) || current_user_can( 'edit_post', $post_id ); 2581 } 2582 2583 if ( ! $can_target_post ) { 2584 if ( $is_note ) { 2585 return new WP_Error( 2586 'rest_cannot_create_note', 2587 __( 'Sorry, you are not allowed to create notes for this post.' ), 2588 array( 'status' => rest_authorization_required_code() ) 2589 ); 2590 } 2591 2592 return new WP_Error( 2593 'rest_cannot_edit', 2594 __( 'Sorry, you are not allowed to edit this comment.' ), 2595 array( 'status' => rest_authorization_required_code() ) 2596 ); 2597 } 2598 2599 $post = get_post( $post_id ); 2600 2601 if ( ! $post ) { 2602 return new WP_Error( 2603 'rest_comment_invalid_post_id', 2604 __( 'Sorry, you are not allowed to create this comment without a post.' ), 2605 array( 'status' => 403 ) 2606 ); 2607 } 2608 2609 /* 2610 * The remaining rules mirror the create-time checks for notes only. They are 2611 * deliberately not applied to other comments, because moderators move comments 2612 * onto posts whose discussion has closed and onto drafts today. Enforcing the 2613 * create-time rules there would break that without blocking anything the 2614 * capability check above already permits. 2615 */ 2616 if ( ! $is_note ) { 2617 return true; 2618 } 2619 2620 if ( ! $this->check_post_type_supports_notes( $post->post_type ) ) { 2621 return new WP_Error( 2622 'rest_comment_not_supported_post_type', 2623 __( 'Sorry, this post type does not support notes.' ), 2624 array( 'status' => 403 ) 2625 ); 2626 } 2627 2628 if ( 'trash' === $post->post_status ) { 2629 return new WP_Error( 2630 'rest_comment_trash_post', 2631 __( 'Sorry, you are not allowed to create a comment on this post.' ), 2632 array( 'status' => 403 ) 2633 ); 2634 } 2635 2636 if ( ! $this->check_read_post_permission( $post, $request ) ) { 2637 return new WP_Error( 2638 'rest_cannot_read_post', 2639 __( 'Sorry, you are not allowed to read the post for this comment.' ), 2640 array( 'status' => rest_authorization_required_code() ) 2641 ); 2642 } 2643 2644 return true; 2645 } 2646 2647 /** 2648 * Check if post type supports notes. 2649 * 2650 * @since 6.9.0 2651 * 2652 * @param string $post_type Post type name. 2653 * @return bool True if post type supports notes, false otherwise. 2654 */ 2655 private function check_post_type_supports_notes( $post_type ) { 2656 $supports = get_all_post_type_supports( $post_type ); 2657 2658 if ( ! isset( $supports['editor'] ) ) { 2659 return false; 2660 } 2661 2662 if ( ! is_array( $supports['editor'] ) ) { 2663 return false; 2664 } 2665 2666 return array_any( $supports['editor'], fn( $item ) => ! empty( $item['notes'] ) ); 2667 } 2668 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
| Generated : Thu Oct 22 08:20:34 2026 | Cross-referenced by PHPXref |