PluginProbe
Gutenberg / trunk
Gutenberg vtrunk
24.1.0 24.0.0 23.9.1 23.9.0 23.8.0 23.7.2 23.7.1 23.7.0 23.6.1 23.6.2 23.6.0 23.5.3 23.5.2 23.5.1 23.5.0 23.4.0 23.3.2 23.3.1 23.3.0 23.2.0 23.2.1 23.2.2 23.1.1 23.1.0 23.0.1 All 404 releases
gutenberg / lib / media / load.php

load.php in Gutenberg trunk, at lib/media/load.php

472 lines 16.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Adds media-related functionality for client-side media processing.
4 *
5 * This file is structured in two tiers:
6 *
7 * 1. HEIC infrastructure — loaded whenever the feature filter is enabled.
8 * Browsers like Safari can decode HEIC via createImageBitmap() even
9 * without VIPS/SharedArrayBuffer, so HEIC MIME types, the custom REST
10 * controller, and REST field/index registrations are always needed.
11 *
12 * 2. Full VIPS/WASM processing — loaded only when the feature filter is
13 * enabled AND requires cross-origin isolation (DIP) at runtime.
14 *
15 * @package gutenberg
16 */
17
18 if ( ! gutenberg_is_client_side_media_processing_enabled() ) {
19 return;
20 }
21
22 // Animated GIF → video: clean up the sideloaded companion video and
23 // poster when their GIF attachment is deleted. The GIF→video swap itself
24 // happens in the editor (the converted block is a real core/video), so no
25 // render-time filtering is needed.
26 require_once __DIR__ . '/animated-gif-to-video.php';
27
28 // ── Tier 1: HEIC infrastructure (always loaded) ─────────────────────
29
30 /**
31 * Registers HEIC/HEIF as allowed upload MIME types.
32 *
33 * HEIC images can be decoded in the browser (via canvas/VideoDecoder).
34 * Registering these MIME types ensures the file picker's accept attribute
35 * includes them, preventing macOS from silently converting HEIC to JPEG
36 * on selection.
37 *
38 * @param array $mimes Allowed MIME types (extension => type).
39 * @return array Modified MIME types.
40 */
41 function gutenberg_add_heic_upload_mimes( array $mimes ): array {
42 $mimes['heic'] = 'image/heic';
43 $mimes['heif'] = 'image/heif';
44 return $mimes;
45 }
46
47 add_filter( 'upload_mimes', 'gutenberg_add_heic_upload_mimes' );
48
49 /**
50 * Overrides the REST controller for the attachment post type.
51 *
52 * @param array $args Array of arguments for registering a post type.
53 * See the register_post_type() function for accepted arguments.
54 * @param string $post_type Post type key.
55 */
56 function gutenberg_filter_attachment_post_type_args( array $args, string $post_type ): array {
57 if ( 'attachment' === $post_type ) {
58 require_once __DIR__ . '/class-gutenberg-rest-attachments-controller.php';
59
60 $args['rest_controller_class'] = Gutenberg_REST_Attachments_Controller::class;
61 }
62
63 return $args;
64 }
65
66 add_filter( 'register_post_type_args', 'gutenberg_filter_attachment_post_type_args', 10, 2 );
67
68 /**
69 * Registers additional REST fields for attachments.
70 */
71 function gutenberg_media_processing_register_rest_fields(): void {
72 register_rest_field(
73 'attachment',
74 'filename',
75 array(
76 'schema' => array(
77 'description' => __( 'Original attachment file name', 'gutenberg' ),
78 'type' => 'string',
79 'context' => array( 'view', 'edit' ),
80 ),
81 'get_callback' => 'gutenberg_rest_get_attachment_filename',
82 )
83 );
84
85 register_rest_field(
86 'attachment',
87 'filesize',
88 array(
89 'schema' => array(
90 'description' => __( 'Attachment file size', 'gutenberg' ),
91 'type' => 'number',
92 'context' => array( 'view', 'edit' ),
93 ),
94 'get_callback' => 'gutenberg_rest_get_attachment_filesize',
95 )
96 );
97 }
98
99 add_action( 'rest_api_init', 'gutenberg_media_processing_register_rest_fields' );
100
101 /**
102 * Returns the attachment's original file name.
103 *
104 * @param array $post Post data.
105 * @return string|null Attachment file name.
106 */
107 function gutenberg_rest_get_attachment_filename( array $post ): ?string {
108 $path = wp_get_original_image_path( $post['id'] );
109
110 if ( $path ) {
111 return basename( $path );
112 }
113
114 $path = get_attached_file( $post['id'] );
115
116 if ( $path ) {
117 return basename( $path );
118 }
119
120 return null;
121 }
122
123 /**
124 * Returns the attachment's file size in bytes.
125 *
126 * @param array $post Post data.
127 * @return int|null Attachment file size.
128 */
129 function gutenberg_rest_get_attachment_filesize( array $post ): ?int {
130 $attachment_id = $post['id'];
131
132 $meta = wp_get_attachment_metadata( $attachment_id );
133
134 if ( isset( $meta['filesize'] ) ) {
135 return $meta['filesize'];
136 }
137
138 $original_path = wp_get_original_image_path( $attachment_id );
139 $attached_file = $original_path ? $original_path : get_attached_file( $attachment_id );
140
141 if ( is_string( $attached_file ) && file_exists( $attached_file ) ) {
142 return wp_filesize( $attached_file );
143 }
144
145 return null;
146 }
147
148 /**
149 * Returns a list of all available image sizes.
150 *
151 * @return array Existing image sizes.
152 */
153 function gutenberg_get_all_image_sizes(): array {
154 $sizes = wp_get_registered_image_subsizes();
155
156 foreach ( $sizes as $name => &$size ) {
157 $size['height'] = (int) $size['height'];
158 $size['width'] = (int) $size['width'];
159 $size['name'] = $name;
160 }
161 unset( $size );
162
163 return $sizes;
164 }
165
166 /**
167 * Filters the REST API root index data to add custom settings.
168 *
169 * @param WP_REST_Response $response Response data.
170 */
171 function gutenberg_media_processing_filter_rest_index( WP_REST_Response $response ) {
172 /** This filter is documented in wp-admin/includes/image.php */
173 $image_size_threshold = (int) apply_filters( 'big_image_size_threshold', 2560, array( 0, 0 ), '', 0 ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
174
175 /** This filter is documented in wp-includes/class-wp-image-editor-imagick.php */
176 $image_strip_meta = (bool) apply_filters( 'image_strip_meta', true ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
177
178 /*
179 * On the server, this filter receives the decoded image's actual bit depth.
180 * The client path never decodes the image on the server, so the filter is
181 * applied with 16 (the maximum depth vips can produce) as both the value
182 * and the current depth. The client caps its output bit depth at the
183 * filtered value, so a plugin lowering it (e.g. to 8) takes effect on
184 * client-generated images too.
185 */
186 /** This filter is documented in wp-includes/class-wp-image-editor-imagick.php */
187 $image_max_bit_depth = (int) apply_filters( 'image_max_bit_depth', 16, 16 ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
188
189 if ( current_user_can( 'upload_files' ) ) {
190 $response->data['image_sizes'] = gutenberg_get_all_image_sizes();
191 $response->data['image_size_threshold'] = $image_size_threshold;
192 $response->data['image_strip_meta'] = $image_strip_meta;
193 $response->data['image_max_bit_depth'] = $image_max_bit_depth;
194 }
195
196 return $response;
197 }
198
199 add_filter( 'rest_index', 'gutenberg_media_processing_filter_rest_index' );
200
201 /**
202 * Sets a global JS variable to indicate that client-side media processing is enabled.
203 *
204 * The flag gates both processing modes: the full VIPS/WASM pipeline (browsers
205 * that pass feature detection) and the HEIC canvas fallback used by browsers
206 * such as Safari that can decode HEIC via createImageBitmap() but lack
207 * SharedArrayBuffer support. The browser-capability check happens client-side.
208 */
209 function gutenberg_set_client_side_media_processing_flag() {
210 // Re-check the filter at action time, since other plugins (loaded after Gutenberg)
211 // may have added a filter to disable client-side media processing.
212 if ( ! gutenberg_is_client_side_media_processing_enabled() ) {
213 return;
214 }
215 wp_add_inline_script( 'wp-block-editor', 'window.__clientSideMediaProcessing = true', 'before' );
216 }
217 add_action( 'admin_init', 'gutenberg_set_client_side_media_processing_flag' );
218
219 /**
220 * Deletes the source-format companion file when its attachment is deleted.
221 *
222 * When the client-side media flow sideloads a source-format original (such as
223 * a HEIC file) alongside a web-viewable derivative, the original's filename is
224 * recorded in the 'source_image' metadata key. WordPress only tracks
225 * 'original_image' in wp_delete_attachment_files(), so without this hook the
226 * companion file would linger on disk after the attachment is deleted.
227 *
228 * @param int $post_id Attachment ID being deleted.
229 * @return bool Whether a companion file was deleted.
230 */
231 function gutenberg_delete_heic_companion_file( int $post_id ): bool {
232 $metadata = wp_get_attachment_metadata( $post_id, true );
233
234 $source_image = $metadata['source_image'] ?? null;
235 if ( ! is_string( $source_image ) || '' === $source_image ) {
236 return false;
237 }
238
239 $attached_file = get_attached_file( $post_id, true );
240
241 if ( ! $attached_file ) {
242 return false;
243 }
244
245 $uploads = wp_get_upload_dir();
246
247 if ( empty( $uploads['basedir'] ) ) {
248 return false;
249 }
250
251 $companion_path = path_join( dirname( $attached_file ), wp_basename( $source_image ) );
252
253 if ( ! file_exists( $companion_path ) ) {
254 return false;
255 }
256
257 return wp_delete_file_from_directory( $companion_path, $uploads['basedir'] );
258 }
259
260 add_action( 'delete_attachment', 'gutenberg_delete_heic_companion_file' );
261
262 // ── Tier 2: Full client-side processing (VIPS/WASM) ─────────────────
263 // Everything below requires cross-origin isolation (Document-Isolation-Policy)
264 // and SharedArrayBuffer support, which is only available in Chromium 137+.
265
266 /**
267 * Filters the list of rewrite rules formatted for output to an .htaccess file.
268 *
269 * Adds support for serving wasm-vips locally.
270 *
271 * @param string $rules mod_rewrite Rewrite rules formatted for .htaccess.
272 * @return string Filtered rewrite rules.
273 */
274 function gutenberg_filter_mod_rewrite_rules( string $rules ): string {
275 $rules .= "\n# BEGIN Gutenberg client-side media processing\n" .
276 "AddType application/wasm wasm\n" .
277 "# END Gutenberg client-side media processing\n";
278
279 return $rules;
280 }
281
282 add_filter( 'mod_rewrite_rules', 'gutenberg_filter_mod_rewrite_rules' );
283
284 /**
285 * Returns the major Chromium version from the current request's User-Agent.
286 *
287 * Matches all Chromium-based browsers (Chrome, Edge, Opera, Brave).
288 *
289 * @return int|null The major Chromium version, or null if not a Chromium browser.
290 */
291 function gutenberg_get_chromium_major_version(): ?int {
292 if ( empty( $_SERVER['HTTP_USER_AGENT'] ) ) {
293 return null;
294 }
295 if ( preg_match( '/Chrome\/(\d+)/', $_SERVER['HTTP_USER_AGENT'], $matches ) ) {
296 return (int) $matches[1];
297 }
298 return null;
299 }
300
301 /**
302 * Enables cross-origin isolation in the block editor.
303 *
304 * Required for enabling SharedArrayBuffer for WebAssembly-based
305 * media processing in the editor. Uses Document-Isolation-Policy
306 * on supported browsers (Chromium 137+).
307 */
308 function gutenberg_set_up_cross_origin_isolation() {
309 // Re-check the filter at action time, since other plugins (loaded after Gutenberg)
310 // may have added a filter to disable client-side media processing.
311 if ( ! gutenberg_is_client_side_media_processing_enabled() ) {
312 return;
313 }
314
315 $screen = get_current_screen();
316
317 if ( ! $screen ) {
318 return;
319 }
320
321 if ( ! $screen->is_block_editor() && 'site-editor' !== $screen->id && ! ( 'widgets' === $screen->id && wp_use_widgets_block_editor() ) ) {
322 return;
323 }
324
325 // Skip when rendering the classic-theme home route, which shows the site
326 // preview in an iframe and must reach its `contentDocument` to neutralize
327 // interactive elements — DIP would block that.
328 if ( 'site-editor' === $screen->id && ! wp_is_block_theme() && ( ! isset( $_GET['p'] ) || '/' === $_GET['p'] ) ) {
329 return;
330 }
331
332 // Skip when a third-party page builder overrides the block editor.
333 // DIP isolates the document into its own agent cluster,
334 // which blocks same-origin iframe access that these editors rely on.
335 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
336 if ( isset( $_GET['action'] ) && 'edit' !== $_GET['action'] ) {
337 return;
338 }
339
340 $user_id = get_current_user_id();
341 if ( ! $user_id ) {
342 return;
343 }
344
345 // Cross-origin isolation is not needed if users can't upload files anyway.
346 if ( ! user_can( $user_id, 'upload_files' ) ) {
347 return;
348 }
349
350 gutenberg_send_document_isolation_policy_header();
351 }
352
353 add_action( 'load-post.php', 'gutenberg_set_up_cross_origin_isolation' );
354 add_action( 'load-post-new.php', 'gutenberg_set_up_cross_origin_isolation' );
355 add_action( 'load-site-editor.php', 'gutenberg_set_up_cross_origin_isolation' );
356 add_action( 'load-widgets.php', 'gutenberg_set_up_cross_origin_isolation' );
357
358 // Remove core's COEP/COOP-based cross-origin isolation in favor of
359 // Gutenberg's DIP-based approach, which also skips third-party editors.
360 remove_action( 'load-post.php', 'wp_set_up_cross_origin_isolation' );
361 remove_action( 'load-post-new.php', 'wp_set_up_cross_origin_isolation' );
362 remove_action( 'load-site-editor.php', 'wp_set_up_cross_origin_isolation' );
363 remove_action( 'load-widgets.php', 'wp_set_up_cross_origin_isolation' );
364
365 /**
366 * Sends the Document-Isolation-Policy header for cross-origin isolation.
367 *
368 * `isolate-and-credentialless` loads cross-origin subresources without
369 * credentials instead of blocking them, so no `crossorigin` attribute is
370 * needed on scripts, styles, images, audio, or video for the page to work.
371 * Forcing `crossorigin="anonymous"` would turn those into CORS requests
372 * and break any resource served without `Access-Control-Allow-Origin`,
373 * such as media offloaded to a CDN.
374 *
375 * @return bool Whether the header was sent.
376 */
377 function gutenberg_send_document_isolation_policy_header(): bool {
378 $chromium_version = gutenberg_get_chromium_major_version();
379
380 /**
381 * Filters whether to use Document-Isolation-Policy for cross-origin isolation.
382 *
383 * Document-Isolation-Policy provides per-document cross-origin isolation
384 * without affecting other iframes on the page, avoiding breakage of plugins
385 * whose iframes lose credentials/DOM access.
386 *
387 * @since 21.8.0
388 *
389 * @param bool $use_dip Whether DIP is supported and should be used.
390 */
391 $use_dip = apply_filters(
392 'gutenberg_use_document_isolation_policy',
393 null !== $chromium_version && $chromium_version >= 137
394 );
395
396 if ( ! $use_dip ) {
397 return false;
398 }
399
400 header( 'Document-Isolation-Policy: isolate-and-credentialless' );
401
402 return true;
403 }
404
405 /**
406 * Removes `crossorigin` attributes from the printed media templates.
407 *
408 * WordPress 7.1 forces `crossorigin="anonymous"` onto the AUDIO and VIDEO
409 * tags inside the Backbone `<script type="text/html">` templates whenever
410 * client-side media processing is enabled. Under
411 * `Document-Isolation-Policy: isolate-and-credentialless` the attribute is
412 * not needed to play cross-origin media, and it turns the load into a CORS
413 * request that fails for media served without CORS headers, such as media
414 * offloaded to a CDN. See https://core.trac.wordpress.org/ticket/65930.
415 *
416 * @param string $html The printed media templates.
417 *
418 * @return string Modified media templates.
419 */
420 function gutenberg_remove_media_template_crossorigin_attributes( string $html ): string {
421 /*
422 * The media templates are inside <script type="text/html"> tags,
423 * whose content is treated as raw text by the HTML Tag Processor.
424 * Extract each script block's content, process it separately,
425 * then reassemble the full output.
426 */
427 $script_processor = new WP_HTML_Tag_Processor( $html );
428 while ( $script_processor->next_tag( 'SCRIPT' ) ) {
429 if ( 'text/html' !== $script_processor->get_attribute( 'type' ) ) {
430 continue;
431 }
432 $template_processor = new WP_HTML_Tag_Processor( $script_processor->get_modifiable_text() );
433 while ( $template_processor->next_tag() ) {
434 if (
435 in_array( $template_processor->get_tag(), array( 'AUDIO', 'IMG', 'VIDEO' ), true )
436 && 'anonymous' === $template_processor->get_attribute( 'crossorigin' )
437 ) {
438 $template_processor->remove_attribute( 'crossorigin' );
439 }
440 }
441 $script_processor->set_modifiable_text( $template_processor->get_updated_html() );
442 }
443
444 return $script_processor->get_updated_html();
445 }
446
447 /**
448 * Overrides templates from wp_print_media_templates with custom ones.
449 *
450 * Only needed on WordPress 7.1, the one release whose
451 * `wp_print_media_templates()` injects `crossorigin="anonymous"` itself.
452 */
453 function gutenberg_override_media_templates(): void {
454 if ( ! function_exists( 'wp_add_crossorigin_attributes' ) || function_exists( 'wp_send_document_isolation_policy_header' ) ) {
455 return;
456 }
457
458 remove_action( 'admin_footer', 'wp_print_media_templates' );
459 add_action(
460 'admin_footer',
461 static function (): void {
462 ob_start();
463 wp_print_media_templates();
464 $html = (string) ob_get_clean();
465
466 echo gutenberg_remove_media_template_crossorigin_attributes( $html ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
467 }
468 );
469 }
470
471 add_action( 'wp_enqueue_media', 'gutenberg_override_media_templates' );
472