diff --git a/lib/experimental/editor-settings.php b/lib/experimental/editor-settings.php index c34984baa0a61..c6bd99a18bf4c 100644 --- a/lib/experimental/editor-settings.php +++ b/lib/experimental/editor-settings.php @@ -37,6 +37,9 @@ function gutenberg_enable_experiments() { if ( $gutenberg_experiments && array_key_exists( 'gutenberg-block-bindings-ui', $gutenberg_experiments ) ) { wp_add_inline_script( 'wp-block-editor', 'window.__experimentalBlockBindingsUI = true', 'before' ); } + if ( $gutenberg_experiments && array_key_exists( 'gutenberg-media-processing', $gutenberg_experiments ) ) { + wp_add_inline_script( 'wp-block-editor', 'window.__experimentalMediaProcessing = true', 'before' ); + } } add_action( 'admin_init', 'gutenberg_enable_experiments' ); diff --git a/lib/experimental/media/class-gutenberg-rest-attachments-controller.php b/lib/experimental/media/class-gutenberg-rest-attachments-controller.php new file mode 100644 index 0000000000000..71bf7b7a95835 --- /dev/null +++ b/lib/experimental/media/class-gutenberg-rest-attachments-controller.php @@ -0,0 +1,367 @@ +namespace, + '/' . $this->rest_base . '/(?P[\d]+)/sideload', + array( + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'sideload_item' ), + 'permission_callback' => array( $this, 'sideload_item_permissions_check' ), + 'args' => array( + 'id' => array( + 'description' => __( 'Unique identifier for the attachment.', 'gutenberg' ), + 'type' => 'integer', + ), + 'image_size' => array( + 'description' => __( 'Image size.', 'gutenberg' ), + 'type' => 'string', + 'enum' => $valid_image_sizes, + 'required' => true, + ), + ), + ), + 'allow_batch' => $this->allow_batch, + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + } + + /** + * Retrieves an array of endpoint arguments from the item schema for the controller. + * + * @param string $method Optional. HTTP method of the request. The arguments for `CREATABLE` requests are + * checked for required values and may fall-back to a given default, this is not done + * on `EDITABLE` requests. Default WP_REST_Server::CREATABLE. + * @return array Endpoint arguments. + */ + public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CREATABLE ) { + $args = rest_get_endpoint_args_for_schema( $this->get_item_schema(), $method ); + + if ( WP_REST_Server::CREATABLE === $method ) { + $args['generate_sub_sizes'] = array( + 'type' => 'boolean', + 'default' => true, + 'description' => __( 'Whether to generate image sub sizes.', 'gutenberg' ), + ); + $args['convert_format'] = array( + 'type' => 'boolean', + 'default' => true, + 'description' => __( 'Whether to convert image formats.', 'gutenberg' ), + ); + } + + return $args; + } + + /** + * Prepares a single attachment output for response. + * + * Ensures 'missing_image_sizes' is set for PDFs and not just images. + * + * @param WP_Post $item Attachment object. + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response Response object. + */ + public function prepare_item_for_response( $item, $request ): WP_REST_Response { + $response = parent::prepare_item_for_response( $item, $request ); + + $data = $response->get_data(); + + // Handle missing image sizes for PDFs. + + $fields = $this->get_fields_for_response( $request ); + + if ( + rest_is_field_included( 'missing_image_sizes', $fields ) && + empty( $data['missing_image_sizes'] ) + ) { + $mime_type = get_post_mime_type( $item ); + + if ( 'application/pdf' === $mime_type ) { + $metadata = wp_get_attachment_metadata( $item->ID, true ); + + if ( ! is_array( $metadata ) ) { + $metadata = array(); + } + + $metadata['sizes'] = $metadata['sizes'] ?? array(); + + $fallback_sizes = array( + 'thumbnail', + 'medium', + 'large', + ); + + // The filter might have been added by ::create_item(). + remove_filter( 'fallback_intermediate_image_sizes', '__return_empty_array', 100 ); + + /** This filter is documented in wp-admin/includes/image.php */ + $fallback_sizes = apply_filters( 'fallback_intermediate_image_sizes', $fallback_sizes, $metadata ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound + + $registered_sizes = wp_get_registered_image_subsizes(); + $merged_sizes = array_keys( array_intersect_key( $registered_sizes, array_flip( $fallback_sizes ) ) ); + + $missing_image_sizes = array_diff( $merged_sizes, array_keys( $metadata['sizes'] ) ); + $data['missing_image_sizes'] = $missing_image_sizes; + } + } + + $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; + $data = $this->add_additional_fields_to_object( $data, $request ); + $data = $this->filter_response_by_context( $data, $context ); + + $links = $response->get_links(); + + $response = rest_ensure_response( $data ); + + foreach ( $links as $rel => $rel_links ) { + foreach ( $rel_links as $link ) { + $response->add_link( $rel, $link['href'], $link['attributes'] ); + } + } + + return $response; + } + + /** + * Creates a single attachment. + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure. + */ + public function create_item( $request ) { + if ( ! $request['generate_sub_sizes'] ) { + add_filter( 'intermediate_image_sizes_advanced', '__return_empty_array', 100 ); + add_filter( 'fallback_intermediate_image_sizes', '__return_empty_array', 100 ); + + } + + if ( ! $request['convert_format'] ) { + add_filter( 'image_editor_output_format', '__return_empty_array', 100 ); + } + + $response = parent::create_item( $request ); + + remove_filter( 'intermediate_image_sizes_advanced', '__return_empty_array', 100 ); + remove_filter( 'fallback_intermediate_image_sizes', '__return_empty_array', 100 ); + remove_filter( 'image_editor_output_format', '__return_empty_array', 100 ); + + return $response; + } + + + /** + * Checks if a given request has access to sideload a file. + * + * Sideloading a file for an existing attachment + * requires both update and create permissions. + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has access to update the item, WP_Error object otherwise. + */ + public function sideload_item_permissions_check( $request ) { + return $this->edit_media_item_permissions_check( $request ); + } + + /** + * Filters {@see 'wp_unique_filename'} during sideloads. + * + * {@see wp_unique_filename()} will always add numeric suffix if the name looks like a sub-size to avoid conflicts. + * + * Adding this closure to the filter helps work around this safeguard. + * + * Example: when uploading myphoto.jpeg, WordPress normally creates myphoto-150x150.jpeg, + * and when uploading myphoto-150x150.jpeg, it will be renamed to myphoto-150x150-1.jpeg + * However, here it is desired not to add the suffix in order to maintain the same + * naming convention as if the file was uploaded regularly. + * + * @link https://github.com/WordPress/wordpress-develop/blob/30954f7ac0840cfdad464928021d7f380940c347/src/wp-includes/functions.php#L2576-L2582 + * + * @param string $filename Unique file name. + * @param string $ext File extension. Example: ".png". + * @param string $dir Directory path. + * @param callable|null $unique_filename_callback Callback function that generates the unique file name. + * @param string[] $alt_filenames Array of alternate file names that were checked for collisions. + * @param int|string $number The highest number that was used to make the file name unique + * or an empty string if unused. + * @return string Filtered file name. + */ + private function filter_wp_unique_filename( $filename, $ext, $dir, $unique_filename_callback, $alt_filenames, $number, $attachment_filename ) { + if ( empty( $number ) || ! $attachment_filename ) { + return $filename; + } + + $ext = pathinfo( $filename, PATHINFO_EXTENSION ); + $name = pathinfo( $filename, PATHINFO_FILENAME ); + $orig_name = pathinfo( $attachment_filename, PATHINFO_FILENAME ); + + if ( ! $ext || ! $name ) { + return $filename; + } + + $matches = array(); + if ( preg_match( '/(.*)(-\d+x\d+)-' . $number . '$/', $name, $matches ) ) { + $filename_without_suffix = $matches[1] . $matches[2] . ".$ext"; + if ( $matches[1] === $orig_name && ! file_exists( "$dir/$filename_without_suffix" ) ) { + return $filename_without_suffix; + } + } + + return $filename; + } + + /** + * Side-loads a media file without creating an attachment. + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure. + */ + public function sideload_item( WP_REST_Request $request ) { + $attachment_id = $request['id']; + + $post = $this->get_post( $attachment_id ); + + if ( is_wp_error( $post ) ) { + return $post; + } + + if ( + ! wp_attachment_is_image( $post ) && + ! wp_attachment_is( 'pdf', $post ) + ) { + return new WP_Error( + 'rest_post_invalid_id', + __( 'Invalid post ID, only images and PDFs can be sideloaded.', 'gutenberg' ), + array( 'status' => 400 ) + ); + } + + if ( ! $request['convert_format'] ) { + // Prevent image conversion as that is done client-side. + add_filter( 'image_editor_output_format', '__return_empty_array', 100 ); + } + + // Get the file via $_FILES or raw data. + $files = $request->get_file_params(); + $headers = $request->get_headers(); + + /* + * wp_unique_filename() will always add numeric suffix if the name looks like a sub-size to avoid conflicts. + * See https://github.com/WordPress/wordpress-develop/blob/30954f7ac0840cfdad464928021d7f380940c347/src/wp-includes/functions.php#L2576-L2582 + * With the following filter we can work around this safeguard. + */ + + $attachment_filename = get_attached_file( $attachment_id, true ); + $attachment_filename = $attachment_filename ? wp_basename( $attachment_filename ) : null; + + /** + * @param string $filename Unique file name. + * @param string $ext File extension. Example: ".png". + * @param string $dir Directory path. + * @param callable|null $unique_filename_callback Callback function that generates the unique file name. + * @param string[] $alt_filenames Array of alternate file names that were checked for collisions. + * @param int|string $number The highest number that was used to make the file name unique + * or an empty string if unused. + * @return string Filtered file name. + */ + $filter_filename = function ( $filename, $ext, $dir, $unique_filename_callback, $alt_filenames, $number ) use ( $attachment_filename ) { + return $this->filter_wp_unique_filename( $filename, $ext, $dir, $unique_filename_callback, $alt_filenames, $number, $attachment_filename ); + }; + + add_filter( 'wp_unique_filename', $filter_filename, 10, 6 ); + + $parent_post = get_post_parent( $attachment_id ); + + $time = null; + + // Matches logic in media_handle_upload(). + // The post date doesn't usually matter for pages, so don't backdate this upload. + if ( $parent_post && 'page' !== $parent_post->post_type && substr( $parent_post->post_date, 0, 4 ) > 0 ) { + $time = $parent_post->post_date; + } + + if ( ! empty( $files ) ) { + $file = $this->upload_from_file( $files, $headers, $time ); + } else { + $file = $this->upload_from_data( $request->get_body(), $headers, $time ); + } + + remove_filter( 'wp_unique_filename', $filter_filename ); + remove_filter( 'image_editor_output_format', '__return_empty_array', 100 ); + + if ( is_wp_error( $file ) ) { + return $file; + } + + $type = $file['type']; + $path = $file['file']; + + $image_size = $request['image_size']; + + $metadata = wp_get_attachment_metadata( $attachment_id, true ); + + if ( ! $metadata ) { + $metadata = array(); + } + + if ( 'original' === $image_size ) { + $metadata['original_image'] = wp_basename( $path ); + } else { + $metadata['sizes'] = $metadata['sizes'] ?? array(); + + $size = wp_getimagesize( $path ); + + $metadata['sizes'][ $image_size ] = array( + 'width' => $size ? $size[0] : 0, + 'height' => $size ? $size[1] : 0, + 'file' => wp_basename( $path ), + 'mime-type' => $type, + 'filesize' => wp_filesize( $path ), + ); + } + + wp_update_attachment_metadata( $attachment_id, $metadata ); + + $response_request = new WP_REST_Request( + WP_REST_Server::READABLE, + rest_get_route_for_post( $attachment_id ) + ); + + $response_request['context'] = 'edit'; + + if ( isset( $request['_fields'] ) ) { + $response_request['_fields'] = $request['_fields']; + } + + $response = $this->prepare_item_for_response( get_post( $attachment_id ), $response_request ); + + $response->header( 'Location', rest_url( rest_get_route_for_post( $attachment_id ) ) ); + + return $response; + } +} diff --git a/lib/experimental/media/load.php b/lib/experimental/media/load.php new file mode 100644 index 0000000000000..5cb16d84e1d8d --- /dev/null +++ b/lib/experimental/media/load.php @@ -0,0 +1,347 @@ + &$size ) { + $size['height'] = (int) $size['height']; + $size['width'] = (int) $size['width']; + $size['name'] = $name; + } + unset( $size ); + + return $sizes; +} + +/** + * Returns the default output format mapping for the supported image formats. + * + * @return array Map of input formats to output formats. + */ +function gutenberg_get_default_image_output_formats() { + $input_formats = array( + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'image/avif', + 'image/heic', + ); + + $output_formats = array(); + + foreach ( $input_formats as $mime_type ) { + /** This filter is documented in wp-includes/media.php */ + $output_formats = apply_filters( + 'image_editor_output_format', // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound + $output_formats, + '', + $mime_type + ); + } + + return $output_formats; +} + +/** + * Filters the REST API root index data to add custom settings. + * + * @param WP_REST_Response $response Response data. + */ +function gutenberg_media_processing_filter_rest_index( WP_REST_Response $response ) { + /** This filter is documented in wp-admin/includes/images.php */ + $image_size_threshold = (int) apply_filters( 'big_image_size_threshold', 2560, array( 0, 0 ), '', 0 ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound + + $default_image_output_formats = gutenberg_get_default_image_output_formats(); + + /** This filter is documented in wp-includes/class-wp-image-editor-imagick.php */ + $jpeg_interlaced = (bool) apply_filters( 'image_save_progressive', false, 'image/jpeg' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound + /** This filter is documented in wp-includes/class-wp-image-editor-imagick.php */ + $png_interlaced = (bool) apply_filters( 'image_save_progressive', false, 'image/png' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound + /** This filter is documented in wp-includes/class-wp-image-editor-imagick.php */ + $gif_interlaced = (bool) apply_filters( 'image_save_progressive', false, 'image/gif' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound + + if ( current_user_can( 'upload_files' ) ) { + $response->data['image_sizes'] = gutenberg_get_all_image_sizes(); + $response->data['image_size_threshold'] = $image_size_threshold; + $response->data['image_output_formats'] = (object) $default_image_output_formats; + $response->data['jpeg_interlaced'] = $jpeg_interlaced; + $response->data['png_interlaced'] = $png_interlaced; + $response->data['gif_interlaced'] = $gif_interlaced; + } + + return $response; +} + +add_filter( 'rest_index', 'gutenberg_media_processing_filter_rest_index' ); + + +/** + * Overrides the REST controller for the attachment post type. + * + * @param array $args Array of arguments for registering a post type. + * See the register_post_type() function for accepted arguments. + * @param string $post_type Post type key. + */ +function gutenberg_filter_attachment_post_type_args( array $args, string $post_type ): array { + if ( 'attachment' === $post_type ) { + require_once __DIR__ . '/class-gutenberg-rest-attachments-controller.php'; + + $args['rest_controller_class'] = Gutenberg_REST_Attachments_Controller::class; + } + + return $args; +} + +add_filter( 'register_post_type_args', 'gutenberg_filter_attachment_post_type_args', 10, 2 ); + + +/** + * Registers additional REST fields for attachments. + */ +function gutenberg_media_processing_register_rest_fields(): void { + register_rest_field( + 'attachment', + 'filename', + array( + 'schema' => array( + 'description' => __( 'Original attachment file name', 'gutenberg' ), + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + ), + 'get_callback' => 'gutenberg_rest_get_attachment_filename', + ) + ); + + register_rest_field( + 'attachment', + 'filesize', + array( + 'schema' => array( + 'description' => __( 'Attachment file size', 'gutenberg' ), + 'type' => 'number', + 'context' => array( 'view', 'edit' ), + ), + 'get_callback' => 'gutenberg_rest_get_attachment_filesize', + ) + ); +} + +add_action( 'rest_api_init', 'gutenberg_media_processing_register_rest_fields' ); + +/** + * Returns the attachment's original file name. + * + * @param array $post Post data. + * @return string|null Attachment file name. + */ +function gutenberg_rest_get_attachment_filename( array $post ): ?string { + $path = wp_get_original_image_path( $post['id'] ); + + if ( $path ) { + return basename( $path ); + } + + $path = get_attached_file( $post['id'] ); + + if ( $path ) { + return basename( $path ); + } + + return null; +} + +/** + * Returns the attachment's file size in bytes. + * + * @param array $post Post data. + * @return int|null Attachment file size. + */ +function gutenberg_rest_get_attachment_filesize( array $post ): ?int { + $attachment_id = $post['id']; + + $meta = wp_get_attachment_metadata( $attachment_id ); + + if ( isset( $meta['filesize'] ) ) { + return $meta['filesize']; + } + + $original_path = wp_get_original_image_path( $attachment_id ); + $attached_file = $original_path ? $original_path : get_attached_file( $attachment_id ); + + if ( is_string( $attached_file ) && file_exists( $attached_file ) ) { + return wp_filesize( $attached_file ); + } + + return null; +} + +/** + * Enables cross-origin isolation in the block editor. + * + * Required for enabling SharedArrayBuffer for WebAssembly-based + * media processing in the editor. + * + * @link https://web.dev/coop-coep/ + */ +function gutenberg_set_up_cross_origin_isolation() { + $screen = get_current_screen(); + + if ( ! $screen ) { + return; + } + + if ( ! $screen->is_block_editor() && 'site-editor' !== $screen->id && ! ( 'widgets' === $screen->id && wp_use_widgets_block_editor() ) ) { + return; + } + + $user_id = get_current_user_id(); + if ( ! $user_id ) { + return; + } + + // Cross-origin isolation is not needed if users can't upload files anyway. + if ( ! user_can( $user_id, 'upload_files' ) ) { + return; + } + + gutenberg_start_cross_origin_isolation_output_buffer(); +} + +add_action( 'load-post.php', 'gutenberg_set_up_cross_origin_isolation' ); +add_action( 'load-post-new.php', 'gutenberg_set_up_cross_origin_isolation' ); +add_action( 'load-site-editor.php', 'gutenberg_set_up_cross_origin_isolation' ); +add_action( 'load-widgets.php', 'gutenberg_set_up_cross_origin_isolation' ); + +/** + * Sends headers for cross-origin isolation. + * + * Uses an output buffer to add crossorigin="anonymous" where needed. + * + * @link https://web.dev/coop-coep/ + */ +function gutenberg_start_cross_origin_isolation_output_buffer(): void { + global $is_safari; + + $coep = $is_safari ? 'require-corp' : 'credentialless'; + + ob_start( + function ( string $output, ?int $phase ) use ( $coep ): string { + // Only send the header when the buffer is not being cleaned. + if ( ( $phase & PHP_OUTPUT_HANDLER_CLEAN ) === 0 ) { + header( 'Cross-Origin-Opener-Policy: same-origin' ); + header( "Cross-Origin-Embedder-Policy: $coep" ); + + $output = gutenberg_add_crossorigin_attributes( $output ); + } + + return $output; + } + ); +} + +/** + * Adds crossorigin="anonymous" to relevant tags in the given HTML string. + * + * @param string $html HTML input. + * + * @return string Modified HTML. + */ +function gutenberg_add_crossorigin_attributes( string $html ): string { + $site_url = site_url(); + + $processor = new WP_HTML_Tag_Processor( $html ); + + // See https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/crossorigin. + $tags = array( + 'AUDIO' => 'src', + 'IMG' => 'src', + 'LINK' => 'href', + 'SCRIPT' => 'src', + 'VIDEO' => 'src', + 'SOURCE' => 'src', + ); + + $tag_names = array_keys( $tags ); + + while ( $processor->next_tag() ) { + $tag = $processor->get_tag(); + + if ( ! in_array( $tag, $tag_names, true ) ) { + continue; + } + + if ( 'AUDIO' === $tag || 'VIDEO' === $tag ) { + $processor->set_bookmark( 'audio-video-parent' ); + } + + $processor->set_bookmark( 'resume' ); + + $seeked = false; + + $crossorigin = $processor->get_attribute( 'crossorigin' ); + + $url = $processor->get_attribute( $tags[ $tag ] ); + + if ( is_string( $url ) && ! str_starts_with( $url, $site_url ) && ! str_starts_with( $url, '/' ) && ! is_string( $crossorigin ) ) { + if ( 'SOURCE' === $tag ) { + $seeked = $processor->seek( 'audio-video-parent' ); + + if ( $seeked ) { + $processor->set_attribute( 'crossorigin', 'anonymous' ); + } + } else { + $processor->set_attribute( 'crossorigin', 'anonymous' ); + } + + if ( $seeked ) { + $processor->seek( 'resume' ); + $processor->release_bookmark( 'audio-video-parent' ); + } + } + } + + return $processor->get_updated_html(); +} + +/** + * Overrides templates from wp_print_media_templates with custom ones. + * + * Adds `crossorigin` attribute to all tags that + * could have assets loaded from a different domain. + */ +function gutenberg_override_media_templates(): void { + remove_action( 'admin_footer', 'wp_print_media_templates' ); + add_action( + 'admin_footer', + static function (): void { + ob_start(); + wp_print_media_templates(); + $html = (string) ob_get_clean(); + + $tags = array( + 'audio', + 'img', + 'video', + ); + + foreach ( $tags as $tag ) { + $html = (string) str_replace( "<$tag", "<$tag crossorigin=\"anonymous\"", $html ); + } + + echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } + ); +} + +add_action( 'wp_enqueue_media', 'gutenberg_override_media_templates' ); diff --git a/lib/experiments-page.php b/lib/experiments-page.php index fa95923061daf..f76dcdca7d18c 100644 --- a/lib/experiments-page.php +++ b/lib/experiments-page.php @@ -187,6 +187,18 @@ function gutenberg_initialize_experiments_settings() { ) ); + add_settings_field( + 'gutenberg-media-processing', + __( 'Client-side media processing', 'gutenberg' ), + 'gutenberg_display_experiment_field', + 'gutenberg-experiments', + 'gutenberg_experiments_section', + array( + 'label' => __( 'Enable client-side media processing.', 'gutenberg' ), + 'id' => 'gutenberg-media-processing', + ) + ); + register_setting( 'gutenberg-experiments', 'gutenberg-experiments' diff --git a/lib/load.php b/lib/load.php index b501f0abd1c97..90f06d79451c8 100644 --- a/lib/load.php +++ b/lib/load.php @@ -184,3 +184,8 @@ function gutenberg_is_experiment_enabled( $name ) { // Data views. require_once __DIR__ . '/experimental/data-views.php'; + +// Client-side media processing. +if ( gutenberg_is_experiment_enabled( 'gutenberg-media-processing' ) ) { + require_once __DIR__ . '/experimental/media/load.php'; +} diff --git a/phpunit/bootstrap.php b/phpunit/bootstrap.php index 55319a752b61e..5d078193f0c3b 100644 --- a/phpunit/bootstrap.php +++ b/phpunit/bootstrap.php @@ -96,6 +96,7 @@ function fail_if_died( $message ) { 'gutenberg-full-site-editing' => 1, 'gutenberg-form-blocks' => 1, 'gutenberg-block-experiments' => 1, + 'gutenberg-media-processing' => 1, ), ); diff --git a/phpunit/experimental/media/class-gutenberg-rest-attachments-controller-test.php b/phpunit/experimental/media/class-gutenberg-rest-attachments-controller-test.php new file mode 100644 index 0000000000000..56286f76ace83 --- /dev/null +++ b/phpunit/experimental/media/class-gutenberg-rest-attachments-controller-test.php @@ -0,0 +1,314 @@ +user->create( + array( + 'role' => 'administrator', + ) + ); + } + + public function set_up() { + parent::set_up(); + + $this->remove_added_uploads(); + } + + public function tear_down() { + $this->remove_added_uploads(); + + parent::tear_down(); + } + + /** + * @covers ::register_routes + */ + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( '/wp/v2/media', $routes ); + $this->assertCount( 2, $routes['/wp/v2/media'] ); + $this->assertArrayHasKey( '/wp/v2/media/(?P[\d]+)', $routes ); + $this->assertCount( 3, $routes['/wp/v2/media/(?P[\d]+)'] ); + $this->assertArrayHasKey( '/wp/v2/media/(?P[\d]+)/sideload', $routes ); + $this->assertCount( 1, $routes['/wp/v2/media/(?P[\d]+)/sideload'] ); + } + + public function test_get_items() { + $this->markTestSkipped( 'No need to implement' ); + } + + public function test_get_item() { + $this->markTestSkipped( 'No need to implement' ); + } + + public function test_update_item() { + $this->markTestSkipped( 'No need to implement' ); + } + + public function test_delete_item() { + $this->markTestSkipped( 'No need to implement' ); + } + + public function test_get_item_schema() { + $this->markTestSkipped( 'No need to implement' ); + } + + public function test_context_param() { + $this->markTestSkipped( 'No need to implement' ); + } + + /** + * Verifies that skipping sub-size generation works. + * + * @covers ::create_item + * @covers ::create_item_permissions_check + */ + public function test_create_item() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=canola.jpg' ); + $request->set_param( 'title', 'My title is very cool' ); + $request->set_param( 'caption', 'This is a better caption.' ); + $request->set_param( 'description', 'Without a description, my attachment is descriptionless.' ); + $request->set_param( 'alt_text', 'Alt text is stored outside post schema.' ); + $request->set_param( 'generate_sub_sizes', false ); + + $request->set_body( file_get_contents( DIR_TESTDATA . '/images/canola.jpg' ) ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 201, $response->get_status() ); + $this->assertSame( 'image', $data['media_type'] ); + $this->assertArrayHasKey( 'missing_image_sizes', $data ); + $this->assertNotEmpty( $data['missing_image_sizes'] ); + } + + /** + * Verifies that skipping sub-size generation works. + * + * @covers ::create_item + * @covers ::create_item_permissions_check + */ + public function test_create_item_insert_additional_metadata() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=canola.jpg' ); + $request->set_param( 'title', 'My title is very cool' ); + $request->set_param( 'caption', 'This is a better caption.' ); + $request->set_param( 'description', 'Without a description, my attachment is descriptionless.' ); + $request->set_param( 'alt_text', 'Alt text is stored outside post schema.' ); + $request->set_param( 'generate_sub_sizes', false ); + + $request->set_body( file_get_contents( DIR_TESTDATA . '/images/canola.jpg' ) ); + $response = rest_get_server()->dispatch( $request ); + + remove_filter( 'wp_generate_attachment_metadata', '__return_empty_array', 1 ); + + $this->assertSame( 201, $response->get_status() ); + + $data = $response->get_data(); + + $this->assertArrayHasKey( 'media_details', $data ); + $this->assertArrayHasKey( 'image_meta', $data['media_details'] ); + } + + public function test_prepare_item() { + $this->markTestSkipped( 'No need to implement' ); + } + + /** + * @covers ::prepare_item_for_response + */ + public function test_prepare_item_lists_missing_image_sizes_for_pdfs() { + wp_set_current_user( self::$admin_id ); + + $attachment_id = self::factory()->attachment->create_object( + DIR_TESTDATA . '/images/test-alpha.pdf', + 0, + array( + 'post_mime_type' => 'application/pdf', + 'post_excerpt' => 'A sample caption', + ) + ); + + $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/media/%d', $attachment_id ) ); + $request->set_param( 'context', 'edit' ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertArrayHasKey( 'missing_image_sizes', $data ); + $this->assertNotEmpty( $data['missing_image_sizes'] ); + $this->assertArrayHasKey( 'filename', $data ); + $this->assertArrayHasKey( 'filesize', $data ); + } + + /** + * @covers ::sideload_item + * @covers ::sideload_item_permissions_check + */ + public function test_sideload_item() { + wp_set_current_user( self::$admin_id ); + + $attachment_id = self::factory()->attachment->create_object( + DIR_TESTDATA . '/images/canola.jpg', + 0, + array( + 'post_mime_type' => 'image/jpeg', + 'post_excerpt' => 'A sample caption', + ) + ); + + wp_update_attachment_metadata( + $attachment_id, + wp_generate_attachment_metadata( $attachment_id, DIR_TESTDATA . '/images/canola.jpg' ) + ); + + $request = new WP_REST_Request( 'POST', "/wp/v2/media/$attachment_id/sideload" ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=canola-777x777.jpg' ); + $request->set_param( 'image_size', 'medium' ); + + $request->set_body( file_get_contents( DIR_TESTDATA . '/images/canola.jpg' ) ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( 'image', $data['media_type'] ); + $this->assertArrayHasKey( 'missing_image_sizes', $data ); + $this->assertEmpty( $data['missing_image_sizes'] ); + $this->assertArrayHasKey( 'media_details', $data ); + $this->assertArrayHasKey( 'sizes', $data['media_details'] ); + $this->assertArrayHasKey( 'medium', $data['media_details']['sizes'] ); + $this->assertArrayHasKey( 'file', $data['media_details']['sizes']['medium'] ); + $this->assertSame( 'canola-777x777.jpg', $data['media_details']['sizes']['medium']['file'] ); + } + + /** + * @covers ::sideload_item + * @covers ::sideload_item_permissions_check + */ + public function test_sideload_item_year_month_based_folders() { + if ( version_compare( get_bloginfo( 'version' ), '6.6-beta1', '<' ) ) { + $this->markTestSkipped( 'This test requires WordPress 6.6+' ); + } + + update_option( 'uploads_use_yearmonth_folders', 1 ); + + wp_set_current_user( self::$admin_id ); + + $published_post = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_date' => '2017-02-14 00:00:00', + 'post_date_gmt' => '2017-02-14 00:00:00', + ) + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=canola-year-month.jpg' ); + $request->set_param( 'post', $published_post ); + $request->set_param( 'generate_sub_sizes', false ); + + $request->set_body( file_get_contents( DIR_TESTDATA . '/images/canola.jpg' ) ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $attachment_id = $data['id']; + + $request = new WP_REST_Request( 'POST', "/wp/v2/media/$attachment_id/sideload" ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=canola-year-month-777x777.jpg' ); + $request->set_param( 'image_size', 'medium' ); + + $request->set_body( file_get_contents( DIR_TESTDATA . '/images/canola.jpg' ) ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + update_option( 'uploads_use_yearmonth_folders', 0 ); + + $this->assertSame( 200, $response->get_status() ); + + $attachment = get_post( $data['id'] ); + + $this->assertSame( $attachment->post_parent, $data['post'] ); + $this->assertSame( $attachment->post_parent, $published_post ); + $this->assertSame( wp_get_attachment_url( $attachment->ID ), $data['source_url'] ); + $this->assertStringContainsString( '2017/02', $data['source_url'] ); + } + + /** + * @covers ::sideload_item + * @covers ::sideload_item_permissions_check + */ + public function test_sideload_item_year_month_based_folders_page_post_type() { + if ( version_compare( get_bloginfo( 'version' ), '6.6-beta1', '<' ) ) { + $this->markTestSkipped( 'This test requires WordPress 6.6+' ); + } + + update_option( 'uploads_use_yearmonth_folders', 1 ); + + wp_set_current_user( self::$admin_id ); + + $published_post = self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + 'post_date' => '2017-02-14 00:00:00', + 'post_date_gmt' => '2017-02-14 00:00:00', + ) + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=canola-year-month-page.jpg' ); + $request->set_param( 'post', $published_post ); + $request->set_param( 'generate_sub_sizes', false ); + + $request->set_body( file_get_contents( DIR_TESTDATA . '/images/canola.jpg' ) ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $attachment_id = $data['id']; + + $request = new WP_REST_Request( 'POST', "/wp/v2/media/$attachment_id/sideload" ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=canola-year-month-page-777x777.jpg' ); + $request->set_param( 'image_size', 'medium' ); + + $request->set_body( file_get_contents( DIR_TESTDATA . '/images/canola.jpg' ) ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + update_option( 'uploads_use_yearmonth_folders', 0 ); + + $time = current_time( 'mysql' ); + $y = substr( $time, 0, 4 ); + $m = substr( $time, 5, 2 ); + $subdir = "/$y/$m"; + + $this->assertSame( 200, $response->get_status() ); + + $attachment = get_post( $data['id'] ); + + $this->assertSame( $attachment->post_parent, $data['post'] ); + $this->assertSame( $attachment->post_parent, $published_post ); + $this->assertSame( wp_get_attachment_url( $attachment->ID ), $data['source_url'] ); + $this->assertStringNotContainsString( '2017/02', $data['source_url'] ); + $this->assertStringContainsString( $subdir, $data['source_url'] ); + } +} diff --git a/phpunit/experimental/media/media-processing-test.php b/phpunit/experimental/media/media-processing-test.php new file mode 100644 index 0000000000000..2717d2582879f --- /dev/null +++ b/phpunit/experimental/media/media-processing-test.php @@ -0,0 +1,197 @@ +user->create( + array( + 'role' => 'administrator', + ) + ); + } + + public function set_up() { + parent::set_up(); + + self::$image_file = get_temp_dir() . 'canola.jpg'; + if ( ! file_exists( self::$image_file ) ) { + copy( DIR_TESTDATA . '/images/canola.jpg', self::$image_file ); + } + } + + public function tear_down() { + $this->remove_added_uploads(); + + parent::tear_down(); + } + + /** + * @covers gutenberg_get_all_image_sizes + */ + public function test_get_all_image_sizes() { + $sizes = gutenberg_get_all_image_sizes(); + $this->assertNotEmpty( $sizes ); + foreach ( $sizes as $size ) { + $this->assertIsInt( $size['width'] ); + $this->assertIsInt( $size['height'] ); + $this->assertIsString( $size['name'] ); + } + } + + /** + * @covers gutenberg_filter_attachment_post_type_args + */ + public function test_filter_attachment_post_type_args() { + $post_type_object = get_post_type_object( 'attachment' ); + $this->assertInstanceOf( Gutenberg_REST_Attachments_Controller::class, $post_type_object->get_rest_controller() ); + + $this->assertSame( + array( 'rest_controller_class' => Gutenberg_REST_Attachments_Controller::class ), + gutenberg_filter_attachment_post_type_args( array(), 'attachment' ) + ); + $this->assertSame( + array(), + gutenberg_filter_attachment_post_type_args( array(), 'post' ) + ); + } + + /** + * @covers ::gutenberg_rest_get_attachment_filesize + */ + public function test_rest_get_attachment_filesize() { + $attachment_id = self::factory()->attachment->create_object( + self::$image_file, + 0, + array( + 'post_mime_type' => 'image/jpeg', + 'post_excerpt' => 'A sample caption', + ) + ); + + $this->assertSame( wp_filesize( self::$image_file ), gutenberg_rest_get_attachment_filesize( array( 'id' => $attachment_id ) ) ); + } + + /** + * @covers ::gutenberg_rest_get_attachment_filename + */ + public function test_rest_get_attachment_filename() { + $attachment_id = self::factory()->attachment->create_object( + self::$image_file, + 0, + array( + 'post_mime_type' => 'image/jpeg', + 'post_excerpt' => 'A sample caption', + ) + ); + + $this->assertSame( 'canola.jpg', gutenberg_rest_get_attachment_filename( array( 'id' => $attachment_id ) ) ); + } + + /** + * @covers ::gutenberg_media_processing_filter_rest_index + */ + public function test_get_rest_index_should_return_additional_settings() { + $server = new WP_REST_Server(); + + $request = new WP_REST_Request( 'GET', '/' ); + $index = $server->dispatch( $request ); + $data = $index->get_data(); + + $this->assertArrayNotHasKey( 'image_size_threshold', $data ); + $this->assertArrayNotHasKey( 'image_output_formats', $data ); + $this->assertArrayNotHasKey( 'jpeg_interlaced', $data ); + $this->assertArrayNotHasKey( 'png_interlaced', $data ); + $this->assertArrayNotHasKey( 'gif_interlaced', $data ); + $this->assertArrayNotHasKey( 'image_sizes', $data ); + } + + /** + * @covers ::gutenberg_media_processing_filter_rest_index + */ + public function test_get_rest_index_should_return_additional_settings_can_upload_files() { + wp_set_current_user( self::$admin_id ); + + $server = new WP_REST_Server(); + + $request = new WP_REST_Request( 'GET', '/' ); + $index = $server->dispatch( $request ); + $data = $index->get_data(); + + $this->assertArrayHasKey( 'image_size_threshold', $data ); + $this->assertArrayHasKey( 'image_output_formats', $data ); + $this->assertArrayHasKey( 'jpeg_interlaced', $data ); + $this->assertArrayHasKey( 'png_interlaced', $data ); + $this->assertArrayHasKey( 'gif_interlaced', $data ); + $this->assertArrayHasKey( 'image_sizes', $data ); + } + + /** + * @covers ::gutenberg_add_crossorigin_attributes + */ + public function test_add_crossorigin_attributes() { + $html = << + + + + + + + + + + + +HTML; + + $expected = << + + + + + + + + + + + +HTML; + + $actual = gutenberg_add_crossorigin_attributes( $html ); + + $this->assertSame( $expected, $actual ); + } + + /** + * @covers ::gutenberg_override_media_templates + */ + public function test_gutenberg_override_media_templates(): void { + if ( ! function_exists( '\wp_print_media_templates' ) ) { + require_once ABSPATH . WPINC . '/media-template.php'; + } + + gutenberg_override_media_templates(); + + ob_start(); + do_action( 'admin_footer' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound + $output = ob_get_clean(); + + $this->assertStringContainsString( '