[ Index ] |
PHP Cross Reference of WordPress |
[Summary view] [Print] [Text view]
1 <?php 2 /** 3 * REST API: WP_REST_Attachments_Controller class 4 * 5 * @package WordPress 6 * @subpackage REST_API 7 * @since 4.7.0 8 */ 9 10 /** 11 * Core controller used to access attachments via the REST API. 12 * 13 * @since 4.7.0 14 * 15 * @see WP_REST_Posts_Controller 16 */ 17 class WP_REST_Attachments_Controller extends WP_REST_Posts_Controller { 18 19 /** 20 * Whether the controller supports batching. 21 * 22 * @since 5.9.0 23 * @var false 24 */ 25 protected $allow_batch = false; 26 27 /** 28 * Registers the routes for attachments. 29 * 30 * @since 5.3.0 31 * 32 * @see register_rest_route() 33 */ 34 public function register_routes() { 35 parent::register_routes(); 36 register_rest_route( 37 $this->namespace, 38 '/' . $this->rest_base . '/(?P<id>[\d]+)/post-process', 39 array( 40 'methods' => WP_REST_Server::CREATABLE, 41 'callback' => array( $this, 'post_process_item' ), 42 'permission_callback' => array( $this, 'post_process_item_permissions_check' ), 43 'args' => array( 44 'id' => array( 45 'description' => __( 'Unique identifier for the attachment.' ), 46 'type' => 'integer', 47 ), 48 'action' => array( 49 'type' => 'string', 50 'enum' => array( 'create-image-subsizes' ), 51 'required' => true, 52 ), 53 ), 54 ) 55 ); 56 register_rest_route( 57 $this->namespace, 58 '/' . $this->rest_base . '/(?P<id>[\d]+)/edit', 59 array( 60 'methods' => WP_REST_Server::CREATABLE, 61 'callback' => array( $this, 'edit_media_item' ), 62 'permission_callback' => array( $this, 'edit_media_item_permissions_check' ), 63 'args' => $this->get_edit_media_item_args(), 64 ) 65 ); 66 } 67 68 /** 69 * Determines the allowed query_vars for a get_items() response and 70 * prepares for WP_Query. 71 * 72 * @since 4.7.0 73 * 74 * @param array $prepared_args Optional. Array of prepared arguments. Default empty array. 75 * @param WP_REST_Request $request Optional. Request to prepare items for. 76 * @return array Array of query arguments. 77 */ 78 protected function prepare_items_query( $prepared_args = array(), $request = null ) { 79 $query_args = parent::prepare_items_query( $prepared_args, $request ); 80 81 if ( empty( $query_args['post_status'] ) ) { 82 $query_args['post_status'] = 'inherit'; 83 } 84 85 $media_types = $this->get_media_types(); 86 87 if ( ! empty( $request['media_type'] ) && isset( $media_types[ $request['media_type'] ] ) ) { 88 $query_args['post_mime_type'] = $media_types[ $request['media_type'] ]; 89 } 90 91 if ( ! empty( $request['mime_type'] ) ) { 92 $parts = explode( '/', $request['mime_type'] ); 93 if ( isset( $media_types[ $parts[0] ] ) && in_array( $request['mime_type'], $media_types[ $parts[0] ], true ) ) { 94 $query_args['post_mime_type'] = $request['mime_type']; 95 } 96 } 97 98 // Filter query clauses to include filenames. 99 if ( isset( $query_args['s'] ) ) { 100 add_filter( 'posts_clauses', '_filter_query_attachment_filenames' ); 101 } 102 103 return $query_args; 104 } 105 106 /** 107 * Checks if a given request has access to create an attachment. 108 * 109 * @since 4.7.0 110 * 111 * @param WP_REST_Request $request Full details about the request. 112 * @return true|WP_Error Boolean true if the attachment may be created, or a WP_Error if not. 113 */ 114 public function create_item_permissions_check( $request ) { 115 $ret = parent::create_item_permissions_check( $request ); 116 117 if ( ! $ret || is_wp_error( $ret ) ) { 118 return $ret; 119 } 120 121 if ( ! current_user_can( 'upload_files' ) ) { 122 return new WP_Error( 123 'rest_cannot_create', 124 __( 'Sorry, you are not allowed to upload media on this site.' ), 125 array( 'status' => 400 ) 126 ); 127 } 128 129 // Attaching media to a post requires ability to edit said post. 130 if ( ! empty( $request['post'] ) && ! current_user_can( 'edit_post', (int) $request['post'] ) ) { 131 return new WP_Error( 132 'rest_cannot_edit', 133 __( 'Sorry, you are not allowed to upload media to this post.' ), 134 array( 'status' => rest_authorization_required_code() ) 135 ); 136 } 137 138 return true; 139 } 140 141 /** 142 * Creates a single attachment. 143 * 144 * @since 4.7.0 145 * 146 * @param WP_REST_Request $request Full details about the request. 147 * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure. 148 */ 149 public function create_item( $request ) { 150 if ( ! empty( $request['post'] ) && in_array( get_post_type( $request['post'] ), array( 'revision', 'attachment' ), true ) ) { 151 return new WP_Error( 152 'rest_invalid_param', 153 __( 'Invalid parent type.' ), 154 array( 'status' => 400 ) 155 ); 156 } 157 158 $insert = $this->insert_attachment( $request ); 159 160 if ( is_wp_error( $insert ) ) { 161 return $insert; 162 } 163 164 $schema = $this->get_item_schema(); 165 166 // Extract by name. 167 $attachment_id = $insert['attachment_id']; 168 $file = $insert['file']; 169 170 if ( isset( $request['alt_text'] ) ) { 171 update_post_meta( $attachment_id, '_wp_attachment_image_alt', sanitize_text_field( $request['alt_text'] ) ); 172 } 173 174 if ( ! empty( $schema['properties']['meta'] ) && isset( $request['meta'] ) ) { 175 $meta_update = $this->meta->update_value( $request['meta'], $attachment_id ); 176 177 if ( is_wp_error( $meta_update ) ) { 178 return $meta_update; 179 } 180 } 181 182 $attachment = get_post( $attachment_id ); 183 $fields_update = $this->update_additional_fields_for_object( $attachment, $request ); 184 185 if ( is_wp_error( $fields_update ) ) { 186 return $fields_update; 187 } 188 189 $request->set_param( 'context', 'edit' ); 190 191 /** 192 * Fires after a single attachment is completely created or updated via the REST API. 193 * 194 * @since 5.0.0 195 * 196 * @param WP_Post $attachment Inserted or updated attachment object. 197 * @param WP_REST_Request $request Request object. 198 * @param bool $creating True when creating an attachment, false when updating. 199 */ 200 do_action( 'rest_after_insert_attachment', $attachment, $request, true ); 201 202 wp_after_insert_post( $attachment, false, null ); 203 204 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) { 205 // Set a custom header with the attachment_id. 206 // Used by the browser/client to resume creating image sub-sizes after a PHP fatal error. 207 header( 'X-WP-Upload-Attachment-ID: ' . $attachment_id ); 208 } 209 210 // Include media and image functions to get access to wp_generate_attachment_metadata(). 211 require_once ABSPATH . 'wp-admin/includes/media.php'; 212 require_once ABSPATH . 'wp-admin/includes/image.php'; 213 214 // Post-process the upload (create image sub-sizes, make PDF thumbnails, etc.) and insert attachment meta. 215 // At this point the server may run out of resources and post-processing of uploaded images may fail. 216 wp_update_attachment_metadata( $attachment_id, wp_generate_attachment_metadata( $attachment_id, $file ) ); 217 218 $response = $this->prepare_item_for_response( $attachment, $request ); 219 $response = rest_ensure_response( $response ); 220 $response->set_status( 201 ); 221 $response->header( 'Location', rest_url( sprintf( '%s/%s/%d', $this->namespace, $this->rest_base, $attachment_id ) ) ); 222 223 return $response; 224 } 225 226 /** 227 * Inserts the attachment post in the database. Does not update the attachment meta. 228 * 229 * @since 5.3.0 230 * 231 * @param WP_REST_Request $request 232 * @return array|WP_Error 233 */ 234 protected function insert_attachment( $request ) { 235 // Get the file via $_FILES or raw data. 236 $files = $request->get_file_params(); 237 $headers = $request->get_headers(); 238 239 if ( ! empty( $files ) ) { 240 $file = $this->upload_from_file( $files, $headers ); 241 } else { 242 $file = $this->upload_from_data( $request->get_body(), $headers ); 243 } 244 245 if ( is_wp_error( $file ) ) { 246 return $file; 247 } 248 249 $name = wp_basename( $file['file'] ); 250 $name_parts = pathinfo( $name ); 251 $name = trim( substr( $name, 0, -( 1 + strlen( $name_parts['extension'] ) ) ) ); 252 253 $url = $file['url']; 254 $type = $file['type']; 255 $file = $file['file']; 256 257 // Include image functions to get access to wp_read_image_metadata(). 258 require_once ABSPATH . 'wp-admin/includes/image.php'; 259 260 // Use image exif/iptc data for title and caption defaults if possible. 261 $image_meta = wp_read_image_metadata( $file ); 262 263 if ( ! empty( $image_meta ) ) { 264 if ( empty( $request['title'] ) && trim( $image_meta['title'] ) && ! is_numeric( sanitize_title( $image_meta['title'] ) ) ) { 265 $request['title'] = $image_meta['title']; 266 } 267 268 if ( empty( $request['caption'] ) && trim( $image_meta['caption'] ) ) { 269 $request['caption'] = $image_meta['caption']; 270 } 271 } 272 273 $attachment = $this->prepare_item_for_database( $request ); 274 275 $attachment->post_mime_type = $type; 276 $attachment->guid = $url; 277 278 if ( empty( $attachment->post_title ) ) { 279 $attachment->post_title = preg_replace( '/\.[^.]+$/', '', wp_basename( $file ) ); 280 } 281 282 // $post_parent is inherited from $attachment['post_parent']. 283 $id = wp_insert_attachment( wp_slash( (array) $attachment ), $file, 0, true, false ); 284 285 if ( is_wp_error( $id ) ) { 286 if ( 'db_update_error' === $id->get_error_code() ) { 287 $id->add_data( array( 'status' => 500 ) ); 288 } else { 289 $id->add_data( array( 'status' => 400 ) ); 290 } 291 292 return $id; 293 } 294 295 $attachment = get_post( $id ); 296 297 /** 298 * Fires after a single attachment is created or updated via the REST API. 299 * 300 * @since 4.7.0 301 * 302 * @param WP_Post $attachment Inserted or updated attachment 303 * object. 304 * @param WP_REST_Request $request The request sent to the API. 305 * @param bool $creating True when creating an attachment, false when updating. 306 */ 307 do_action( 'rest_insert_attachment', $attachment, $request, true ); 308 309 return array( 310 'attachment_id' => $id, 311 'file' => $file, 312 ); 313 } 314 315 /** 316 * Updates a single attachment. 317 * 318 * @since 4.7.0 319 * 320 * @param WP_REST_Request $request Full details about the request. 321 * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure. 322 */ 323 public function update_item( $request ) { 324 if ( ! empty( $request['post'] ) && in_array( get_post_type( $request['post'] ), array( 'revision', 'attachment' ), true ) ) { 325 return new WP_Error( 326 'rest_invalid_param', 327 __( 'Invalid parent type.' ), 328 array( 'status' => 400 ) 329 ); 330 } 331 332 $attachment_before = get_post( $request['id'] ); 333 $response = parent::update_item( $request ); 334 335 if ( is_wp_error( $response ) ) { 336 return $response; 337 } 338 339 $response = rest_ensure_response( $response ); 340 $data = $response->get_data(); 341 342 if ( isset( $request['alt_text'] ) ) { 343 update_post_meta( $data['id'], '_wp_attachment_image_alt', $request['alt_text'] ); 344 } 345 346 $attachment = get_post( $request['id'] ); 347 348 $fields_update = $this->update_additional_fields_for_object( $attachment, $request ); 349 350 if ( is_wp_error( $fields_update ) ) { 351 return $fields_update; 352 } 353 354 $request->set_param( 'context', 'edit' ); 355 356 /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php */ 357 do_action( 'rest_after_insert_attachment', $attachment, $request, false ); 358 359 wp_after_insert_post( $attachment, true, $attachment_before ); 360 361 $response = $this->prepare_item_for_response( $attachment, $request ); 362 $response = rest_ensure_response( $response ); 363 364 return $response; 365 } 366 367 /** 368 * Performs post processing on an attachment. 369 * 370 * @since 5.3.0 371 * 372 * @param WP_REST_Request $request Full details about the request. 373 * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure. 374 */ 375 public function post_process_item( $request ) { 376 switch ( $request['action'] ) { 377 case 'create-image-subsizes': 378 require_once ABSPATH . 'wp-admin/includes/image.php'; 379 wp_update_image_subsizes( $request['id'] ); 380 break; 381 } 382 383 $request['context'] = 'edit'; 384 385 return $this->prepare_item_for_response( get_post( $request['id'] ), $request ); 386 } 387 388 /** 389 * Checks if a given request can perform post processing on an attachment. 390 * 391 * @since 5.3.0 392 * 393 * @param WP_REST_Request $request Full details about the request. 394 * @return true|WP_Error True if the request has access to update the item, WP_Error object otherwise. 395 */ 396 public function post_process_item_permissions_check( $request ) { 397 return $this->update_item_permissions_check( $request ); 398 } 399 400 /** 401 * Checks if a given request has access to editing media. 402 * 403 * @since 5.5.0 404 * 405 * @param WP_REST_Request $request Full details about the request. 406 * @return true|WP_Error True if the request has read access, WP_Error object otherwise. 407 */ 408 public function edit_media_item_permissions_check( $request ) { 409 if ( ! current_user_can( 'upload_files' ) ) { 410 return new WP_Error( 411 'rest_cannot_edit_image', 412 __( 'Sorry, you are not allowed to upload media on this site.' ), 413 array( 'status' => rest_authorization_required_code() ) 414 ); 415 } 416 417 return $this->update_item_permissions_check( $request ); 418 } 419 420 /** 421 * Applies edits to a media item and creates a new attachment record. 422 * 423 * @since 5.5.0 424 * 425 * @param WP_REST_Request $request Full details about the request. 426 * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure. 427 */ 428 public function edit_media_item( $request ) { 429 require_once ABSPATH . 'wp-admin/includes/image.php'; 430 431 $attachment_id = $request['id']; 432 433 // This also confirms the attachment is an image. 434 $image_file = wp_get_original_image_path( $attachment_id ); 435 $image_meta = wp_get_attachment_metadata( $attachment_id ); 436 437 if ( 438 ! $image_meta || 439 ! $image_file || 440 ! wp_image_file_matches_image_meta( $request['src'], $image_meta, $attachment_id ) 441 ) { 442 return new WP_Error( 443 'rest_unknown_attachment', 444 __( 'Unable to get meta information for file.' ), 445 array( 'status' => 404 ) 446 ); 447 } 448 449 $supported_types = array( 'image/jpeg', 'image/png', 'image/gif', 'image/webp' ); 450 $mime_type = get_post_mime_type( $attachment_id ); 451 if ( ! in_array( $mime_type, $supported_types, true ) ) { 452 return new WP_Error( 453 'rest_cannot_edit_file_type', 454 __( 'This type of file cannot be edited.' ), 455 array( 'status' => 400 ) 456 ); 457 } 458 459 // The `modifiers` param takes precedence over the older format. 460 if ( isset( $request['modifiers'] ) ) { 461 $modifiers = $request['modifiers']; 462 } else { 463 $modifiers = array(); 464 465 if ( ! empty( $request['rotation'] ) ) { 466 $modifiers[] = array( 467 'type' => 'rotate', 468 'args' => array( 469 'angle' => $request['rotation'], 470 ), 471 ); 472 } 473 474 if ( isset( $request['x'], $request['y'], $request['width'], $request['height'] ) ) { 475 $modifiers[] = array( 476 'type' => 'crop', 477 'args' => array( 478 'left' => $request['x'], 479 'top' => $request['y'], 480 'width' => $request['width'], 481 'height' => $request['height'], 482 ), 483 ); 484 } 485 486 if ( 0 === count( $modifiers ) ) { 487 return new WP_Error( 488 'rest_image_not_edited', 489 __( 'The image was not edited. Edit the image before applying the changes.' ), 490 array( 'status' => 400 ) 491 ); 492 } 493 } 494 495 /* 496 * If the file doesn't exist, attempt a URL fopen on the src link. 497 * This can occur with certain file replication plugins. 498 * Keep the original file path to get a modified name later. 499 */ 500 $image_file_to_edit = $image_file; 501 if ( ! file_exists( $image_file_to_edit ) ) { 502 $image_file_to_edit = _load_image_to_edit_path( $attachment_id ); 503 } 504 505 $image_editor = wp_get_image_editor( $image_file_to_edit ); 506 507 if ( is_wp_error( $image_editor ) ) { 508 return new WP_Error( 509 'rest_unknown_image_file_type', 510 __( 'Unable to edit this image.' ), 511 array( 'status' => 500 ) 512 ); 513 } 514 515 foreach ( $modifiers as $modifier ) { 516 $args = $modifier['args']; 517 switch ( $modifier['type'] ) { 518 case 'rotate': 519 // Rotation direction: clockwise vs. counter clockwise. 520 $rotate = 0 - $args['angle']; 521 522 if ( 0 !== $rotate ) { 523 $result = $image_editor->rotate( $rotate ); 524 525 if ( is_wp_error( $result ) ) { 526 return new WP_Error( 527 'rest_image_rotation_failed', 528 __( 'Unable to rotate this image.' ), 529 array( 'status' => 500 ) 530 ); 531 } 532 } 533 534 break; 535 536 case 'crop': 537 $size = $image_editor->get_size(); 538 539 $crop_x = round( ( $size['width'] * $args['left'] ) / 100.0 ); 540 $crop_y = round( ( $size['height'] * $args['top'] ) / 100.0 ); 541 $width = round( ( $size['width'] * $args['width'] ) / 100.0 ); 542 $height = round( ( $size['height'] * $args['height'] ) / 100.0 ); 543 544 if ( $size['width'] !== $width && $size['height'] !== $height ) { 545 $result = $image_editor->crop( $crop_x, $crop_y, $width, $height ); 546 547 if ( is_wp_error( $result ) ) { 548 return new WP_Error( 549 'rest_image_crop_failed', 550 __( 'Unable to crop this image.' ), 551 array( 'status' => 500 ) 552 ); 553 } 554 } 555 556 break; 557 558 } 559 } 560 561 // Calculate the file name. 562 $image_ext = pathinfo( $image_file, PATHINFO_EXTENSION ); 563 $image_name = wp_basename( $image_file, ".{$image_ext}" ); 564 565 // Do not append multiple `-edited` to the file name. 566 // The user may be editing a previously edited image. 567 if ( preg_match( '/-edited(-\d+)?$/', $image_name ) ) { 568 // Remove any `-1`, `-2`, etc. `wp_unique_filename()` will add the proper number. 569 $image_name = preg_replace( '/-edited(-\d+)?$/', '-edited', $image_name ); 570 } else { 571 // Append `-edited` before the extension. 572 $image_name .= '-edited'; 573 } 574 575 $filename = "{$image_name}.{$image_ext}"; 576 577 // Create the uploads sub-directory if needed. 578 $uploads = wp_upload_dir(); 579 580 // Make the file name unique in the (new) upload directory. 581 $filename = wp_unique_filename( $uploads['path'], $filename ); 582 583 // Save to disk. 584 $saved = $image_editor->save( $uploads['path'] . "/$filename" ); 585 586 if ( is_wp_error( $saved ) ) { 587 return $saved; 588 } 589 590 // Create new attachment post. 591 $new_attachment_post = array( 592 'post_mime_type' => $saved['mime-type'], 593 'guid' => $uploads['url'] . "/$filename", 594 'post_title' => $image_name, 595 'post_content' => '', 596 ); 597 598 // Copy post_content, post_excerpt, and post_title from the edited image's attachment post. 599 $attachment_post = get_post( $attachment_id ); 600 601 if ( $attachment_post ) { 602 $new_attachment_post['post_content'] = $attachment_post->post_content; 603 $new_attachment_post['post_excerpt'] = $attachment_post->post_excerpt; 604 $new_attachment_post['post_title'] = $attachment_post->post_title; 605 } 606 607 $new_attachment_id = wp_insert_attachment( wp_slash( $new_attachment_post ), $saved['path'], 0, true ); 608 609 if ( is_wp_error( $new_attachment_id ) ) { 610 if ( 'db_update_error' === $new_attachment_id->get_error_code() ) { 611 $new_attachment_id->add_data( array( 'status' => 500 ) ); 612 } else { 613 $new_attachment_id->add_data( array( 'status' => 400 ) ); 614 } 615 616 return $new_attachment_id; 617 } 618 619 // Copy the image alt text from the edited image. 620 $image_alt = get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ); 621 622 if ( ! empty( $image_alt ) ) { 623 // update_post_meta() expects slashed. 624 update_post_meta( $new_attachment_id, '_wp_attachment_image_alt', wp_slash( $image_alt ) ); 625 } 626 627 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) { 628 // Set a custom header with the attachment_id. 629 // Used by the browser/client to resume creating image sub-sizes after a PHP fatal error. 630 header( 'X-WP-Upload-Attachment-ID: ' . $new_attachment_id ); 631 } 632 633 // Generate image sub-sizes and meta. 634 $new_image_meta = wp_generate_attachment_metadata( $new_attachment_id, $saved['path'] ); 635 636 // Copy the EXIF metadata from the original attachment if not generated for the edited image. 637 if ( isset( $image_meta['image_meta'] ) && isset( $new_image_meta['image_meta'] ) && is_array( $new_image_meta['image_meta'] ) ) { 638 // Merge but skip empty values. 639 foreach ( (array) $image_meta['image_meta'] as $key => $value ) { 640 if ( empty( $new_image_meta['image_meta'][ $key ] ) && ! empty( $value ) ) { 641 $new_image_meta['image_meta'][ $key ] = $value; 642 } 643 } 644 } 645 646 // Reset orientation. At this point the image is edited and orientation is correct. 647 if ( ! empty( $new_image_meta['image_meta']['orientation'] ) ) { 648 $new_image_meta['image_meta']['orientation'] = 1; 649 } 650 651 // The attachment_id may change if the site is exported and imported. 652 $new_image_meta['parent_image'] = array( 653 'attachment_id' => $attachment_id, 654 // Path to the originally uploaded image file relative to the uploads directory. 655 'file' => _wp_relative_upload_path( $image_file ), 656 ); 657 658 /** 659 * Filters the meta data for the new image created by editing an existing image. 660 * 661 * @since 5.5.0 662 * 663 * @param array $new_image_meta Meta data for the new image. 664 * @param int $new_attachment_id Attachment post ID for the new image. 665 * @param int $attachment_id Attachment post ID for the edited (parent) image. 666 */ 667 $new_image_meta = apply_filters( 'wp_edited_image_metadata', $new_image_meta, $new_attachment_id, $attachment_id ); 668 669 wp_update_attachment_metadata( $new_attachment_id, $new_image_meta ); 670 671 $response = $this->prepare_item_for_response( get_post( $new_attachment_id ), $request ); 672 $response->set_status( 201 ); 673 $response->header( 'Location', rest_url( sprintf( '%s/%s/%s', $this->namespace, $this->rest_base, $new_attachment_id ) ) ); 674 675 return $response; 676 } 677 678 /** 679 * Prepares a single attachment for create or update. 680 * 681 * @since 4.7.0 682 * 683 * @param WP_REST_Request $request Request object. 684 * @return stdClass|WP_Error Post object. 685 */ 686 protected function prepare_item_for_database( $request ) { 687 $prepared_attachment = parent::prepare_item_for_database( $request ); 688 689 // Attachment caption (post_excerpt internally). 690 if ( isset( $request['caption'] ) ) { 691 if ( is_string( $request['caption'] ) ) { 692 $prepared_attachment->post_excerpt = $request['caption']; 693 } elseif ( isset( $request['caption']['raw'] ) ) { 694 $prepared_attachment->post_excerpt = $request['caption']['raw']; 695 } 696 } 697 698 // Attachment description (post_content internally). 699 if ( isset( $request['description'] ) ) { 700 if ( is_string( $request['description'] ) ) { 701 $prepared_attachment->post_content = $request['description']; 702 } elseif ( isset( $request['description']['raw'] ) ) { 703 $prepared_attachment->post_content = $request['description']['raw']; 704 } 705 } 706 707 if ( isset( $request['post'] ) ) { 708 $prepared_attachment->post_parent = (int) $request['post']; 709 } 710 711 return $prepared_attachment; 712 } 713 714 /** 715 * Prepares a single attachment output for response. 716 * 717 * @since 4.7.0 718 * @since 5.9.0 Renamed `$post` to `$item` to match parent class for PHP 8 named parameter support. 719 * 720 * @param WP_Post $item Attachment object. 721 * @param WP_REST_Request $request Request object. 722 * @return WP_REST_Response Response object. 723 */ 724 public function prepare_item_for_response( $item, $request ) { 725 // Restores the more descriptive, specific name for use within this method. 726 $post = $item; 727 $response = parent::prepare_item_for_response( $post, $request ); 728 $fields = $this->get_fields_for_response( $request ); 729 $data = $response->get_data(); 730 731 if ( in_array( 'description', $fields, true ) ) { 732 $data['description'] = array( 733 'raw' => $post->post_content, 734 /** This filter is documented in wp-includes/post-template.php */ 735 'rendered' => apply_filters( 'the_content', $post->post_content ), 736 ); 737 } 738 739 if ( in_array( 'caption', $fields, true ) ) { 740 /** This filter is documented in wp-includes/post-template.php */ 741 $caption = apply_filters( 'get_the_excerpt', $post->post_excerpt, $post ); 742 743 /** This filter is documented in wp-includes/post-template.php */ 744 $caption = apply_filters( 'the_excerpt', $caption ); 745 746 $data['caption'] = array( 747 'raw' => $post->post_excerpt, 748 'rendered' => $caption, 749 ); 750 } 751 752 if ( in_array( 'alt_text', $fields, true ) ) { 753 $data['alt_text'] = get_post_meta( $post->ID, '_wp_attachment_image_alt', true ); 754 } 755 756 if ( in_array( 'media_type', $fields, true ) ) { 757 $data['media_type'] = wp_attachment_is_image( $post->ID ) ? 'image' : 'file'; 758 } 759 760 if ( in_array( 'mime_type', $fields, true ) ) { 761 $data['mime_type'] = $post->post_mime_type; 762 } 763 764 if ( in_array( 'media_details', $fields, true ) ) { 765 $data['media_details'] = wp_get_attachment_metadata( $post->ID ); 766 767 // Ensure empty details is an empty object. 768 if ( empty( $data['media_details'] ) ) { 769 $data['media_details'] = new stdClass; 770 } elseif ( ! empty( $data['media_details']['sizes'] ) ) { 771 772 foreach ( $data['media_details']['sizes'] as $size => &$size_data ) { 773 774 if ( isset( $size_data['mime-type'] ) ) { 775 $size_data['mime_type'] = $size_data['mime-type']; 776 unset( $size_data['mime-type'] ); 777 } 778 779 // Use the same method image_downsize() does. 780 $image_src = wp_get_attachment_image_src( $post->ID, $size ); 781 if ( ! $image_src ) { 782 continue; 783 } 784 785 $size_data['source_url'] = $image_src[0]; 786 } 787 788 $full_src = wp_get_attachment_image_src( $post->ID, 'full' ); 789 790 if ( ! empty( $full_src ) ) { 791 $data['media_details']['sizes']['full'] = array( 792 'file' => wp_basename( $full_src[0] ), 793 'width' => $full_src[1], 794 'height' => $full_src[2], 795 'mime_type' => $post->post_mime_type, 796 'source_url' => $full_src[0], 797 ); 798 } 799 } else { 800 $data['media_details']['sizes'] = new stdClass; 801 } 802 } 803 804 if ( in_array( 'post', $fields, true ) ) { 805 $data['post'] = ! empty( $post->post_parent ) ? (int) $post->post_parent : null; 806 } 807 808 if ( in_array( 'source_url', $fields, true ) ) { 809 $data['source_url'] = wp_get_attachment_url( $post->ID ); 810 } 811 812 if ( in_array( 'missing_image_sizes', $fields, true ) ) { 813 require_once ABSPATH . 'wp-admin/includes/image.php'; 814 $data['missing_image_sizes'] = array_keys( wp_get_missing_image_subsizes( $post->ID ) ); 815 } 816 817 $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; 818 819 $data = $this->filter_response_by_context( $data, $context ); 820 821 $links = $response->get_links(); 822 823 // Wrap the data in a response object. 824 $response = rest_ensure_response( $data ); 825 826 foreach ( $links as $rel => $rel_links ) { 827 foreach ( $rel_links as $link ) { 828 $response->add_link( $rel, $link['href'], $link['attributes'] ); 829 } 830 } 831 832 /** 833 * Filters an attachment returned from the REST API. 834 * 835 * Allows modification of the attachment right before it is returned. 836 * 837 * @since 4.7.0 838 * 839 * @param WP_REST_Response $response The response object. 840 * @param WP_Post $post The original attachment post. 841 * @param WP_REST_Request $request Request used to generate the response. 842 */ 843 return apply_filters( 'rest_prepare_attachment', $response, $post, $request ); 844 } 845 846 /** 847 * Retrieves the attachment's schema, conforming to JSON Schema. 848 * 849 * @since 4.7.0 850 * 851 * @return array Item schema as an array. 852 */ 853 public function get_item_schema() { 854 if ( $this->schema ) { 855 return $this->add_additional_fields_schema( $this->schema ); 856 } 857 858 $schema = parent::get_item_schema(); 859 860 $schema['properties']['alt_text'] = array( 861 'description' => __( 'Alternative text to display when attachment is not displayed.' ), 862 'type' => 'string', 863 'context' => array( 'view', 'edit', 'embed' ), 864 'arg_options' => array( 865 'sanitize_callback' => 'sanitize_text_field', 866 ), 867 ); 868 869 $schema['properties']['caption'] = array( 870 'description' => __( 'The attachment caption.' ), 871 'type' => 'object', 872 'context' => array( 'view', 'edit', 'embed' ), 873 'arg_options' => array( 874 'sanitize_callback' => null, // Note: sanitization implemented in self::prepare_item_for_database(). 875 'validate_callback' => null, // Note: validation implemented in self::prepare_item_for_database(). 876 ), 877 'properties' => array( 878 'raw' => array( 879 'description' => __( 'Caption for the attachment, as it exists in the database.' ), 880 'type' => 'string', 881 'context' => array( 'edit' ), 882 ), 883 'rendered' => array( 884 'description' => __( 'HTML caption for the attachment, transformed for display.' ), 885 'type' => 'string', 886 'context' => array( 'view', 'edit', 'embed' ), 887 'readonly' => true, 888 ), 889 ), 890 ); 891 892 $schema['properties']['description'] = array( 893 'description' => __( 'The attachment description.' ), 894 'type' => 'object', 895 'context' => array( 'view', 'edit' ), 896 'arg_options' => array( 897 'sanitize_callback' => null, // Note: sanitization implemented in self::prepare_item_for_database(). 898 'validate_callback' => null, // Note: validation implemented in self::prepare_item_for_database(). 899 ), 900 'properties' => array( 901 'raw' => array( 902 'description' => __( 'Description for the attachment, as it exists in the database.' ), 903 'type' => 'string', 904 'context' => array( 'edit' ), 905 ), 906 'rendered' => array( 907 'description' => __( 'HTML description for the attachment, transformed for display.' ), 908 'type' => 'string', 909 'context' => array( 'view', 'edit' ), 910 'readonly' => true, 911 ), 912 ), 913 ); 914 915 $schema['properties']['media_type'] = array( 916 'description' => __( 'Attachment type.' ), 917 'type' => 'string', 918 'enum' => array( 'image', 'file' ), 919 'context' => array( 'view', 'edit', 'embed' ), 920 'readonly' => true, 921 ); 922 923 $schema['properties']['mime_type'] = array( 924 'description' => __( 'The attachment MIME type.' ), 925 'type' => 'string', 926 'context' => array( 'view', 'edit', 'embed' ), 927 'readonly' => true, 928 ); 929 930 $schema['properties']['media_details'] = array( 931 'description' => __( 'Details about the media file, specific to its type.' ), 932 'type' => 'object', 933 'context' => array( 'view', 'edit', 'embed' ), 934 'readonly' => true, 935 ); 936 937 $schema['properties']['post'] = array( 938 'description' => __( 'The ID for the associated post of the attachment.' ), 939 'type' => 'integer', 940 'context' => array( 'view', 'edit' ), 941 ); 942 943 $schema['properties']['source_url'] = array( 944 'description' => __( 'URL to the original attachment file.' ), 945 'type' => 'string', 946 'format' => 'uri', 947 'context' => array( 'view', 'edit', 'embed' ), 948 'readonly' => true, 949 ); 950 951 $schema['properties']['missing_image_sizes'] = array( 952 'description' => __( 'List of the missing image sizes of the attachment.' ), 953 'type' => 'array', 954 'items' => array( 'type' => 'string' ), 955 'context' => array( 'edit' ), 956 'readonly' => true, 957 ); 958 959 unset( $schema['properties']['password'] ); 960 961 $this->schema = $schema; 962 963 return $this->add_additional_fields_schema( $this->schema ); 964 } 965 966 /** 967 * Handles an upload via raw POST data. 968 * 969 * @since 4.7.0 970 * 971 * @param array $data Supplied file data. 972 * @param array $headers HTTP headers from the request. 973 * @return array|WP_Error Data from wp_handle_sideload(). 974 */ 975 protected function upload_from_data( $data, $headers ) { 976 if ( empty( $data ) ) { 977 return new WP_Error( 978 'rest_upload_no_data', 979 __( 'No data supplied.' ), 980 array( 'status' => 400 ) 981 ); 982 } 983 984 if ( empty( $headers['content_type'] ) ) { 985 return new WP_Error( 986 'rest_upload_no_content_type', 987 __( 'No Content-Type supplied.' ), 988 array( 'status' => 400 ) 989 ); 990 } 991 992 if ( empty( $headers['content_disposition'] ) ) { 993 return new WP_Error( 994 'rest_upload_no_content_disposition', 995 __( 'No Content-Disposition supplied.' ), 996 array( 'status' => 400 ) 997 ); 998 } 999 1000 $filename = self::get_filename_from_disposition( $headers['content_disposition'] ); 1001 1002 if ( empty( $filename ) ) { 1003 return new WP_Error( 1004 'rest_upload_invalid_disposition', 1005 __( 'Invalid Content-Disposition supplied. Content-Disposition needs to be formatted as `attachment; filename="image.png"` or similar.' ), 1006 array( 'status' => 400 ) 1007 ); 1008 } 1009 1010 if ( ! empty( $headers['content_md5'] ) ) { 1011 $content_md5 = array_shift( $headers['content_md5'] ); 1012 $expected = trim( $content_md5 ); 1013 $actual = md5( $data ); 1014 1015 if ( $expected !== $actual ) { 1016 return new WP_Error( 1017 'rest_upload_hash_mismatch', 1018 __( 'Content hash did not match expected.' ), 1019 array( 'status' => 412 ) 1020 ); 1021 } 1022 } 1023 1024 // Get the content-type. 1025 $type = array_shift( $headers['content_type'] ); 1026 1027 // Include filesystem functions to get access to wp_tempnam() and wp_handle_sideload(). 1028 require_once ABSPATH . 'wp-admin/includes/file.php'; 1029 1030 // Save the file. 1031 $tmpfname = wp_tempnam( $filename ); 1032 1033 $fp = fopen( $tmpfname, 'w+' ); 1034 1035 if ( ! $fp ) { 1036 return new WP_Error( 1037 'rest_upload_file_error', 1038 __( 'Could not open file handle.' ), 1039 array( 'status' => 500 ) 1040 ); 1041 } 1042 1043 fwrite( $fp, $data ); 1044 fclose( $fp ); 1045 1046 // Now, sideload it in. 1047 $file_data = array( 1048 'error' => null, 1049 'tmp_name' => $tmpfname, 1050 'name' => $filename, 1051 'type' => $type, 1052 ); 1053 1054 $size_check = self::check_upload_size( $file_data ); 1055 if ( is_wp_error( $size_check ) ) { 1056 return $size_check; 1057 } 1058 1059 $overrides = array( 1060 'test_form' => false, 1061 ); 1062 1063 $sideloaded = wp_handle_sideload( $file_data, $overrides ); 1064 1065 if ( isset( $sideloaded['error'] ) ) { 1066 @unlink( $tmpfname ); 1067 1068 return new WP_Error( 1069 'rest_upload_sideload_error', 1070 $sideloaded['error'], 1071 array( 'status' => 500 ) 1072 ); 1073 } 1074 1075 return $sideloaded; 1076 } 1077 1078 /** 1079 * Parses filename from a Content-Disposition header value. 1080 * 1081 * As per RFC6266: 1082 * 1083 * content-disposition = "Content-Disposition" ":" 1084 * disposition-type *( ";" disposition-parm ) 1085 * 1086 * disposition-type = "inline" | "attachment" | disp-ext-type 1087 * ; case-insensitive 1088 * disp-ext-type = token 1089 * 1090 * disposition-parm = filename-parm | disp-ext-parm 1091 * 1092 * filename-parm = "filename" "=" value 1093 * | "filename*" "=" ext-value 1094 * 1095 * disp-ext-parm = token "=" value 1096 * | ext-token "=" ext-value 1097 * ext-token = <the characters in token, followed by "*"> 1098 * 1099 * @since 4.7.0 1100 * 1101 * @link https://tools.ietf.org/html/rfc2388 1102 * @link https://tools.ietf.org/html/rfc6266 1103 * 1104 * @param string[] $disposition_header List of Content-Disposition header values. 1105 * @return string|null Filename if available, or null if not found. 1106 */ 1107 public static function get_filename_from_disposition( $disposition_header ) { 1108 // Get the filename. 1109 $filename = null; 1110 1111 foreach ( $disposition_header as $value ) { 1112 $value = trim( $value ); 1113 1114 if ( strpos( $value, ';' ) === false ) { 1115 continue; 1116 } 1117 1118 list( $type, $attr_parts ) = explode( ';', $value, 2 ); 1119 1120 $attr_parts = explode( ';', $attr_parts ); 1121 $attributes = array(); 1122 1123 foreach ( $attr_parts as $part ) { 1124 if ( strpos( $part, '=' ) === false ) { 1125 continue; 1126 } 1127 1128 list( $key, $value ) = explode( '=', $part, 2 ); 1129 1130 $attributes[ trim( $key ) ] = trim( $value ); 1131 } 1132 1133 if ( empty( $attributes['filename'] ) ) { 1134 continue; 1135 } 1136 1137 $filename = trim( $attributes['filename'] ); 1138 1139 // Unquote quoted filename, but after trimming. 1140 if ( substr( $filename, 0, 1 ) === '"' && substr( $filename, -1, 1 ) === '"' ) { 1141 $filename = substr( $filename, 1, -1 ); 1142 } 1143 } 1144 1145 return $filename; 1146 } 1147 1148 /** 1149 * Retrieves the query params for collections of attachments. 1150 * 1151 * @since 4.7.0 1152 * 1153 * @return array Query parameters for the attachment collection as an array. 1154 */ 1155 public function get_collection_params() { 1156 $params = parent::get_collection_params(); 1157 $params['status']['default'] = 'inherit'; 1158 $params['status']['items']['enum'] = array( 'inherit', 'private', 'trash' ); 1159 $media_types = $this->get_media_types(); 1160 1161 $params['media_type'] = array( 1162 'default' => null, 1163 'description' => __( 'Limit result set to attachments of a particular media type.' ), 1164 'type' => 'string', 1165 'enum' => array_keys( $media_types ), 1166 ); 1167 1168 $params['mime_type'] = array( 1169 'default' => null, 1170 'description' => __( 'Limit result set to attachments of a particular MIME type.' ), 1171 'type' => 'string', 1172 ); 1173 1174 return $params; 1175 } 1176 1177 /** 1178 * Handles an upload via multipart/form-data ($_FILES). 1179 * 1180 * @since 4.7.0 1181 * 1182 * @param array $files Data from the `$_FILES` superglobal. 1183 * @param array $headers HTTP headers from the request. 1184 * @return array|WP_Error Data from wp_handle_upload(). 1185 */ 1186 protected function upload_from_file( $files, $headers ) { 1187 if ( empty( $files ) ) { 1188 return new WP_Error( 1189 'rest_upload_no_data', 1190 __( 'No data supplied.' ), 1191 array( 'status' => 400 ) 1192 ); 1193 } 1194 1195 // Verify hash, if given. 1196 if ( ! empty( $headers['content_md5'] ) ) { 1197 $content_md5 = array_shift( $headers['content_md5'] ); 1198 $expected = trim( $content_md5 ); 1199 $actual = md5_file( $files['file']['tmp_name'] ); 1200 1201 if ( $expected !== $actual ) { 1202 return new WP_Error( 1203 'rest_upload_hash_mismatch', 1204 __( 'Content hash did not match expected.' ), 1205 array( 'status' => 412 ) 1206 ); 1207 } 1208 } 1209 1210 // Pass off to WP to handle the actual upload. 1211 $overrides = array( 1212 'test_form' => false, 1213 ); 1214 1215 // Bypasses is_uploaded_file() when running unit tests. 1216 if ( defined( 'DIR_TESTDATA' ) && DIR_TESTDATA ) { 1217 $overrides['action'] = 'wp_handle_mock_upload'; 1218 } 1219 1220 $size_check = self::check_upload_size( $files['file'] ); 1221 if ( is_wp_error( $size_check ) ) { 1222 return $size_check; 1223 } 1224 1225 // Include filesystem functions to get access to wp_handle_upload(). 1226 require_once ABSPATH . 'wp-admin/includes/file.php'; 1227 1228 $file = wp_handle_upload( $files['file'], $overrides ); 1229 1230 if ( isset( $file['error'] ) ) { 1231 return new WP_Error( 1232 'rest_upload_unknown_error', 1233 $file['error'], 1234 array( 'status' => 500 ) 1235 ); 1236 } 1237 1238 return $file; 1239 } 1240 1241 /** 1242 * Retrieves the supported media types. 1243 * 1244 * Media types are considered the MIME type category. 1245 * 1246 * @since 4.7.0 1247 * 1248 * @return array Array of supported media types. 1249 */ 1250 protected function get_media_types() { 1251 $media_types = array(); 1252 1253 foreach ( get_allowed_mime_types() as $mime_type ) { 1254 $parts = explode( '/', $mime_type ); 1255 1256 if ( ! isset( $media_types[ $parts[0] ] ) ) { 1257 $media_types[ $parts[0] ] = array(); 1258 } 1259 1260 $media_types[ $parts[0] ][] = $mime_type; 1261 } 1262 1263 return $media_types; 1264 } 1265 1266 /** 1267 * Determine if uploaded file exceeds space quota on multisite. 1268 * 1269 * Replicates check_upload_size(). 1270 * 1271 * @since 4.9.8 1272 * 1273 * @param array $file $_FILES array for a given file. 1274 * @return true|WP_Error True if can upload, error for errors. 1275 */ 1276 protected function check_upload_size( $file ) { 1277 if ( ! is_multisite() ) { 1278 return true; 1279 } 1280 1281 if ( get_site_option( 'upload_space_check_disabled' ) ) { 1282 return true; 1283 } 1284 1285 $space_left = get_upload_space_available(); 1286 1287 $file_size = filesize( $file['tmp_name'] ); 1288 1289 if ( $space_left < $file_size ) { 1290 return new WP_Error( 1291 'rest_upload_limited_space', 1292 /* translators: %s: Required disk space in kilobytes. */ 1293 sprintf( __( 'Not enough space to upload. %s KB needed.' ), number_format( ( $file_size - $space_left ) / KB_IN_BYTES ) ), 1294 array( 'status' => 400 ) 1295 ); 1296 } 1297 1298 if ( $file_size > ( KB_IN_BYTES * get_site_option( 'fileupload_maxk', 1500 ) ) ) { 1299 return new WP_Error( 1300 'rest_upload_file_too_big', 1301 /* translators: %s: Maximum allowed file size in kilobytes. */ 1302 sprintf( __( 'This file is too big. Files must be less than %s KB in size.' ), get_site_option( 'fileupload_maxk', 1500 ) ), 1303 array( 'status' => 400 ) 1304 ); 1305 } 1306 1307 // Include multisite admin functions to get access to upload_is_user_over_quota(). 1308 require_once ABSPATH . 'wp-admin/includes/ms.php'; 1309 1310 if ( upload_is_user_over_quota( false ) ) { 1311 return new WP_Error( 1312 'rest_upload_user_quota_exceeded', 1313 __( 'You have used your space quota. Please delete files before uploading.' ), 1314 array( 'status' => 400 ) 1315 ); 1316 } 1317 1318 return true; 1319 } 1320 1321 /** 1322 * Gets the request args for the edit item route. 1323 * 1324 * @since 5.5.0 1325 * 1326 * @return array 1327 */ 1328 protected function get_edit_media_item_args() { 1329 return array( 1330 'src' => array( 1331 'description' => __( 'URL to the edited image file.' ), 1332 'type' => 'string', 1333 'format' => 'uri', 1334 'required' => true, 1335 ), 1336 'modifiers' => array( 1337 'description' => __( 'Array of image edits.' ), 1338 'type' => 'array', 1339 'minItems' => 1, 1340 'items' => array( 1341 'description' => __( 'Image edit.' ), 1342 'type' => 'object', 1343 'required' => array( 1344 'type', 1345 'args', 1346 ), 1347 'oneOf' => array( 1348 array( 1349 'title' => __( 'Rotation' ), 1350 'properties' => array( 1351 'type' => array( 1352 'description' => __( 'Rotation type.' ), 1353 'type' => 'string', 1354 'enum' => array( 'rotate' ), 1355 ), 1356 'args' => array( 1357 'description' => __( 'Rotation arguments.' ), 1358 'type' => 'object', 1359 'required' => array( 1360 'angle', 1361 ), 1362 'properties' => array( 1363 'angle' => array( 1364 'description' => __( 'Angle to rotate clockwise in degrees.' ), 1365 'type' => 'number', 1366 ), 1367 ), 1368 ), 1369 ), 1370 ), 1371 array( 1372 'title' => __( 'Crop' ), 1373 'properties' => array( 1374 'type' => array( 1375 'description' => __( 'Crop type.' ), 1376 'type' => 'string', 1377 'enum' => array( 'crop' ), 1378 ), 1379 'args' => array( 1380 'description' => __( 'Crop arguments.' ), 1381 'type' => 'object', 1382 'required' => array( 1383 'left', 1384 'top', 1385 'width', 1386 'height', 1387 ), 1388 'properties' => array( 1389 'left' => array( 1390 'description' => __( 'Horizontal position from the left to begin the crop as a percentage of the image width.' ), 1391 'type' => 'number', 1392 ), 1393 'top' => array( 1394 'description' => __( 'Vertical position from the top to begin the crop as a percentage of the image height.' ), 1395 'type' => 'number', 1396 ), 1397 'width' => array( 1398 'description' => __( 'Width of the crop as a percentage of the image width.' ), 1399 'type' => 'number', 1400 ), 1401 'height' => array( 1402 'description' => __( 'Height of the crop as a percentage of the image height.' ), 1403 'type' => 'number', 1404 ), 1405 ), 1406 ), 1407 ), 1408 ), 1409 ), 1410 ), 1411 ), 1412 'rotation' => array( 1413 'description' => __( 'The amount to rotate the image clockwise in degrees. DEPRECATED: Use `modifiers` instead.' ), 1414 'type' => 'integer', 1415 'minimum' => 0, 1416 'exclusiveMinimum' => true, 1417 'maximum' => 360, 1418 'exclusiveMaximum' => true, 1419 ), 1420 'x' => array( 1421 'description' => __( 'As a percentage of the image, the x position to start the crop from. DEPRECATED: Use `modifiers` instead.' ), 1422 'type' => 'number', 1423 'minimum' => 0, 1424 'maximum' => 100, 1425 ), 1426 'y' => array( 1427 'description' => __( 'As a percentage of the image, the y position to start the crop from. DEPRECATED: Use `modifiers` instead.' ), 1428 'type' => 'number', 1429 'minimum' => 0, 1430 'maximum' => 100, 1431 ), 1432 'width' => array( 1433 'description' => __( 'As a percentage of the image, the width to crop the image to. DEPRECATED: Use `modifiers` instead.' ), 1434 'type' => 'number', 1435 'minimum' => 0, 1436 'maximum' => 100, 1437 ), 1438 'height' => array( 1439 'description' => __( 'As a percentage of the image, the height to crop the image to. DEPRECATED: Use `modifiers` instead.' ), 1440 'type' => 'number', 1441 'minimum' => 0, 1442 'maximum' => 100, 1443 ), 1444 ); 1445 } 1446 1447 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
Generated: Tue Dec 24 01:00:02 2024 | Cross-referenced by PHPXref 0.7.1 |