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

480 lines 14.3 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 source-format companion file when its attachment is deleted.
194 *
195 * When the client-side media flow sideloads a source-format original (such as
196 * a HEIC file) alongside a web-viewable derivative, the original's filename is
197 * recorded in the 'source_image' metadata key. WordPress only tracks
198 * 'original_image' in wp_delete_attachment_files(), so without this hook the
199 * companion file would linger on disk after the attachment is deleted.
200 *
201 * @param int $post_id Attachment ID being deleted.
202 * @return bool Whether a companion file was deleted.
203 */
204 function gutenberg_delete_heic_companion_file( int $post_id ): bool {
205 $metadata = wp_get_attachment_metadata( $post_id, true );
206
207 $source_image = $metadata['source_image'] ?? null;
208 if ( ! is_string( $source_image ) || '' === $source_image ) {
209 return false;
210 }
211
212 $attached_file = get_attached_file( $post_id, true );
213
214 if ( ! $attached_file ) {
215 return false;
216 }
217
218 $uploads = wp_get_upload_dir();
219
220 if ( empty( $uploads['basedir'] ) ) {
221 return false;
222 }
223
224 $companion_path = path_join( dirname( $attached_file ), wp_basename( $source_image ) );
225
226 if ( ! file_exists( $companion_path ) ) {
227 return false;
228 }
229
230 return wp_delete_file_from_directory( $companion_path, $uploads['basedir'] );
231 }
232
233 add_action( 'delete_attachment', 'gutenberg_delete_heic_companion_file' );
234
235 // ── Tier 2: Full client-side processing (VIPS/WASM) ─────────────────
236 // Everything below requires cross-origin isolation (Document-Isolation-Policy)
237 // and SharedArrayBuffer support, which is only available in Chromium 137+.
238
239 /**
240 * Sets a global JS variable to indicate that client-side media processing is enabled.
241 */
242 function gutenberg_set_client_side_media_processing_flag() {
243 if ( ! gutenberg_is_client_side_media_processing_enabled() ) {
244 return;
245 }
246 wp_add_inline_script( 'wp-block-editor', 'window.__clientSideMediaProcessing = true', 'before' );
247 }
248 add_action( 'admin_init', 'gutenberg_set_client_side_media_processing_flag' );
249
250 /**
251 * Filters the list of rewrite rules formatted for output to an .htaccess file.
252 *
253 * Adds support for serving wasm-vips locally.
254 *
255 * @param string $rules mod_rewrite Rewrite rules formatted for .htaccess.
256 * @return string Filtered rewrite rules.
257 */
258 function gutenberg_filter_mod_rewrite_rules( string $rules ): string {
259 $rules .= "\n# BEGIN Gutenberg client-side media processing\n" .
260 "AddType application/wasm wasm\n" .
261 "# END Gutenberg client-side media processing\n";
262
263 return $rules;
264 }
265
266 add_filter( 'mod_rewrite_rules', 'gutenberg_filter_mod_rewrite_rules' );
267
268 /**
269 * Returns the major Chromium version from the current request's User-Agent.
270 *
271 * Matches all Chromium-based browsers (Chrome, Edge, Opera, Brave).
272 *
273 * @return int|null The major Chromium version, or null if not a Chromium browser.
274 */
275 function gutenberg_get_chromium_major_version(): ?int {
276 if ( empty( $_SERVER['HTTP_USER_AGENT'] ) ) {
277 return null;
278 }
279 if ( preg_match( '/Chrome\/(\d+)/', $_SERVER['HTTP_USER_AGENT'], $matches ) ) {
280 return (int) $matches[1];
281 }
282 return null;
283 }
284
285 /**
286 * Enables cross-origin isolation in the block editor.
287 *
288 * Required for enabling SharedArrayBuffer for WebAssembly-based
289 * media processing in the editor. Uses Document-Isolation-Policy
290 * on supported browsers (Chromium 137+).
291 */
292 function gutenberg_set_up_cross_origin_isolation() {
293 // Re-check the filter at action time, since other plugins (loaded after Gutenberg)
294 // may have added a filter to disable client-side media processing.
295 if ( ! gutenberg_is_client_side_media_processing_enabled() ) {
296 return;
297 }
298
299 $screen = get_current_screen();
300
301 if ( ! $screen ) {
302 return;
303 }
304
305 if ( ! $screen->is_block_editor() && 'site-editor' !== $screen->id && ! ( 'widgets' === $screen->id && wp_use_widgets_block_editor() ) ) {
306 return;
307 }
308
309 // Skip when rendering the classic-theme home route, which shows the site
310 // preview in an iframe and must reach its `contentDocument` to neutralize
311 // interactive elements — DIP would block that.
312 if ( 'site-editor' === $screen->id && ! wp_is_block_theme() && ( ! isset( $_GET['p'] ) || '/' === $_GET['p'] ) ) {
313 return;
314 }
315
316 // Skip when a third-party page builder overrides the block editor.
317 // DIP isolates the document into its own agent cluster,
318 // which blocks same-origin iframe access that these editors rely on.
319 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
320 if ( isset( $_GET['action'] ) && 'edit' !== $_GET['action'] ) {
321 return;
322 }
323
324 $user_id = get_current_user_id();
325 if ( ! $user_id ) {
326 return;
327 }
328
329 // Cross-origin isolation is not needed if users can't upload files anyway.
330 if ( ! user_can( $user_id, 'upload_files' ) ) {
331 return;
332 }
333
334 gutenberg_start_cross_origin_isolation_output_buffer();
335 }
336
337 add_action( 'load-post.php', 'gutenberg_set_up_cross_origin_isolation' );
338 add_action( 'load-post-new.php', 'gutenberg_set_up_cross_origin_isolation' );
339 add_action( 'load-site-editor.php', 'gutenberg_set_up_cross_origin_isolation' );
340 add_action( 'load-widgets.php', 'gutenberg_set_up_cross_origin_isolation' );
341
342 // Remove core's COEP/COOP-based cross-origin isolation in favor of
343 // Gutenberg's DIP-based approach, which also skips third-party editors.
344 remove_action( 'load-post.php', 'wp_set_up_cross_origin_isolation' );
345 remove_action( 'load-post-new.php', 'wp_set_up_cross_origin_isolation' );
346 remove_action( 'load-site-editor.php', 'wp_set_up_cross_origin_isolation' );
347 remove_action( 'load-widgets.php', 'wp_set_up_cross_origin_isolation' );
348
349 /**
350 * Sends the Document-Isolation-Policy header for cross-origin isolation.
351 *
352 * Uses an output buffer to add crossorigin="anonymous" where needed.
353 */
354 function gutenberg_start_cross_origin_isolation_output_buffer(): void {
355 $chromium_version = gutenberg_get_chromium_major_version();
356
357 /**
358 * Filters whether to use Document-Isolation-Policy for cross-origin isolation.
359 *
360 * Document-Isolation-Policy provides per-document cross-origin isolation
361 * without affecting other iframes on the page, avoiding breakage of plugins
362 * whose iframes lose credentials/DOM access.
363 *
364 * @since 21.8.0
365 *
366 * @param bool $use_dip Whether DIP is supported and should be used.
367 */
368 $use_dip = apply_filters(
369 'gutenberg_use_document_isolation_policy',
370 null !== $chromium_version && $chromium_version >= 137
371 );
372
373 if ( ! $use_dip ) {
374 return;
375 }
376
377 ob_start(
378 function ( string $output ): string {
379 header( 'Document-Isolation-Policy: isolate-and-credentialless' );
380
381 return gutenberg_add_crossorigin_attributes( $output );
382 }
383 );
384 }
385
386 /**
387 * Adds crossorigin="anonymous" to relevant tags in the given HTML string.
388 *
389 * @param string $html HTML input.
390 *
391 * @return string Modified HTML.
392 */
393 function gutenberg_add_crossorigin_attributes( string $html ): string {
394 $site_url = site_url();
395
396 $processor = new WP_HTML_Tag_Processor( $html );
397
398 // See https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/crossorigin.
399 $tags = array(
400 'AUDIO' => 'src',
401 'LINK' => 'href',
402 'SCRIPT' => 'src',
403 'VIDEO' => 'src',
404 'SOURCE' => 'src',
405 );
406
407 $tag_names = array_keys( $tags );
408
409 while ( $processor->next_tag() ) {
410 $tag = $processor->get_tag();
411
412 if ( ! in_array( $tag, $tag_names, true ) ) {
413 continue;
414 }
415
416 if ( 'AUDIO' === $tag || 'VIDEO' === $tag ) {
417 $processor->set_bookmark( 'audio-video-parent' );
418 }
419
420 $processor->set_bookmark( 'resume' );
421
422 $sought = false;
423
424 $crossorigin = $processor->get_attribute( 'crossorigin' );
425
426 $url = $processor->get_attribute( $tags[ $tag ] );
427
428 if ( is_string( $url ) && ! str_starts_with( $url, $site_url ) && ! str_starts_with( $url, '/' ) && ! is_string( $crossorigin ) ) {
429 if ( 'SOURCE' === $tag ) {
430 $sought = $processor->seek( 'audio-video-parent' );
431
432 if ( $sought ) {
433 $processor->set_attribute( 'crossorigin', 'anonymous' );
434 }
435 } else {
436 $processor->set_attribute( 'crossorigin', 'anonymous' );
437 }
438
439 if ( $sought ) {
440 $processor->seek( 'resume' );
441 $processor->release_bookmark( 'audio-video-parent' );
442 }
443 }
444 }
445
446 return $processor->get_updated_html();
447 }
448
449 /**
450 * Overrides templates from wp_print_media_templates with custom ones.
451 *
452 * Adds `crossorigin` attribute to all tags that
453 * could have assets loaded from a different domain.
454 */
455 function gutenberg_override_media_templates(): void {
456 remove_action( 'admin_footer', 'wp_print_media_templates' );
457 add_action(
458 'admin_footer',
459 static function (): void {
460 ob_start();
461 wp_print_media_templates();
462 $html = (string) ob_get_clean();
463
464 $tags = array(
465 'audio',
466 'img',
467 'video',
468 );
469
470 foreach ( $tags as $tag ) {
471 $html = (string) str_replace( "<$tag", "<$tag crossorigin=\"anonymous\"", $html );
472 }
473
474 echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
475 }
476 );
477 }
478
479 add_action( 'wp_enqueue_media', 'gutenberg_override_media_templates' );
480