PluginProbe
Gutenberg / 23.6.0
Gutenberg v23.6.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 12.6.0 7.4.0 All 402 releases
gutenberg / lib / media / load.php

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

496 lines 15.5 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_start_cross_origin_isolation_output_buffer();
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 * Uses an output buffer to add crossorigin="anonymous" where needed.
369 */
370 function gutenberg_start_cross_origin_isolation_output_buffer(): void {
371 $chromium_version = gutenberg_get_chromium_major_version();
372
373 /**
374 * Filters whether to use Document-Isolation-Policy for cross-origin isolation.
375 *
376 * Document-Isolation-Policy provides per-document cross-origin isolation
377 * without affecting other iframes on the page, avoiding breakage of plugins
378 * whose iframes lose credentials/DOM access.
379 *
380 * @since 21.8.0
381 *
382 * @param bool $use_dip Whether DIP is supported and should be used.
383 */
384 $use_dip = apply_filters(
385 'gutenberg_use_document_isolation_policy',
386 null !== $chromium_version && $chromium_version >= 137
387 );
388
389 if ( ! $use_dip ) {
390 return;
391 }
392
393 ob_start(
394 function ( string $output ): string {
395 header( 'Document-Isolation-Policy: isolate-and-credentialless' );
396
397 return gutenberg_add_crossorigin_attributes( $output );
398 }
399 );
400 }
401
402 /**
403 * Adds crossorigin="anonymous" to relevant tags in the given HTML string.
404 *
405 * @param string $html HTML input.
406 *
407 * @return string Modified HTML.
408 */
409 function gutenberg_add_crossorigin_attributes( string $html ): string {
410 $site_url = site_url();
411
412 $processor = new WP_HTML_Tag_Processor( $html );
413
414 // See https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/crossorigin.
415 $tags = array(
416 'AUDIO' => 'src',
417 'LINK' => 'href',
418 'SCRIPT' => 'src',
419 'VIDEO' => 'src',
420 'SOURCE' => 'src',
421 );
422
423 $tag_names = array_keys( $tags );
424
425 while ( $processor->next_tag() ) {
426 $tag = $processor->get_tag();
427
428 if ( ! in_array( $tag, $tag_names, true ) ) {
429 continue;
430 }
431
432 if ( 'AUDIO' === $tag || 'VIDEO' === $tag ) {
433 $processor->set_bookmark( 'audio-video-parent' );
434 }
435
436 $processor->set_bookmark( 'resume' );
437
438 $sought = false;
439
440 $crossorigin = $processor->get_attribute( 'crossorigin' );
441
442 $url = $processor->get_attribute( $tags[ $tag ] );
443
444 if ( is_string( $url ) && ! str_starts_with( $url, $site_url ) && ! str_starts_with( $url, '/' ) && ! is_string( $crossorigin ) ) {
445 if ( 'SOURCE' === $tag ) {
446 $sought = $processor->seek( 'audio-video-parent' );
447
448 if ( $sought ) {
449 $processor->set_attribute( 'crossorigin', 'anonymous' );
450 }
451 } else {
452 $processor->set_attribute( 'crossorigin', 'anonymous' );
453 }
454
455 if ( $sought ) {
456 $processor->seek( 'resume' );
457 $processor->release_bookmark( 'audio-video-parent' );
458 }
459 }
460 }
461
462 return $processor->get_updated_html();
463 }
464
465 /**
466 * Overrides templates from wp_print_media_templates with custom ones.
467 *
468 * Adds `crossorigin` attribute to all tags that
469 * could have assets loaded from a different domain.
470 */
471 function gutenberg_override_media_templates(): void {
472 remove_action( 'admin_footer', 'wp_print_media_templates' );
473 add_action(
474 'admin_footer',
475 static function (): void {
476 ob_start();
477 wp_print_media_templates();
478 $html = (string) ob_get_clean();
479
480 $tags = array(
481 'audio',
482 'img',
483 'video',
484 );
485
486 foreach ( $tags as $tag ) {
487 $html = (string) str_replace( "<$tag", "<$tag crossorigin=\"anonymous\"", $html );
488 }
489
490 echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
491 }
492 );
493 }
494
495 add_action( 'wp_enqueue_media', 'gutenberg_override_media_templates' );
496