PluginProbe
ActivityPub / trunk
ActivityPub vtrunk
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 trunk, at includes/class-attachments.php

778 lines 24.5 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 // Remote JSON can hand us an array where a string was expected.
269 $name = $attachment_data['name'] ?? '';
270 $plain_name = \is_string( $name ) ? \wp_strip_all_tags( $name ) : '';
271
272 // Prepare attachment post data.
273 // Let WordPress auto-detect the mime type from the file.
274 $post_data = array(
275
276 /*
277 * `name` comes from the remote object or an uploaded archive, and callers can run
278 * as a user with `unfiltered_html`, for which `kses_init()` installs no filters:
279 * `media_handle_sideload()` would store whatever it was given. Attachment pages
280 * are public, and `post_content` renders through `the_content` there, so it gets
281 * the same treatment as remote post content.
282 */
283 'post_title' => \wp_slash( $plain_name ),
284 'post_content' => \wp_slash( $plain_name ),
285 'post_author' => $author_id,
286 'meta_input' => array(
287 '_source_url' => $attachment_data['url'],
288 ),
289 );
290
291 // Add alt text for images.
292 if ( '' !== $plain_name ) {
293 $original_mime = $attachment_data['mediaType'] ?? '';
294 if ( 'image' === \strtok( $original_mime, '/' ) ) {
295 /*
296 * The same plain-text value as the title. Core does not sanitize this meta
297 * on write -- it only strips tags in the media-modal AJAX handler --. `Transformer\Attachment` federates it straight back
298 * out as the ActivityPub `name`, and consumers expect it to be plain text.
299 */
300 // Slashed like the columns above: update_metadata() unslashes what it is given.
301 $post_data['meta_input']['_wp_attachment_image_alt'] = \wp_slash( $plain_name );
302 }
303 }
304
305 // Sideload the attachment into WordPress.
306 $attachment_id = \media_handle_sideload( $file_array, $post_id, '', $post_data );
307
308 // Clean up temp file if there was an error.
309 if ( \is_wp_error( $attachment_id ) ) {
310 \wp_delete_file( $tmp_file );
311 }
312
313 return $attachment_id;
314 }
315
316 /**
317 * Get a unique file path by appending a counter if the file already exists.
318 *
319 * @param string $file_path The desired file path.
320 *
321 * @return string A unique file path that doesn't exist.
322 */
323 private static function get_unique_path( $file_path ) {
324 if ( ! \file_exists( $file_path ) ) {
325 return $file_path;
326 }
327
328 $path_info = \pathinfo( $file_path );
329 $dir = $path_info['dirname'];
330 $base_name = $path_info['filename'];
331 $extension = isset( $path_info['extension'] ) ? '.' . $path_info['extension'] : '';
332 $counter = 1;
333
334 do {
335 $new_path = $dir . '/' . $base_name . '-' . $counter . $extension;
336 ++$counter;
337 } while ( \file_exists( $new_path ) );
338
339 return $new_path;
340 }
341
342 /**
343 * Check if a local file path is within allowed directories.
344 *
345 * Prevents arbitrary file access by restricting local paths to known safe
346 * directories like the uploads folder or WordPress temp directory.
347 *
348 * @param string $file_path The local file path to validate.
349 *
350 * @return bool True if the path is allowed, false otherwise.
351 */
352 private static function is_allowed_local_path( $file_path ) {
353 // Normalize the path and resolve any relative components.
354 $real_path = \realpath( $file_path );
355 if ( false === $real_path ) {
356 // If file doesn't exist yet, check the directory.
357 $dir_path = \realpath( \dirname( $file_path ) );
358 if ( false === $dir_path ) {
359 return false;
360 }
361 $real_path = $dir_path . '/' . \basename( $file_path );
362 }
363
364 // Get allowed base directories.
365 $upload_dir = \wp_upload_dir();
366 $allowed_dirs = array(
367 \realpath( $upload_dir['basedir'] ),
368 \realpath( \get_temp_dir() ),
369 \realpath( ABSPATH . 'wp-content' ),
370 );
371
372 /**
373 * Filters the allowed directories for local file imports.
374 *
375 * @since 5.6.0
376 *
377 * @param string[] $allowed_dirs Array of allowed directory paths.
378 * @param string $file_path The file path being validated.
379 */
380 $allowed_dirs = \apply_filters( 'activitypub_allowed_import_directories', $allowed_dirs, $file_path );
381
382 // Remove any false values from realpath failures.
383 $allowed_dirs = \array_filter( $allowed_dirs );
384
385 // Check if the file is within any allowed directory.
386 foreach ( $allowed_dirs as $allowed_dir ) {
387 if ( \str_starts_with( $real_path, $allowed_dir ) ) {
388 return true;
389 }
390 }
391
392 return false;
393 }
394
395 /**
396 * Optimize an image file by resizing and converting to WebP.
397 *
398 * Uses WordPress image editor to resize large images and convert them
399 * to WebP format for better compression while maintaining quality.
400 *
401 * @param string $file_path Path to the image file.
402 * @param int $max_dimension Maximum width/height in pixels.
403 *
404 * @return string The optimized file path.
405 */
406 private static function optimize_image( $file_path, $max_dimension ) {
407 // Check if it's an image.
408 $mime_type = \wp_check_filetype( $file_path )['type'] ?? '';
409 if ( ! $mime_type || ! \str_starts_with( $mime_type, 'image/' ) ) {
410 return $file_path;
411 }
412
413 // Skip SVG and GIF files (GIFs may be animated).
414 if ( \in_array( $mime_type, array( 'image/svg+xml', 'image/gif' ), true ) ) {
415 return $file_path;
416 }
417
418 $editor = \wp_get_image_editor( $file_path );
419 if ( \is_wp_error( $editor ) ) {
420 return $file_path;
421 }
422
423 $size = $editor->get_size();
424 $needs_resize = $size['width'] > $max_dimension || $size['height'] > $max_dimension;
425
426 // Resize if needed.
427 if ( $needs_resize ) {
428 $editor->resize( $max_dimension, $max_dimension, false );
429 }
430
431 // Check if WebP is supported.
432 $can_webp = $editor->supports_mime_type( 'image/webp' );
433
434 // Determine output format and save.
435 if ( $can_webp ) {
436 // Convert to WebP.
437 $new_path = self::get_unique_path( \preg_replace( '/\.[^.]+$/', '.webp', $file_path ) );
438 $result = $editor->save( $new_path, 'image/webp' );
439 } elseif ( \in_array( $mime_type, array( 'image/png', 'image/webp' ), true ) ) {
440 // Keep original format for potentially transparent images when WebP not available.
441 if ( ! $needs_resize ) {
442 // No changes needed.
443 return $file_path;
444 }
445 $result = $editor->save( $file_path );
446 } else {
447 // Convert to JPEG when WebP not available.
448 $new_path = self::get_unique_path( \preg_replace( '/\.[^.]+$/', '.jpg', $file_path ) );
449 $result = $editor->save( $new_path, 'image/jpeg' );
450 }
451
452 if ( \is_wp_error( $result ) ) {
453 return $file_path;
454 }
455
456 // Handle result - $result is always an array from $editor->save().
457 $result_path = $result['path'] ?? $file_path;
458
459 // If path changed (format conversion), delete the original file.
460 if ( $result_path !== $file_path ) {
461 \wp_delete_file( $file_path );
462 }
463
464 return $result_path;
465 }
466
467 /**
468 * Append media to post content.
469 *
470 * @param int $post_id The post ID.
471 * @param int[] $attachment_ids Array of attachment IDs.
472 */
473 private static function append_media_to_post_content( $post_id, $attachment_ids ) {
474 $post = \get_post( $post_id );
475 if ( ! $post ) {
476 return;
477 }
478
479 $media = self::generate_media_markup( $attachment_ids );
480 $separator = empty( \trim( $post->post_content ) ) ? '' : "\n\n";
481
482 \wp_update_post(
483 array(
484 'ID' => $post_id,
485 'post_content' => $post->post_content . $separator . $media,
486 )
487 );
488 }
489
490 /**
491 * Generate media markup for attachments.
492 *
493 * @param int[] $attachment_ids Array of attachment IDs.
494 *
495 * @return string The generated markup.
496 */
497 private static function generate_media_markup( $attachment_ids ) {
498 if ( empty( $attachment_ids ) ) {
499 return '';
500 }
501
502 /**
503 * Filters the media markup for ActivityPub attachments.
504 *
505 * Allows plugins to provide custom markup for attachments.
506 * If this filter returns a non-empty string, it will be used instead of
507 * the default block markup.
508 *
509 * @param string $markup The custom markup. Default empty string.
510 * @param int[] $attachment_ids Array of attachment IDs.
511 */
512 $custom_markup = \apply_filters( 'activitypub_attachments_media_markup', '', $attachment_ids );
513
514 if ( ! empty( $custom_markup ) ) {
515 return $custom_markup;
516 }
517
518 // Default to block markup.
519 $type = \strtok( \get_post_mime_type( $attachment_ids[0] ), '/' );
520
521 // Single video or audio file.
522 if ( 1 === \count( $attachment_ids ) && ( 'video' === $type || 'audio' === $type ) ) {
523 return \sprintf(
524 '<!-- wp:%1$s {"id":"%2$s"} --><figure class="wp-block-%1$s"><%1$s controls src="%3$s"></%1$s></figure><!-- /wp:%1$s -->',
525 \esc_attr( $type ),
526 \esc_attr( $attachment_ids[0] ),
527 \esc_url( \wp_get_attachment_url( $attachment_ids[0] ) )
528 );
529 }
530
531 // Single image: use standalone image block.
532 if ( 1 === \count( $attachment_ids ) && 'image' === $type ) {
533 return self::get_image_block( $attachment_ids[0] );
534 }
535
536 // Multiple attachments: use gallery block.
537 return self::get_gallery_block( $attachment_ids );
538 }
539
540 /**
541 * Get standalone image block markup.
542 *
543 * @param int $attachment_id The attachment ID.
544 *
545 * @return string The image block markup.
546 */
547 private static function get_image_block( $attachment_id ) {
548 $image_src = \wp_get_attachment_image_src( $attachment_id, 'large' );
549 if ( ! $image_src ) {
550 return '';
551 }
552
553 $alt = \get_post_meta( $attachment_id, '_wp_attachment_image_alt', true );
554 if ( ! $alt ) {
555 $alt = \get_post_field( 'post_excerpt', $attachment_id );
556 }
557
558 $block = '<!-- wp:image {"id":' . \esc_attr( $attachment_id ) . ',"sizeSlug":"large","linkDestination":"none"} -->' . "\n";
559 $block .= '<figure class="wp-block-image size-large">';
560 $block .= '<img src="' . \esc_url( $image_src[0] ) . '" alt="' . \esc_attr( $alt ) . '" class="' . \esc_attr( 'wp-image-' . $attachment_id ) . '"/>';
561 $block .= '</figure>' . "\n";
562 $block .= '<!-- /wp:image -->';
563
564 return $block;
565 }
566
567 /**
568 * Get gallery block markup.
569 *
570 * @param int[] $attachment_ids The attachment IDs to use.
571 *
572 * @return string The gallery block markup.
573 */
574 private static function get_gallery_block( $attachment_ids ) {
575 $gallery = '<!-- wp:gallery {"columns":2,"linkTo":"none","sizeSlug":"large","imageCrop":true} -->' . "\n";
576 $gallery .= '<figure class="wp-block-gallery has-nested-images columns-2 is-cropped">';
577
578 foreach ( $attachment_ids as $id ) {
579 $image_src = \wp_get_attachment_image_src( $id, 'large' );
580 if ( ! $image_src ) {
581 continue;
582 }
583
584 $alt = \get_post_meta( $id, '_wp_attachment_image_alt', true );
585 if ( ! $alt ) {
586 $alt = \get_post_field( 'post_excerpt', $id );
587 }
588
589 $gallery .= "\n" . '<!-- wp:image {"id":' . \esc_attr( $id ) . ',"sizeSlug":"large","linkDestination":"none"} -->' . "\n";
590 $gallery .= '<figure class="wp-block-image size-large">';
591 $gallery .= '<img src="' . \esc_url( $image_src[0] ) . '" alt="' . \esc_attr( $alt ) . '" class="' . \esc_attr( 'wp-image-' . $id ) . '"/>';
592 $gallery .= '</figure>';
593 $gallery .= "\n<!-- /wp:image -->\n";
594 }
595
596 $gallery .= "</figure>\n";
597 $gallery .= '<!-- /wp:gallery -->';
598
599 return $gallery;
600 }
601
602 /**
603 * Get content from an object based on its type.
604 *
605 * @param int $object_id The object ID (post or comment).
606 * @param string $object_type The object type ('post' or 'comment').
607 *
608 * @return string The object content.
609 */
610 private static function get_object_content( $object_id, $object_type ) {
611 if ( 'comment' === $object_type ) {
612 $comment = \get_comment( $object_id );
613 return $comment ? $comment->comment_content : '';
614 }
615
616 return \get_post_field( 'post_content', $object_id );
617 }
618
619 /**
620 * Update content for an object based on its type.
621 *
622 * @param int $object_id The object ID (post or comment).
623 * @param string $object_type The object type ('post' or 'comment').
624 * @param string $content The new content.
625 */
626 private static function update_object_content( $object_id, $object_type, $content ) {
627 if ( 'comment' === $object_type ) {
628 \wp_update_comment(
629 array(
630 'comment_ID' => $object_id,
631 'comment_content' => $content,
632 )
633 );
634 } else {
635 \wp_update_post(
636 array(
637 'ID' => $object_id,
638 'post_content' => $content,
639 )
640 );
641 }
642 }
643
644 /**
645 * Append file-based media markup to an object's content.
646 *
647 * Used for cached remote media (via Cache classes) that doesn't go through
648 * the Media Library. Works with posts and comments.
649 *
650 * @param int $object_id The object ID (post or comment).
651 * @param array $files Array of file data arrays with 'url', 'mime_type', and 'alt' keys.
652 * @param string $object_type The object type ('post' or 'comment').
653 */
654 public static function append_files_to_content( $object_id, $files, $object_type = 'post' ) {
655 $content = self::get_object_content( $object_id, $object_type );
656 if ( empty( $content ) ) {
657 return;
658 }
659
660 $media = self::generate_files_markup( $files );
661 $separator = empty( \trim( $content ) ) ? '' : "\n\n";
662
663 self::update_object_content( $object_id, $object_type, $content . $separator . $media );
664 }
665
666 /**
667 * Generate media markup for file-based attachments.
668 *
669 * Creates WordPress block markup from file data arrays. Used for cached
670 * remote media that doesn't have WordPress attachment posts.
671 *
672 * @param array[] $files {
673 * Array of file data arrays.
674 *
675 * @type string $url Full URL to the file.
676 * @type string $mime_type MIME type of the file.
677 * @type string $alt Alt text for the file.
678 * }
679 *
680 * @return string The generated markup.
681 */
682 public static function generate_files_markup( $files ) {
683 if ( empty( $files ) ) {
684 return '';
685 }
686
687 /**
688 * Filters the media markup for ActivityPub file-based attachments.
689 *
690 * Allows plugins to provide custom markup for file-based attachments.
691 * If this filter returns a non-empty string, it will be used instead of
692 * the default block markup.
693 *
694 * @param string $markup The custom markup. Default empty string.
695 * @param array $files Array of file data arrays.
696 */
697 $custom_markup = \apply_filters( 'activitypub_files_media_markup', '', $files );
698
699 if ( ! empty( $custom_markup ) ) {
700 return $custom_markup;
701 }
702
703 // Default to block markup.
704 $type = \strtok( $files[0]['mime_type'], '/' );
705
706 // Single video or audio file.
707 if ( 1 === \count( $files ) && ( 'video' === $type || 'audio' === $type ) ) {
708 return \sprintf(
709 '<!-- wp:%1$s --><figure class="wp-block-%1$s"><%1$s controls src="%2$s"></%1$s></figure><!-- /wp:%1$s -->',
710 \esc_attr( $type ),
711 \esc_url( $files[0]['url'] )
712 );
713 }
714
715 // Single image: use standalone image block.
716 if ( 1 === \count( $files ) && 'image' === $type ) {
717 return self::get_files_image_block( $files[0] );
718 }
719
720 // Multiple attachments: use gallery block.
721 return self::get_files_gallery_block( $files );
722 }
723
724 /**
725 * Get standalone image block markup for file-based attachments.
726 *
727 * @param array $file {
728 * File data array.
729 *
730 * @type string $url Full URL to the file.
731 * @type string $mime_type MIME type of the file.
732 * @type string $alt Alt text for the file.
733 * }
734 *
735 * @return string The image block markup.
736 */
737 public static function get_files_image_block( $file ) {
738 $block = '<!-- wp:image {"sizeSlug":"large","linkDestination":"none"} -->' . "\n";
739 $block .= '<figure class="wp-block-image size-large">';
740 $block .= '<img src="' . \esc_url( $file['url'] ) . '" alt="' . \esc_attr( $file['alt'] ?? '' ) . '"/>';
741 $block .= '</figure>' . "\n";
742 $block .= '<!-- /wp:image -->';
743
744 return $block;
745 }
746
747 /**
748 * Get gallery block markup for file-based attachments.
749 *
750 * @param array[] $files {
751 * Array of file data arrays.
752 *
753 * @type string $url Full URL to the file.
754 * @type string $mime_type MIME type of the file.
755 * @type string $alt Alt text for the file.
756 * }
757 *
758 * @return string The gallery block markup.
759 */
760 public static function get_files_gallery_block( $files ) {
761 $gallery = '<!-- wp:gallery {"columns":2,"linkTo":"none","imageCrop":true} -->' . "\n";
762 $gallery .= '<figure class="wp-block-gallery has-nested-images columns-2 is-cropped">';
763
764 foreach ( $files as $file ) {
765 $gallery .= "\n<!-- wp:image {\"sizeSlug\":\"large\",\"linkDestination\":\"none\"} -->\n";
766 $gallery .= '<figure class="wp-block-image size-large">';
767 $gallery .= '<img src="' . \esc_url( $file['url'] ) . '" alt="' . \esc_attr( $file['alt'] ?? '' ) . '"/>';
768 $gallery .= '</figure>';
769 $gallery .= "\n<!-- /wp:image -->\n";
770 }
771
772 $gallery .= "</figure>\n";
773 $gallery .= '<!-- /wp:gallery -->';
774
775 return $gallery;
776 }
777 }
778