PluginProbe
Gutenberg / 23.4.0
Gutenberg v23.4.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.4.0, at lib/media/load.php

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