PluginProbe
ActivityPub / 7.8.2
ActivityPub v7.8.2
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 7.8.2, at includes/class-attachments.php

1,047 lines 31.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 use Activitypub\Collection\Posts;
11 use Activitypub\Collection\Remote_Actors;
12
13 /**
14 * Attachments processor class.
15 */
16 class Attachments {
17 /**
18 * Directory for storing ap_post media files.
19 *
20 * @var string
21 */
22 public static $ap_posts_dir = '/activitypub/ap_posts/';
23
24 /**
25 * Directory for storing comment media files.
26 *
27 * @var string
28 */
29 public static $comments_dir = '/activitypub/comments/';
30
31 /**
32 * Directory for storing actor avatar files.
33 *
34 * @var string
35 */
36 public static $actors_dir = '/activitypub/actors/';
37
38 /**
39 * Maximum width for imported images.
40 *
41 * @var int
42 */
43 const MAX_IMAGE_DIMENSION = 1200;
44
45 /**
46 * Maximum width for actor avatars.
47 *
48 * @var int
49 */
50 const MAX_AVATAR_DIMENSION = 512;
51
52 /**
53 * Initialize the class and set up filters.
54 */
55 public static function init() {
56 \add_action( 'before_delete_post', array( self::class, 'delete_ap_posts_directory' ) );
57 \add_action( 'before_delete_post', array( self::class, 'delete_actors_directory' ) );
58 }
59
60 /**
61 * Delete the activitypub files directory for a post.
62 *
63 * @param int $post_id The post ID.
64 */
65 public static function delete_ap_posts_directory( $post_id ) {
66 if ( Posts::POST_TYPE !== \get_post_type( $post_id ) ) {
67 return;
68 }
69
70 require_once ABSPATH . 'wp-admin/includes/file.php';
71
72 \WP_Filesystem();
73 global $wp_filesystem;
74
75 $activitypub_dir = self::get_storage_paths( $post_id, 'post' )['basedir'];
76
77 if ( $wp_filesystem->is_dir( $activitypub_dir ) ) {
78 $wp_filesystem->delete( $activitypub_dir, true );
79 }
80 }
81
82 /**
83 * Import attachments from an ActivityPub object and attach them to a post.
84 *
85 * Creates full WordPress attachment posts in the media library. Each attachment
86 * becomes a searchable, manageable attachment post that appears in the WordPress
87 * Media Library and is part of the user's content.
88 *
89 * Use this when:
90 * - Importing content that will be owned and editable by the user.
91 * - You need WordPress attachment posts with full metadata support.
92 * - Media should be searchable and manageable in the Media Library.
93 * - Working with content that will be part of the user's site (e.g., importers).
94 *
95 * @param array $attachments Array of ActivityPub attachment objects.
96 * @param int $post_id The post ID to attach files to.
97 * @param int $author_id Optional. User ID to set as attachment author. Default 0.
98 *
99 * @return array Array of attachment IDs.
100 */
101 public static function import( $attachments, $post_id, $author_id = 0 ) {
102 // First, import inline images from the post content.
103 $inline_mappings = self::import_inline_images( $post_id, $author_id );
104
105 if ( empty( $attachments ) || ! is_array( $attachments ) ) {
106 return array();
107 }
108
109 $attachment_ids = array();
110 foreach ( $attachments as $attachment ) {
111 $attachment_data = self::normalize_attachment( $attachment );
112
113 if ( empty( $attachment_data['url'] ) ) {
114 continue;
115 }
116
117 // Skip if this URL was already processed as an inline image.
118 if ( isset( $inline_mappings[ $attachment_data['url'] ] ) ) {
119 continue;
120 }
121
122 $attachment_id = self::save_attachment( $attachment_data, $post_id, $author_id );
123
124 if ( ! \is_wp_error( $attachment_id ) ) {
125 $attachment_ids[] = $attachment_id;
126 }
127 }
128
129 // Append media markup to post content.
130 if ( ! empty( $attachment_ids ) ) {
131 self::append_media_to_post_content( $post_id, $attachment_ids );
132 }
133
134 return $attachment_ids;
135 }
136
137 /**
138 * Import attachments as direct files for posts.
139 *
140 * Saves files directly to uploads/activitypub/ap_posts/{post_id}/ without creating
141 * WordPress attachment posts. This lightweight approach is ideal for federated content
142 * that doesn't require full WordPress media management.
143 *
144 * Files are stored in a dedicated directory structure and automatically cleaned up
145 * when the parent post is deleted. Media URLs point directly to the stored files
146 * rather than going through WordPress attachment APIs.
147 *
148 * Use this when:
149 * - Processing ActivityPub Create/Update activities from the inbox.
150 * - Handling federated content that won't be owned or edited by the user.
151 * - You want lightweight storage without Media Library overhead.
152 *
153 * @param array $attachments Array of ActivityPub attachment objects.
154 * @param int $post_id The post ID to attach files to.
155 *
156 * @return array[] Array of file data arrays.
157 */
158 public static function import_post_files( $attachments, $post_id ) {
159 return self::import_files_for_object( $attachments, $post_id, 'post' );
160 }
161
162 /**
163 * Import attachments as direct files for any object type.
164 *
165 * Saves files directly to uploads/activitypub/{type}/{id}/ without creating
166 * WordPress attachment posts. This is the internal method that handles
167 * the actual import logic for both posts and comments.
168 *
169 * @param array $attachments Array of ActivityPub attachment objects.
170 * @param int $object_id The object ID (post or comment).
171 * @param string $object_type The object type ('post' or 'comment').
172 *
173 * @return array[] Array of file data arrays.
174 */
175 private static function import_files_for_object( $attachments, $object_id, $object_type ) {
176 // First, import inline images from the content.
177 $inline_mappings = self::import_inline_files( $object_id, $object_type );
178
179 if ( empty( $attachments ) || ! is_array( $attachments ) ) {
180 return array();
181 }
182
183 $files = array();
184 foreach ( $attachments as $attachment ) {
185 $attachment_data = self::normalize_attachment( $attachment );
186
187 if ( empty( $attachment_data['url'] ) ) {
188 continue;
189 }
190
191 // Skip if this URL was already processed as an inline image.
192 if ( isset( $inline_mappings[ $attachment_data['url'] ] ) ) {
193 continue;
194 }
195
196 $file_data = self::save_file( $attachment_data, $object_id, $object_type );
197
198 if ( ! \is_wp_error( $file_data ) ) {
199 $files[] = $file_data;
200 }
201 }
202
203 // Append media markup to content.
204 if ( ! empty( $files ) ) {
205 self::append_files_to_content( $object_id, $files, $object_type );
206 }
207
208 return $files;
209 }
210
211 /**
212 * Get storage paths for an object based on its type.
213 *
214 * @param int $object_id The object ID (post or comment).
215 * @param string $object_type The object type ('post' or 'comment').
216 *
217 * @return array {
218 * Storage paths for the object.
219 *
220 * @type string $basedir Base directory path.
221 * @type string $baseurl Base URL.
222 * }
223 */
224 private static function get_storage_paths( $object_id, $object_type ) {
225 $upload_dir = \wp_upload_dir();
226
227 switch ( $object_type ) {
228 case 'comment':
229 $sub_dir = self::$comments_dir;
230 break;
231 case 'actor':
232 $sub_dir = self::$actors_dir;
233 break;
234 default:
235 $sub_dir = self::$ap_posts_dir;
236 break;
237 }
238
239 return array(
240 'basedir' => $upload_dir['basedir'] . $sub_dir . $object_id,
241 'baseurl' => $upload_dir['baseurl'] . $sub_dir . $object_id,
242 );
243 }
244
245 /**
246 * Get content for an object based on its type.
247 *
248 * @param int $object_id The object ID (post or comment).
249 * @param string $object_type The object type ('post' or 'comment').
250 *
251 * @return string The content string or empty if not found.
252 */
253 private static function get_object_content( $object_id, $object_type ) {
254 if ( 'comment' === $object_type ) {
255 $comment = \get_comment( $object_id );
256 return $comment ? $comment->comment_content : '';
257 }
258
259 return \get_post_field( 'post_content', $object_id );
260 }
261
262 /**
263 * Update content for an object based on its type.
264 *
265 * @param int $object_id The object ID (post or comment).
266 * @param string $object_type The object type ('post' or 'comment').
267 * @param string $content The new content.
268 */
269 private static function update_object_content( $object_id, $object_type, $content ) {
270 if ( 'comment' === $object_type ) {
271 \wp_update_comment(
272 array(
273 'comment_ID' => $object_id,
274 'comment_content' => $content,
275 )
276 );
277 } else {
278 \wp_update_post(
279 array(
280 'ID' => $object_id,
281 'post_content' => $content,
282 )
283 );
284 }
285 }
286
287 /**
288 * Check if an attachment with the same source URL already exists for a post.
289 *
290 * @param string $source_url The source URL to check.
291 * @param int $post_id The post ID to check attachments for.
292 *
293 * @return int|false The existing attachment ID or false if not found.
294 */
295 private static function get_existing_attachment( $source_url, $post_id ) {
296 foreach ( \get_attached_media( '', $post_id ) as $attachment ) {
297 if ( \get_post_meta( $attachment->ID, '_source_url', true ) === $source_url ) {
298 return $attachment->ID;
299 }
300 }
301
302 return false;
303 }
304
305 /**
306 * Process inline images from post content.
307 *
308 * @param int $post_id The post ID.
309 * @param int $author_id Optional. User ID to set as attachment author. Default 0.
310 *
311 * @return array Array of URL mappings (old URL => new URL).
312 */
313 private static function import_inline_images( $post_id, $author_id = 0 ) {
314 $post = \get_post( $post_id );
315 if ( ! $post || empty( $post->post_content ) ) {
316 return array();
317 }
318
319 // Find all img tags in the content.
320 preg_match_all( '/<img[^>]+src=["\']([^"\']+)["\'][^>]*>/i', $post->post_content, $matches );
321
322 if ( empty( $matches[1] ) ) {
323 return array();
324 }
325
326 $url_mappings = array();
327 $content = $post->post_content;
328
329 foreach ( $matches[1] as $image_url ) {
330 // Skip if already processed or is a local URL.
331 if ( isset( $url_mappings[ $image_url ] ) ) {
332 continue;
333 }
334
335 // Check if this image was already processed as an attachment.
336 $attachment_id = self::get_existing_attachment( $image_url, $post_id );
337 if ( ! $attachment_id ) {
338 $attachment_id = self::save_attachment( array( 'url' => $image_url ), $post_id, $author_id );
339
340 if ( \is_wp_error( $attachment_id ) ) {
341 continue;
342 }
343 }
344
345 $new_url = \wp_get_attachment_url( $attachment_id );
346 if ( $new_url ) {
347 $url_mappings[ $image_url ] = $new_url;
348 $content = \str_replace( $image_url, $new_url, $content );
349 }
350 }
351
352 // Update post content if URLs were replaced.
353 if ( ! empty( $url_mappings ) ) {
354 \wp_update_post(
355 array(
356 'ID' => $post_id,
357 'post_content' => $content,
358 )
359 );
360 }
361
362 return $url_mappings;
363 }
364
365 /**
366 * Process inline images from content (for direct file storage).
367 *
368 * @param int $object_id The post or comment ID.
369 * @param string $object_type The object type ('post' or 'comment').
370 *
371 * @return array Array of URL mappings (old URL => new URL).
372 */
373 private static function import_inline_files( $object_id, $object_type ) {
374 $content = self::get_object_content( $object_id, $object_type );
375 if ( ! $content ) {
376 return array();
377 }
378
379 // Find all img tags in the content.
380 preg_match_all( '/<img[^>]+src=["\']([^"\']+)["\'][^>]*>/i', $content, $matches );
381
382 if ( empty( $matches[1] ) ) {
383 return array();
384 }
385
386 $url_mappings = array();
387
388 foreach ( $matches[1] as $image_url ) {
389 // Skip if already processed.
390 if ( isset( $url_mappings[ $image_url ] ) ) {
391 continue;
392 }
393
394 $file_data = self::save_file( array( 'url' => $image_url ), $object_id, $object_type );
395
396 if ( \is_wp_error( $file_data ) ) {
397 continue;
398 }
399
400 $new_url = $file_data['url'];
401 if ( $new_url ) {
402 $url_mappings[ $image_url ] = $new_url;
403 $content = \str_replace( $image_url, $new_url, $content );
404 }
405 }
406
407 // Update content if URLs were replaced.
408 if ( ! empty( $url_mappings ) ) {
409 self::update_object_content( $object_id, $object_type, $content );
410 }
411
412 return $url_mappings;
413 }
414
415 /**
416 * Normalize an ActivityPub attachment object to a standard format.
417 *
418 * @param mixed $attachment The attachment data (array or object).
419 *
420 * @return array|false Normalized attachment data or false on failure.
421 */
422 private static function normalize_attachment( $attachment ) {
423 // Convert object to array if needed.
424 if ( \is_object( $attachment ) ) {
425 $attachment = \get_object_vars( $attachment );
426 }
427
428 if ( ! is_array( $attachment ) || empty( $attachment['url'] ) ) {
429 return false;
430 }
431
432 return array(
433 'url' => $attachment['url'],
434 'mediaType' => $attachment['mediaType'] ?? '',
435 'name' => $attachment['name'] ?? '',
436 'type' => $attachment['type'] ?? 'Document',
437 );
438 }
439
440 /**
441 * Save an attachment (local file or remote URL) to the media library.
442 *
443 * @param array $attachment_data The normalized attachment data.
444 * @param int $post_id The post ID to attach to.
445 * @param int $author_id Optional. User ID to set as attachment author. Default 0.
446 *
447 * @return int|\WP_Error The attachment ID or WP_Error on failure.
448 */
449 private static function save_attachment( $attachment_data, $post_id, $author_id = 0 ) {
450 // Ensure required WordPress functions are loaded.
451 if ( ! \function_exists( 'media_handle_sideload' ) || ! \function_exists( 'download_url' ) ) {
452 require_once ABSPATH . 'wp-admin/includes/media.php';
453 require_once ABSPATH . 'wp-admin/includes/file.php';
454 require_once ABSPATH . 'wp-admin/includes/image.php';
455 }
456
457 // Initialize filesystem.
458 \WP_Filesystem();
459 global $wp_filesystem;
460
461 $is_local = ! preg_match( '#^https?://#i', $attachment_data['url'] );
462
463 if ( $is_local ) {
464 // Read local file from disk.
465 if ( ! $wp_filesystem->exists( $attachment_data['url'] ) ) {
466 /* translators: %s: file path */
467 return new \WP_Error( 'file_not_found', sprintf( \__( 'File not found: %s', 'activitypub' ), $attachment_data['url'] ) );
468 }
469
470 // Copy to temp file so media_handle_sideload doesn't move the original.
471 $tmp_file = \wp_tempnam( \basename( $attachment_data['url'] ) );
472 $wp_filesystem->copy( $attachment_data['url'], $tmp_file, true );
473 } else {
474 // Download remote URL.
475 $tmp_file = \download_url( $attachment_data['url'] );
476
477 if ( \is_wp_error( $tmp_file ) ) {
478 return $tmp_file;
479 }
480 }
481
482 // Get original filename from URL.
483 $original_name = \basename( \wp_parse_url( $attachment_data['url'], PHP_URL_PATH ) );
484
485 // Rename temp file to have proper extension for optimize_image to detect mime type.
486 $original_ext = \pathinfo( $original_name, PATHINFO_EXTENSION );
487 if ( $original_ext ) {
488 $renamed_tmp = $tmp_file . '.' . $original_ext;
489 if ( $wp_filesystem->move( $tmp_file, $renamed_tmp, true ) ) {
490 $tmp_file = $renamed_tmp;
491 }
492 }
493
494 // Optimize images before sideloading (resize and convert to WebP).
495 $tmp_file = self::optimize_image( $tmp_file, self::MAX_IMAGE_DIMENSION );
496
497 // Update filename extension to match optimized file.
498 $new_ext = \pathinfo( $tmp_file, PATHINFO_EXTENSION );
499 if ( $new_ext ) {
500 $original_name = \preg_replace( '/\.[^.]+$/', '.' . $new_ext, $original_name );
501 }
502
503 $file_array = array(
504 'name' => $original_name,
505 'tmp_name' => $tmp_file,
506 );
507
508 // Prepare attachment post data.
509 // Let WordPress auto-detect the mime type from the file.
510 $post_data = array(
511 'post_title' => $attachment_data['name'] ?? '',
512 'post_content' => $attachment_data['name'] ?? '',
513 'post_author' => $author_id,
514 'meta_input' => array(
515 '_source_url' => $attachment_data['url'],
516 ),
517 );
518
519 // Add alt text for images.
520 if ( ! empty( $attachment_data['name'] ) ) {
521 $original_mime = $attachment_data['mediaType'] ?? '';
522 if ( 'image' === strtok( $original_mime, '/' ) ) {
523 $post_data['meta_input']['_wp_attachment_image_alt'] = $attachment_data['name'];
524 }
525 }
526
527 // Sideload the attachment into WordPress.
528 $attachment_id = \media_handle_sideload( $file_array, $post_id, '', $post_data );
529
530 // Clean up temp file if there was an error.
531 if ( \is_wp_error( $attachment_id ) ) {
532 \wp_delete_file( $tmp_file );
533 }
534
535 return $attachment_id;
536 }
537
538 /**
539 * Save a file directly to uploads/activitypub/{type}/{id}/.
540 *
541 * For video and audio files, returns the remote URL directly without downloading
542 * to avoid storage overhead for large media files.
543 *
544 * @param array $attachment_data The normalized attachment data.
545 * @param int $object_id The post or comment ID to attach to.
546 * @param string $object_type The object type ('post' or 'comment').
547 * @param int $max_dimension Optional. Maximum image dimension in pixels. Default MAX_IMAGE_DIMENSION.
548 *
549 * @return array|\WP_Error {
550 * Array of file data on success, WP_Error on failure.
551 *
552 * @type string $url Full URL to the saved file (or remote URL for video/audio).
553 * @type string $mime_type MIME type of the file.
554 * @type string $alt Alt text from attachment name field.
555 * }
556 */
557 private static function save_file( $attachment_data, $object_id, $object_type, $max_dimension = self::MAX_IMAGE_DIMENSION ) {
558 $mime_type = $attachment_data['mediaType'] ?? '';
559
560 // Skip download for video and audio files - use remote URL directly.
561 if ( str_starts_with( $mime_type, 'video/' ) || str_starts_with( $mime_type, 'audio/' ) ) {
562 return array(
563 'url' => $attachment_data['url'],
564 'mime_type' => $attachment_data['mediaType'],
565 'alt' => $attachment_data['name'] ?? '',
566 );
567 }
568
569 if ( ! \function_exists( 'download_url' ) ) {
570 require_once ABSPATH . 'wp-admin/includes/file.php';
571 }
572
573 // Download remote URL.
574 $tmp_file = \download_url( $attachment_data['url'] );
575
576 if ( \is_wp_error( $tmp_file ) ) {
577 return $tmp_file;
578 }
579
580 // Get storage paths for this object.
581 $paths = self::get_storage_paths( $object_id, $object_type );
582
583 // Create directory if it doesn't exist.
584 \wp_mkdir_p( $paths['basedir'] );
585
586 // Generate unique file name.
587 $url_path = \wp_parse_url( $attachment_data['url'], PHP_URL_PATH );
588 $file_name = \sanitize_file_name( \basename( $url_path ) );
589 $file_path = $paths['basedir'] . '/' . $file_name;
590
591 // Initialize filesystem if needed.
592 \WP_Filesystem();
593 global $wp_filesystem;
594
595 // Make sure file name is unique.
596 $counter = 1;
597 while ( $wp_filesystem->exists( $file_path ) ) {
598 $path_info = pathinfo( $file_name );
599 $file_name = $path_info['filename'] . '-' . $counter;
600 if ( ! empty( $path_info['extension'] ) ) {
601 $file_name .= '.' . $path_info['extension'];
602 }
603 $file_path = $paths['basedir'] . '/' . $file_name;
604 ++$counter;
605 }
606
607 // Move file to destination.
608 if ( ! $wp_filesystem->move( $tmp_file, $file_path, true ) ) {
609 \wp_delete_file( $tmp_file );
610 return new \WP_Error( 'file_move_failed', \__( 'Failed to move file to destination.', 'activitypub' ) );
611 }
612
613 // Optimize images (resize and convert to WebP).
614 $file_path = self::optimize_image( $file_path, $max_dimension );
615 $file_name = \basename( $file_path );
616
617 // Get mime type and validate file.
618 $file_info = \wp_check_filetype_and_ext( $file_path, $file_name );
619 $mime_type = $file_info['type'] ?? $attachment_data['mediaType'] ?? '';
620
621 return array(
622 'url' => $paths['baseurl'] . '/' . $file_name,
623 'mime_type' => $mime_type,
624 'alt' => $attachment_data['name'] ?? '',
625 );
626 }
627
628 /**
629 * Get a unique file path by appending a counter if the file already exists.
630 *
631 * @param string $file_path The desired file path.
632 *
633 * @return string A unique file path that doesn't exist.
634 */
635 private static function get_unique_path( $file_path ) {
636 if ( ! \file_exists( $file_path ) ) {
637 return $file_path;
638 }
639
640 $path_info = \pathinfo( $file_path );
641 $dir = $path_info['dirname'];
642 $base_name = $path_info['filename'];
643 $extension = isset( $path_info['extension'] ) ? '.' . $path_info['extension'] : '';
644 $counter = 1;
645
646 do {
647 $new_path = $dir . '/' . $base_name . '-' . $counter . $extension;
648 ++$counter;
649 } while ( \file_exists( $new_path ) );
650
651 return $new_path;
652 }
653
654 /**
655 * Optimize an image file by resizing and converting to WebP.
656 *
657 * Uses WordPress image editor to resize large images and convert them
658 * to WebP format for better compression while maintaining quality.
659 *
660 * @param string $file_path Path to the image file.
661 * @param int $max_dimension Maximum width/height in pixels.
662 *
663 * @return string The optimized file path.
664 */
665 private static function optimize_image( $file_path, $max_dimension ) {
666 // Check if it's an image.
667 $mime_type = \wp_check_filetype( $file_path )['type'] ?? '';
668 if ( ! $mime_type || ! \str_starts_with( $mime_type, 'image/' ) ) {
669 return $file_path;
670 }
671
672 // Skip SVG and GIF files (GIFs may be animated).
673 if ( \in_array( $mime_type, array( 'image/svg+xml', 'image/gif' ), true ) ) {
674 return $file_path;
675 }
676
677 $editor = \wp_get_image_editor( $file_path );
678 if ( \is_wp_error( $editor ) ) {
679 return $file_path;
680 }
681
682 $size = $editor->get_size();
683 $needs_resize = $size['width'] > $max_dimension || $size['height'] > $max_dimension;
684
685 // Resize if needed.
686 if ( $needs_resize ) {
687 $editor->resize( $max_dimension, $max_dimension, false );
688 }
689
690 // Check if WebP is supported.
691 $can_webp = $editor->supports_mime_type( 'image/webp' );
692
693 // Determine output format and save.
694 if ( $can_webp ) {
695 // Convert to WebP.
696 $new_path = self::get_unique_path( \preg_replace( '/\.[^.]+$/', '.webp', $file_path ) );
697 $result = $editor->save( $new_path, 'image/webp' );
698 } elseif ( \in_array( $mime_type, array( 'image/png', 'image/webp' ), true ) ) {
699 // Keep original format for potentially transparent images when WebP not available.
700 if ( ! $needs_resize ) {
701 // No changes needed.
702 return $file_path;
703 }
704 $result = $editor->save( $file_path );
705 } else {
706 // Convert to JPEG when WebP not available.
707 $new_path = self::get_unique_path( \preg_replace( '/\.[^.]+$/', '.jpg', $file_path ) );
708 $result = $editor->save( $new_path, 'image/jpeg' );
709 }
710
711 if ( \is_wp_error( $result ) ) {
712 return $file_path;
713 }
714
715 // Handle result - $result is always an array from $editor->save().
716 $result_path = $result['path'] ?? $file_path;
717
718 // If path changed (format conversion), delete the original file.
719 if ( $result_path !== $file_path ) {
720 \wp_delete_file( $file_path );
721 }
722
723 return $result_path;
724 }
725
726 /**
727 * Append media to post content.
728 *
729 * @param int $post_id The post ID.
730 * @param int[] $attachment_ids Array of attachment IDs.
731 */
732 private static function append_media_to_post_content( $post_id, $attachment_ids ) {
733 $post = \get_post( $post_id );
734 if ( ! $post ) {
735 return;
736 }
737
738 $media = self::generate_media_markup( $attachment_ids );
739 $separator = empty( trim( $post->post_content ) ) ? '' : "\n\n";
740
741 \wp_update_post(
742 array(
743 'ID' => $post_id,
744 'post_content' => $post->post_content . $separator . $media,
745 )
746 );
747 }
748
749 /**
750 * Append file-based media to content.
751 *
752 * @param int $object_id The post or comment ID.
753 * @param array[] $files Array of file data arrays.
754 * @param string $object_type The object type ('post' or 'comment').
755 */
756 private static function append_files_to_content( $object_id, $files, $object_type ) {
757 $content = self::get_object_content( $object_id, $object_type );
758 if ( empty( $content ) ) {
759 return;
760 }
761
762 $media = self::generate_files_markup( $files );
763 $separator = empty( trim( $content ) ) ? '' : "\n\n";
764
765 self::update_object_content( $object_id, $object_type, $content . $separator . $media );
766 }
767
768 /**
769 * Generate media markup for attachments.
770 *
771 * @param int[] $attachment_ids Array of attachment IDs.
772 *
773 * @return string The generated markup.
774 */
775 private static function generate_media_markup( $attachment_ids ) {
776 if ( empty( $attachment_ids ) ) {
777 return '';
778 }
779
780 /**
781 * Filters the media markup for ActivityPub attachments.
782 *
783 * Allows plugins to provide custom markup for attachments.
784 * If this filter returns a non-empty string, it will be used instead of
785 * the default block markup.
786 *
787 * @param string $markup The custom markup. Default empty string.
788 * @param int[] $attachment_ids Array of attachment IDs.
789 */
790 $custom_markup = \apply_filters( 'activitypub_attachments_media_markup', '', $attachment_ids );
791
792 if ( ! empty( $custom_markup ) ) {
793 return $custom_markup;
794 }
795
796 // Default to block markup.
797 $type = strtok( \get_post_mime_type( $attachment_ids[0] ), '/' );
798
799 // Single video or audio file.
800 if ( 1 === \count( $attachment_ids ) && ( 'video' === $type || 'audio' === $type ) ) {
801 return sprintf(
802 '<!-- wp:%1$s {"id":"%2$s"} --><figure class="wp-block-%1$s"><%1$s controls src="%3$s"></%1$s></figure><!-- /wp:%1$s -->',
803 \esc_attr( $type ),
804 \esc_attr( $attachment_ids[0] ),
805 \esc_url( \wp_get_attachment_url( $attachment_ids[0] ) )
806 );
807 }
808
809 // Single image: use standalone image block.
810 if ( 1 === \count( $attachment_ids ) && 'image' === $type ) {
811 return self::get_image_block( $attachment_ids[0] );
812 }
813
814 // Multiple attachments: use gallery block.
815 return self::get_gallery_block( $attachment_ids );
816 }
817
818 /**
819 * Generate media markup for file-based attachments.
820 *
821 * @param array[] $files {
822 * Array of file data arrays.
823 *
824 * @type string $url Full URL to the file.
825 * @type string $mime_type MIME type of the file.
826 * @type string $alt Alt text for the file.
827 * }
828 *
829 * @return string The generated markup.
830 */
831 private static function generate_files_markup( $files ) {
832 if ( empty( $files ) ) {
833 return '';
834 }
835
836 /**
837 * Filters the media markup for ActivityPub file-based attachments.
838 *
839 * Allows plugins to provide custom markup for file-based attachments.
840 * If this filter returns a non-empty string, it will be used instead of
841 * the default block markup.
842 *
843 * @param string $markup The custom markup. Default empty string.
844 * @param array $files Array of file data arrays.
845 */
846 $custom_markup = \apply_filters( 'activitypub_files_media_markup', '', $files );
847
848 if ( ! empty( $custom_markup ) ) {
849 return $custom_markup;
850 }
851
852 // Default to block markup.
853 $type = strtok( $files[0]['mime_type'], '/' );
854
855 // Single video or audio file.
856 if ( 1 === \count( $files ) && ( 'video' === $type || 'audio' === $type ) ) {
857 return sprintf(
858 '<!-- wp:%1$s --><figure class="wp-block-%1$s"><%1$s controls src="%2$s"></%1$s></figure><!-- /wp:%1$s -->',
859 \esc_attr( $type ),
860 \esc_url( $files[0]['url'] )
861 );
862 }
863
864 // Single image: use standalone image block.
865 if ( 1 === \count( $files ) && 'image' === $type ) {
866 return self::get_files_image_block( $files[0] );
867 }
868
869 // Multiple attachments: use gallery block.
870 return self::get_files_gallery_block( $files );
871 }
872
873 /**
874 * Get standalone image block markup for file-based attachments.
875 *
876 * @param array $file {
877 * File data array.
878 *
879 * @type string $url Full URL to the file.
880 * @type string $mime_type MIME type of the file.
881 * @type string $alt Alt text for the file.
882 * }
883 *
884 * @return string The image block markup.
885 */
886 private static function get_files_image_block( $file ) {
887 $block = '<!-- wp:image {"sizeSlug":"large","linkDestination":"none"} -->' . "\n";
888 $block .= '<figure class="wp-block-image size-large">';
889 $block .= '<img src="' . \esc_url( $file['url'] ) . '" alt="' . \esc_attr( $file['alt'] ) . '"/>';
890 $block .= '</figure>' . "\n";
891 $block .= '<!-- /wp:image -->';
892
893 return $block;
894 }
895
896 /**
897 * Get standalone image block markup.
898 *
899 * @param int $attachment_id The attachment ID.
900 *
901 * @return string The image block markup.
902 */
903 private static function get_image_block( $attachment_id ) {
904 $image_src = \wp_get_attachment_image_src( $attachment_id, 'large' );
905 if ( ! $image_src ) {
906 return '';
907 }
908
909 $alt = \get_post_meta( $attachment_id, '_wp_attachment_image_alt', true );
910 if ( ! $alt ) {
911 $alt = \get_post_field( 'post_excerpt', $attachment_id );
912 }
913
914 $block = '<!-- wp:image {"id":' . \esc_attr( $attachment_id ) . ',"sizeSlug":"large","linkDestination":"none"} -->' . "\n";
915 $block .= '<figure class="wp-block-image size-large">';
916 $block .= '<img src="' . \esc_url( $image_src[0] ) . '" alt="' . \esc_attr( $alt ) . '" class="' . \esc_attr( 'wp-image-' . $attachment_id ) . '"/>';
917 $block .= '</figure>' . "\n";
918 $block .= '<!-- /wp:image -->';
919
920 return $block;
921 }
922
923 /**
924 * Get gallery block markup.
925 *
926 * @param int[] $attachment_ids The attachment IDs to use.
927 *
928 * @return string The gallery block markup.
929 */
930 private static function get_gallery_block( $attachment_ids ) {
931 $gallery = '<!-- wp:gallery {"columns":2,"linkTo":"none","sizeSlug":"large","imageCrop":true} -->' . "\n";
932 $gallery .= '<figure class="wp-block-gallery has-nested-images columns-2 is-cropped">';
933
934 foreach ( $attachment_ids as $id ) {
935 $image_src = \wp_get_attachment_image_src( $id, 'large' );
936 if ( ! $image_src ) {
937 continue;
938 }
939
940 $alt = \get_post_meta( $id, '_wp_attachment_image_alt', true );
941 if ( ! $alt ) {
942 $alt = \get_post_field( 'post_excerpt', $id );
943 }
944
945 $gallery .= "\n" . '<!-- wp:image {"id":' . \esc_attr( $id ) . ',"sizeSlug":"large","linkDestination":"none"} -->' . "\n";
946 $gallery .= '<figure class="wp-block-image size-large">';
947 $gallery .= '<img src="' . \esc_url( $image_src[0] ) . '" alt="' . \esc_attr( $alt ) . '" class="' . \esc_attr( 'wp-image-' . $id ) . '"/>';
948 $gallery .= '</figure>';
949 $gallery .= "\n<!-- /wp:image -->\n";
950 }
951
952 $gallery .= "</figure>\n";
953 $gallery .= '<!-- /wp:gallery -->';
954
955 return $gallery;
956 }
957
958 /**
959 * Get gallery block markup for file-based attachments.
960 *
961 * @param array[] $files {
962 * Array of file data arrays.
963 *
964 * @type string $url Full URL to the file.
965 * @type string $mime_type MIME type of the file.
966 * @type string $alt Alt text for the file.
967 * }
968 *
969 * @return string The gallery block markup.
970 */
971 private static function get_files_gallery_block( $files ) {
972 $gallery = '<!-- wp:gallery {"columns":2,"linkTo":"none","imageCrop":true} -->' . "\n";
973 $gallery .= '<figure class="wp-block-gallery has-nested-images columns-2 is-cropped">';
974
975 foreach ( $files as $file ) {
976 $gallery .= "\n<!-- wp:image {\"sizeSlug\":\"large\",\"linkDestination\":\"none\"} -->\n";
977 $gallery .= '<figure class="wp-block-image size-large">';
978 $gallery .= '<img src="' . \esc_url( $file['url'] ) . '" alt="' . \esc_attr( $file['alt'] ) . '"/>';
979 $gallery .= '</figure>';
980 $gallery .= "\n<!-- /wp:image -->\n";
981 }
982
983 $gallery .= "</figure>\n";
984 $gallery .= '<!-- /wp:gallery -->';
985
986 return $gallery;
987 }
988
989 /**
990 * Save a remote actor's avatar locally.
991 *
992 * Downloads the avatar image, optimizes it, and stores it in the actors directory.
993 * Returns the local URL for the saved avatar.
994 *
995 * @param int $actor_id The local actor post ID.
996 * @param string $avatar_url The remote avatar URL.
997 *
998 * @return string|false The local avatar URL on success, false on failure.
999 */
1000 public static function save_actor_avatar( $actor_id, $avatar_url ) {
1001 // Validate actor_id is a positive integer to prevent path traversal.
1002 $actor_id = (int) $actor_id;
1003 if ( $actor_id <= 0 ) {
1004 return false;
1005 }
1006
1007 if ( empty( $avatar_url ) || ! \filter_var( $avatar_url, FILTER_VALIDATE_URL ) ) {
1008 return false;
1009 }
1010
1011 // Delete existing avatar files before saving new one.
1012 // This prevents accumulating old avatar files since save_file creates unique filenames.
1013 self::delete_actors_directory( $actor_id );
1014
1015 $attachment_data = array( 'url' => $avatar_url );
1016 $result = self::save_file( $attachment_data, $actor_id, 'actor', self::MAX_AVATAR_DIMENSION );
1017
1018 if ( \is_wp_error( $result ) || ! isset( $result['url'] ) ) {
1019 return false;
1020 }
1021
1022 return $result['url'];
1023 }
1024
1025 /**
1026 * Delete the activitypub files directory for an actor.
1027 *
1028 * @param int $actor_id The actor post ID.
1029 */
1030 public static function delete_actors_directory( $actor_id ) {
1031 if ( Remote_Actors::POST_TYPE !== \get_post_type( $actor_id ) ) {
1032 return;
1033 }
1034
1035 require_once ABSPATH . 'wp-admin/includes/file.php';
1036
1037 \WP_Filesystem();
1038 global $wp_filesystem;
1039
1040 $activitypub_dir = self::get_storage_paths( $actor_id, 'actor' )['basedir'];
1041
1042 if ( $wp_filesystem->is_dir( $activitypub_dir ) ) {
1043 $wp_filesystem->rmdir( $activitypub_dir, true );
1044 }
1045 }
1046 }
1047