PluginProbe
Gutenberg / 23.3.2
Gutenberg v23.3.2
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.3.2, at lib/media/load.php

467 lines 13.8 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 // Client-side media processing is currently plugin-only while the feature matures.
19 if ( ! defined( 'IS_GUTENBERG_PLUGIN' ) || ! IS_GUTENBERG_PLUGIN ) {
20 return;
21 }
22
23 if ( ! gutenberg_is_client_side_media_processing_enabled() ) {
24 return;
25 }
26
27 // ── Tier 1: HEIC infrastructure (always loaded) ─────────────────────
28
29 /**
30 * Registers HEIC/HEIF as allowed upload MIME types.
31 *
32 * HEIC images can be decoded in the browser (via canvas/VideoDecoder).
33 * Registering these MIME types ensures the file picker's accept attribute
34 * includes them, preventing macOS from silently converting HEIC to JPEG
35 * on selection.
36 *
37 * @param array $mimes Allowed MIME types (extension => type).
38 * @return array Modified MIME types.
39 */
40 function gutenberg_add_heic_upload_mimes( array $mimes ): array {
41 $mimes['heic'] = 'image/heic';
42 $mimes['heif'] = 'image/heif';
43 return $mimes;
44 }
45
46 add_filter( 'upload_mimes', 'gutenberg_add_heic_upload_mimes' );
47
48 /**
49 * Overrides the REST controller for the attachment post type.
50 *
51 * @param array $args Array of arguments for registering a post type.
52 * See the register_post_type() function for accepted arguments.
53 * @param string $post_type Post type key.
54 */
55 function gutenberg_filter_attachment_post_type_args( array $args, string $post_type ): array {
56 if ( 'attachment' === $post_type ) {
57 require_once __DIR__ . '/class-gutenberg-rest-attachments-controller.php';
58
59 $args['rest_controller_class'] = Gutenberg_REST_Attachments_Controller::class;
60 }
61
62 return $args;
63 }
64
65 add_filter( 'register_post_type_args', 'gutenberg_filter_attachment_post_type_args', 10, 2 );
66
67 /**
68 * Registers additional REST fields for attachments.
69 */
70 function gutenberg_media_processing_register_rest_fields(): void {
71 register_rest_field(
72 'attachment',
73 'filename',
74 array(
75 'schema' => array(
76 'description' => __( 'Original attachment file name', 'gutenberg' ),
77 'type' => 'string',
78 'context' => array( 'view', 'edit' ),
79 ),
80 'get_callback' => 'gutenberg_rest_get_attachment_filename',
81 )
82 );
83
84 register_rest_field(
85 'attachment',
86 'filesize',
87 array(
88 'schema' => array(
89 'description' => __( 'Attachment file size', 'gutenberg' ),
90 'type' => 'number',
91 'context' => array( 'view', 'edit' ),
92 ),
93 'get_callback' => 'gutenberg_rest_get_attachment_filesize',
94 )
95 );
96 }
97
98 add_action( 'rest_api_init', 'gutenberg_media_processing_register_rest_fields' );
99
100 /**
101 * Returns the attachment's original file name.
102 *
103 * @param array $post Post data.
104 * @return string|null Attachment file name.
105 */
106 function gutenberg_rest_get_attachment_filename( array $post ): ?string {
107 $path = wp_get_original_image_path( $post['id'] );
108
109 if ( $path ) {
110 return basename( $path );
111 }
112
113 $path = get_attached_file( $post['id'] );
114
115 if ( $path ) {
116 return basename( $path );
117 }
118
119 return null;
120 }
121
122 /**
123 * Returns the attachment's file size in bytes.
124 *
125 * @param array $post Post data.
126 * @return int|null Attachment file size.
127 */
128 function gutenberg_rest_get_attachment_filesize( array $post ): ?int {
129 $attachment_id = $post['id'];
130
131 $meta = wp_get_attachment_metadata( $attachment_id );
132
133 if ( isset( $meta['filesize'] ) ) {
134 return $meta['filesize'];
135 }
136
137 $original_path = wp_get_original_image_path( $attachment_id );
138 $attached_file = $original_path ? $original_path : get_attached_file( $attachment_id );
139
140 if ( is_string( $attached_file ) && file_exists( $attached_file ) ) {
141 return wp_filesize( $attached_file );
142 }
143
144 return null;
145 }
146
147 /**
148 * Returns a list of all available image sizes.
149 *
150 * @return array Existing image sizes.
151 */
152 function gutenberg_get_all_image_sizes(): array {
153 $sizes = wp_get_registered_image_subsizes();
154
155 foreach ( $sizes as $name => &$size ) {
156 $size['height'] = (int) $size['height'];
157 $size['width'] = (int) $size['width'];
158 $size['name'] = $name;
159 }
160 unset( $size );
161
162 return $sizes;
163 }
164
165 /**
166 * Filters the REST API root index data to add custom settings.
167 *
168 * @param WP_REST_Response $response Response data.
169 */
170 function gutenberg_media_processing_filter_rest_index( WP_REST_Response $response ) {
171 /** This filter is documented in wp-admin/includes/images.php */
172 $image_size_threshold = (int) apply_filters( 'big_image_size_threshold', 2560, array( 0, 0 ), '', 0 ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
173
174 if ( current_user_can( 'upload_files' ) ) {
175 $response->data['image_sizes'] = gutenberg_get_all_image_sizes();
176 $response->data['image_size_threshold'] = $image_size_threshold;
177 }
178
179 return $response;
180 }
181
182 add_filter( 'rest_index', 'gutenberg_media_processing_filter_rest_index' );
183
184 /**
185 * Sets a global JS variable to indicate that HEIC canvas-based upload support is available.
186 *
187 * This flag is set whenever the media processing feature is enabled,
188 * regardless of whether the browser supports full VIPS-based processing.
189 * Browsers like Safari can use createImageBitmap() to decode HEIC images
190 * and convert them to JPEG for server-side sub-size generation.
191 */
192 function gutenberg_set_heic_upload_support_flag() {
193 wp_add_inline_script( 'wp-block-editor', 'window.__heicUploadSupport = true', 'before' );
194 }
195 add_action( 'admin_init', 'gutenberg_set_heic_upload_support_flag' );
196
197 /**
198 * Deletes the HEIC companion file when its attachment is deleted.
199 *
200 * The HEIC is sideloaded alongside a JPEG derivative and recorded in
201 * $metadata['original']. WordPress core's wp_delete_attachment_files()
202 * only knows about 'original_image', so without this hook the HEIC
203 * would linger on disk after the attachment is deleted.
204 *
205 * @param int $post_id Attachment ID being deleted.
206 */
207 function gutenberg_delete_heic_companion_file( int $post_id ): void {
208 $metadata = wp_get_attachment_metadata( $post_id, true );
209
210 if ( empty( $metadata['original'] ) || ! is_string( $metadata['original'] ) ) {
211 return;
212 }
213
214 $attached_file = get_attached_file( $post_id, true );
215
216 if ( ! $attached_file ) {
217 return;
218 }
219
220 $heic_path = path_join( dirname( $attached_file ), $metadata['original'] );
221
222 if ( file_exists( $heic_path ) ) {
223 wp_delete_file( $heic_path );
224 }
225 }
226
227 add_action( 'delete_attachment', 'gutenberg_delete_heic_companion_file' );
228
229 // ── Tier 2: Full client-side processing (VIPS/WASM) ─────────────────
230 // Everything below requires cross-origin isolation (Document-Isolation-Policy)
231 // and SharedArrayBuffer support, which is only available in Chromium 137+.
232
233 /**
234 * Sets a global JS variable to indicate that client-side media processing is enabled.
235 */
236 function gutenberg_set_client_side_media_processing_flag() {
237 if ( ! gutenberg_is_client_side_media_processing_enabled() ) {
238 return;
239 }
240 wp_add_inline_script( 'wp-block-editor', 'window.__clientSideMediaProcessing = true', 'before' );
241 }
242 add_action( 'admin_init', 'gutenberg_set_client_side_media_processing_flag' );
243
244 /**
245 * Filters the list of rewrite rules formatted for output to an .htaccess file.
246 *
247 * Adds support for serving wasm-vips locally.
248 *
249 * @param string $rules mod_rewrite Rewrite rules formatted for .htaccess.
250 * @return string Filtered rewrite rules.
251 */
252 function gutenberg_filter_mod_rewrite_rules( string $rules ): string {
253 $rules .= "\n# BEGIN Gutenberg client-side media processing\n" .
254 "AddType application/wasm wasm\n" .
255 "# END Gutenberg client-side media processing\n";
256
257 return $rules;
258 }
259
260 add_filter( 'mod_rewrite_rules', 'gutenberg_filter_mod_rewrite_rules' );
261
262 /**
263 * Returns the major Chromium version from the current request's User-Agent.
264 *
265 * Matches all Chromium-based browsers (Chrome, Edge, Opera, Brave).
266 *
267 * @return int|null The major Chromium version, or null if not a Chromium browser.
268 */
269 function gutenberg_get_chromium_major_version(): ?int {
270 if ( empty( $_SERVER['HTTP_USER_AGENT'] ) ) {
271 return null;
272 }
273 if ( preg_match( '/Chrome\/(\d+)/', $_SERVER['HTTP_USER_AGENT'], $matches ) ) {
274 return (int) $matches[1];
275 }
276 return null;
277 }
278
279 /**
280 * Enables cross-origin isolation in the block editor.
281 *
282 * Required for enabling SharedArrayBuffer for WebAssembly-based
283 * media processing in the editor. Uses Document-Isolation-Policy
284 * on supported browsers (Chromium 137+).
285 */
286 function gutenberg_set_up_cross_origin_isolation() {
287 // Re-check the filter at action time, since other plugins (loaded after Gutenberg)
288 // may have added a filter to disable client-side media processing.
289 if ( ! gutenberg_is_client_side_media_processing_enabled() ) {
290 return;
291 }
292
293 $screen = get_current_screen();
294
295 if ( ! $screen ) {
296 return;
297 }
298
299 if ( ! $screen->is_block_editor() && 'site-editor' !== $screen->id && ! ( 'widgets' === $screen->id && wp_use_widgets_block_editor() ) ) {
300 return;
301 }
302
303 // Skip when a third-party page builder overrides the block editor.
304 // DIP isolates the document into its own agent cluster,
305 // which blocks same-origin iframe access that these editors rely on.
306 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
307 if ( isset( $_GET['action'] ) && 'edit' !== $_GET['action'] ) {
308 return;
309 }
310
311 $user_id = get_current_user_id();
312 if ( ! $user_id ) {
313 return;
314 }
315
316 // Cross-origin isolation is not needed if users can't upload files anyway.
317 if ( ! user_can( $user_id, 'upload_files' ) ) {
318 return;
319 }
320
321 gutenberg_start_cross_origin_isolation_output_buffer();
322 }
323
324 add_action( 'load-post.php', 'gutenberg_set_up_cross_origin_isolation' );
325 add_action( 'load-post-new.php', 'gutenberg_set_up_cross_origin_isolation' );
326 add_action( 'load-site-editor.php', 'gutenberg_set_up_cross_origin_isolation' );
327 add_action( 'load-widgets.php', 'gutenberg_set_up_cross_origin_isolation' );
328
329 // Remove core's COEP/COOP-based cross-origin isolation in favor of
330 // Gutenberg's DIP-based approach, which also skips third-party editors.
331 remove_action( 'load-post.php', 'wp_set_up_cross_origin_isolation' );
332 remove_action( 'load-post-new.php', 'wp_set_up_cross_origin_isolation' );
333 remove_action( 'load-site-editor.php', 'wp_set_up_cross_origin_isolation' );
334 remove_action( 'load-widgets.php', 'wp_set_up_cross_origin_isolation' );
335
336 /**
337 * Sends the Document-Isolation-Policy header for cross-origin isolation.
338 *
339 * Uses an output buffer to add crossorigin="anonymous" where needed.
340 */
341 function gutenberg_start_cross_origin_isolation_output_buffer(): void {
342 $chromium_version = gutenberg_get_chromium_major_version();
343
344 /**
345 * Filters whether to use Document-Isolation-Policy for cross-origin isolation.
346 *
347 * Document-Isolation-Policy provides per-document cross-origin isolation
348 * without affecting other iframes on the page, avoiding breakage of plugins
349 * whose iframes lose credentials/DOM access.
350 *
351 * @since 21.8.0
352 *
353 * @param bool $use_dip Whether DIP is supported and should be used.
354 */
355 $use_dip = apply_filters(
356 'gutenberg_use_document_isolation_policy',
357 null !== $chromium_version && $chromium_version >= 137
358 );
359
360 if ( ! $use_dip ) {
361 return;
362 }
363
364 ob_start(
365 function ( string $output ): string {
366 header( 'Document-Isolation-Policy: isolate-and-credentialless' );
367
368 return gutenberg_add_crossorigin_attributes( $output );
369 }
370 );
371 }
372
373 /**
374 * Adds crossorigin="anonymous" to relevant tags in the given HTML string.
375 *
376 * @param string $html HTML input.
377 *
378 * @return string Modified HTML.
379 */
380 function gutenberg_add_crossorigin_attributes( string $html ): string {
381 $site_url = site_url();
382
383 $processor = new WP_HTML_Tag_Processor( $html );
384
385 // See https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/crossorigin.
386 $tags = array(
387 'AUDIO' => 'src',
388 'LINK' => 'href',
389 'SCRIPT' => 'src',
390 'VIDEO' => 'src',
391 'SOURCE' => 'src',
392 );
393
394 $tag_names = array_keys( $tags );
395
396 while ( $processor->next_tag() ) {
397 $tag = $processor->get_tag();
398
399 if ( ! in_array( $tag, $tag_names, true ) ) {
400 continue;
401 }
402
403 if ( 'AUDIO' === $tag || 'VIDEO' === $tag ) {
404 $processor->set_bookmark( 'audio-video-parent' );
405 }
406
407 $processor->set_bookmark( 'resume' );
408
409 $sought = false;
410
411 $crossorigin = $processor->get_attribute( 'crossorigin' );
412
413 $url = $processor->get_attribute( $tags[ $tag ] );
414
415 if ( is_string( $url ) && ! str_starts_with( $url, $site_url ) && ! str_starts_with( $url, '/' ) && ! is_string( $crossorigin ) ) {
416 if ( 'SOURCE' === $tag ) {
417 $sought = $processor->seek( 'audio-video-parent' );
418
419 if ( $sought ) {
420 $processor->set_attribute( 'crossorigin', 'anonymous' );
421 }
422 } else {
423 $processor->set_attribute( 'crossorigin', 'anonymous' );
424 }
425
426 if ( $sought ) {
427 $processor->seek( 'resume' );
428 $processor->release_bookmark( 'audio-video-parent' );
429 }
430 }
431 }
432
433 return $processor->get_updated_html();
434 }
435
436 /**
437 * Overrides templates from wp_print_media_templates with custom ones.
438 *
439 * Adds `crossorigin` attribute to all tags that
440 * could have assets loaded from a different domain.
441 */
442 function gutenberg_override_media_templates(): void {
443 remove_action( 'admin_footer', 'wp_print_media_templates' );
444 add_action(
445 'admin_footer',
446 static function (): void {
447 ob_start();
448 wp_print_media_templates();
449 $html = (string) ob_get_clean();
450
451 $tags = array(
452 'audio',
453 'img',
454 'video',
455 );
456
457 foreach ( $tags as $tag ) {
458 $html = (string) str_replace( "<$tag", "<$tag crossorigin=\"anonymous\"", $html );
459 }
460
461 echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
462 }
463 );
464 }
465
466 add_action( 'wp_enqueue_media', 'gutenberg_override_media_templates' );
467