PluginProbe
ActivityPub / 9.0.1
ActivityPub v9.0.1
9.3.1 9.3.0 9.2.2 9.2.1 9.2.0 9.1.0 9.0.2 9.0.1 9.0.0 8.3.0 8.2.1 8.2.0 8.1.1 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.2.0 1.3.0 2.0.0 2.0.1 2.1.0 2.1.1 All 160 releases
activitypub / includes / class-attachments.php

class-attachments.php in ActivityPub 9.0.1, at includes/class-attachments.php

760 lines 23.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Attachments processing file.
4 *
5 * @package Activitypub
6 */
7
8 namespace Activitypub;
9
10 /**
11 * Attachments processor class.
12 *
13 * Handles importing media attachments into the WordPress Media Library.
14 * Creates full WordPress attachment posts that are searchable and manageable.
15 *
16 * For lightweight file caching without Media Library overhead, use the
17 * Cache\Media, Cache\Avatar, and Cache\Emoji classes instead.
18 *
19 * @since 1.0.0
20 */
21 class Attachments {
22 /**
23 * Maximum width for imported images into Media Library.
24 *
25 * @var int
26 */
27 const MAX_IMAGE_DIMENSION = 1200;
28
29 /**
30 * Import attachments from an ActivityPub object and attach them to a post.
31 *
32 * Creates full WordPress attachment posts in the media library. Each attachment
33 * becomes a searchable, manageable attachment post that appears in the WordPress
34 * Media Library and is part of the user's content.
35 *
36 * Use this when:
37 * - Importing content that will be owned and editable by the user.
38 * - You need WordPress attachment posts with full metadata support.
39 * - Media should be searchable and manageable in the Media Library.
40 * - Working with content that will be part of the user's site (e.g., importers).
41 *
42 * @param array $attachments Array of ActivityPub attachment objects.
43 * @param int $post_id The post ID to attach files to.
44 * @param int $author_id Optional. User ID to set as attachment author. Default 0.
45 *
46 * @return array Array of attachment IDs.
47 */
48 public static function import( $attachments, $post_id, $author_id = 0 ) {
49 // First, import inline images from the post content.
50 $inline_mappings = self::import_inline_images( $post_id, $author_id );
51
52 if ( empty( $attachments ) || ! is_array( $attachments ) ) {
53 return array();
54 }
55
56 $attachment_ids = array();
57 foreach ( $attachments as $attachment ) {
58 $attachment_data = self::normalize_attachment( $attachment );
59
60 if ( empty( $attachment_data['url'] ) ) {
61 continue;
62 }
63
64 // Skip if this URL was already processed as an inline image.
65 if ( isset( $inline_mappings[ $attachment_data['url'] ] ) ) {
66 continue;
67 }
68
69 $attachment_id = self::save_attachment( $attachment_data, $post_id, $author_id );
70
71 if ( ! \is_wp_error( $attachment_id ) ) {
72 $attachment_ids[] = $attachment_id;
73 }
74 }
75
76 // Append media markup to post content.
77 if ( ! empty( $attachment_ids ) ) {
78 self::append_media_to_post_content( $post_id, $attachment_ids );
79 }
80
81 return $attachment_ids;
82 }
83
84 /**
85 * Check if an attachment with the same source URL already exists for a post.
86 *
87 * @param string $source_url The source URL to check.
88 * @param int $post_id The post ID to check attachments for.
89 *
90 * @return int|false The existing attachment ID or false if not found.
91 */
92 private static function get_existing_attachment( $source_url, $post_id ) {
93 foreach ( \get_attached_media( '', $post_id ) as $attachment ) {
94 if ( \get_post_meta( $attachment->ID, '_source_url', true ) === $source_url ) {
95 return $attachment->ID;
96 }
97 }
98
99 return false;
100 }
101
102 /**
103 * Process inline images from post content.
104 *
105 * @param int $post_id The post ID.
106 * @param int $author_id Optional. User ID to set as attachment author. Default 0.
107 *
108 * @return array Array of URL mappings (old URL => new URL).
109 */
110 private static function import_inline_images( $post_id, $author_id = 0 ) {
111 $post = \get_post( $post_id );
112 if ( ! $post || empty( $post->post_content ) ) {
113 return array();
114 }
115
116 // Find all img tags in the content.
117 preg_match_all( '/<img[^>]+src=["\']([^"\']+)["\'][^>]*>/i', $post->post_content, $matches );
118
119 if ( empty( $matches[1] ) ) {
120 return array();
121 }
122
123 $url_mappings = array();
124 $content = $post->post_content;
125
126 foreach ( $matches[1] as $image_url ) {
127 // Skip if already processed or is a local URL.
128 if ( isset( $url_mappings[ $image_url ] ) ) {
129 continue;
130 }
131
132 // Check if this image was already processed as an attachment.
133 $attachment_id = self::get_existing_attachment( $image_url, $post_id );
134 if ( ! $attachment_id ) {
135 $attachment_id = self::save_attachment( array( 'url' => $image_url ), $post_id, $author_id );
136
137 if ( \is_wp_error( $attachment_id ) ) {
138 continue;
139 }
140 }
141
142 $new_url = \wp_get_attachment_url( $attachment_id );
143 if ( $new_url ) {
144 $url_mappings[ $image_url ] = $new_url;
145 $content = \str_replace( $image_url, $new_url, $content );
146 }
147 }
148
149 // Update post content if URLs were replaced.
150 if ( ! empty( $url_mappings ) ) {
151 \wp_update_post(
152 array(
153 'ID' => $post_id,
154 'post_content' => $content,
155 )
156 );
157 }
158
159 return $url_mappings;
160 }
161
162 /**
163 * Normalize an ActivityPub attachment object to a standard format.
164 *
165 * @param mixed $attachment The attachment data (array or object).
166 *
167 * @return array|false Normalized attachment data or false on failure.
168 */
169 private static function normalize_attachment( $attachment ) {
170 // Convert object to array if needed.
171 if ( \is_object( $attachment ) ) {
172 $attachment = \get_object_vars( $attachment );
173 }
174
175 if ( ! is_array( $attachment ) || empty( $attachment['url'] ) ) {
176 return false;
177 }
178
179 return array(
180 'url' => $attachment['url'],
181 'mediaType' => $attachment['mediaType'] ?? '',
182 'name' => $attachment['name'] ?? '',
183 'type' => $attachment['type'] ?? 'Document',
184 );
185 }
186
187 /**
188 * Save an attachment (local file or remote URL) to the media library.
189 *
190 * @param array $attachment_data The normalized attachment data.
191 * @param int $post_id The post ID to attach to.
192 * @param int $author_id Optional. User ID to set as attachment author. Default 0.
193 *
194 * @return int|\WP_Error The attachment ID or WP_Error on failure.
195 */
196 private static function save_attachment( $attachment_data, $post_id, $author_id = 0 ) {
197 // Ensure required WordPress functions are loaded.
198 if ( ! \function_exists( 'media_handle_sideload' ) || ! \function_exists( 'download_url' ) ) {
199 require_once ABSPATH . 'wp-admin/includes/media.php';
200 require_once ABSPATH . 'wp-admin/includes/file.php';
201 require_once ABSPATH . 'wp-admin/includes/image.php';
202 }
203
204 // Use WP_Filesystem_Direct explicitly to avoid FTP fallback from WP_Filesystem().
205 require_once ABSPATH . 'wp-admin/includes/class-wp-filesystem-base.php';
206 require_once ABSPATH . 'wp-admin/includes/class-wp-filesystem-direct.php';
207
208 $filesystem = new \WP_Filesystem_Direct( null );
209
210 $is_local = ! preg_match( '#^https?://#i', $attachment_data['url'] );
211
212 if ( $is_local ) {
213 // Validate local path is within allowed directories to prevent file disclosure.
214 $allowed = self::is_allowed_local_path( $attachment_data['url'] );
215 if ( ! $allowed ) {
216 return new \WP_Error( 'invalid_path', \__( 'Local file path is not within allowed directories.', 'activitypub' ) );
217 }
218
219 // Read local file from disk.
220 if ( ! $filesystem->exists( $attachment_data['url'] ) ) {
221 /* translators: %s: file path */
222 return new \WP_Error( 'file_not_found', sprintf( \__( 'File not found: %s', 'activitypub' ), $attachment_data['url'] ) );
223 }
224
225 // Copy to temp file so media_handle_sideload doesn't move the original.
226 $tmp_file = \wp_tempnam( \basename( $attachment_data['url'] ) );
227 $filesystem->copy( $attachment_data['url'], $tmp_file, true );
228 } else {
229 // Validate remote URL before downloading.
230 if ( ! \wp_http_validate_url( $attachment_data['url'] ) ) {
231 return new \WP_Error( 'invalid_url', \__( 'URL is not allowed.', 'activitypub' ) );
232 }
233
234 // Download remote URL.
235 $tmp_file = \download_url( $attachment_data['url'] );
236
237 if ( \is_wp_error( $tmp_file ) ) {
238 return $tmp_file;
239 }
240 }
241
242 // Get original filename from URL.
243 $original_name = \basename( \wp_parse_url( $attachment_data['url'], PHP_URL_PATH ) );
244
245 // Rename temp file to have proper extension for optimize_image to detect mime type.
246 $original_ext = \pathinfo( $original_name, PATHINFO_EXTENSION );
247 if ( $original_ext ) {
248 $renamed_tmp = $tmp_file . '.' . $original_ext;
249 if ( $filesystem->move( $tmp_file, $renamed_tmp, true ) ) {
250 $tmp_file = $renamed_tmp;
251 }
252 }
253
254 // Optimize images before sideloading (resize and convert to WebP).
255 $tmp_file = self::optimize_image( $tmp_file, self::MAX_IMAGE_DIMENSION );
256
257 // Update filename extension to match optimized file.
258 $new_ext = \pathinfo( $tmp_file, PATHINFO_EXTENSION );
259 if ( $new_ext ) {
260 $original_name = \preg_replace( '/\.[^.]+$/', '.' . $new_ext, $original_name );
261 }
262
263 $file_array = array(
264 'name' => $original_name,
265 'tmp_name' => $tmp_file,
266 );
267
268 // Prepare attachment post data.
269 // Let WordPress auto-detect the mime type from the file.
270 $post_data = array(
271 'post_title' => $attachment_data['name'] ?? '',
272 'post_content' => $attachment_data['name'] ?? '',
273 'post_author' => $author_id,
274 'meta_input' => array(
275 '_source_url' => $attachment_data['url'],
276 ),
277 );
278
279 // Add alt text for images.
280 if ( ! empty( $attachment_data['name'] ) ) {
281 $original_mime = $attachment_data['mediaType'] ?? '';
282 if ( 'image' === strtok( $original_mime, '/' ) ) {
283 $post_data['meta_input']['_wp_attachment_image_alt'] = $attachment_data['name'];
284 }
285 }
286
287 // Sideload the attachment into WordPress.
288 $attachment_id = \media_handle_sideload( $file_array, $post_id, '', $post_data );
289
290 // Clean up temp file if there was an error.
291 if ( \is_wp_error( $attachment_id ) ) {
292 \wp_delete_file( $tmp_file );
293 }
294
295 return $attachment_id;
296 }
297
298 /**
299 * Get a unique file path by appending a counter if the file already exists.
300 *
301 * @param string $file_path The desired file path.
302 *
303 * @return string A unique file path that doesn't exist.
304 */
305 private static function get_unique_path( $file_path ) {
306 if ( ! \file_exists( $file_path ) ) {
307 return $file_path;
308 }
309
310 $path_info = \pathinfo( $file_path );
311 $dir = $path_info['dirname'];
312 $base_name = $path_info['filename'];
313 $extension = isset( $path_info['extension'] ) ? '.' . $path_info['extension'] : '';
314 $counter = 1;
315
316 do {
317 $new_path = $dir . '/' . $base_name . '-' . $counter . $extension;
318 ++$counter;
319 } while ( \file_exists( $new_path ) );
320
321 return $new_path;
322 }
323
324 /**
325 * Check if a local file path is within allowed directories.
326 *
327 * Prevents arbitrary file access by restricting local paths to known safe
328 * directories like the uploads folder or WordPress temp directory.
329 *
330 * @param string $file_path The local file path to validate.
331 *
332 * @return bool True if the path is allowed, false otherwise.
333 */
334 private static function is_allowed_local_path( $file_path ) {
335 // Normalize the path and resolve any relative components.
336 $real_path = \realpath( $file_path );
337 if ( false === $real_path ) {
338 // If file doesn't exist yet, check the directory.
339 $dir_path = \realpath( \dirname( $file_path ) );
340 if ( false === $dir_path ) {
341 return false;
342 }
343 $real_path = $dir_path . '/' . \basename( $file_path );
344 }
345
346 // Get allowed base directories.
347 $upload_dir = \wp_upload_dir();
348 $allowed_dirs = array(
349 \realpath( $upload_dir['basedir'] ),
350 \realpath( \get_temp_dir() ),
351 \realpath( ABSPATH . 'wp-content' ),
352 );
353
354 /**
355 * Filters the allowed directories for local file imports.
356 *
357 * @since 5.6.0
358 *
359 * @param string[] $allowed_dirs Array of allowed directory paths.
360 * @param string $file_path The file path being validated.
361 */
362 $allowed_dirs = \apply_filters( 'activitypub_allowed_import_directories', $allowed_dirs, $file_path );
363
364 // Remove any false values from realpath failures.
365 $allowed_dirs = \array_filter( $allowed_dirs );
366
367 // Check if the file is within any allowed directory.
368 foreach ( $allowed_dirs as $allowed_dir ) {
369 if ( \str_starts_with( $real_path, $allowed_dir ) ) {
370 return true;
371 }
372 }
373
374 return false;
375 }
376
377 /**
378 * Optimize an image file by resizing and converting to WebP.
379 *
380 * Uses WordPress image editor to resize large images and convert them
381 * to WebP format for better compression while maintaining quality.
382 *
383 * @param string $file_path Path to the image file.
384 * @param int $max_dimension Maximum width/height in pixels.
385 *
386 * @return string The optimized file path.
387 */
388 private static function optimize_image( $file_path, $max_dimension ) {
389 // Check if it's an image.
390 $mime_type = \wp_check_filetype( $file_path )['type'] ?? '';
391 if ( ! $mime_type || ! \str_starts_with( $mime_type, 'image/' ) ) {
392 return $file_path;
393 }
394
395 // Skip SVG and GIF files (GIFs may be animated).
396 if ( \in_array( $mime_type, array( 'image/svg+xml', 'image/gif' ), true ) ) {
397 return $file_path;
398 }
399
400 $editor = \wp_get_image_editor( $file_path );
401 if ( \is_wp_error( $editor ) ) {
402 return $file_path;
403 }
404
405 $size = $editor->get_size();
406 $needs_resize = $size['width'] > $max_dimension || $size['height'] > $max_dimension;
407
408 // Resize if needed.
409 if ( $needs_resize ) {
410 $editor->resize( $max_dimension, $max_dimension, false );
411 }
412
413 // Check if WebP is supported.
414 $can_webp = $editor->supports_mime_type( 'image/webp' );
415
416 // Determine output format and save.
417 if ( $can_webp ) {
418 // Convert to WebP.
419 $new_path = self::get_unique_path( \preg_replace( '/\.[^.]+$/', '.webp', $file_path ) );
420 $result = $editor->save( $new_path, 'image/webp' );
421 } elseif ( \in_array( $mime_type, array( 'image/png', 'image/webp' ), true ) ) {
422 // Keep original format for potentially transparent images when WebP not available.
423 if ( ! $needs_resize ) {
424 // No changes needed.
425 return $file_path;
426 }
427 $result = $editor->save( $file_path );
428 } else {
429 // Convert to JPEG when WebP not available.
430 $new_path = self::get_unique_path( \preg_replace( '/\.[^.]+$/', '.jpg', $file_path ) );
431 $result = $editor->save( $new_path, 'image/jpeg' );
432 }
433
434 if ( \is_wp_error( $result ) ) {
435 return $file_path;
436 }
437
438 // Handle result - $result is always an array from $editor->save().
439 $result_path = $result['path'] ?? $file_path;
440
441 // If path changed (format conversion), delete the original file.
442 if ( $result_path !== $file_path ) {
443 \wp_delete_file( $file_path );
444 }
445
446 return $result_path;
447 }
448
449 /**
450 * Append media to post content.
451 *
452 * @param int $post_id The post ID.
453 * @param int[] $attachment_ids Array of attachment IDs.
454 */
455 private static function append_media_to_post_content( $post_id, $attachment_ids ) {
456 $post = \get_post( $post_id );
457 if ( ! $post ) {
458 return;
459 }
460
461 $media = self::generate_media_markup( $attachment_ids );
462 $separator = empty( trim( $post->post_content ) ) ? '' : "\n\n";
463
464 \wp_update_post(
465 array(
466 'ID' => $post_id,
467 'post_content' => $post->post_content . $separator . $media,
468 )
469 );
470 }
471
472 /**
473 * Generate media markup for attachments.
474 *
475 * @param int[] $attachment_ids Array of attachment IDs.
476 *
477 * @return string The generated markup.
478 */
479 private static function generate_media_markup( $attachment_ids ) {
480 if ( empty( $attachment_ids ) ) {
481 return '';
482 }
483
484 /**
485 * Filters the media markup for ActivityPub attachments.
486 *
487 * Allows plugins to provide custom markup for attachments.
488 * If this filter returns a non-empty string, it will be used instead of
489 * the default block markup.
490 *
491 * @param string $markup The custom markup. Default empty string.
492 * @param int[] $attachment_ids Array of attachment IDs.
493 */
494 $custom_markup = \apply_filters( 'activitypub_attachments_media_markup', '', $attachment_ids );
495
496 if ( ! empty( $custom_markup ) ) {
497 return $custom_markup;
498 }
499
500 // Default to block markup.
501 $type = strtok( \get_post_mime_type( $attachment_ids[0] ), '/' );
502
503 // Single video or audio file.
504 if ( 1 === \count( $attachment_ids ) && ( 'video' === $type || 'audio' === $type ) ) {
505 return sprintf(
506 '<!-- wp:%1$s {"id":"%2$s"} --><figure class="wp-block-%1$s"><%1$s controls src="%3$s"></%1$s></figure><!-- /wp:%1$s -->',
507 \esc_attr( $type ),
508 \esc_attr( $attachment_ids[0] ),
509 \esc_url( \wp_get_attachment_url( $attachment_ids[0] ) )
510 );
511 }
512
513 // Single image: use standalone image block.
514 if ( 1 === \count( $attachment_ids ) && 'image' === $type ) {
515 return self::get_image_block( $attachment_ids[0] );
516 }
517
518 // Multiple attachments: use gallery block.
519 return self::get_gallery_block( $attachment_ids );
520 }
521
522 /**
523 * Get standalone image block markup.
524 *
525 * @param int $attachment_id The attachment ID.
526 *
527 * @return string The image block markup.
528 */
529 private static function get_image_block( $attachment_id ) {
530 $image_src = \wp_get_attachment_image_src( $attachment_id, 'large' );
531 if ( ! $image_src ) {
532 return '';
533 }
534
535 $alt = \get_post_meta( $attachment_id, '_wp_attachment_image_alt', true );
536 if ( ! $alt ) {
537 $alt = \get_post_field( 'post_excerpt', $attachment_id );
538 }
539
540 $block = '<!-- wp:image {"id":' . \esc_attr( $attachment_id ) . ',"sizeSlug":"large","linkDestination":"none"} -->' . "\n";
541 $block .= '<figure class="wp-block-image size-large">';
542 $block .= '<img src="' . \esc_url( $image_src[0] ) . '" alt="' . \esc_attr( $alt ) . '" class="' . \esc_attr( 'wp-image-' . $attachment_id ) . '"/>';
543 $block .= '</figure>' . "\n";
544 $block .= '<!-- /wp:image -->';
545
546 return $block;
547 }
548
549 /**
550 * Get gallery block markup.
551 *
552 * @param int[] $attachment_ids The attachment IDs to use.
553 *
554 * @return string The gallery block markup.
555 */
556 private static function get_gallery_block( $attachment_ids ) {
557 $gallery = '<!-- wp:gallery {"columns":2,"linkTo":"none","sizeSlug":"large","imageCrop":true} -->' . "\n";
558 $gallery .= '<figure class="wp-block-gallery has-nested-images columns-2 is-cropped">';
559
560 foreach ( $attachment_ids as $id ) {
561 $image_src = \wp_get_attachment_image_src( $id, 'large' );
562 if ( ! $image_src ) {
563 continue;
564 }
565
566 $alt = \get_post_meta( $id, '_wp_attachment_image_alt', true );
567 if ( ! $alt ) {
568 $alt = \get_post_field( 'post_excerpt', $id );
569 }
570
571 $gallery .= "\n" . '<!-- wp:image {"id":' . \esc_attr( $id ) . ',"sizeSlug":"large","linkDestination":"none"} -->' . "\n";
572 $gallery .= '<figure class="wp-block-image size-large">';
573 $gallery .= '<img src="' . \esc_url( $image_src[0] ) . '" alt="' . \esc_attr( $alt ) . '" class="' . \esc_attr( 'wp-image-' . $id ) . '"/>';
574 $gallery .= '</figure>';
575 $gallery .= "\n<!-- /wp:image -->\n";
576 }
577
578 $gallery .= "</figure>\n";
579 $gallery .= '<!-- /wp:gallery -->';
580
581 return $gallery;
582 }
583
584 /**
585 * Get content from an object based on its type.
586 *
587 * @param int $object_id The object ID (post or comment).
588 * @param string $object_type The object type ('post' or 'comment').
589 *
590 * @return string The object content.
591 */
592 private static function get_object_content( $object_id, $object_type ) {
593 if ( 'comment' === $object_type ) {
594 $comment = \get_comment( $object_id );
595 return $comment ? $comment->comment_content : '';
596 }
597
598 return \get_post_field( 'post_content', $object_id );
599 }
600
601 /**
602 * Update content for an object based on its type.
603 *
604 * @param int $object_id The object ID (post or comment).
605 * @param string $object_type The object type ('post' or 'comment').
606 * @param string $content The new content.
607 */
608 private static function update_object_content( $object_id, $object_type, $content ) {
609 if ( 'comment' === $object_type ) {
610 \wp_update_comment(
611 array(
612 'comment_ID' => $object_id,
613 'comment_content' => $content,
614 )
615 );
616 } else {
617 \wp_update_post(
618 array(
619 'ID' => $object_id,
620 'post_content' => $content,
621 )
622 );
623 }
624 }
625
626 /**
627 * Append file-based media markup to an object's content.
628 *
629 * Used for cached remote media (via Cache classes) that doesn't go through
630 * the Media Library. Works with posts and comments.
631 *
632 * @param int $object_id The object ID (post or comment).
633 * @param array $files Array of file data arrays with 'url', 'mime_type', and 'alt' keys.
634 * @param string $object_type The object type ('post' or 'comment').
635 */
636 public static function append_files_to_content( $object_id, $files, $object_type = 'post' ) {
637 $content = self::get_object_content( $object_id, $object_type );
638 if ( empty( $content ) ) {
639 return;
640 }
641
642 $media = self::generate_files_markup( $files );
643 $separator = empty( trim( $content ) ) ? '' : "\n\n";
644
645 self::update_object_content( $object_id, $object_type, $content . $separator . $media );
646 }
647
648 /**
649 * Generate media markup for file-based attachments.
650 *
651 * Creates WordPress block markup from file data arrays. Used for cached
652 * remote media that doesn't have WordPress attachment posts.
653 *
654 * @param array[] $files {
655 * Array of file data arrays.
656 *
657 * @type string $url Full URL to the file.
658 * @type string $mime_type MIME type of the file.
659 * @type string $alt Alt text for the file.
660 * }
661 *
662 * @return string The generated markup.
663 */
664 public static function generate_files_markup( $files ) {
665 if ( empty( $files ) ) {
666 return '';
667 }
668
669 /**
670 * Filters the media markup for ActivityPub file-based attachments.
671 *
672 * Allows plugins to provide custom markup for file-based attachments.
673 * If this filter returns a non-empty string, it will be used instead of
674 * the default block markup.
675 *
676 * @param string $markup The custom markup. Default empty string.
677 * @param array $files Array of file data arrays.
678 */
679 $custom_markup = \apply_filters( 'activitypub_files_media_markup', '', $files );
680
681 if ( ! empty( $custom_markup ) ) {
682 return $custom_markup;
683 }
684
685 // Default to block markup.
686 $type = strtok( $files[0]['mime_type'], '/' );
687
688 // Single video or audio file.
689 if ( 1 === \count( $files ) && ( 'video' === $type || 'audio' === $type ) ) {
690 return sprintf(
691 '<!-- wp:%1$s --><figure class="wp-block-%1$s"><%1$s controls src="%2$s"></%1$s></figure><!-- /wp:%1$s -->',
692 \esc_attr( $type ),
693 \esc_url( $files[0]['url'] )
694 );
695 }
696
697 // Single image: use standalone image block.
698 if ( 1 === \count( $files ) && 'image' === $type ) {
699 return self::get_files_image_block( $files[0] );
700 }
701
702 // Multiple attachments: use gallery block.
703 return self::get_files_gallery_block( $files );
704 }
705
706 /**
707 * Get standalone image block markup for file-based attachments.
708 *
709 * @param array $file {
710 * File data array.
711 *
712 * @type string $url Full URL to the file.
713 * @type string $mime_type MIME type of the file.
714 * @type string $alt Alt text for the file.
715 * }
716 *
717 * @return string The image block markup.
718 */
719 public static function get_files_image_block( $file ) {
720 $block = '<!-- wp:image {"sizeSlug":"large","linkDestination":"none"} -->' . "\n";
721 $block .= '<figure class="wp-block-image size-large">';
722 $block .= '<img src="' . \esc_url( $file['url'] ) . '" alt="' . \esc_attr( $file['alt'] ?? '' ) . '"/>';
723 $block .= '</figure>' . "\n";
724 $block .= '<!-- /wp:image -->';
725
726 return $block;
727 }
728
729 /**
730 * Get gallery block markup for file-based attachments.
731 *
732 * @param array[] $files {
733 * Array of file data arrays.
734 *
735 * @type string $url Full URL to the file.
736 * @type string $mime_type MIME type of the file.
737 * @type string $alt Alt text for the file.
738 * }
739 *
740 * @return string The gallery block markup.
741 */
742 public static function get_files_gallery_block( $files ) {
743 $gallery = '<!-- wp:gallery {"columns":2,"linkTo":"none","imageCrop":true} -->' . "\n";
744 $gallery .= '<figure class="wp-block-gallery has-nested-images columns-2 is-cropped">';
745
746 foreach ( $files as $file ) {
747 $gallery .= "\n<!-- wp:image {\"sizeSlug\":\"large\",\"linkDestination\":\"none\"} -->\n";
748 $gallery .= '<figure class="wp-block-image size-large">';
749 $gallery .= '<img src="' . \esc_url( $file['url'] ) . '" alt="' . \esc_attr( $file['alt'] ?? '' ) . '"/>';
750 $gallery .= '</figure>';
751 $gallery .= "\n<!-- /wp:image -->\n";
752 }
753
754 $gallery .= "</figure>\n";
755 $gallery .= '<!-- /wp:gallery -->';
756
757 return $gallery;
758 }
759 }
760