From 8fe08b74cd42750952a1ffae2e0a11433524ea57 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Tue, 20 Aug 2024 16:29:28 +0200 Subject: [PATCH] Add experiment and PHP changes for client-side media processing --- lib/experimental/editor-settings.php | 3 + ...-gutenberg-rest-attachments-controller.php | 350 +++++++++++++++++ lib/experimental/media/load.php | 366 ++++++++++++++++++ lib/experiments-page.php | 12 + lib/load.php | 3 + phpunit/bootstrap.php | 1 + .../block-editor-settings-mobile-test.php | 10 +- ...nberg-rest-attachments-controller-test.php | 337 ++++++++++++++++ .../media/media-processing-test.php | 197 ++++++++++ 9 files changed, 1274 insertions(+), 5 deletions(-) create mode 100644 lib/experimental/media/class-gutenberg-rest-attachments-controller.php create mode 100644 lib/experimental/media/load.php create mode 100644 phpunit/experimental/media/class-gutenberg-rest-attachments-controller-test.php create mode 100644 phpunit/experimental/media/media-processing-test.php diff --git a/lib/experimental/editor-settings.php b/lib/experimental/editor-settings.php index c34984baa0a619..c6bd99a18bf4c7 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 00000000000000..ae9d7a986c7cfc --- /dev/null +++ b/lib/experimental/media/class-gutenberg-rest-attachments-controller.php @@ -0,0 +1,350 @@ +get_endpoint_args_for_item_schema(); + $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 support server-side format conversion.', 'gutenberg' ), + ); + + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this, 'get_items_permissions_check' ), + 'args' => $this->get_collection_params(), + ), + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'create_item' ), + 'permission_callback' => array( $this, 'create_item_permissions_check' ), + 'args' => $args, + ), + 'allow_batch' => $this->allow_batch, + 'schema' => array( $this, 'get_public_item_schema' ), + ), + true + ); + + register_rest_route( + $this->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', + 'required' => true, + ), + ), + ), + 'allow_batch' => $this->allow_batch, + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + } + + /** + * 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 { + $fields = $this->get_fields_for_response( $request ); + $response = parent::prepare_item_for_response( $item, $request ); + + $data = $response->get_data(); + + if ( rest_is_field_included( 'missing_image_sizes', $fields ) ) { + $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', + ); + + 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 ( false === $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 ( false === $request['convert_format'] ) { + // Prevent image conversion as that is done client-side. + 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 ) { + $post = $this->get_post( $request['id'] ); + + if ( is_wp_error( $post ) ) { + return $post; + } + + if ( ! $this->check_update_permission( $post ) ) { + return new WP_Error( + 'rest_cannot_edit', + __( 'Sorry, you are not allowed to edit this post.', 'gutenberg' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + if ( ! current_user_can( 'upload_files' ) ) { + return new WP_Error( + 'rest_cannot_create', + __( 'Sorry, you are not allowed to upload media on this site.', 'gutenberg' ), + array( 'status' => 400 ) + ); + } + + return true; + } + + /** + * 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']; + + if ( 'attachment' !== get_post_type( $attachment_id ) ) { + return new WP_Error( + 'rest_invalid_param', + __( 'Invalid parent type.', 'gutenberg' ), + array( 'status' => 400 ) + ); + } + + if ( false === $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 ? basename( $attachment_filename ) : null; + + /** + * Filters the result when generating a unique file name. + * + * @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 = static function ( $filename, $ext, $dir, $unique_filename_callback, $alt_filenames, $number ) use ( $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; + }; + + 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'] = 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' => 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_url( sprintf( '%s/%s/%d', $this->namespace, $this->rest_base, $attachment_id ) ) + ); + + $response_request->set_param( 'context', 'edit' ); + + if ( isset( $request['_fields'] ) ) { + $response_request['_fields'] = $request['_fields']; + } + + $response = $this->prepare_item_for_response( get_post( $attachment_id ), $response_request ); + + $response->set_status( 201 ); + $response->header( 'Location', rest_url( sprintf( '%s/%s/%d', $this->namespace, $this->rest_base, $attachment_id ) ) ); + + return $response; + } +} diff --git a/lib/experimental/media/load.php b/lib/experimental/media/load.php new file mode 100644 index 00000000000000..1c020470f5d463 --- /dev/null +++ b/lib/experimental/media/load.php @@ -0,0 +1,366 @@ + &$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 ) { + $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 ) { + if ( ! gutenberg_is_experiment_enabled( 'gutenberg-media-processing' ) ) { + return $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 ( ! gutenberg_is_experiment_enabled( 'gutenberg-media-processing' ) ) { + return $args; + } + + 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 { + if ( ! gutenberg_is_experiment_enabled( 'gutenberg-media-processing' ) ) { + return; + } + + 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() { + if ( ! gutenberg_is_experiment_enabled( 'gutenberg-media-processing' ) ) { + return; + } + + $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 { + if ( ! gutenberg_is_experiment_enabled( 'gutenberg-media-processing' ) ) { + return; + } + + 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 fa95923061daff..83c13b3c386d2c 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' => __( 'Add ability and UI for 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 b501f0abd1c978..00bee150877886 100644 --- a/lib/load.php +++ b/lib/load.php @@ -184,3 +184,6 @@ function gutenberg_is_experiment_enabled( $name ) { // Data views. require_once __DIR__ . '/experimental/data-views.php'; + +// Client-side media processing. +require_once __DIR__ . '/experimental/media/load.php'; diff --git a/phpunit/bootstrap.php b/phpunit/bootstrap.php index 55319a752b61e6..5d078193f0c3bf 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/block-editor-settings-mobile-test.php b/phpunit/experimental/block-editor-settings-mobile-test.php index 907b750d8c8638..b850279d2ca4d1 100644 --- a/phpunit/experimental/block-editor-settings-mobile-test.php +++ b/phpunit/experimental/block-editor-settings-mobile-test.php @@ -10,11 +10,6 @@ * * @covers WP_REST_Block_Editor_Settings_Controller */ - -if ( ! defined( 'REST_REQUEST' ) ) { - define( 'REST_REQUEST', true ); -} - class Gutenberg_REST_Block_Editor_Settings_Controller_Test extends WP_Test_REST_Controller_Testcase { /** * @var int @@ -54,6 +49,11 @@ public function test_register_routes() { } public function test_get_items() { + // See https://github.com/WordPress/gutenberg/pull/64743. + if ( ! defined( 'REST_REQUEST' ) ) { + define( 'REST_REQUEST', true ); + } + wp_set_current_user( self::$admin_id ); $request = new WP_REST_Request( 'GET', '/wp-block-editor/v1/settings' ); // Set context for mobile settings. 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 00000000000000..19d119ee3ec368 --- /dev/null +++ b/phpunit/experimental/media/class-gutenberg-rest-attachments-controller-test.php @@ -0,0 +1,337 @@ +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 ); + } + + self::$image_file_2 = get_temp_dir() . 'canola.jpg'; + if ( ! file_exists( self::$image_file_2 ) ) { + copy( DIR_TESTDATA . '/images/canola.jpg', self::$image_file_2 ); + } + + self::$pdf_file = get_temp_dir() . 'test-alpha.pdf'; + if ( ! file_exists( self::$pdf_file ) ) { + copy( DIR_TESTDATA . '/images/test-alpha.pdf', self::$pdf_file ); + } + } + + 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( self::$image_file ) ); + $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( self::$image_file ) ); + $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'] ); + } + + /** + * Verifies that the controller adds missing_image_sizes to the response for PDFs. + * + * @covers ::prepare_item_for_response + */ + public function test_prepare_item() { + wp_set_current_user( self::$admin_id ); + + $attachment_id = self::factory()->attachment->create_object( + self::$pdf_file, + 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( + self::$image_file, + 0, + array( + 'post_mime_type' => 'image/jpeg', + 'post_excerpt' => 'A sample caption', + ) + ); + + wp_update_attachment_metadata( $attachment_id, wp_generate_attachment_metadata( $attachment_id, self::$image_file ) ); + + $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.jpg' ); + $request->set_param( 'image_size', 'medium' ); + + $request->set_body( file_get_contents( self::$image_file_2 ) ); + $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->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.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.jpg' ); + $request->set_param( 'post', $published_post ); + $request->set_param( 'generate_sub_sizes', false ); + + $request->set_body( file_get_contents( self::$image_file ) ); + $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.jpg' ); + $request->set_param( 'image_size', 'medium' ); + + $request->set_body( file_get_contents( self::$image_file_2 ) ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + update_option( 'uploads_use_yearmonth_folders', 0 ); + + $this->assertSame( 201, $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.jpg' ); + $request->set_param( 'post', $published_post ); + $request->set_param( 'generate_sub_sizes', false ); + + $request->set_body( file_get_contents( self::$image_file ) ); + $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.jpg' ); + $request->set_param( 'image_size', 'medium' ); + + $request->set_body( file_get_contents( self::$image_file_2 ) ); + $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( 201, $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 00000000000000..2717d2582879ff --- /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( '