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
← All changes | includes/class-attachments.php +502 -215 8.2.07.8.2 View file →
@@ -6,28 +6,81 @@
6 6 */
7 7
8 8 namespace Activitypub;
9 9
10 +use Activitypub\Collection\Posts;
11 +use Activitypub\Collection\Remote_Actors;
12 +
10 13 /**
11 14 * 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 15 */
21 16 class Attachments {
22 17 /**
23 - * Maximum width for imported images into Media Library.
18 + * Directory for storing ap_post media files.
24 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 + *
25 41 * @var int
26 42 */
27 43 const MAX_IMAGE_DIMENSION = 1200;
28 44
29 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 + /**
30 83 * Import attachments from an ActivityPub object and attach them to a post.
31 84 *
32 85 * Creates full WordPress attachment posts in the media library. Each attachment
33 86 * becomes a searchable, manageable attachment post that appears in the WordPress
@@ -81,8 +134,158 @@
81 134 return $attachment_ids;
82 135 }
83 136
84 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 + /**
85 288 * Check if an attachment with the same source URL already exists for a post.
86 289 *
87 290 * @param string $source_url The source URL to check.
88 291 * @param int $post_id The post ID to check attachments for.
@@ -159,8 +362,58 @@
159 362 return $url_mappings;
160 363 }
161 364
162 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 + /**
163 416 * Normalize an ActivityPub attachment object to a standard format.
164 417 *
165 418 * @param mixed $attachment The attachment data (array or object).
166 419 *
@@ -200,25 +453,17 @@
200 453 require_once ABSPATH . 'wp-admin/includes/file.php';
201 454 require_once ABSPATH . 'wp-admin/includes/image.php';
202 455 }
203 456
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';
457 + // Initialize filesystem.
458 + \WP_Filesystem();
459 + global $wp_filesystem;
207 460
208 - $filesystem = new \WP_Filesystem_Direct( null );
209 -
210 461 $is_local = ! preg_match( '#^https?://#i', $attachment_data['url'] );
211 462
212 463 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 464 // Read local file from disk.
220 - if ( ! $filesystem->exists( $attachment_data['url'] ) ) {
465 + if ( ! $wp_filesystem->exists( $attachment_data['url'] ) ) {
221 466 /* translators: %s: file path */
222 467 return new \WP_Error( 'file_not_found', sprintf( \__( 'File not found: %s', 'activitypub' ), $attachment_data['url'] ) );
223 468 }
224 469
@@ -223,15 +468,10 @@
223 468 }
224 469
225 470 // Copy to temp file so media_handle_sideload doesn't move the original.
226 471 $tmp_file = \wp_tempnam( \basename( $attachment_data['url'] ) );
227 - $filesystem->copy( $attachment_data['url'], $tmp_file, true );
472 + $wp_filesystem->copy( $attachment_data['url'], $tmp_file, true );
228 473 } 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 474 // Download remote URL.
235 475 $tmp_file = \download_url( $attachment_data['url'] );
236 476
237 477 if ( \is_wp_error( $tmp_file ) ) {
@@ -245,9 +485,9 @@
245 485 // Rename temp file to have proper extension for optimize_image to detect mime type.
246 486 $original_ext = \pathinfo( $original_name, PATHINFO_EXTENSION );
247 487 if ( $original_ext ) {
248 488 $renamed_tmp = $tmp_file . '.' . $original_ext;
249 - if ( $filesystem->move( $tmp_file, $renamed_tmp, true ) ) {
489 + if ( $wp_filesystem->move( $tmp_file, $renamed_tmp, true ) ) {
250 490 $tmp_file = $renamed_tmp;
251 491 }
252 492 }
253 493
@@ -295,8 +535,98 @@
295 535 return $attachment_id;
296 536 }
297 537
298 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 + /**
299 629 * Get a unique file path by appending a counter if the file already exists.
300 630 *
301 631 * @param string $file_path The desired file path.
302 632 *
@@ -321,61 +651,8 @@
321 651 return $new_path;
322 652 }
323 653
324 654 /**
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 655 * Optimize an image file by resizing and converting to WebP.
379 656 *
380 657 * Uses WordPress image editor to resize large images and convert them
381 658 * to WebP format for better compression while maintaining quality.
@@ -469,8 +746,27 @@
469 746 );
470 747 }
471 748
472 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 + /**
473 769 * Generate media markup for attachments.
474 770 *
475 771 * @param int[] $attachment_ids Array of attachment IDs.
476 772 *
@@ -519,139 +815,10 @@
519 815 return self::get_gallery_block( $attachment_ids );
520 816 }
521 817
522 818 /**
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 819 * Generate media markup for file-based attachments.
650 820 *
651 - * Creates WordPress block markup from file data arrays. Used for cached
652 - * remote media that doesn't have WordPress attachment posts.
653 - *
654 821 * @param array[] $files {
655 822 * Array of file data arrays.
656 823 *
657 824 * @type string $url Full URL to the file.
@@ -660,9 +827,9 @@
660 827 * }
661 828 *
662 829 * @return string The generated markup.
663 830 */
664 - public static function generate_files_markup( $files ) {
831 + private static function generate_files_markup( $files ) {
665 832 if ( empty( $files ) ) {
666 833 return '';
667 834 }
668 835
@@ -715,12 +882,12 @@
715 882 * }
716 883 *
717 884 * @return string The image block markup.
718 885 */
719 - public static function get_files_image_block( $file ) {
886 + private static function get_files_image_block( $file ) {
720 887 $block = '<!-- wp:image {"sizeSlug":"large","linkDestination":"none"} -->' . "\n";
721 888 $block .= '<figure class="wp-block-image size-large">';
722 - $block .= '<img src="' . \esc_url( $file['url'] ) . '" alt="' . \esc_attr( $file['alt'] ?? '' ) . '"/>';
889 + $block .= '<img src="' . \esc_url( $file['url'] ) . '" alt="' . \esc_attr( $file['alt'] ) . '"/>';
723 890 $block .= '</figure>' . "\n";
724 891 $block .= '<!-- /wp:image -->';
725 892
726 893 return $block;
@@ -726,8 +893,70 @@
726 893 return $block;
727 894 }
728 895
729 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 + /**
730 959 * Get gallery block markup for file-based attachments.
731 960 *
732 961 * @param array[] $files {
733 962 * Array of file data arrays.
@@ -738,9 +967,9 @@
738 967 * }
739 968 *
740 969 * @return string The gallery block markup.
741 970 */
742 - public static function get_files_gallery_block( $files ) {
971 + private static function get_files_gallery_block( $files ) {
743 972 $gallery = '<!-- wp:gallery {"columns":2,"linkTo":"none","imageCrop":true} -->' . "\n";
744 973 $gallery .= '<figure class="wp-block-gallery has-nested-images columns-2 is-cropped">';
745 974
746 975 foreach ( $files as $file ) {
@@ -745,9 +974,9 @@
745 974
746 975 foreach ( $files as $file ) {
747 976 $gallery .= "\n<!-- wp:image {\"sizeSlug\":\"large\",\"linkDestination\":\"none\"} -->\n";
748 977 $gallery .= '<figure class="wp-block-image size-large">';
749 - $gallery .= '<img src="' . \esc_url( $file['url'] ) . '" alt="' . \esc_attr( $file['alt'] ?? '' ) . '"/>';
978 + $gallery .= '<img src="' . \esc_url( $file['url'] ) . '" alt="' . \esc_attr( $file['alt'] ) . '"/>';
750 979 $gallery .= '</figure>';
751 980 $gallery .= "\n<!-- /wp:image -->\n";
752 981 }
753 982
@@ -754,6 +983,64 @@
754 983 $gallery .= "</figure>\n";
755 984 $gallery .= '<!-- /wp:gallery -->';
756 985
757 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 + }
758 1045 }
759 1046 }